Skip to main content

max / makenotwork

17.3 KB · 404 lines History Blame Raw
1 //! What kind of mail a send is, and whether the recipient can turn it off.
2 //!
3 //! Before this existed, whether an email respected a preference was decided by
4 //! the *caller*: sixteen scattered `db::lists::may_notify` calls across seven
5 //! route modules, and roughly thirty `send_*` methods with no way to tell which
6 //! of them were meant to be gated. Three things followed, and all three were
7 //! problems:
8 //!
9 //! 1. The gate could be forgotten. Nothing in the send path required a check,
10 //! so a new opt-outable email simply was not one, and no test noticed.
11 //! 2. The non-optional set was not enumerable. Password reset, verification,
12 //! lockout, deletion confirmation and suspension all bypassed the gate by
13 //! not calling it. That is the right behaviour and it was written down
14 //! nowhere; you could not answer "which emails can I not turn off" without
15 //! grepping every route.
16 //! 3. Nobody had been told. Migration 187 seeds seven platform list kinds and
17 //! the settings UI offers them. A user reading that page would reasonably
18 //! conclude the list was exhaustive. It never was.
19 //!
20 //! So the class moves into the send path. Every message declares one, the
21 //! dispatcher evaluates it, and the compiler asks the question that nothing
22 //! used to ask.
23
24 use crate::db::ListKind;
25
26 /// Why a message overrides the recipient's preferences, or which preference it
27 /// answers to.
28 ///
29 /// Deliberately closed. [`OperationalKind::ALL`] is asserted against a
30 /// checked-in expected set by `email::class::tests`, so growing the
31 /// cannot-opt-out category fails the build until someone updates that list on
32 /// purpose.
33 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 pub enum EmailClass {
35 /// Cannot be opted out of. The recipient gets this whatever their settings
36 /// say, and the variant carries the sentence explaining why.
37 Operational(OperationalKind),
38
39 /// Gated on the recipient's platform notification preference. The send path
40 /// itself calls `may_notify` and drops the message if the answer is no, so
41 /// no caller has to remember to.
42 Optional(ListKind),
43
44 /// Sent to an audience already resolved from a mailing-list subscription
45 /// (`db::lists::resolve_audience`), where the subscription *is* the consent
46 /// and every recipient carries a per-list unsubscribe link.
47 ///
48 /// This arm exists because the platform notification preferences and the
49 /// per-project mailing lists are two different consent systems, and
50 /// pretending otherwise would be worse than naming both: running
51 /// `may_notify` over a resolved list audience would gate a project
52 /// subscription on an unrelated account-level toggle, which is not the
53 /// promise the subscribe button makes.
54 ListAudience,
55 }
56
57 /// The closed set of messages a recipient cannot turn off.
58 ///
59 /// Each variant owns its justification copy rather than a code comment, because
60 /// that string is what the notification settings page and the docs render. One
61 /// source, so the prose cannot drift from the behaviour.
62 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63 pub enum OperationalKind {
64 PasswordReset,
65 EmailVerification,
66 LoginLink,
67 AccountLockout,
68 AccountExists,
69 DeletionConfirmation,
70 PolicyWarning,
71 Suspension,
72 AppealDecision,
73 ContentRemoval,
74 ContentRestored,
75 AccountTermination,
76 PlatformShutdown,
77 PurchaseReceipt,
78 SubscriptionBilling,
79 FanPlus,
80 ContentExport,
81 CreatorDeparture,
82 UsageLimit,
83 AcknowledgementRequired,
84 /// Mail to an operator address (support routing, webhook failures, monitor
85 /// alerts). Not addressed to a user account at all, so there is no
86 /// preference to consult; it is named rather than left as an unclassified
87 /// hole in the enum.
88 OperatorAlert,
89 }
90
91 impl OperationalKind {
92 /// Every variant, in the order the settings page and the docs list them.
93 ///
94 /// Adding a variant means adding it here, and the guard test means adding
95 /// it here fails until the expected set is updated too.
96 pub const ALL: &'static [OperationalKind] = &[
97 OperationalKind::PasswordReset,
98 OperationalKind::EmailVerification,
99 OperationalKind::LoginLink,
100 OperationalKind::AccountLockout,
101 OperationalKind::AccountExists,
102 OperationalKind::DeletionConfirmation,
103 OperationalKind::PolicyWarning,
104 OperationalKind::Suspension,
105 OperationalKind::AppealDecision,
106 OperationalKind::ContentRemoval,
107 OperationalKind::ContentRestored,
108 OperationalKind::AccountTermination,
109 OperationalKind::PlatformShutdown,
110 OperationalKind::PurchaseReceipt,
111 OperationalKind::SubscriptionBilling,
112 OperationalKind::FanPlus,
113 OperationalKind::ContentExport,
114 OperationalKind::CreatorDeparture,
115 OperationalKind::UsageLimit,
116 OperationalKind::AcknowledgementRequired,
117 OperationalKind::OperatorAlert,
118 ];
119
120 /// Short label, shown as the row heading on the settings page.
121 pub fn label(self) -> &'static str {
122 match self {
123 Self::PasswordReset => "Password reset",
124 Self::EmailVerification => "Email verification",
125 Self::LoginLink => "Login link",
126 Self::AccountLockout => "Account lockout",
127 Self::AccountExists => "Account already exists",
128 Self::DeletionConfirmation => "Account deletion confirmation",
129 Self::PolicyWarning => "Policy notice",
130 Self::Suspension => "Account suspension",
131 Self::AppealDecision => "Appeal decision",
132 Self::ContentRemoval => "Content removed",
133 Self::ContentRestored => "Content restored",
134 Self::AccountTermination => "Account termination",
135 Self::PlatformShutdown => "Platform shutdown notice",
136 Self::PurchaseReceipt => "Purchase receipt",
137 Self::SubscriptionBilling => "Subscription billing",
138 Self::FanPlus => "Fan+ membership and credits",
139 Self::ContentExport => "Content export",
140 Self::CreatorDeparture => "A creator you bought from is leaving",
141 Self::UsageLimit => "Usage limit warning",
142 Self::AcknowledgementRequired => "Something needs your confirmation",
143 Self::OperatorAlert => "Operator alert",
144 }
145 }
146
147 /// Whether this is mail a user account receives.
148 ///
149 /// `OperatorAlert` is the one that is not: it goes to a Makenotwork
150 /// operations mailbox. It is a variant so the enum covers every send rather
151 /// than leaving a hole, and it is filtered out of the settings page and the
152 /// guide, where listing it would tell users about mail that is not theirs.
153 pub fn user_facing(self) -> bool {
154 !matches!(self, Self::OperatorAlert)
155 }
156
157 /// One sentence saying why this message overrides preference.
158 ///
159 /// User-facing copy, not a comment. Written in the second person and plain
160 /// about the consequence of not receiving it.
161 pub fn justification(self) -> &'static str {
162 match self {
163 Self::PasswordReset => {
164 "You asked to reset your password, and the link is the only way to finish."
165 }
166 Self::EmailVerification => {
167 "Verifying the address is how we know mail to it reaches you."
168 }
169 Self::LoginLink => "You asked for a link to sign in, and this message carries it.",
170 Self::AccountLockout => {
171 "Your account was locked after failed sign-ins, which is worth knowing about \
172 whether or not it was you."
173 }
174 Self::AccountExists => {
175 "Someone tried to sign up with your address. Only you receive this, and it is \
176 how you recover an account you had forgotten."
177 }
178 Self::DeletionConfirmation => {
179 "Deleting an account is permanent, so it is confirmed by a link only the \
180 address owner receives."
181 }
182 Self::PolicyWarning => {
183 "A problem with your account or content, sent before anything is acted on so \
184 you have the chance to address it."
185 }
186 Self::Suspension => {
187 "Your account has been suspended. You cannot appeal a decision you were never \
188 told about."
189 }
190 Self::AppealDecision => "The outcome of an appeal you submitted.",
191 Self::ContentRemoval => {
192 "Something you published is no longer publicly reachable, and you would \
193 otherwise find out by accident."
194 }
195 Self::ContentRestored => "Something of yours that had been removed is back.",
196 Self::AccountTermination => {
197 "Your account is being closed and you have a limited window to export your \
198 data."
199 }
200 Self::PlatformShutdown => {
201 "Makenotwork is shutting down and you have a limited window to take your work \
202 elsewhere. No-lock-in is meaningless if the notice is optional."
203 }
204 Self::PurchaseReceipt => {
205 "A record of money you spent, and for a guest purchase the download link \
206 itself."
207 }
208 Self::SubscriptionBilling => {
209 "A recurring charge starting, renewing, or ending. You are entitled to know \
210 what you are being billed."
211 }
212 Self::FanPlus => {
213 "Your Fan+ membership status and the monthly credit code, which has no other \
214 delivery route."
215 }
216 Self::ContentExport => {
217 "The export you requested is ready, or failed. The download link expires, and \
218 a failed job would otherwise leave you waiting."
219 }
220 Self::CreatorDeparture => {
221 "A creator you bought from has left, and what you purchased stays available \
222 for a limited time. Sent by the platform, not the creator."
223 }
224 Self::UsageLimit => {
225 "You are approaching or have reached a plan limit. Past it, requests are \
226 refused, so a silent limit would read as an outage."
227 }
228 Self::AcknowledgementRequired => {
229 "Something changed that only you can act on, and it repeats until you \
230 confirm you have seen it. Confirming is what stops it."
231 }
232 Self::OperatorAlert => {
233 "Sent to a Makenotwork operations address, not to a user account."
234 }
235 }
236 }
237 }
238
239 /// The cannot-opt-out set as the published guide page.
240 ///
241 /// The docs are static markdown, so the page is a checked-in artifact that this
242 /// function generates and a test asserts against, the same shape `openapi.json`
243 /// uses. Rendering it live would put a handler in front of one page in a tree of
244 /// eighty flat files; hand-maintaining it would put the prose one release away
245 /// from being a lie.
246 pub fn operational_mail_doc() -> String {
247 let mut s = String::from(
248 "# Email you cannot turn off\n\
249 \n\
250 Most of our email is optional. You can turn it off in Dashboard, Account,\n\
251 Notification Preferences, and we will not send it again.\n\
252 \n\
253 The messages below are the exception. Each one carries something you\n\
254 cannot act on if it never arrives: a link only you receive, a record of\n\
255 money that moved, or notice of something happening to your account. They\n\
256 are sent whatever your preferences say, and this page is the complete\n\
257 list.\n\
258 \n\
259 This page is generated from the code that sends the mail, so it cannot\n\
260 drift from what actually happens.\n\
261 \n",
262 );
263 for kind in OperationalKind::ALL.iter().filter(|k| k.user_facing()) {
264 use std::fmt::Write as _;
265 let _ = write!(s, "## {}\n\n{}\n\n", kind.label(), kind.justification());
266 }
267 s.push_str(
268 "## Everything else\n\
269 \n\
270 Sales, tips, followers, new-device sign-ins, issue activity, platform\n\
271 status, release and blog announcements, and onboarding tips are all\n\
272 optional. Announcements from a creator you follow are governed by the\n\
273 mailing list you subscribed to, and every one of them carries a\n\
274 one-click unsubscribe link.\n",
275 );
276 s
277 }
278
279 #[cfg(test)]
280 mod tests {
281 use super::*;
282
283 /// Adding an `OperationalKind` should be a deliberate act.
284 ///
285 /// The cannot-opt-out category is the one that grows quietly: every new
286 /// email feels important to whoever is writing it. This asserts the variant
287 /// list against a checked-in expected set, so growing it fails the build
288 /// until someone edits this list on purpose and a reviewer sees the diff.
289 #[test]
290 fn operational_set_is_closed() {
291 let actual: Vec<&str> = OperationalKind::ALL.iter().map(|k| k.label()).collect();
292 let expected = [
293 "Password reset",
294 "Email verification",
295 "Login link",
296 "Account lockout",
297 "Account already exists",
298 "Account deletion confirmation",
299 "Policy notice",
300 "Account suspension",
301 "Appeal decision",
302 "Content removed",
303 "Content restored",
304 "Account termination",
305 "Platform shutdown notice",
306 "Purchase receipt",
307 "Subscription billing",
308 "Fan+ membership and credits",
309 "Content export",
310 "A creator you bought from is leaving",
311 "Usage limit warning",
312 "Something needs your confirmation",
313 "Operator alert",
314 ];
315 assert_eq!(
316 actual, expected,
317 "the set of emails a user cannot opt out of changed. That is a promise to users, \
318 not an implementation detail: confirm the new one genuinely cannot be optional, \
319 write its justification copy, then update this expected list."
320 );
321 }
322
323 /// Every variant is reachable from `ALL`. A variant added to the enum but
324 /// not to `ALL` would be invisible to the settings page and the docs while
325 /// still being unstoppable mail, which is the exact failure this module
326 /// exists to prevent.
327 #[test]
328 fn all_is_exhaustive() {
329 // Exhaustive match: adding a variant fails to compile here until it is
330 // handled, and the assertion below catches a missing ALL entry.
331 for k in OperationalKind::ALL {
332 let _: &str = match k {
333 OperationalKind::PasswordReset
334 | OperationalKind::EmailVerification
335 | OperationalKind::LoginLink
336 | OperationalKind::AccountLockout
337 | OperationalKind::AccountExists
338 | OperationalKind::DeletionConfirmation
339 | OperationalKind::PolicyWarning
340 | OperationalKind::Suspension
341 | OperationalKind::AppealDecision
342 | OperationalKind::ContentRemoval
343 | OperationalKind::ContentRestored
344 | OperationalKind::AccountTermination
345 | OperationalKind::PlatformShutdown
346 | OperationalKind::PurchaseReceipt
347 | OperationalKind::SubscriptionBilling
348 | OperationalKind::FanPlus
349 | OperationalKind::ContentExport
350 | OperationalKind::CreatorDeparture
351 | OperationalKind::UsageLimit
352 | OperationalKind::AcknowledgementRequired
353 | OperationalKind::OperatorAlert => k.justification(),
354 };
355 }
356 assert_eq!(OperationalKind::ALL.len(), 21);
357 }
358
359 /// The published guide page matches the enum.
360 ///
361 /// Regenerate with `cargo run --bin export-operational-mail-doc` when the
362 /// operational set or its copy changes. Without this the settings page and
363 /// the docs would be two hand-kept lists of the same promise, which is one
364 /// list too many.
365 #[test]
366 fn committed_doc_matches_generated() {
367 const DOC: &str = concat!(
368 env!("CARGO_MANIFEST_DIR"),
369 "/site-docs/public/guide/email-you-cannot-turn-off.md"
370 );
371 let committed = std::fs::read_to_string(DOC).expect("the guide page is committed");
372 assert_eq!(
373 committed,
374 operational_mail_doc(),
375 "site-docs/public/guide/email-you-cannot-turn-off.md is stale. Regenerate it with \
376 `cargo run --bin export-operational-mail-doc`."
377 );
378 }
379
380 /// Justification copy is user-facing, so it is held to the house rules: a
381 /// real sentence, no em dashes, no double spaces.
382 #[test]
383 fn justifications_are_sentences() {
384 for k in OperationalKind::ALL {
385 let j = k.justification();
386 assert!(
387 j.ends_with('.'),
388 "{}: justification should be a sentence",
389 k.label()
390 );
391 assert!(
392 !j.contains('\u{2014}') && !j.contains(" -- "),
393 "{}: no connective dashes in user-facing copy",
394 k.label()
395 );
396 assert!(
397 !j.contains(" "),
398 "{}: double space in justification",
399 k.label()
400 );
401 }
402 }
403 }
404