//! What kind of mail a send is, and whether the recipient can turn it off. //! //! Before this existed, whether an email respected a preference was decided by //! the *caller*: sixteen scattered `db::lists::may_notify` calls across seven //! route modules, and roughly thirty `send_*` methods with no way to tell which //! of them were meant to be gated. Three things followed, and all three were //! problems: //! //! 1. The gate could be forgotten. Nothing in the send path required a check, //! so a new opt-outable email simply was not one, and no test noticed. //! 2. The non-optional set was not enumerable. Password reset, verification, //! lockout, deletion confirmation and suspension all bypassed the gate by //! not calling it. That is the right behaviour and it was written down //! nowhere; you could not answer "which emails can I not turn off" without //! grepping every route. //! 3. Nobody had been told. Migration 187 seeds seven platform list kinds and //! the settings UI offers them. A user reading that page would reasonably //! conclude the list was exhaustive. It never was. //! //! So the class moves into the send path. Every message declares one, the //! dispatcher evaluates it, and the compiler asks the question that nothing //! used to ask. use crate::db::ListKind; /// Why a message overrides the recipient's preferences, or which preference it /// answers to. /// /// Deliberately closed. [`OperationalKind::ALL`] is asserted against a /// checked-in expected set by `email::class::tests`, so growing the /// cannot-opt-out category fails the build until someone updates that list on /// purpose. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EmailClass { /// Cannot be opted out of. The recipient gets this whatever their settings /// say, and the variant carries the sentence explaining why. Operational(OperationalKind), /// Gated on the recipient's platform notification preference. The send path /// itself calls `may_notify` and drops the message if the answer is no, so /// no caller has to remember to. Optional(ListKind), /// Sent to an audience already resolved from a mailing-list subscription /// (`db::lists::resolve_audience`), where the subscription *is* the consent /// and every recipient carries a per-list unsubscribe link. /// /// This arm exists because the platform notification preferences and the /// per-project mailing lists are two different consent systems, and /// pretending otherwise would be worse than naming both: running /// `may_notify` over a resolved list audience would gate a project /// subscription on an unrelated account-level toggle, which is not the /// promise the subscribe button makes. ListAudience, } /// The closed set of messages a recipient cannot turn off. /// /// Each variant owns its justification copy rather than a code comment, because /// that string is what the notification settings page and the docs render. One /// source, so the prose cannot drift from the behaviour. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperationalKind { PasswordReset, EmailVerification, LoginLink, AccountLockout, AccountExists, DeletionConfirmation, PolicyWarning, Suspension, AppealDecision, ContentRemoval, ContentRestored, AccountTermination, PlatformShutdown, PurchaseReceipt, SubscriptionBilling, FanPlus, ContentExport, CreatorDeparture, UsageLimit, AcknowledgementRequired, /// Mail to an operator address (support routing, webhook failures, monitor /// alerts). Not addressed to a user account at all, so there is no /// preference to consult; it is named rather than left as an unclassified /// hole in the enum. OperatorAlert, } impl OperationalKind { /// Every variant, in the order the settings page and the docs list them. /// /// Adding a variant means adding it here, and the guard test means adding /// it here fails until the expected set is updated too. pub const ALL: &'static [OperationalKind] = &[ OperationalKind::PasswordReset, OperationalKind::EmailVerification, OperationalKind::LoginLink, OperationalKind::AccountLockout, OperationalKind::AccountExists, OperationalKind::DeletionConfirmation, OperationalKind::PolicyWarning, OperationalKind::Suspension, OperationalKind::AppealDecision, OperationalKind::ContentRemoval, OperationalKind::ContentRestored, OperationalKind::AccountTermination, OperationalKind::PlatformShutdown, OperationalKind::PurchaseReceipt, OperationalKind::SubscriptionBilling, OperationalKind::FanPlus, OperationalKind::ContentExport, OperationalKind::CreatorDeparture, OperationalKind::UsageLimit, OperationalKind::AcknowledgementRequired, OperationalKind::OperatorAlert, ]; /// Short label, shown as the row heading on the settings page. pub fn label(self) -> &'static str { match self { Self::PasswordReset => "Password reset", Self::EmailVerification => "Email verification", Self::LoginLink => "Login link", Self::AccountLockout => "Account lockout", Self::AccountExists => "Account already exists", Self::DeletionConfirmation => "Account deletion confirmation", Self::PolicyWarning => "Policy notice", Self::Suspension => "Account suspension", Self::AppealDecision => "Appeal decision", Self::ContentRemoval => "Content removed", Self::ContentRestored => "Content restored", Self::AccountTermination => "Account termination", Self::PlatformShutdown => "Platform shutdown notice", Self::PurchaseReceipt => "Purchase receipt", Self::SubscriptionBilling => "Subscription billing", Self::FanPlus => "Fan+ membership and credits", Self::ContentExport => "Content export", Self::CreatorDeparture => "A creator you bought from is leaving", Self::UsageLimit => "Usage limit warning", Self::AcknowledgementRequired => "Something needs your confirmation", Self::OperatorAlert => "Operator alert", } } /// Whether this is mail a user account receives. /// /// `OperatorAlert` is the one that is not: it goes to a Makenotwork /// operations mailbox. It is a variant so the enum covers every send rather /// than leaving a hole, and it is filtered out of the settings page and the /// guide, where listing it would tell users about mail that is not theirs. pub fn user_facing(self) -> bool { !matches!(self, Self::OperatorAlert) } /// One sentence saying why this message overrides preference. /// /// User-facing copy, not a comment. Written in the second person and plain /// about the consequence of not receiving it. pub fn justification(self) -> &'static str { match self { Self::PasswordReset => { "You asked to reset your password, and the link is the only way to finish." } Self::EmailVerification => { "Verifying the address is how we know mail to it reaches you." } Self::LoginLink => "You asked for a link to sign in, and this message carries it.", Self::AccountLockout => { "Your account was locked after failed sign-ins, which is worth knowing about \ whether or not it was you." } Self::AccountExists => { "Someone tried to sign up with your address. Only you receive this, and it is \ how you recover an account you had forgotten." } Self::DeletionConfirmation => { "Deleting an account is permanent, so it is confirmed by a link only the \ address owner receives." } Self::PolicyWarning => { "A problem with your account or content, sent before anything is acted on so \ you have the chance to address it." } Self::Suspension => { "Your account has been suspended. You cannot appeal a decision you were never \ told about." } Self::AppealDecision => "The outcome of an appeal you submitted.", Self::ContentRemoval => { "Something you published is no longer publicly reachable, and you would \ otherwise find out by accident." } Self::ContentRestored => "Something of yours that had been removed is back.", Self::AccountTermination => { "Your account is being closed and you have a limited window to export your \ data." } Self::PlatformShutdown => { "Makenotwork is shutting down and you have a limited window to take your work \ elsewhere. No-lock-in is meaningless if the notice is optional." } Self::PurchaseReceipt => { "A record of money you spent, and for a guest purchase the download link \ itself." } Self::SubscriptionBilling => { "A recurring charge starting, renewing, or ending. You are entitled to know \ what you are being billed." } Self::FanPlus => { "Your Fan+ membership status and the monthly credit code, which has no other \ delivery route." } Self::ContentExport => { "The export you requested is ready, or failed. The download link expires, and \ a failed job would otherwise leave you waiting." } Self::CreatorDeparture => { "A creator you bought from has left, and what you purchased stays available \ for a limited time. Sent by the platform, not the creator." } Self::UsageLimit => { "You are approaching or have reached a plan limit. Past it, requests are \ refused, so a silent limit would read as an outage." } Self::AcknowledgementRequired => { "Something changed that only you can act on, and it repeats until you \ confirm you have seen it. Confirming is what stops it." } Self::OperatorAlert => { "Sent to a Makenotwork operations address, not to a user account." } } } } /// The cannot-opt-out set as the published guide page. /// /// The docs are static markdown, so the page is a checked-in artifact that this /// function generates and a test asserts against, the same shape `openapi.json` /// uses. Rendering it live would put a handler in front of one page in a tree of /// eighty flat files; hand-maintaining it would put the prose one release away /// from being a lie. pub fn operational_mail_doc() -> String { let mut s = String::from( "# Email you cannot turn off\n\ \n\ Most of our email is optional. You can turn it off in Dashboard, Account,\n\ Notification Preferences, and we will not send it again.\n\ \n\ The messages below are the exception. Each one carries something you\n\ cannot act on if it never arrives: a link only you receive, a record of\n\ money that moved, or notice of something happening to your account. They\n\ are sent whatever your preferences say, and this page is the complete\n\ list.\n\ \n\ This page is generated from the code that sends the mail, so it cannot\n\ drift from what actually happens.\n\ \n", ); for kind in OperationalKind::ALL.iter().filter(|k| k.user_facing()) { use std::fmt::Write as _; let _ = write!(s, "## {}\n\n{}\n\n", kind.label(), kind.justification()); } s.push_str( "## Everything else\n\ \n\ Sales, tips, followers, new-device sign-ins, issue activity, platform\n\ status, release and blog announcements, and onboarding tips are all\n\ optional. Announcements from a creator you follow are governed by the\n\ mailing list you subscribed to, and every one of them carries a\n\ one-click unsubscribe link.\n", ); s } #[cfg(test)] mod tests { use super::*; /// Adding an `OperationalKind` should be a deliberate act. /// /// The cannot-opt-out category is the one that grows quietly: every new /// email feels important to whoever is writing it. This asserts the variant /// list against a checked-in expected set, so growing it fails the build /// until someone edits this list on purpose and a reviewer sees the diff. #[test] fn operational_set_is_closed() { let actual: Vec<&str> = OperationalKind::ALL.iter().map(|k| k.label()).collect(); let expected = [ "Password reset", "Email verification", "Login link", "Account lockout", "Account already exists", "Account deletion confirmation", "Policy notice", "Account suspension", "Appeal decision", "Content removed", "Content restored", "Account termination", "Platform shutdown notice", "Purchase receipt", "Subscription billing", "Fan+ membership and credits", "Content export", "A creator you bought from is leaving", "Usage limit warning", "Something needs your confirmation", "Operator alert", ]; assert_eq!( actual, expected, "the set of emails a user cannot opt out of changed. That is a promise to users, \ not an implementation detail: confirm the new one genuinely cannot be optional, \ write its justification copy, then update this expected list." ); } /// Every variant is reachable from `ALL`. A variant added to the enum but /// not to `ALL` would be invisible to the settings page and the docs while /// still being unstoppable mail, which is the exact failure this module /// exists to prevent. #[test] fn all_is_exhaustive() { // Exhaustive match: adding a variant fails to compile here until it is // handled, and the assertion below catches a missing ALL entry. for k in OperationalKind::ALL { let _: &str = match k { OperationalKind::PasswordReset | OperationalKind::EmailVerification | OperationalKind::LoginLink | OperationalKind::AccountLockout | OperationalKind::AccountExists | OperationalKind::DeletionConfirmation | OperationalKind::PolicyWarning | OperationalKind::Suspension | OperationalKind::AppealDecision | OperationalKind::ContentRemoval | OperationalKind::ContentRestored | OperationalKind::AccountTermination | OperationalKind::PlatformShutdown | OperationalKind::PurchaseReceipt | OperationalKind::SubscriptionBilling | OperationalKind::FanPlus | OperationalKind::ContentExport | OperationalKind::CreatorDeparture | OperationalKind::UsageLimit | OperationalKind::AcknowledgementRequired | OperationalKind::OperatorAlert => k.justification(), }; } assert_eq!(OperationalKind::ALL.len(), 21); } /// The published guide page matches the enum. /// /// Regenerate with `cargo run --bin export-operational-mail-doc` when the /// operational set or its copy changes. Without this the settings page and /// the docs would be two hand-kept lists of the same promise, which is one /// list too many. #[test] fn committed_doc_matches_generated() { const DOC: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/site-docs/public/guide/email-you-cannot-turn-off.md" ); let committed = std::fs::read_to_string(DOC).expect("the guide page is committed"); assert_eq!( committed, operational_mail_doc(), "site-docs/public/guide/email-you-cannot-turn-off.md is stale. Regenerate it with \ `cargo run --bin export-operational-mail-doc`." ); } /// Justification copy is user-facing, so it is held to the house rules: a /// real sentence, no em dashes, no double spaces. #[test] fn justifications_are_sentences() { for k in OperationalKind::ALL { let j = k.justification(); assert!( j.ends_with('.'), "{}: justification should be a sentence", k.label() ); assert!( !j.contains('\u{2014}') && !j.contains(" -- "), "{}: no connective dashes in user-facing copy", k.label() ); assert!( !j.contains(" "), "{}: double space in justification", k.label() ); } } }