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 | + | ||
| 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(¤t.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 <[email protected]>"> |
|
| 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 %} |