Skip to main content

max / makenotwork

10.8 KB · 298 lines History Blame Raw
1 //! Alerts that repeat until a human says they read them.
2 //!
3 //! Most mail is sent and hoped for. A few messages carry something only the
4 //! recipient can act on, where a message that silently failed to land is
5 //! indistinguishable from one that landed and was ignored, and the difference
6 //! matters. Those get a row here instead of a single send.
7 //!
8 //! The shape is: a caller [`open`]s a row, the scheduler sends it and keeps
9 //! sending it weekly, and following the link and pressing the button
10 //! [`acknowledge`]s it and stops the sending. Nothing sends from [`open`]
11 //! itself, so the first message and the repeats travel the same path and cannot
12 //! drift apart.
13 //!
14 //! **Acknowledgement requires a POST.** The whole value of the row is evidence
15 //! a human read the message, and link prefetchers and corporate mail scanners
16 //! follow GETs. Email verification gets away with a bare GET because a
17 //! prefetcher confirming an address that the address owner did receive is
18 //! harmless; here a prefetcher would record a person's attention that never
19 //! happened, which is worse than recording nothing.
20 //! <!-- wiki: mnw-server-overview -->
21
22 use sqlx::PgPool;
23
24 use super::enums::AckKind;
25 use super::id_types::{PendingAcknowledgementId, UserId};
26 use crate::error::Result;
27
28 /// What the acknowledgement page renders. Deliberately not the whole row: the
29 /// page is reachable without a session, by anyone holding the link, so it says
30 /// what happened and nothing about the account it belongs to.
31 #[derive(Debug, Clone, sqlx::FromRow)]
32 pub struct AcknowledgementView {
33 pub kind: AckKind,
34 pub details: serde_json::Value,
35 pub acknowledged: bool,
36 }
37
38 /// One alert waiting on its recipient.
39 #[derive(Debug, Clone, sqlx::FromRow)]
40 pub struct PendingAcknowledgement {
41 pub id: PendingAcknowledgementId,
42 pub user_id: UserId,
43 pub kind: AckKind,
44 pub dedup_key: String,
45 pub details: serde_json::Value,
46 pub notify_count: i32,
47 pub email: String,
48 pub display_name: Option<String>,
49 }
50
51 impl PendingAcknowledgement {
52 /// This row's link token. Derived, so every send produces the same one and
53 /// the link in an early message still works when a later one arrives.
54 pub fn token(&self, signing_secret: &str) -> String {
55 crate::email::acknowledgement_token(
56 self.user_id,
57 &self.kind.to_string(),
58 &self.dedup_key,
59 signing_secret,
60 )
61 }
62 }
63
64 /// Open an alert, or return the one already open for the same thing.
65 ///
66 /// Reports whether a row was opened, so a caller can log the new case without
67 /// logging every restatement of it. Nothing is sent here: the scheduler owns
68 /// every send, including the first.
69 ///
70 /// Idempotent by `(user_id, kind, dedup_key)` while unacknowledged. A Stripe
71 /// account whose currency flaps between the same two values produces one row,
72 /// not a pile; a genuinely different change has a different `dedup_key` and
73 /// opens its own.
74 #[tracing::instrument(skip_all)]
75 pub async fn open(
76 pool: &PgPool,
77 user_id: UserId,
78 kind: AckKind,
79 dedup_key: &str,
80 details: serde_json::Value,
81 signing_secret: &str,
82 ) -> Result<bool> {
83 let token_hash = crate::email::hash_opaque_token(&crate::email::acknowledgement_token(
84 user_id,
85 &kind.to_string(),
86 dedup_key,
87 signing_secret,
88 ));
89
90 // DO NOTHING rather than DO UPDATE: an open row is already saying this, and
91 // resetting its notify_count would restart a nag that is halfway to
92 // escalation.
93 let inserted = sqlx::query_scalar::<_, PendingAcknowledgementId>(
94 "INSERT INTO pending_acknowledgements (user_id, kind, dedup_key, details, token_hash) \
95 VALUES ($1, $2, $3, $4, $5) \
96 ON CONFLICT (user_id, kind, dedup_key) WHERE acknowledged_at IS NULL \
97 DO NOTHING \
98 RETURNING id",
99 )
100 .bind(user_id)
101 .bind(kind.to_string())
102 .bind(dedup_key)
103 .bind(&details)
104 .bind(&token_hash)
105 .fetch_optional(pool)
106 .await?;
107
108 Ok(inserted.is_some())
109 }
110
111 /// Alerts that should be sent now: never sent, or last sent longer ago than
112 /// `repeat_days`. Escalated rows are excluded, having been handed to a person.
113 ///
114 /// Joined to `users` here rather than by the caller so the send path cannot
115 /// mail a suspended or deleted account, and bounded by `limit` so one pass
116 /// cannot unbox an unbounded set into a mail queue.
117 #[tracing::instrument(skip_all)]
118 pub async fn due(
119 pool: &PgPool,
120 repeat_days: i64,
121 limit: i64,
122 ) -> Result<Vec<PendingAcknowledgement>> {
123 let rows = sqlx::query_as::<_, PendingAcknowledgement>(
124 "SELECT pa.id, pa.user_id, pa.kind, pa.dedup_key, pa.details, pa.notify_count, \
125 u.email, u.display_name \
126 FROM pending_acknowledgements pa \
127 JOIN users u ON u.id = pa.user_id \
128 WHERE pa.acknowledged_at IS NULL \
129 AND pa.escalated_at IS NULL \
130 AND u.suspended_at IS NULL \
131 AND (pa.last_notified_at IS NULL \
132 OR pa.last_notified_at < NOW() - make_interval(days => $1::int)) \
133 ORDER BY pa.last_notified_at ASC NULLS FIRST \
134 LIMIT $2",
135 )
136 .bind(i32::try_from(repeat_days).unwrap_or(7))
137 .bind(limit)
138 .fetch_all(pool)
139 .await?;
140 Ok(rows)
141 }
142
143 /// Record that one more message went out.
144 ///
145 /// Stamped only after a successful send, so a mail provider outage re-sends
146 /// next pass rather than burning a message towards escalation.
147 #[tracing::instrument(skip_all)]
148 pub async fn mark_notified(pool: &PgPool, id: PendingAcknowledgementId) -> Result<()> {
149 sqlx::query(
150 "UPDATE pending_acknowledgements \
151 SET last_notified_at = NOW(), notify_count = notify_count + 1 \
152 WHERE id = $1",
153 )
154 .bind(id)
155 .execute(pool)
156 .await?;
157 Ok(())
158 }
159
160 /// Hand the alert to a person and stop sending it.
161 ///
162 /// Returns whether this call was the one that escalated, so the caller files
163 /// exactly one ticket. Without that, every subsequent pass would file another.
164 #[tracing::instrument(skip_all)]
165 pub async fn escalate(pool: &PgPool, id: PendingAcknowledgementId) -> Result<bool> {
166 let moved = sqlx::query(
167 "UPDATE pending_acknowledgements SET escalated_at = NOW() \
168 WHERE id = $1 AND escalated_at IS NULL AND acknowledged_at IS NULL",
169 )
170 .bind(id)
171 .execute(pool)
172 .await?
173 .rows_affected()
174 > 0;
175 Ok(moved)
176 }
177
178 /// The open alert a link token points at, or `None`.
179 ///
180 /// Acknowledged and escalated rows both still resolve. Someone clicking an old
181 /// link should be told what it was about rather than shown a dead end, and the
182 /// page decides what to say from `acknowledged`.
183 #[tracing::instrument(skip_all)]
184 pub async fn find_by_token(pool: &PgPool, token: &str) -> Result<Option<AcknowledgementView>> {
185 let hash = crate::email::hash_opaque_token(token);
186 let row = sqlx::query_as::<_, AcknowledgementView>(
187 "SELECT pa.kind, pa.details, (pa.acknowledged_at IS NOT NULL) AS acknowledged \
188 FROM pending_acknowledgements pa \
189 WHERE pa.token_hash = $1",
190 )
191 .bind(&hash)
192 .fetch_optional(pool)
193 .await?;
194 Ok(row)
195 }
196
197 /// Record that a human read it. Idempotent: a second POST reports `false` and
198 /// is not an error, because a double-submitted form is not a failure.
199 #[tracing::instrument(skip_all)]
200 pub async fn acknowledge(pool: &PgPool, token: &str) -> Result<bool> {
201 let hash = crate::email::hash_opaque_token(token);
202 let moved = sqlx::query(
203 "UPDATE pending_acknowledgements SET acknowledged_at = NOW() \
204 WHERE token_hash = $1 AND acknowledged_at IS NULL",
205 )
206 .bind(&hash)
207 .execute(pool)
208 .await?
209 .rows_affected()
210 > 0;
211 Ok(moved)
212 }
213
214 /// The body copy for one alert, rendered from its `details`.
215 ///
216 /// One source for the email and for the page the email links to. Rendered
217 /// rather than stored so fixing the wording is an edit and not a migration over
218 /// rows written months ago, and so the two can never say different things about
219 /// the same event.
220 ///
221 /// Paragraphs are separated by a blank line, which is what the mail body wants;
222 /// the page splits on it.
223 pub fn detail_copy(kind: AckKind, details: &serde_json::Value) -> String {
224 match kind {
225 AckKind::SettlementCurrencyChanged => {
226 let from = details["from"].as_str().unwrap_or("its old currency");
227 let to = details["to"].as_str().unwrap_or("a new currency");
228 format!(
229 "Your Stripe account now settles in {to}, where it used to settle in {from}.\n\n\
230 Every price you have already set is stored as a plain number, so those numbers \
231 now mean {to}. A price that was 10 {from} is now 10 {to}. We have not converted \
232 anything and we have not changed any of your prices, because guessing an \
233 exchange rate on your behalf is not ours to do.\n\n\
234 Please check your prices."
235 )
236 }
237 }
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 #[test]
245 fn kind_round_trip() {
246 for k in AckKind::ALL {
247 assert_eq!(k.to_string().parse::<AckKind>().unwrap(), *k);
248 }
249 }
250
251 /// Every `AckKind` is accepted by the database.
252 ///
253 /// Same guard, and the same reason, as
254 /// `db::lists::every_kind_is_allowed_by_the_check_constraint`: a kind lives
255 /// in this enum and in a CHECK constraint, and adding it to one only fails
256 /// at INSERT on a deployed database, a long way from the edit that caused
257 /// it.
258 #[test]
259 fn every_kind_is_allowed_by_the_check_constraint() {
260 const SQL: &str = include_str!("../../migrations/196_pending_acknowledgements.sql");
261 let clause = SQL
262 .split_once("kind TEXT NOT NULL CHECK (kind IN (")
263 .expect("the constraint has the expected shape")
264 .1
265 .split_once("))")
266 .expect("the constraint list is closed")
267 .0;
268 let allowed: Vec<&str> = clause
269 .split(',')
270 .map(|s| s.trim().trim_matches('\'').trim())
271 .filter(|s| !s.is_empty())
272 .collect();
273
274 for kind in AckKind::ALL {
275 let s = kind.to_string();
276 assert!(
277 allowed.contains(&s.as_str()),
278 "AckKind::{kind:?} (\"{s}\") is not in the pending_acknowledgements kind \
279 constraint. Adding a kind takes a migration as well as an enum variant.",
280 );
281 }
282 assert_eq!(allowed.len(), AckKind::ALL.len());
283 }
284
285 /// The title is user-facing copy, so it follows the house rules.
286 #[test]
287 fn titles_are_clean_copy() {
288 for k in AckKind::ALL {
289 let t = k.title();
290 assert!(!t.is_empty());
291 assert!(
292 !t.contains('\u{2014}') && !t.contains(" -- "),
293 "{t}: no connective dashes in user-facing copy"
294 );
295 }
296 }
297 }
298