Tahinli/forge
public0 watching
★ 0
| 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 | |
| 8 | use forge_core::mail::{MailSettings, MailSettingsUpdate, OutboxEntry, Security}; |
| 9 | use forge_core::tz::format_in_tz; |
| 10 | use forge_core::{AuditAction, Error}; |
| 11 | use serde::Deserialize; |
| 12 | use topcoat::Result; |
| 13 | use topcoat::context::{Cx, app_context}; |
| 14 | use topcoat::router::content::Form; |
| 15 | use topcoat::router::error::{forbidden, see_other}; |
| 16 | use topcoat::router::response::Response; |
| 17 | use topcoat::router::{page, query_params, route}; |
| 18 | use topcoat::view::{View, component, view}; |
| 19 | |
| 20 | use crate::audit::{self, Actor, Target}; |
| 21 | use crate::auth::check_csrf; |
| 22 | use crate::i18n::admin::{self as g, K}; |
| 23 | use crate::i18n::{self, Key, Lang}; |
| 24 | use crate::mail::{enqueue, templates}; |
| 25 | use crate::pages::admin_users::admin_only; |
| 26 | use crate::pages::issues_list::stamp_unix; |
| 27 | use crate::state::AppState; |
| 28 | use crate::views::{enc, shell}; |
| 29 | |
| 30 | /// Rows shown in the outbox list. |
| 31 | const LIST_LIMIT: u32 = 50; |
| 32 | |
| 33 | #[query_params(error = bad_request)] |
| 34 | pub(crate) struct NoticeQuery { |
| 35 | notice: Option<String>, |
| 36 | } |
| 37 | |
| 38 | /// One outbox row, already formatted. There is no body field on purpose. |
| 39 | struct 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. |
| 49 | struct 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 | |
| 61 | fn 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 | |
| 72 | async 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] |
| 95 | async 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")] |
| 186 | async 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)] |
| 200 | struct 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")] |
| 222 | async 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(¬ice)); |
| 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. |
| 240 | async 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)] |
| 290 | struct TestForm { |
| 291 | #[serde(default)] |
| 292 | csrf: String, |
| 293 | } |
| 294 | |
| 295 | #[route(POST "/admin/mail/test")] |
| 296 | async 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(¬ice)); |
| 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. |
| 314 | async 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)] |
| 348 | mod 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 | } |