//! The page an acknowledgement alert's link lands on. //! //! Two handlers, and the split between them is the whole point. `GET` renders //! what happened and records nothing. `POST` records that a human read it. //! //! Email verification gets away with acting on a bare `GET`, and this //! deliberately does not. The entire value of the row is evidence that a person //! saw the message, and link prefetchers, corporate mail scanners and //! antivirus proxies follow `GET`s without anybody reading anything. A bare //! `GET` here would silently record attention that never happened, which is //! worse than recording nothing: it would stop the reminders on exactly the //! alerts nobody read. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use sqlx::PgPool; use crate::{db, error::Result, templates::AcknowledgeTemplate}; /// Render the alert behind a link token. /// /// An unknown token renders the same generic page as an expired one rather /// than 404ing, so the endpoint does not answer "is this a real token" to /// somebody guessing. Guessing is already infeasible (the token is an HMAC over /// the signing secret), and this costs nothing. #[tracing::instrument(skip_all, name = "email_actions::acknowledge_page")] pub(super) async fn acknowledge_page( State(db): State, Path(token): Path, ) -> Result { let Some(view) = db::acknowledgements::find_by_token(&db, &token).await? else { return Ok(unknown_link()); }; Ok(AcknowledgeTemplate { csrf_token: None, title: view.kind.title().to_string(), detail: paragraphs(&db::acknowledgements::detail_copy(view.kind, &view.details)), token, acknowledged: view.acknowledged, } .into_response()) } /// Record that a human read it. /// /// Idempotent: a second submit renders the acknowledged page rather than an /// error, because a double-submitted form is not a failure. #[tracing::instrument(skip_all, name = "email_actions::acknowledge_handler")] pub(super) async fn acknowledge_handler( State(db): State, Path(token): Path, ) -> Result { let Some(view) = db::acknowledgements::find_by_token(&db, &token).await? else { return Ok(unknown_link()); }; if db::acknowledgements::acknowledge(&db, &token).await? { tracing::info!(kind = %view.kind, "acknowledgement recorded"); } Ok(AcknowledgeTemplate { csrf_token: None, title: view.kind.title().to_string(), detail: paragraphs(&db::acknowledgements::detail_copy(view.kind, &view.details)), token, acknowledged: true, } .into_response()) } /// What a token we do not recognise gets. Deliberately says nothing about /// whether the token was wrong, expired, or never existed. fn unknown_link() -> Response { AcknowledgeTemplate { csrf_token: None, title: "This link is no longer active".to_string(), detail: vec![ "The link you followed does not point at anything we are still waiting on. \ If you were asked to confirm something and this is not it, sign in and \ check your account, or reply to the email." .to_string(), ], token: String::new(), acknowledged: true, } .into_response() } /// Split mail-body copy into paragraphs for the page. /// /// The copy has one source and its native form is an email body, so the blank /// lines are already there. Splitting here keeps the template from parsing /// prose. fn paragraphs(body: &str) -> Vec { body.split("\n\n") .map(|p| p.replace('\n', " ").trim().to_string()) .filter(|p| !p.is_empty()) .collect() } #[cfg(test)] mod tests { use super::paragraphs; #[test] fn splits_on_blank_lines_and_unwraps() { let body = "First line\nwrapped.\n\nSecond paragraph."; assert_eq!( paragraphs(body), vec!["First line wrapped.", "Second paragraph."] ); } #[test] fn drops_empty_paragraphs() { assert!(paragraphs("\n\n\n\n").is_empty()); } }