OXYGENHUB

Mail settings on the admin page, with a test message 3556d08e17f9

Administration has a Mail form: the SMTP URL and From address are
saved in the settings table and override hub.toml's [email] from then
on (App.mail, loaded at start). The password is shown masked, and a
masked value left as it was keeps the stored one; an empty URL turns
mail off; a button sends a test message to the administrator's own
address. Spec §17.1.

closes #101
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 15:07 UTC
Signature
✔ signed by Andy McCall
Hash
3556d08e17f96274207d9960d92fe2c0ea228f8642e2ce2d248883071116abfe
Depends on
19bb31ff1f5c 34beb1a7e376 5dead1f4f26d 5e6d6484c28e 749d9a5fcd9f 74be97962ed8 93be9ce4653e a6c241e8eba0 b5668108487f b64e2537d702 b6b7e4cf6b24 d23464c0ac60
Depended on by
1062a0f8e985 262ec9c51a5c 737de0fac722 e57765dee61a 2c47eecfeab6 a7aa76ac52d7 965b950f9b96 2cbf6f348595 3dd6582de060 b91e2d81b3f1 cd46e56b1709 a46d15e72101 e8e7158bbc73 83940d53664f 4ac01a732552 2a83f5303458 e71846aa07b9
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
18 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +6 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A runner's own labels and time limit (#98).** A runner's page under Admin → Runners has a Settings form: labels an administrator gives it beside those it reports (jobs that ask for a label match either), and a job time limit in minutes below the site's, applied when it claims.
8 +
9 +
- **Mail settings on the admin page (#101).** The SMTP URL and From address are set and saved from Administration, overriding `hub.toml`'s `[email]`, with a button that sends a test message to the administrator's own address; the password is kept on the server and shown masked. An empty URL turns mail off.
10 +
11 +
- **Spacing (#99, #100).** The Add a runner button stands off the runners table and the webhook and collaborator forms fill their panel; a proposal's project entries keep a space before remove.
12 +
7 13
## 0.4.0 (2026-10-04)
8 14
9 15
- **Who did the work.** A change an AI agent signed for its author says so wherever the author is shown: `via NAME` with an `agent` badge in history rows, a `via` row under the author on the change page (the agent's identity, the delegation it acted under and its expiry, its key), a count under the author in Contributors, and `via` in the API's change summaries. The author stays the human; a revoked delegation turns the badge red.
…
83 unchanged lines
modified crates/hub/src/lib.rs +5 −0
…
36 unchanged lines
37 37
    pub events: events::Events,
38 38
    /// Who is resolving which conflict together (hub spec §6.10).
39 39
    pub rooms: Arc<live::Rooms>,
40 +
    /// Outgoing mail: hub.toml's `[email]`, overridden by what the admin
41 +
    /// page saved (#101).
42 +
    pub mail: std::sync::RwLock<config::Email>,
40 43
    pub started: std::time::Instant,
41 44
}
42 45
…
10 unchanged lines
53 56
    let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current());
54 57
    let slot = backend.app_slot();
55 58
    let server = oxy_server::Server::new(backend);
59 +
    let mail = mail::load(&db, &config.email).await;
56 60
    let app = Arc::new(App {
61 +
        mail: std::sync::RwLock::new(mail),
57 62
        config,
58 63
        db,
59 64
        repos,
…
56 unchanged lines
modified crates/hub/src/mail.rs +40 −2
…
1 unchanged lines
2 2
//! development and tests, `file:///dir`, which writes each message to that
3 3
//! directory instead of sending it.
4 4
5 +
use crate::config::Email;
5 6
use crate::Shared;
6 7
8 +
pub const SETTING_SMTP: &str = "mail.smtp";
9 +
pub const SETTING_FROM: &str = "mail.from";
10 +
11 +
/// The mail settings in force: what the admin page saved (#101), else
12 +
/// hub.toml's `[email]`.
13 +
pub async fn load(db: &crate::db::Pool, from_config: &Email) -> Email {
14 +
    let mut mail = from_config.clone();
15 +
    if let Ok(Some(s)) = crate::db::accounts::setting(db, SETTING_SMTP).await {
16 +
        mail.smtp = s;
17 +
        mail.from = crate::db::accounts::setting(db, SETTING_FROM).await.ok().flatten().unwrap_or_default();
18 +
    }
19 +
    mail
20 +
}
21 +
22 +
pub fn settings(app: &Shared) -> Email {
23 +
    app.mail.read().unwrap_or_else(|p| p.into_inner()).clone()
24 +
}
25 +
26 +
/// Save the settings and use them from now on. An empty URL turns mail
27 +
/// off.
28 +
pub async fn save(app: &Shared, smtp: &str, from: &str) -> sqlx::Result<()> {
29 +
    crate::db::accounts::set_setting(&app.db, SETTING_SMTP, smtp).await?;
30 +
    crate::db::accounts::set_setting(&app.db, SETTING_FROM, from).await?;
31 +
    *app.mail.write().unwrap_or_else(|p| p.into_inner()) = Email { smtp: smtp.to_string(), from: from.to_string() };
32 +
    Ok(())
33 +
}
34 +
35 +
/// The SMTP URL with its password hidden, for the page.
36 +
pub fn masked(smtp: &str) -> String {
37 +
    let Some((scheme, rest)) = smtp.split_once("://") else { return smtp.to_string() };
38 +
    let Some((userinfo, host)) = rest.rsplit_once('@') else { return smtp.to_string() };
39 +
    match userinfo.split_once(':') {
40 +
        Some((user, _)) => format!("{scheme}://{user}:***@{host}"),
41 +
        None => smtp.to_string(),
42 +
    }
43 +
}
44 +
7 45
/// Whether the hub can send mail at all.
8 46
pub fn enabled(app: &Shared) -> bool {
9 -
    let cfg = &app.config.email;
47 +
    let cfg = settings(app);
10 48
    !cfg.smtp.is_empty() && !cfg.from.is_empty()
11 49
}
12 50
…
2 unchanged lines
15 53
    if !enabled(app) {
16 54
        return Err("this hub does not send mail".into());
17 55
    }
18 -
    let cfg = &app.config.email;
56 +
    let cfg = settings(app);
19 57
    let subject = format!("[{}] {subject}", app.config.site.name);
20 58
    if let Some(dir) = cfg.smtp.strip_prefix("file://") {
21 59
        let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4));
…
17 unchanged lines
modified crates/hub/src/web/admin.rs +93 −1
…
20 unchanged lines
21 21
    audit: Vec<accounts::AuditRow>,
22 22
    repos: usize,
23 23
    notice: Option<String>,
24 +
    /// Mail settings (#101): the SMTP URL with its password masked, the
25 +
    /// From address, whether mail is on, and the administrator's own
26 +
    /// address for a test message.
27 +
    mail_smtp: String,
28 +
    mail_from: String,
29 +
    mail_enabled: bool,
30 +
    my_email: String,
24 31
}
25 32
26 33
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
…
11 unchanged lines
38 45
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
39 46
    let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?;
40 47
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
41 -
    Ok(Page(AdminPage { shell, accounts: accounts_list, orgs, mode, invitations, audit, repos, notice }))
48 +
    let mail = crate::mail::settings(app);
49 +
    let my_email = match shell.user.as_deref() {
50 +
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
51 +
        None => String::new(),
52 +
    };
53 +
    Ok(Page(AdminPage {
54 +
        shell,
55 +
        accounts: accounts_list,
56 +
        orgs,
57 +
        mode,
58 +
        invitations,
59 +
        audit,
60 +
        repos,
61 +
        notice,
62 +
        mail_smtp: crate::mail::masked(&mail.smtp),
63 +
        mail_from: mail.from.clone(),
64 +
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
65 +
        my_email,
66 +
    }))
67 +
}
68 +
69 +
#[derive(Deserialize)]
70 +
pub struct MailForm {
71 +
    #[serde(rename = "_csrf")]
72 +
    csrf_token: String,
73 +
    #[serde(default)]
74 +
    smtp: String,
75 +
    #[serde(default)]
76 +
    from: String,
77 +
}
78 +
79 +
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
80 +
/// comes back masked, so a masked value left as it was keeps the stored
81 +
/// one; an empty URL turns mail off.
82 +
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
83 +
    let shell = Shell::new(&app, "/admin", &viewer);
84 +
    let me = require_admin(&viewer, &shell)?;
85 +
    csrf(&viewer, &f.csrf_token, &shell)?;
86 +
    let current = crate::mail::settings(&app);
87 +
    let smtp = f.smtp.trim();
88 +
    let smtp = if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) { current.smtp.clone() } else { smtp.to_string() };
89 +
    let from = f.from.trim().to_string();
90 +
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
91 +
        return admin_page(
92 +
            &app,
93 +
            shell,
94 +
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
95 +
        )
96 +
        .await;
97 +
    }
98 +
    if !smtp.is_empty() && from.is_empty() {
99 +
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <[email protected]>.".into())).await;
100 +
    }
101 +
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
102 +
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
103 +
    admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await
104 +
}
105 +
106 +
/// `POST /admin/mail/test`: a message to the administrator's own address.
107 +
pub async fn test_mail(
108 +
    State(app): State<Shared>,
109 +
    viewer: Viewer,
110 +
    Form(f): Form<super::session::CsrfForm>,
111 +
) -> Result<Page<AdminPage>, Error> {
112 +
    let shell = Shell::new(&app, "/admin", &viewer);
113 +
    let me = require_admin(&viewer, &shell)?;
114 +
    csrf(&viewer, &f.csrf, &shell)?;
115 +
    if !crate::mail::enabled(&app) {
116 +
        return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
117 +
    }
118 +
    if me.email.is_empty() {
119 +
        return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await;
120 +
    }
121 +
    let notice = match crate::mail::send(
122 +
        &app,
123 +
        me.name(),
124 +
        &me.email,
125 +
        "Test message",
126 +
        "This is a test message from your hub's mail settings. If you can read it, mail works.",
127 +
    )
128 +
    .await
129 +
    {
130 +
        Ok(()) => format!("Sent a test message to {}.", me.email),
131 +
        Err(e) => format!("Sending failed: {e}"),
132 +
    };
133 +
    admin_page(&app, shell, Some(notice)).await
42 134
}
43 135
44 136
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
…
227 unchanged lines
modified crates/hub/src/web/mod.rs +3 −0
…
102 unchanged lines
103 103
        .route("/admin/runners/{id}", get(runners::show))
104 104
        .route("/admin/runners/{id}/state", post(runners::set_state))
105 105
        .route("/admin/runners/{id}/scope", post(runners::set_scope))
106 +
        .route("/admin/runners/{id}/limits", post(runners::set_limits))
107 +
        .route("/admin/mail", post(admin::set_mail))
108 +
        .route("/admin/mail/test", post(admin::test_mail))
106 109
        .route("/admin/runners/{id}/remove", post(runners::remove))
107 110
        .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize))
108 111
        .route("/{owner}", get(home::owner))
…
516 unchanged lines
modified crates/hub/tests/accounts.rs +56 −0
…
569 unchanged lines
570 570
    let bob = register(app, "bob", &code).await;
571 571
    assert_ne!(get(app, "/admin/runners/settings", Some(&bob)).await.status, StatusCode::OK, "not for others");
572 572
}
573 +
574 +
/// Mail settings from the admin page (#101): saved over hub.toml, shown
575 +
/// with the password masked, a test message sent to the administrator.
576 +
#[tokio::test(flavor = "multi_thread")]
577 +
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page() {
578 +
    let h = hub().await;
579 +
    let app = &h.app;
580 +
    let andy = register(app, "andy", "").await;
581 +
    let page = get(app, "/admin", Some(&andy)).await.body;
582 +
    assert!(page.contains("Mail is off"), "{page}");
583 +
    let csrf = csrf_of(&page);
584 +
    // A directory store for messages, as hub.toml's file:// would be.
585 +
    let outbox = tempfile::tempdir().unwrap();
586 +
    let r = post(
587 +
        app,
588 +
        "/admin/mail",
589 +
        Some(&andy),
590 +
        &format!("_csrf={csrf}&smtp=file%3A%2F%2F{}&from=Hub+%3Chub%40example.org%3E", outbox.path().display()),
591 +
    )
592 +
    .await;
593 +
    assert!(r.body.contains("Mail settings saved") && r.body.contains("Mail is on"), "{}", r.body);
594 +
    assert!(oxygenhub::mail::enabled(app));
595 +
    let r = post(app, "/admin/mail/test", Some(&andy), &format!("_csrf={csrf}")).await;
596 +
    assert!(r.body.contains("Sent a test message to"), "{}", r.body);
597 +
    let sent: Vec<_> = std::fs::read_dir(outbox.path()).unwrap().flatten().collect();
598 +
    assert_eq!(sent.len(), 1);
599 +
    let text = std::fs::read_to_string(sent[0].path()).unwrap();
600 +
    assert!(text.contains("Subject: [") && text.contains("Test message") && text.contains("From: Hub <[email protected]>"), "{text}");
601 +
    // An SMTP URL comes back masked, and a masked value left alone keeps the password.
602 +
    let r = post(
603 +
        app,
604 +
        "/admin/mail",
605 +
        Some(&andy),
606 +
        &format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"),
607 +
    )
608 +
    .await;
609 +
    assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("secret"), "{}", r.body);
610 +
    let r = post(
611 +
        app,
612 +
        "/admin/mail",
613 +
        Some(&andy),
614 +
        &format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"),
615 +
    )
616 +
    .await;
617 +
    assert!(r.body.contains("Mail settings saved"), "{}", r.body);
618 +
    assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://hub:[email protected]:465");
619 +
    // A bad URL is refused; an empty one turns mail off.
620 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&smtp=mail.example.org&from=x")).await;
621 +
    assert!(r.body.contains("starts with smtp://"), "{}", r.body);
622 +
    let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&smtp=&from=")).await;
623 +
    assert!(r.body.contains("Mail is off"), "{}", r.body);
624 +
    assert!(!oxygenhub::mail::enabled(app));
625 +
    // Not for everyone.
626 +
    let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await;
627 +
    assert_ne!(r.status, StatusCode::OK);
628 +
}
modified docs/spec.md +2 −1
…
169 unchanged lines
170 170
- **Reset by email.** `/login/reset` takes a username or a verified email. When it matches an active account with a verified email and the hub can send mail, a link valid for one hour goes to that address, at most one every two minutes per account. The page answers the same way whether or not anything matched, so it cannot be used to find accounts.
171 171
- **Reset by an administrator.** When the hub cannot send mail, or the account has no verified email, an administrator issues a reset link from the admin page (valid 24 hours), shown once to hand over by other means. The audit log records who issued it.
172 172
- **Using a reset link** sets a new password (the same rules as registration), rotates the session salt so every existing session ends, voids the account's other reset links, and records `password.reset` in the audit log. It does not turn off TOTP: a reset account with two-factor sign-in still needs its code. A banned account cannot be reset.
173 -
- **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it.
173 +
- **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. Administration has a Mail form (#101) whose SMTP URL and From address, once saved, override `[email]`; the password is shown masked, an empty URL turns mail off, and a button sends a test message to the administrator's own address.
174 174
175 175
### 5.2 Organisations and teams
176 176
…
295 unchanged lines
472 472
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
473 473
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
474 474
- **Runners** (Admin → Runners, site administrators).
475 +
  - A runner's page has a Settings form (#98): labels an administrator gives it beside those it reports (a job asking for a label matches either), and a job time limit in minutes below the site's, applied when it claims.
475 476
  - **Registering:**
476 477
    - An administrator creates a one-time registration token, valid for a day and shown once with the command that uses it.
477 478
    - On the runner, `flow runner register https://hub --token …` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`) and gets its own credential (`flowrunner_…`, stored hashed).
…
208 unchanged lines
modified templates/admin.html +11 −0
…
41 unchanged lines
42 42
  <ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>
43 43
  <h3>Organisations</h3>
44 44
  <ul class="plain">{% for o in orgs %}<li><a href="/orgs/{{ o.name }}/settings">{{ o.name }}</a></li>{% endfor %}</ul>
45 +
  <h3>Mail</h3>
46 +
  <form class="card" method="post" action="/admin/mail">
47 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
48 +
    <label>SMTP URL</label>
49 +
    <input type="text" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465">
50 +
    <label>From</label>
51 +
    <input type="text" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;">
52 +
    <button type="submit">SAVE</button>
53 +
  </form>
54 +
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
55 +
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p>
45 56
</aside>
46 57
</div>
47 58
{% endblock %}