//! Page: email notifications (`/settings/notifications`). One checkbox: whether notifications are //! also sent by email. The page notes when the viewer's address is not verified, or when instance //! mail is off, since then nothing is sent. Every POST checks the CSRF token first. use forge_core::{Session, Store, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::{page, query_params, route}; use topcoat::view::{View, component, view}; use crate::auth::{require_csrf, require_user}; use crate::i18n::{self, Key, Lang, t}; use crate::state::AppState; use crate::views::shell; #[query_params(error = bad_request)] struct NotificationsQuery { saved: Option, } #[derive(Deserialize)] struct NotificationsForm { #[serde(default)] csrf: String, /// A cleared checkbox sends nothing, so an absent field means "off". #[serde(default)] email_notifications: Option, } struct NotificationsPage { lang: Lang, csrf: String, on: bool, /// A note when the address is not verified, `None` when it is. unverified_note: Option<&'static str>, /// A note when instance mail is off, `None` when it is on. server_note: Option<&'static str>, notice: Option<&'static str>, } #[component] async fn notifications_body(page: NotificationsPage) -> Result { let NotificationsPage { lang, csrf, on, unverified_note, server_note, notice, } = page; Ok(view! {

(t(lang, Key::Notifications))

if let Some(message) = notice {

(message)

}
(t(lang, Key::EmailNotifyHelp)) if let Some(message) = unverified_note {

(message)

} if let Some(message) = server_note {

(message)

}
}) } /// Builds the page for a viewer, reading the instance mail switch and the address state. async fn page_for( store: &Store, viewer: &(User, Session), lang: Lang, notice: Option<&'static str>, ) -> forge_core::Result { let server_on = store.mail_settings().await?.enabled; let verified = store.email_is_verified(&viewer.0.id).await?; Ok(NotificationsPage { lang, csrf: viewer.1.csrf_token.clone(), on: viewer.0.email_notifications, unverified_note: (!verified).then(|| t(lang, Key::EmailNotifyUnverified)), server_note: (!server_on).then(|| t(lang, Key::EmailNotifyServerOff)), notice, }) } #[page("/settings/notifications")] async fn notifications_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let store: &Store = &app_context::(cx).store; let lang = i18n::lang(cx, Some(&viewer.0)); let saved = query_params::(cx)?.saved.clone(); let notice = (saved.as_deref() == Some("1")).then(|| t(lang, Key::EmailNotifySaved)); let page = page_for(store, &viewer, lang, notice).await?; Ok(view! { shell(title: t(lang, Key::Notifications), viewer: Some(&viewer), notifications_body(page: page)) }) } #[route(POST "/settings/notifications")] async fn notifications_save(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let store: &Store = &app_context::(cx).store; store .set_email_notifications(&viewer.0.id, form.email_notifications.is_some()) .await?; Ok(see_other("/settings/notifications?saved=1")) } #[cfg(test)] mod tests { 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 TOKEN: &str = "notif-session"; const CSRF: &str = "notif-csrf"; struct Fixture { _dir: tempfile::TempDir, router: Router, store: Store, user_id: String, } 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 user = state .store .create_user("nina", None, "h", false) .await .expect("user"); create_session(&state.store, &user.id, TOKEN, CSRF).await; Fixture { _dir: dir, router: app_router(state.clone()), store: state.store.clone(), user_id: user.id, } } 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()) } async fn flag(fx: &Fixture) -> bool { fx.store .user_by_id(&fx.user_id) .await .expect("lookup") .expect("user") .email_notifications } #[tokio::test] async fn the_page_needs_a_session() { let fx = fixture().await; let (status, body) = send(&fx.router, "GET", "/settings/notifications", None, None).await; assert_ne!(status, 200, "{body}"); assert!(!body.contains("Email me about notifications")); } #[tokio::test] async fn the_page_shows_the_checkbox_and_the_notes() { let fx = fixture().await; let (status, body) = send( &fx.router, "GET", "/settings/notifications", Some(TOKEN), None, ) .await; assert_eq!(status, 200, "{body}"); assert!(body.contains("Email me about notifications"), "{body}"); assert!(body.contains("Verify your email address first"), "{body}"); assert!( body.contains("Email is turned off on this server"), "{body}" ); } #[tokio::test] async fn the_checkbox_toggles_the_flag() { let fx = fixture().await; assert!(flag(&fx).await, "on by default"); let off = format!("csrf={CSRF}"); let (status, _) = send( &fx.router, "POST", "/settings/notifications", Some(TOKEN), Some(off), ) .await; assert_eq!(status, 303); assert!(!flag(&fx).await, "an unchecked box turns it off"); let on = format!("csrf={CSRF}&email_notifications=1"); let (status, _) = send( &fx.router, "POST", "/settings/notifications", Some(TOKEN), Some(on), ) .await; assert_eq!(status, 303); assert!(flag(&fx).await); } #[tokio::test] async fn a_wrong_csrf_token_changes_nothing() { let fx = fixture().await; let (status, _) = send( &fx.router, "POST", "/settings/notifications", Some(TOKEN), Some("csrf=wrong".to_owned()), ) .await; assert_ne!(status, 303); assert!(flag(&fx).await, "still on"); } }