//! Page: outgoing mail (`/admin/mail`). Instance admins only; anyone else gets 404. //! //! The form stores the SMTP server, sender and password (the password is write-only: a blank field //! keeps the stored one). "Send test mail" queues one message to the admin's own address. The //! list shows the newest 50 outbox rows with their status and last error, never the body. //! Saving the settings and queuing a test are audited. use forge_core::mail::{MailSettings, MailSettingsUpdate, OutboxEntry, Security}; use forge_core::tz::format_in_tz; use forge_core::{AuditAction, Error}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, see_other}; use topcoat::router::response::Response; use topcoat::router::{page, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::check_csrf; use crate::i18n::admin::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::mail::{enqueue, templates}; use crate::pages::admin_users::admin_only; use crate::pages::issues_list::stamp_unix; use crate::state::AppState; use crate::views::{enc, shell}; /// Rows shown in the outbox list. const LIST_LIMIT: u32 = 50; #[query_params(error = bad_request)] pub(crate) struct NoticeQuery { notice: Option, } /// One outbox row, already formatted. There is no body field on purpose. struct MailRow { to: String, subject: String, status: &'static str, attempts: u32, error: String, created: String, } /// Everything the page shows. The password is never in here. struct PageData { host: String, port: String, security: Security, username: String, from_addr: String, from_name: String, enabled: bool, has_password: bool, rows: Vec, } fn row_view(entry: &OutboxEntry, tz: &str, lang: Lang) -> MailRow { MailRow { to: entry.to_addr.clone(), subject: entry.subject.clone(), status: g::outbox_status(lang, entry.status()), attempts: entry.attempts, error: entry.last_error.clone().unwrap_or_default(), created: format_in_tz(stamp_unix(&entry.created_at), tz), } } async fn page_data(state: &AppState, tz: &str, lang: Lang) -> forge_core::Result { let settings: MailSettings = state.store.mail_settings().await?; let rows = state .store .recent_mail(LIST_LIMIT) .await? .iter() .map(|entry| row_view(entry, tz, lang)) .collect(); Ok(PageData { host: settings.host, port: settings.port.to_string(), security: settings.security, username: settings.username, from_addr: settings.from_addr, from_name: settings.from_name, enabled: settings.enabled, has_password: settings.has_password, rows, }) } #[component] async fn mail_view( lang: Lang, csrf: String, data: PageData, notice: Option, ) -> Result { let starttls = data.security == Security::StartTls; let tls = data.security == Security::Tls; let plain = data.security == Security::Plain; let password_hint = if data.has_password { g::t(lang, K::PasswordStoredHint) } else { g::t(lang, K::NoPasswordStored) }; Ok(view! {

(g::t(lang, K::Mail))

(g::t(lang, K::MailIntro))

if let Some(message) = notice {

(message.as_str())

}

(g::t(lang, K::SmtpServer))

(password_hint)

(g::t(lang, K::RecentMessages))

if data.rows.is_empty() {

(g::t(lang, K::NothingQueued))

} else {
for row in data.rows.into_iter() { }
(g::t(lang, K::TableQueued)) (g::t(lang, K::TableTo)) (g::t(lang, K::TableSubject)) (g::t(lang, K::TableStatus)) (g::t(lang, K::TableTries)) (g::t(lang, K::TableLastError))
(row.created.as_str()) (row.to.as_str()) (row.subject.as_str()) (row.status) (row.attempts) (row.error.as_str())
}
}) } #[page("/admin/mail")] async fn mail_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let query = query_params::(cx)?; let state = app_context::(cx); let lang = i18n::lang(cx, Some(&viewer.0)); let data = page_data(state, viewer.0.timezone.as_str(), lang).await?; let csrf = viewer.1.csrf_token.clone(); let title = g::t(lang, K::Mail); Ok(view! { shell(title: title, viewer: Some(&viewer), mail_view(lang: lang, csrf: csrf, data: data, notice: query.notice.clone())) }) } #[derive(Deserialize, Default)] struct SettingsForm { #[serde(default)] csrf: String, #[serde(default)] host: String, #[serde(default)] port: String, #[serde(default)] security: String, #[serde(default)] username: String, #[serde(default)] password: String, #[serde(default)] from_addr: String, #[serde(default)] from_name: String, #[serde(default)] enabled: String, } #[route(POST "/admin/mail")] async fn mail_save(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let notice = match save_settings(cx, state, &viewer.0, &form, lang).await { Ok(()) => g::t(lang, K::MailSettingsSaved).to_owned(), Err(Error::Invalid(message)) => message, Err(other) => other.to_string(), }; let location = format!("/admin/mail?notice={}", enc(¬ice)); Err(see_other(&location).into()) } /// Validates and stores the settings, wakes the worker, and audits the change. The password is /// not part of the audit entry. async fn save_settings( cx: &Cx, state: &AppState, user: &forge_core::User, form: &SettingsForm, lang: Lang, ) -> forge_core::Result<()> { let port: u16 = form .port .trim() .parse() .map_err(|_| Error::Invalid(g::t(lang, K::ErrPortRange).to_owned()))?; let security = Security::parse(form.security.trim()) .ok_or_else(|| Error::Invalid(g::t(lang, K::ErrChooseSecurity).to_owned()))?; let password = (!form.password.is_empty()).then_some(form.password.as_str()); let update = MailSettingsUpdate { host: form.host.trim(), port, security, username: form.username.trim(), from_addr: form.from_addr.trim(), from_name: form.from_name.trim(), enabled: form.enabled == "on", password, }; state.store.update_mail_settings(&update).await?; audit::record( &state.store, Some(cx), Actor::User(user), AuditAction::MailSettingsUpdated, Target { kind: "mail", id: "", name: "", }, serde_json::json!({ "host": update.host, "port": update.port, "security": update.security.as_str(), "enabled": update.enabled, "password_changed": password.is_some(), }), ) .await; state.mail_waker.notify_one(); Ok(()) } #[derive(Deserialize, Default)] struct TestForm { #[serde(default)] csrf: String, } #[route(POST "/admin/mail/test")] async fn mail_test(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let notice = match queue_test(cx, state, &viewer.0, lang).await { Ok(()) => g::t(lang, K::TestQueued).to_owned(), Err(Error::Invalid(message)) => message, Err(other) => other.to_string(), }; let location = format!("/admin/mail?notice={}", enc(¬ice)); Err(see_other(&location).into()) } /// Queues a test message to the admin's own address. Refused while mail is off or when the admin /// has no address on the account. async fn queue_test( cx: &Cx, state: &AppState, user: &forge_core::User, lang: Lang, ) -> forge_core::Result<()> { if !state.store.mail_settings().await?.enabled { return Err(Error::Invalid(g::t(lang, K::ErrTestNeedsMailOn).to_owned())); } let Some(address) = user.email.as_deref() else { return Err(Error::Invalid( g::t(lang, K::ErrNoAddressForTest).to_owned(), )); }; let (subject, body) = templates::test_mail(); let key = format!("test:{}", ulid::Ulid::generate()); enqueue(state, &key, address, &subject, &body).await?; audit::record( &state.store, Some(cx), Actor::User(user), AuditAction::MailTestQueued, Target { kind: "mail", id: "", name: "", }, serde_json::json!({}), ) .await; Ok(()) } #[cfg(test)] mod tests { use super::*; use forge_core::Store; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const ADMIN_TOKEN: &str = "mail-admin-session"; const USER_TOKEN: &str = "mail-user-session"; const CSRF: &str = "mail-csrf"; struct Fixture { _dir: tempfile::TempDir, router: Router, state: AppState, } /// An instance with an admin (who has an address) and a plain user. async fn fixture() -> Fixture { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().join("data"); std::fs::create_dir_all(&data_dir).expect("data dir"); let state = test_state(&data_dir).await; let admin = state .store .create_user("root", Some("admin@example.com"), "h", true) .await .expect("admin"); let user = state .store .create_user("plain", None, "h", false) .await .expect("user"); create_session(&state.store, &admin.id, ADMIN_TOKEN, CSRF).await; create_session(&state.store, &user.id, USER_TOKEN, CSRF).await; let router = app_router(state.clone()); Fixture { _dir: dir, router, state, } } async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(builder.body(body).expect("request")).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); (status, String::from_utf8_lossy(&bytes).into_owned()) } fn settings_form(csrf: &str, port: &str, password: &str) -> String { format!( "csrf={csrf}&host=smtp.example&port={port}&security=tls&username=forge\ &password={password}&from_addr=forge%40example.com&from_name=forge&enabled=on" ) } async fn audit_has(store: &Store, action: AuditAction) -> bool { store .list_audit(&Default::default(), None, 50) .await .expect("audit") .iter() .any(|e| e.action == action) } #[tokio::test] async fn the_page_and_its_posts_are_404_for_anonymous_and_non_admins() { let fx = fixture().await; let (anon, _) = send(&fx.router, "GET", "/admin/mail", None, None).await; assert_eq!(anon, 404); let (user, _) = send(&fx.router, "GET", "/admin/mail", Some(USER_TOKEN), None).await; assert_eq!(user, 404); let (posted, _) = send( &fx.router, "POST", "/admin/mail", Some(USER_TOKEN), Some(settings_form(CSRF, "587", "pw")), ) .await; assert_eq!(posted, 404); let (tested, _) = send( &fx.router, "POST", "/admin/mail/test", Some(USER_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(tested, 404); assert!( !fx.state .store .mail_settings() .await .expect("settings") .has_password ); } #[tokio::test] async fn an_admin_saves_settings_and_the_password_is_never_shown() { let fx = fixture().await; let (status, _) = send( &fx.router, "POST", "/admin/mail", Some(ADMIN_TOKEN), Some(settings_form("wrong-csrf", "587", "pw-secret-9")), ) .await; assert_eq!(status, 403, "a missing CSRF token is refused"); let (status, _) = send( &fx.router, "POST", "/admin/mail", Some(ADMIN_TOKEN), Some(settings_form(CSRF, "465", "pw-secret-9")), ) .await; assert_eq!(status, 303); let saved = fx.state.store.mail_settings().await.expect("settings"); assert_eq!(saved.host, "smtp.example"); assert_eq!(saved.port, 465); assert_eq!(saved.security, Security::Tls); assert!(saved.enabled); assert!(saved.has_password); assert!(audit_has(&fx.state.store, AuditAction::MailSettingsUpdated).await); let (page, body) = send(&fx.router, "GET", "/admin/mail", Some(ADMIN_TOKEN), None).await; assert_eq!(page, 200); assert!(body.contains("SMTP server")); assert!( !body.contains("pw-secret-9"), "the stored password is never rendered" ); } #[tokio::test] async fn a_bad_port_changes_nothing() { let fx = fixture().await; let (status, _) = send( &fx.router, "POST", "/admin/mail", Some(ADMIN_TOKEN), Some(settings_form(CSRF, "70000", "pw")), ) .await; assert_eq!(status, 303, "the answer is a redirect with a notice"); let saved = fx.state.store.mail_settings().await.expect("settings"); assert_eq!(saved.host, ""); assert!(!saved.has_password); assert!(!audit_has(&fx.state.store, AuditAction::MailSettingsUpdated).await); } #[tokio::test] async fn a_test_mail_is_queued_only_while_mail_is_on() { let fx = fixture().await; let (status, _) = send( &fx.router, "POST", "/admin/mail/test", Some(ADMIN_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(status, 303); assert!( fx.state .store .recent_mail(10) .await .expect("rows") .is_empty(), "nothing is queued while mail is off" ); send( &fx.router, "POST", "/admin/mail", Some(ADMIN_TOKEN), Some(settings_form(CSRF, "587", "")), ) .await; let (status, _) = send( &fx.router, "POST", "/admin/mail/test", Some(ADMIN_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(status, 303); let rows = fx.state.store.recent_mail(10).await.expect("rows"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].to_addr, "admin@example.com"); assert!(rows[0].idem_key.starts_with("test:")); assert!(audit_has(&fx.state.store, AuditAction::MailTestQueued).await); } #[tokio::test] async fn turkish_viewer_gets_the_turkish_mail_page() { let fx = fixture().await; let req = Request::builder() .method("GET") .uri("/admin/mail") .header("cookie", format!("__Host-{SESSION_COOKIE}={ADMIN_TOKEN}")) .header("accept-language", "tr") .body(Body::empty()) .expect("request"); let res = fx.router.handle(req).await; let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); let body = String::from_utf8_lossy(&bytes).into_owned(); assert!(body.contains("SMTP sunucusu"), "Turkish section heading"); assert!(body.contains("Bağlantı noktası"), "Turkish port label"); assert!(!body.contains("SMTP server"), "no English heading left"); } }