Skip to main content

max / makenotwork

9.4 KB · 282 lines History Blame Raw
1 //! Who was mailing, when an address bounces or complains.
2 //!
3 //! `93f23f00`. [`super::email_suppressions`] records `(email, reason)` and is
4 //! keyed on the address alone: it says an address complained and cannot say
5 //! what it complained about. So no complaint rate was computable at any
6 //! granularity, and complaint rate is the number a mail provider actually
7 //! judges an account on -- the one that predicts reputation damage before the
8 //! shared Postmark IP pool feels it.
9 //!
10 //! # Both halves, because a rate has two
11 //!
12 //! [`record_send`] writes the denominator and [`record_incident`] the
13 //! numerator. Nothing recorded how much mail a list send put on the wire before
14 //! this: `creator_mail_usage` counts what a creator sent, which answers the
15 //! cap's question and not this one -- it is a running counter per billing
16 //! period, so it cannot be windowed to a fortnight and knows nothing about
17 //! lists.
18 //!
19 //! # The finest grain, and the other two derived
20 //!
21 //! A send, carrying its list and its creator. A coarser grain forecloses the
22 //! other two and saves nothing, because the attribution work is identical either
23 //! way.
24 //!
25 //! Bounces are recorded beside complaints. Bounce rate is the other half of
26 //! what a mail provider judges an account on, and it arrives on the same
27 //! webhook for free.
28
29 use chrono::{DateTime, Utc};
30 use sqlx::PgPool;
31
32 use super::id_types::{EmailSendId, ListId, UserId};
33 use crate::error::Result;
34
35 /// What a send was. Stored as text for [`super::admin_alerts`]' reason:
36 /// extending the set should be a code change and not a migration.
37 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38 pub enum SendKind {
39 /// A creator's broadcast to their followers.
40 Broadcast,
41 /// A release or blog announcement fanned out to a list.
42 Announcement,
43 }
44
45 impl SendKind {
46 /// Canonical storage token.
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Self::Broadcast => "broadcast",
50 Self::Announcement => "announcement",
51 }
52 }
53 }
54
55 /// What happened to one address.
56 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57 pub enum IncidentKind {
58 /// The address does not exist, or refused permanently.
59 HardBounce,
60 /// A reader pressed the spam button.
61 Complaint,
62 }
63
64 impl IncidentKind {
65 /// Canonical storage token.
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::HardBounce => "hard_bounce",
69 Self::Complaint => "complaint",
70 }
71 }
72 }
73
74 /// Complaints and bounces over a window, against what was sent in it.
75 ///
76 /// The two counts rather than a computed fraction: a caller that wants a
77 /// percentage has one division to do and a caller that wants "3 of 4,000" has
78 /// the numbers it needs, whereas a fraction alone hides that a rate of 1.0 can
79 /// be one complaint out of one mail.
80 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81 pub struct Rate {
82 /// Mails put on the wire in the window.
83 pub sent: i64,
84 /// Complaints raised in it.
85 pub complaints: i64,
86 /// Hard bounces raised in it.
87 pub bounces: i64,
88 }
89
90 impl Rate {
91 /// Complaints as a fraction of what was sent.
92 ///
93 /// `0.0` when nothing was sent, which is the honest answer: a rate over an
94 /// empty denominator is not "perfect", it is unmeasured, and the caller
95 /// deciding what to do about that reads [`sent`](Self::sent).
96 #[must_use]
97 pub fn complaint_rate(&self) -> f64 {
98 if self.sent <= 0 {
99 return 0.0;
100 }
101 #[allow(clippy::cast_precision_loss)]
102 {
103 self.complaints as f64 / self.sent as f64
104 }
105 }
106
107 /// Hard bounces as a fraction of what was sent. See
108 /// [`complaint_rate`](Self::complaint_rate).
109 #[must_use]
110 pub fn bounce_rate(&self) -> f64 {
111 if self.sent <= 0 {
112 return 0.0;
113 }
114 #[allow(clippy::cast_precision_loss)]
115 {
116 self.bounces as f64 / self.sent as f64
117 }
118 }
119 }
120
121 /// Record a fan-out, and hand back the id the mail will carry.
122 ///
123 /// Called before the mail goes out, because the id has to ride on it. A send
124 /// row for mail that then fails to leave overstates the denominator by that
125 /// send, which is the safe direction: it dilutes a rate rather than inflating
126 /// one, and a rate that reads low is a rate nobody acts on.
127 #[tracing::instrument(skip_all)]
128 pub async fn record_send(
129 pool: &PgPool,
130 creator_id: UserId,
131 list_id: Option<ListId>,
132 kind: SendKind,
133 recipients: i64,
134 ) -> Result<EmailSendId> {
135 let id = sqlx::query_scalar::<_, EmailSendId>(
136 "INSERT INTO email_sends (creator_id, list_id, kind, recipients) \
137 VALUES ($1, $2, $3, $4) RETURNING id",
138 )
139 .bind(creator_id)
140 .bind(list_id)
141 .bind(kind.as_str())
142 .bind(recipients)
143 .fetch_one(pool)
144 .await?;
145 Ok(id)
146 }
147
148 /// Who an incident landed on. Handed back by [`record_incident`] so a caller
149 /// that has to react to the incident does not re-derive the attribution the
150 /// insert already resolved, and cannot disagree with it.
151 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
152 pub struct Attribution {
153 /// The creator whose send drew the incident.
154 pub creator_id: UserId,
155 /// The list it went to, absent for a send that belonged to no list.
156 pub list_id: Option<ListId>,
157 }
158
159 /// Record one bounce or complaint, attributed to the send that caused it where
160 /// the mail carried one.
161 ///
162 /// The creator and the list are looked up from the send rather than passed,
163 /// so a webhook cannot attribute an incident to a creator the send did not
164 /// belong to. An unknown send attributes nothing and still counts: dropping it
165 /// would flatter the rate, which is the wrong direction for a number that
166 /// exists to warn. That is the `None` return: the row is written either way.
167 #[tracing::instrument(skip_all)]
168 pub async fn record_incident(
169 pool: &PgPool,
170 send_id: Option<EmailSendId>,
171 email: &str,
172 kind: IncidentKind,
173 ) -> Result<Option<Attribution>> {
174 let row = sqlx::query_as::<_, (Option<UserId>, Option<ListId>)>(
175 "INSERT INTO email_incidents (send_id, creator_id, list_id, email, kind) \
176 SELECT s.id, s.creator_id, s.list_id, LOWER($2), $3 \
177 FROM email_sends s WHERE s.id = $1 \
178 UNION ALL \
179 SELECT NULL, NULL, NULL, LOWER($2), $3 \
180 WHERE NOT EXISTS (SELECT 1 FROM email_sends WHERE id = $1) \
181 LIMIT 1 \
182 RETURNING creator_id, list_id",
183 )
184 .bind(send_id)
185 .bind(email)
186 .bind(kind.as_str())
187 .fetch_optional(pool)
188 .await?;
189
190 Ok(row.and_then(|(creator_id, list_id)| {
191 creator_id.map(|creator_id| Attribution {
192 creator_id,
193 list_id,
194 })
195 }))
196 }
197
198 /// What one creator sent in a window, and what came back.
199 #[tracing::instrument(skip_all)]
200 pub async fn creator_rate(pool: &PgPool, creator_id: UserId, since: DateTime<Utc>) -> Result<Rate> {
201 rate_from(pool, "creator_id", creator_id.into(), since).await
202 }
203
204 /// What one list sent in a window, and what came back.
205 #[tracing::instrument(skip_all)]
206 pub async fn list_rate(pool: &PgPool, list_id: ListId, since: DateTime<Utc>) -> Result<Rate> {
207 rate_from(pool, "list_id", list_id.into(), since).await
208 }
209
210 /// The one query both rates are, with the column named by the caller.
211 ///
212 /// The column is a `&'static str` from exactly two call sites above and never
213 /// reaches this from outside the module, so it is a constant in the query
214 /// rather than a bind: Postgres will not take an identifier as a parameter, and
215 /// the alternative is the same query written twice.
216 async fn rate_from(
217 pool: &PgPool,
218 column: &'static str,
219 id: uuid::Uuid,
220 since: DateTime<Utc>,
221 ) -> Result<Rate> {
222 let sent = sqlx::query_scalar::<_, Option<i64>>(&format!(
223 "SELECT SUM(recipients) FROM email_sends WHERE {column} = $1 AND created_at >= $2"
224 ))
225 .bind(id)
226 .bind(since)
227 .fetch_one(pool)
228 .await?
229 .unwrap_or(0);
230
231 let row = sqlx::query_as::<_, (i64, i64)>(&format!(
232 "SELECT \
233 COUNT(*) FILTER (WHERE kind = 'complaint'), \
234 COUNT(*) FILTER (WHERE kind = 'hard_bounce') \
235 FROM email_incidents WHERE {column} = $1 AND created_at >= $2"
236 ))
237 .bind(id)
238 .bind(since)
239 .fetch_one(pool)
240 .await?;
241
242 Ok(Rate {
243 sent,
244 complaints: row.0,
245 bounces: row.1,
246 })
247 }
248
249 #[cfg(test)]
250 mod tests {
251 use super::*;
252
253 #[test]
254 fn a_rate_over_nothing_sent_is_unmeasured_rather_than_perfect() {
255 let nothing = Rate::default();
256 assert!((nothing.complaint_rate() - 0.0).abs() < f64::EPSILON);
257 assert!((nothing.bounce_rate() - 0.0).abs() < f64::EPSILON);
258 assert_eq!(nothing.sent, 0);
259 }
260
261 #[test]
262 fn a_rate_is_the_fraction_of_what_went_out() {
263 let measured = Rate {
264 sent: 4000,
265 complaints: 3,
266 bounces: 12,
267 };
268 assert!((measured.complaint_rate() - 0.00075).abs() < 1e-9);
269 assert!((measured.bounce_rate() - 0.003).abs() < 1e-9);
270 }
271
272 /// The tokens are storage, so a rename that did not reach the migration
273 /// would silently stop matching every row already written.
274 #[test]
275 fn the_stored_tokens_are_the_ones_the_queries_filter_on() {
276 assert_eq!(IncidentKind::Complaint.as_str(), "complaint");
277 assert_eq!(IncidentKind::HardBounce.as_str(), "hard_bounce");
278 assert_eq!(SendKind::Broadcast.as_str(), "broadcast");
279 assert_eq!(SendKind::Announcement.as_str(), "announcement");
280 }
281 }
282