forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.1 KiB
1//! Page: outgoing mail (`/admin/mail`). Instance admins only; anyone else gets 404.
2//!
3//! The form stores the SMTP server, sender and password (the password is write-only: a blank field
4//! keeps the stored one). "Send test mail" queues one message to the admin's own address. The
5//! list shows the newest 50 outbox rows with their status and last error, never the body.
6//! Saving the settings and queuing a test are audited.
7
8use forge_core::mail::{MailSettings, MailSettingsUpdate, OutboxEntry, Security};
9use forge_core::tz::format_in_tz;
10use forge_core::{AuditAction, Error};
11use serde::Deserialize;
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::content::Form;
15use topcoat::router::error::{forbidden, see_other};
16use topcoat::router::response::Response;
17use topcoat::router::{page, query_params, route};
18use topcoat::view::{View, component, view};
19
20use crate::audit::{self, Actor, Target};
21use crate::auth::check_csrf;
22use crate::i18n::admin::{self as g, K};
23use crate::i18n::{self, Key, Lang};
24use crate::mail::{enqueue, templates};
25use crate::pages::admin_users::admin_only;
26use crate::pages::issues_list::stamp_unix;
27use crate::state::AppState;
28use crate::views::{enc, shell};
29
30/// Rows shown in the outbox list.
31const LIST_LIMIT: u32 = 50;
32
33#[query_params(error = bad_request)]
34pub(crate) struct NoticeQuery {
35 notice: Option<String>,
36}
37
38/// One outbox row, already formatted. There is no body field on purpose.
39struct MailRow {
40 to: String,
41 subject: String,
42 status: &'static str,
43 attempts: u32,
44 error: String,
45 created: String,
46}
47
48/// Everything the page shows. The password is never in here.
49struct PageData {
50 host: String,
51 port: String,
52 security: Security,
53 username: String,
54 from_addr: String,
55 from_name: String,
56 enabled: bool,
57 has_password: bool,
58 rows: Vec<MailRow>,
59}
60
61fn row_view(entry: &OutboxEntry, tz: &str, lang: Lang) -> MailRow {
62 MailRow {
63 to: entry.to_addr.clone(),
64 subject: entry.subject.clone(),
65 status: g::outbox_status(lang, entry.status()),
66 attempts: entry.attempts,
67 error: entry.last_error.clone().unwrap_or_default(),
68 created: format_in_tz(stamp_unix(&entry.created_at), tz),
69 }
70}
71
72async fn page_data(state: &AppState, tz: &str, lang: Lang) -> forge_core::Result<PageData> {
73 let settings: MailSettings = state.store.mail_settings().await?;
74 let rows = state
75 .store
76 .recent_mail(LIST_LIMIT)
77 .await?
78 .iter()
79 .map(|entry| row_view(entry, tz, lang))
80 .collect();
81 Ok(PageData {
82 host: settings.host,
83 port: settings.port.to_string(),
84 security: settings.security,
85 username: settings.username,
86 from_addr: settings.from_addr,
87 from_name: settings.from_name,
88 enabled: settings.enabled,
89 has_password: settings.has_password,
90 rows,
91 })
92}
93
94#[component]
95async fn mail_view(
96 lang: Lang,
97 csrf: String,
98 data: PageData,
99 notice: Option<String>,
100) -> Result<impl View> {
101 let starttls = data.security == Security::StartTls;
102 let tls = data.security == Security::Tls;
103 let plain = data.security == Security::Plain;
104 let password_hint = if data.has_password {
105 g::t(lang, K::PasswordStoredHint)
106 } else {
107 g::t(lang, K::NoPasswordStored)
108 };
109 Ok(view! {
110 <section class="page-head">
111 <h1>(g::t(lang, K::Mail))</h1>
112 <p class="muted">(g::t(lang, K::MailIntro))</p>
113 </section>
114 if let Some(message) = notice {
115 <section class="card notice" role="status">
116 <p>(message.as_str())</p>
117 </section>
118 }
119 <section class="card">
120 <h2>(g::t(lang, K::SmtpServer))</h2>
121 <form method="post" action="/admin/mail">
122 <input type="hidden" name="csrf" value=(csrf.as_str())>
123 <label>(g::t(lang, K::MailHost)) <input type="text" name="host" value=(data.host.as_str()) maxlength="253" autocomplete="off"></label>
124 <label>(g::t(lang, K::MailPort)) <input type="number" name="port" value=(data.port.as_str()) min="1" max="65535"></label>
125 <label>(i18n::t(lang, Key::Security))
126 <select name="security">
127 <option value="starttls" selected=(starttls)>"STARTTLS"</option>
128 <option value="tls" selected=(tls)>"TLS"</option>
129 <option value="none" selected=(plain)>(g::t(lang, K::SecurityNone))</option>
130 </select>
131 </label>
132 <label>(i18n::t(lang, Key::Username)) <input type="text" name="username" value=(data.username.as_str()) maxlength="256" autocomplete="off"></label>
133 <label>(i18n::t(lang, Key::Password)) <input type="password" name="password" autocomplete="new-password" maxlength="1024"></label>
134 <p class="muted small">(password_hint)</p>
135 <label>(g::t(lang, K::FromAddress)) <input type="email" name="from_addr" value=(data.from_addr.as_str()) maxlength="254"></label>
136 <label>(g::t(lang, K::FromName)) <input type="text" name="from_name" value=(data.from_name.as_str()) maxlength="100"></label>
137 <label><input type="checkbox" name="enabled" value="on" checked=(data.enabled)>(g::t(lang, K::SendMailCheckbox))</label>
138 <div class="actions">
139 <button type="submit" class="primary">(i18n::t(lang, Key::Save))</button>
140 </div>
141 </form>
142 <form method="post" action="/admin/mail/test">
143 <input type="hidden" name="csrf" value=(csrf.as_str())>
144 <div class="actions">
145 <button type="submit">(g::t(lang, K::SendTestMailToMe))</button>
146 </div>
147 </form>
148 </section>
149 <section class="card">
150 <h2>(g::t(lang, K::RecentMessages))</h2>
151 if data.rows.is_empty() {
152 <p class="muted">(g::t(lang, K::NothingQueued))</p>
153 } else {
154 <div class="table-wrap">
155 <table class="table">
156 <thead>
157 <tr>
158 <th>(g::t(lang, K::TableQueued))</th>
159 <th>(g::t(lang, K::TableTo))</th>
160 <th>(g::t(lang, K::TableSubject))</th>
161 <th>(g::t(lang, K::TableStatus))</th>
162 <th>(g::t(lang, K::TableTries))</th>
163 <th>(g::t(lang, K::TableLastError))</th>
164 </tr>
165 </thead>
166 <tbody>
167 for row in data.rows.into_iter() {
168 <tr>
169 <td class="small">(row.created.as_str())</td>
170 <td>(row.to.as_str())</td>
171 <td>(row.subject.as_str())</td>
172 <td>(row.status)</td>
173 <td>(row.attempts)</td>
174 <td class="small">(row.error.as_str())</td>
175 </tr>
176 }
177 </tbody>
178 </table>
179 </div>
180 }
181 </section>
182 })
183}
184
185#[page("/admin/mail")]
186async fn mail_page(cx: &Cx) -> Result<impl View> {
187 let viewer = admin_only(cx).await?;
188 let query = query_params::<NoticeQuery>(cx)?;
189 let state = app_context::<AppState>(cx);
190 let lang = i18n::lang(cx, Some(&viewer.0));
191 let data = page_data(state, viewer.0.timezone.as_str(), lang).await?;
192 let csrf = viewer.1.csrf_token.clone();
193 let title = g::t(lang, K::Mail);
194 Ok(view! {
195 shell(title: title, viewer: Some(&viewer), mail_view(lang: lang, csrf: csrf, data: data, notice: query.notice.clone()))
196 })
197}
198
199#[derive(Deserialize, Default)]
200struct SettingsForm {
201 #[serde(default)]
202 csrf: String,
203 #[serde(default)]
204 host: String,
205 #[serde(default)]
206 port: String,
207 #[serde(default)]
208 security: String,
209 #[serde(default)]
210 username: String,
211 #[serde(default)]
212 password: String,
213 #[serde(default)]
214 from_addr: String,
215 #[serde(default)]
216 from_name: String,
217 #[serde(default)]
218 enabled: String,
219}
220
221#[route(POST "/admin/mail")]
222async fn mail_save(cx: &Cx, Form(form): Form<SettingsForm>) -> Result<Response> {
223 let viewer = admin_only(cx).await?;
224 if !check_csrf(&viewer.1, &form.csrf) {
225 return Err(forbidden().into());
226 }
227 let lang = i18n::lang(cx, Some(&viewer.0));
228 let state = app_context::<AppState>(cx);
229 let notice = match save_settings(cx, state, &viewer.0, &form, lang).await {
230 Ok(()) => g::t(lang, K::MailSettingsSaved).to_owned(),
231 Err(Error::Invalid(message)) => message,
232 Err(other) => other.to_string(),
233 };
234 let location = format!("/admin/mail?notice={}", enc(&notice));
235 Err(see_other(&location).into())
236}
237
238/// Validates and stores the settings, wakes the worker, and audits the change. The password is
239/// not part of the audit entry.
240async fn save_settings(
241 cx: &Cx,
242 state: &AppState,
243 user: &forge_core::User,
244 form: &SettingsForm,
245 lang: Lang,
246) -> forge_core::Result<()> {
247 let port: u16 = form
248 .port
249 .trim()
250 .parse()
251 .map_err(|_| Error::Invalid(g::t(lang, K::ErrPortRange).to_owned()))?;
252 let security = Security::parse(form.security.trim())
253 .ok_or_else(|| Error::Invalid(g::t(lang, K::ErrChooseSecurity).to_owned()))?;
254 let password = (!form.password.is_empty()).then_some(form.password.as_str());
255 let update = MailSettingsUpdate {
256 host: form.host.trim(),
257 port,
258 security,
259 username: form.username.trim(),
260 from_addr: form.from_addr.trim(),
261 from_name: form.from_name.trim(),
262 enabled: form.enabled == "on",
263 password,
264 };
265 state.store.update_mail_settings(&update).await?;
266 audit::record(
267 &state.store,
268 Some(cx),
269 Actor::User(user),
270 AuditAction::MailSettingsUpdated,
271 Target {
272 kind: "mail",
273 id: "",
274 name: "",
275 },
276 serde_json::json!({
277 "host": update.host,
278 "port": update.port,
279 "security": update.security.as_str(),
280 "enabled": update.enabled,
281 "password_changed": password.is_some(),
282 }),
283 )
284 .await;
285 state.mail_waker.notify_one();
286 Ok(())
287}
288
289#[derive(Deserialize, Default)]
290struct TestForm {
291 #[serde(default)]
292 csrf: String,
293}
294
295#[route(POST "/admin/mail/test")]
296async fn mail_test(cx: &Cx, Form(form): Form<TestForm>) -> Result<Response> {
297 let viewer = admin_only(cx).await?;
298 if !check_csrf(&viewer.1, &form.csrf) {
299 return Err(forbidden().into());
300 }
301 let lang = i18n::lang(cx, Some(&viewer.0));
302 let state = app_context::<AppState>(cx);
303 let notice = match queue_test(cx, state, &viewer.0, lang).await {
304 Ok(()) => g::t(lang, K::TestQueued).to_owned(),
305 Err(Error::Invalid(message)) => message,
306 Err(other) => other.to_string(),
307 };
308 let location = format!("/admin/mail?notice={}", enc(&notice));
309 Err(see_other(&location).into())
310}
311
312/// Queues a test message to the admin's own address. Refused while mail is off or when the admin
313/// has no address on the account.
314async fn queue_test(
315 cx: &Cx,
316 state: &AppState,
317 user: &forge_core::User,
318 lang: Lang,
319) -> forge_core::Result<()> {
320 if !state.store.mail_settings().await?.enabled {
321 return Err(Error::Invalid(g::t(lang, K::ErrTestNeedsMailOn).to_owned()));
322 }
323 let Some(address) = user.email.as_deref() else {
324 return Err(Error::Invalid(
325 g::t(lang, K::ErrNoAddressForTest).to_owned(),
326 ));
327 };
328 let (subject, body) = templates::test_mail();
329 let key = format!("test:{}", ulid::Ulid::generate());
330 enqueue(state, &key, address, &subject, &body).await?;
331 audit::record(
332 &state.store,
333 Some(cx),
334 Actor::User(user),
335 AuditAction::MailTestQueued,
336 Target {
337 kind: "mail",
338 id: "",
339 name: "",
340 },
341 serde_json::json!({}),
342 )
343 .await;
344 Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use forge_core::Store;
351 use topcoat::router::request::Request;
352 use topcoat::router::{Body, Router, to_bytes};
353
354 use crate::auth::SESSION_COOKIE;
355 use crate::test_support::{app_router, create_session, test_state};
356
357 const ADMIN_TOKEN: &str = "mail-admin-session";
358 const USER_TOKEN: &str = "mail-user-session";
359 const CSRF: &str = "mail-csrf";
360
361 struct Fixture {
362 _dir: tempfile::TempDir,
363 router: Router,
364 state: AppState,
365 }
366
367 /// An instance with an admin (who has an address) and a plain user.
368 async fn fixture() -> Fixture {
369 let dir = tempfile::tempdir().expect("tempdir");
370 let data_dir = dir.path().join("data");
371 std::fs::create_dir_all(&data_dir).expect("data dir");
372 let state = test_state(&data_dir).await;
373 let admin = state
374 .store
375 .create_user("root", Some("admin@example.com"), "h", true)
376 .await
377 .expect("admin");
378 let user = state
379 .store
380 .create_user("plain", None, "h", false)
381 .await
382 .expect("user");
383 create_session(&state.store, &admin.id, ADMIN_TOKEN, CSRF).await;
384 create_session(&state.store, &user.id, USER_TOKEN, CSRF).await;
385 let router = app_router(state.clone());
386 Fixture {
387 _dir: dir,
388 router,
389 state,
390 }
391 }
392
393 async fn send(
394 router: &Router,
395 method: &str,
396 uri: &str,
397 token: Option<&str>,
398 form: Option<String>,
399 ) -> (u16, String) {
400 let mut builder = Request::builder().method(method).uri(uri);
401 if let Some(token) = token {
402 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
403 }
404 let body = match form {
405 Some(form) => {
406 builder = builder.header("content-type", "application/x-www-form-urlencoded");
407 Body::from(form)
408 }
409 None => Body::empty(),
410 };
411 let res = router.handle(builder.body(body).expect("request")).await;
412 let status = res.status().as_u16();
413 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
414 (status, String::from_utf8_lossy(&bytes).into_owned())
415 }
416
417 fn settings_form(csrf: &str, port: &str, password: &str) -> String {
418 format!(
419 "csrf={csrf}&host=smtp.example&port={port}&security=tls&username=forge\
420 &password={password}&from_addr=forge%40example.com&from_name=forge&enabled=on"
421 )
422 }
423
424 async fn audit_has(store: &Store, action: AuditAction) -> bool {
425 store
426 .list_audit(&Default::default(), None, 50)
427 .await
428 .expect("audit")
429 .iter()
430 .any(|e| e.action == action)
431 }
432
433 #[tokio::test]
434 async fn the_page_and_its_posts_are_404_for_anonymous_and_non_admins() {
435 let fx = fixture().await;
436 let (anon, _) = send(&fx.router, "GET", "/admin/mail", None, None).await;
437 assert_eq!(anon, 404);
438 let (user, _) = send(&fx.router, "GET", "/admin/mail", Some(USER_TOKEN), None).await;
439 assert_eq!(user, 404);
440 let (posted, _) = send(
441 &fx.router,
442 "POST",
443 "/admin/mail",
444 Some(USER_TOKEN),
445 Some(settings_form(CSRF, "587", "pw")),
446 )
447 .await;
448 assert_eq!(posted, 404);
449 let (tested, _) = send(
450 &fx.router,
451 "POST",
452 "/admin/mail/test",
453 Some(USER_TOKEN),
454 Some(format!("csrf={CSRF}")),
455 )
456 .await;
457 assert_eq!(tested, 404);
458 assert!(
459 !fx.state
460 .store
461 .mail_settings()
462 .await
463 .expect("settings")
464 .has_password
465 );
466 }
467
468 #[tokio::test]
469 async fn an_admin_saves_settings_and_the_password_is_never_shown() {
470 let fx = fixture().await;
471 let (status, _) = send(
472 &fx.router,
473 "POST",
474 "/admin/mail",
475 Some(ADMIN_TOKEN),
476 Some(settings_form("wrong-csrf", "587", "pw-secret-9")),
477 )
478 .await;
479 assert_eq!(status, 403, "a missing CSRF token is refused");
480
481 let (status, _) = send(
482 &fx.router,
483 "POST",
484 "/admin/mail",
485 Some(ADMIN_TOKEN),
486 Some(settings_form(CSRF, "465", "pw-secret-9")),
487 )
488 .await;
489 assert_eq!(status, 303);
490 let saved = fx.state.store.mail_settings().await.expect("settings");
491 assert_eq!(saved.host, "smtp.example");
492 assert_eq!(saved.port, 465);
493 assert_eq!(saved.security, Security::Tls);
494 assert!(saved.enabled);
495 assert!(saved.has_password);
496 assert!(audit_has(&fx.state.store, AuditAction::MailSettingsUpdated).await);
497
498 let (page, body) = send(&fx.router, "GET", "/admin/mail", Some(ADMIN_TOKEN), None).await;
499 assert_eq!(page, 200);
500 assert!(body.contains("SMTP server"));
501 assert!(
502 !body.contains("pw-secret-9"),
503 "the stored password is never rendered"
504 );
505 }
506
507 #[tokio::test]
508 async fn a_bad_port_changes_nothing() {
509 let fx = fixture().await;
510 let (status, _) = send(
511 &fx.router,
512 "POST",
513 "/admin/mail",
514 Some(ADMIN_TOKEN),
515 Some(settings_form(CSRF, "70000", "pw")),
516 )
517 .await;
518 assert_eq!(status, 303, "the answer is a redirect with a notice");
519 let saved = fx.state.store.mail_settings().await.expect("settings");
520 assert_eq!(saved.host, "");
521 assert!(!saved.has_password);
522 assert!(!audit_has(&fx.state.store, AuditAction::MailSettingsUpdated).await);
523 }
524
525 #[tokio::test]
526 async fn a_test_mail_is_queued_only_while_mail_is_on() {
527 let fx = fixture().await;
528 let (status, _) = send(
529 &fx.router,
530 "POST",
531 "/admin/mail/test",
532 Some(ADMIN_TOKEN),
533 Some(format!("csrf={CSRF}")),
534 )
535 .await;
536 assert_eq!(status, 303);
537 assert!(
538 fx.state
539 .store
540 .recent_mail(10)
541 .await
542 .expect("rows")
543 .is_empty(),
544 "nothing is queued while mail is off"
545 );
546
547 send(
548 &fx.router,
549 "POST",
550 "/admin/mail",
551 Some(ADMIN_TOKEN),
552 Some(settings_form(CSRF, "587", "")),
553 )
554 .await;
555 let (status, _) = send(
556 &fx.router,
557 "POST",
558 "/admin/mail/test",
559 Some(ADMIN_TOKEN),
560 Some(format!("csrf={CSRF}")),
561 )
562 .await;
563 assert_eq!(status, 303);
564 let rows = fx.state.store.recent_mail(10).await.expect("rows");
565 assert_eq!(rows.len(), 1);
566 assert_eq!(rows[0].to_addr, "admin@example.com");
567 assert!(rows[0].idem_key.starts_with("test:"));
568 assert!(audit_has(&fx.state.store, AuditAction::MailTestQueued).await);
569 }
570
571 #[tokio::test]
572 async fn turkish_viewer_gets_the_turkish_mail_page() {
573 let fx = fixture().await;
574 let req = Request::builder()
575 .method("GET")
576 .uri("/admin/mail")
577 .header("cookie", format!("__Host-{SESSION_COOKIE}={ADMIN_TOKEN}"))
578 .header("accept-language", "tr")
579 .body(Body::empty())
580 .expect("request");
581 let res = fx.router.handle(req).await;
582 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
583 let body = String::from_utf8_lossy(&bytes).into_owned();
584 assert!(body.contains("SMTP sunucusu"), "Turkish section heading");
585 assert!(body.contains("Bağlantı noktası"), "Turkish port label");
586 assert!(!body.contains("SMTP server"), "no English heading left");
587 }
588}