Skip to main content

max / makenotwork

32.3 KB · 896 lines History Blame Raw
1 //! The soft monthly ceiling on how much mail one creator sends.
2 //!
3 //! # What it protects
4 //!
5 //! The shared Postmark IP pool. One creator's fan-out degrades delivery for
6 //! every other creator on it. [`super::lists::resolve_audience`] caps one
7 //! audience at 10,000 and `users.last_broadcast_at` allows one broadcast per 24
8 //! hours, neither of which bounds the monthly count across several projects, and
9 //! the count is the number reputation follows.
10 //!
11 //! # Soft, and the softness is the design
12 //!
13 //! The rule is to protect the commons through soft maximums with
14 //! application-based exceptions. Four things follow from that, and none of them
15 //! is polish:
16 //!
17 //! - The window is the creator's own **billing period**, not a calendar month,
18 //! so the cap is legible next to what they pay for rather than being a second
19 //! calendar to track. A creator with no subscription falls back to the
20 //! calendar month, since they still send.
21 //! - A send that would cross the cap is **refused with a message**, never
22 //! throttled or queued. A silent throttle reads as the platform losing mail,
23 //! which is worse than a refusal, and a partial fan-out leaves half a list
24 //! mailed.
25 //! - The count, the cap and the reset date are **visible before** either is met,
26 //! which is the half that makes the cap acceptable.
27 //! - The number is set where a real creator never meets it, and the
28 //! [application path](create_request) carries the rest. A cap that stops
29 //! legitimate sends is a worse failure than one that lets a marginal send
30 //! through.
31 //!
32 //! # Reserved up front, per send
33 //!
34 //! [`reserve`] takes the whole audience before any mail leaves. Counting each
35 //! mail as it went would be 8,000 writes for one announcement, and it would
36 //! decide the question halfway through a fan-out, which is exactly the
37 //! half-mailed list the refusal exists to avoid.
38 //!
39 //! # The complaint rate sits beside the counter, and never touches the cap
40 //!
41 //! Volume is what the cap bounds and it is the wrong number for reputation.
42 //! What a mail provider judges an account on is the complaint rate, so the
43 //! gauge shows that too, out of the attribution [`super::mail_attribution`]
44 //! records. Warn the creator, notify an operator, leave the cap alone.
45 //!
46 //! Reducing an allowance because a rate crossed a line would be a hard silent
47 //! limit, which the soft-maximum rule forbids, and it would give the cap the
48 //! punitive reading it is built not to have. So this mechanism fails slow on
49 //! purpose: a burst
50 //! between the warning firing and a human looking still goes out, and that is
51 //! accepted. Seeing a rate before Postmark does is the whole value, and what is
52 //! being defended is the shared account every creator's transactional mail runs
53 //! through.
54
55 use std::fmt::Write as _;
56
57 use chrono::{DateTime, Datelike as _, Duration, TimeZone as _, Utc};
58 use sqlx::PgPool;
59
60 use super::mail_attribution::{self, Rate};
61 use crate::db::UserId;
62 use crate::db::id_types::ListId;
63 use crate::error::Result;
64
65 /// The billing month a count belongs to.
66 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
67 pub struct Window {
68 pub start: DateTime<Utc>,
69 pub end: DateTime<Utc>,
70 }
71
72 impl Window {
73 /// The calendar month containing `now`, in UTC.
74 ///
75 /// The fallback for a creator with no subscription period to align to. Also
76 /// the fallback when the stored period does not contain `now`, which is a
77 /// Stripe update that has not landed yet rather than a period that really
78 /// ran out: rolling the stale window forward would count this month's mail
79 /// against last month's row.
80 #[must_use]
81 pub fn calendar_month(now: DateTime<Utc>) -> Self {
82 let start = Utc
83 .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
84 .single()
85 .unwrap_or(now);
86 let (next_year, next_month) = if now.month() == 12 {
87 (now.year() + 1, 1)
88 } else {
89 (now.year(), now.month() + 1)
90 };
91 let end = Utc
92 .with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
93 .single()
94 .unwrap_or(now);
95 Self { start, end }
96 }
97
98 fn contains(&self, at: DateTime<Utc>) -> bool {
99 at >= self.start && at < self.end
100 }
101 }
102
103 /// What one creator has sent this window, against what they may.
104 #[derive(Debug, Clone, Copy)]
105 pub struct Usage {
106 pub sent: i64,
107 pub cap: i64,
108 pub window: Window,
109 }
110
111 impl Usage {
112 /// How much of the allowance is left. Never negative: a cap lowered under a
113 /// creator who has already sent past it reads as nothing left rather than as
114 /// a debt.
115 #[must_use]
116 pub fn remaining(&self) -> i64 {
117 (self.cap - self.sent).max(0)
118 }
119
120 /// Percent of the allowance used, clamped to 100 for the gauge.
121 #[must_use]
122 pub fn percent(&self) -> i32 {
123 if self.cap <= 0 {
124 return 100;
125 }
126 #[expect(
127 clippy::cast_possible_truncation,
128 clippy::cast_precision_loss,
129 reason = "clamped to 0..=100 before the cast"
130 )]
131 let pct = ((self.sent as f64 / self.cap as f64) * 100.0).clamp(0.0, 100.0) as i32;
132 pct
133 }
134
135 /// Whether the creator is inside the warning band and should be told before
136 /// they meet the cap rather than when they hit it.
137 #[must_use]
138 pub fn in_warning_band(&self) -> bool {
139 let warn_at = crate::tier_prices::TierPrices::global().mail_cap_warn_at;
140 #[expect(clippy::cast_precision_loss, reason = "counts, not currency")]
141 let threshold = self.cap as f64 * warn_at;
142 #[expect(clippy::cast_precision_loss, reason = "counts, not currency")]
143 let sent = self.sent as f64;
144 sent >= threshold
145 }
146 }
147
148 /// What [`reserve`] decided.
149 #[derive(Debug, Clone, Copy)]
150 pub enum Verdict {
151 /// The send may go. `usage` already counts it.
152 Admitted { usage: Usage },
153 /// The send may not go, and nothing was reserved. `usage` is the state that
154 /// refused it, so the caller can say how much room is left rather than only
155 /// that there is none.
156 Refused { usage: Usage, requested: i64 },
157 }
158
159 impl Verdict {
160 #[must_use]
161 pub fn is_admitted(&self) -> bool {
162 matches!(self, Self::Admitted { .. })
163 }
164
165 #[must_use]
166 pub fn usage(&self) -> Usage {
167 match *self {
168 Self::Admitted { usage } | Self::Refused { usage, .. } => usage,
169 }
170 }
171
172 /// The sentence a creator is owed when a send is refused. Says what the cap
173 /// is, when it resets and what to do about it, because a refusal that only
174 /// says no is the failure this whole mechanism is trying not to be.
175 #[must_use]
176 pub fn refusal_message(&self) -> Option<String> {
177 let Self::Refused { usage, requested } = self else {
178 return None;
179 };
180 Some(format!(
181 "This send would reach {requested} recipients, and you have {remaining} of your \
182 {cap} monthly emails left. The allowance resets on {reset}. Reply to \
183 info@makenot.work with what you need and why, and we will raise it.",
184 remaining = usage.remaining(),
185 cap = usage.cap,
186 reset = usage.window.end.format("%B %-d, %Y"),
187 ))
188 }
189 }
190
191 /// The window this creator's count belongs to.
192 ///
193 /// Their Stripe billing period when there is one covering now, the calendar
194 /// month otherwise. See [`Window::calendar_month`] for why a stale period does
195 /// not roll forward.
196 #[tracing::instrument(skip_all)]
197 pub async fn window_for(pool: &PgPool, user_id: UserId) -> Result<Window> {
198 let now = Utc::now();
199 let period = sqlx::query_as::<_, (Option<DateTime<Utc>>, Option<DateTime<Utc>>)>(
200 "SELECT current_period_start, current_period_end \
201 FROM creator_subscriptions WHERE user_id = $1",
202 )
203 .bind(user_id)
204 .fetch_optional(pool)
205 .await?;
206
207 if let Some((Some(start), Some(end))) = period {
208 let window = Window { start, end };
209 if window.contains(now) {
210 return Ok(window);
211 }
212 }
213 Ok(Window::calendar_month(now))
214 }
215
216 /// This creator's monthly allowance: their per-account override if an operator
217 /// has granted one, otherwise their tier's default from `assumptions.toml`.
218 ///
219 /// The override is nullable rather than defaulted to the tier number on purpose.
220 /// A defaulted column would freeze today's number onto every row and stop a
221 /// config change from reaching an account that never asked for anything.
222 #[tracing::instrument(skip_all)]
223 pub async fn effective_cap(pool: &PgPool, user_id: UserId) -> Result<i64> {
224 let override_cap = sqlx::query_scalar::<_, Option<i32>>(
225 "SELECT monthly_mail_cap_override FROM users WHERE id = $1",
226 )
227 .bind(user_id)
228 .fetch_optional(pool)
229 .await?
230 .flatten();
231
232 if let Some(cap) = override_cap {
233 return Ok(i64::from(cap));
234 }
235
236 let tier = super::creator_tiers::get_active_creator_tier(pool, user_id).await?;
237 Ok(crate::tier_prices::TierPrices::global().monthly_mail_cap_for(tier))
238 }
239
240 /// What this creator has sent this window, without reserving anything. The read
241 /// behind the dashboard gauge.
242 #[tracing::instrument(skip_all)]
243 pub async fn usage(pool: &PgPool, user_id: UserId) -> Result<Usage> {
244 let window = window_for(pool, user_id).await?;
245 let cap = effective_cap(pool, user_id).await?;
246 let sent = sent_in_window(pool, user_id, window).await?;
247 Ok(Usage { sent, cap, window })
248 }
249
250 async fn sent_in_window(pool: &PgPool, user_id: UserId, window: Window) -> Result<i64> {
251 let sent = sqlx::query_scalar::<_, Option<i64>>(
252 "SELECT sent_count FROM creator_mail_usage WHERE user_id = $1 AND period_start = $2",
253 )
254 .bind(user_id)
255 .bind(window.start)
256 .fetch_optional(pool)
257 .await?
258 .flatten()
259 .unwrap_or(0);
260 Ok(sent)
261 }
262
263 /// Claim `mails` against this creator's allowance, all or nothing.
264 ///
265 /// Atomic: the guard rides on the `ON CONFLICT` update, so two sends racing
266 /// cannot both see room that only one of them has. The single-send-larger-than-
267 /// the-whole-cap case is checked before the statement, because the insert branch
268 /// of an upsert has no `WHERE` to fail and would otherwise admit it.
269 #[tracing::instrument(skip_all, fields(mails))]
270 pub async fn reserve(pool: &PgPool, user_id: UserId, mails: i64) -> Result<Verdict> {
271 let window = window_for(pool, user_id).await?;
272 let cap = effective_cap(pool, user_id).await?;
273
274 // Nothing to reserve, and no reason to make a row for a send with no
275 // recipients.
276 if mails <= 0 {
277 let sent = sent_in_window(pool, user_id, window).await?;
278 return Ok(Verdict::Admitted {
279 usage: Usage { sent, cap, window },
280 });
281 }
282
283 if mails > cap {
284 let sent = sent_in_window(pool, user_id, window).await?;
285 return Ok(Verdict::Refused {
286 usage: Usage { sent, cap, window },
287 requested: mails,
288 });
289 }
290
291 let reserved = sqlx::query_scalar::<_, i64>(
292 r"
293 INSERT INTO creator_mail_usage (user_id, period_start, period_end, sent_count)
294 VALUES ($1, $2, $3, $4)
295 ON CONFLICT (user_id, period_start) DO UPDATE
296 SET sent_count = creator_mail_usage.sent_count + EXCLUDED.sent_count,
297 period_end = EXCLUDED.period_end,
298 updated_at = now()
299 WHERE creator_mail_usage.sent_count + EXCLUDED.sent_count <= $5
300 RETURNING sent_count
301 ",
302 )
303 .bind(user_id)
304 .bind(window.start)
305 .bind(window.end)
306 .bind(mails)
307 .bind(cap)
308 .fetch_optional(pool)
309 .await?;
310
311 match reserved {
312 Some(sent) => Ok(Verdict::Admitted {
313 usage: Usage { sent, cap, window },
314 }),
315 None => {
316 let sent = sent_in_window(pool, user_id, window).await?;
317 Ok(Verdict::Refused {
318 usage: Usage { sent, cap, window },
319 requested: mails,
320 })
321 }
322 }
323 }
324
325 /// Hand back a reservation that never turned into mail.
326 ///
327 /// A caller that reserved and then failed to send owes the allowance back;
328 /// keeping it would charge a creator for a send that did not happen. Saturates
329 /// at zero rather than going negative.
330 #[tracing::instrument(skip_all)]
331 pub async fn release(pool: &PgPool, user_id: UserId, mails: i64) -> Result<()> {
332 if mails <= 0 {
333 return Ok(());
334 }
335 let window = window_for(pool, user_id).await?;
336 sqlx::query(
337 "UPDATE creator_mail_usage \
338 SET sent_count = GREATEST(sent_count - $3, 0), updated_at = now() \
339 WHERE user_id = $1 AND period_start = $2",
340 )
341 .bind(user_id)
342 .bind(window.start)
343 .bind(mails)
344 .execute(pool)
345 .await?;
346 Ok(())
347 }
348
349 // --- The application path ---
350
351 /// One creator's ask for a bigger allowance.
352 #[derive(Debug, Clone, sqlx::FromRow)]
353 pub struct MailCapRequest {
354 pub id: uuid::Uuid,
355 pub user_id: UserId,
356 pub requested_cap: i32,
357 pub reason: String,
358 pub status: String,
359 pub granted_cap: Option<i32>,
360 pub created_at: DateTime<Utc>,
361 pub decided_at: Option<DateTime<Utc>>,
362 }
363
364 /// File an increase request. Returns `false` when one is already open: a second
365 /// ask is an amendment rather than a queue, and two identical pending rows means
366 /// an operator grants one and leaves the other to be granted again later.
367 #[tracing::instrument(skip_all)]
368 pub async fn create_request(
369 pool: &PgPool,
370 user_id: UserId,
371 requested_cap: i32,
372 reason: &str,
373 ) -> Result<bool> {
374 let inserted = sqlx::query(
375 "INSERT INTO mail_cap_requests (user_id, requested_cap, reason) \
376 VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
377 )
378 .bind(user_id)
379 .bind(requested_cap)
380 .bind(reason)
381 .execute(pool)
382 .await?;
383 Ok(inserted.rows_affected() > 0)
384 }
385
386 /// This creator's most recent request, for the dashboard to show them where
387 /// their ask got to.
388 #[tracing::instrument(skip_all)]
389 pub async fn latest_request(pool: &PgPool, user_id: UserId) -> Result<Option<MailCapRequest>> {
390 let row = sqlx::query_as::<_, MailCapRequest>(
391 "SELECT id, user_id, requested_cap, reason, status, granted_cap, created_at, decided_at \
392 FROM mail_cap_requests WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1",
393 )
394 .bind(user_id)
395 .fetch_optional(pool)
396 .await?;
397 Ok(row)
398 }
399
400 /// The operator queue: open requests, oldest first.
401 #[tracing::instrument(skip_all)]
402 pub async fn pending_requests(pool: &PgPool) -> Result<Vec<MailCapRequest>> {
403 let rows = sqlx::query_as::<_, MailCapRequest>(
404 "SELECT id, user_id, requested_cap, reason, status, granted_cap, created_at, decided_at \
405 FROM mail_cap_requests WHERE status = 'pending' ORDER BY created_at LIMIT 200",
406 )
407 .fetch_all(pool)
408 .await?;
409 Ok(rows)
410 }
411
412 /// Grant a request at `granted_cap`, writing the per-account override in the
413 /// same transaction as the decision. The two are one act: a granted request
414 /// whose override never landed is a creator still being refused.
415 #[tracing::instrument(skip_all)]
416 pub async fn grant_request(
417 pool: &PgPool,
418 request_id: uuid::Uuid,
419 granted_cap: i32,
420 decided_by: UserId,
421 ) -> Result<Option<UserId>> {
422 let mut tx = pool.begin().await?;
423
424 let user_id = sqlx::query_scalar::<_, UserId>(
425 "UPDATE mail_cap_requests \
426 SET status = 'granted', granted_cap = $2, decided_at = now(), decided_by = $3 \
427 WHERE id = $1 AND status = 'pending' \
428 RETURNING user_id",
429 )
430 .bind(request_id)
431 .bind(granted_cap)
432 .bind(decided_by)
433 .fetch_optional(&mut *tx)
434 .await?;
435
436 let Some(user_id) = user_id else {
437 tx.rollback().await?;
438 return Ok(None);
439 };
440
441 sqlx::query("UPDATE users SET monthly_mail_cap_override = $2 WHERE id = $1")
442 .bind(user_id)
443 .bind(granted_cap)
444 .execute(&mut *tx)
445 .await?;
446
447 tx.commit().await?;
448 Ok(Some(user_id))
449 }
450
451 /// Deny a request. Leaves the override alone, so the tier default keeps
452 /// applying.
453 #[tracing::instrument(skip_all)]
454 pub async fn deny_request(
455 pool: &PgPool,
456 request_id: uuid::Uuid,
457 decided_by: UserId,
458 ) -> Result<Option<UserId>> {
459 let user_id = sqlx::query_scalar::<_, UserId>(
460 "UPDATE mail_cap_requests \
461 SET status = 'denied', decided_at = now(), decided_by = $2 \
462 WHERE id = $1 AND status = 'pending' \
463 RETURNING user_id",
464 )
465 .bind(request_id)
466 .bind(decided_by)
467 .fetch_optional(pool)
468 .await?;
469 Ok(user_id)
470 }
471
472 /// The tier a cap belongs to, for the dashboard gauge. Same three bands the
473 /// storage gauge uses, read from the same helper so the two cannot drift.
474 #[must_use]
475 pub fn gauge_tier(usage: &Usage) -> &'static str {
476 crate::types::gauge_tier(usage.percent())
477 }
478
479 // --- The complaint rate ---
480
481 /// How far back a complaint rate looks.
482 ///
483 /// Thirty days rather than the billing period the volume counter uses. The two
484 /// windows answer different questions: the counter is an allowance and resets
485 /// with the invoice, while reputation does not, and a rate that reset on the
486 /// first of the period would read as unmeasured to anyone who looked on the
487 /// second. Long enough to hold several sends from an ordinary creator, short
488 /// enough that a list cleaned up last month stops being held against them.
489 pub const COMPLAINT_WINDOW_DAYS: i64 = 30;
490
491 /// Where a complaint rate stops being background noise. 0.1% is the industry
492 /// danger line and roughly where a provider acts at the account level, so it is
493 /// the number to be under rather than a house preference.
494 pub const COMPLAINT_RATE_LIMIT: f64 = 0.001;
495
496 /// What a healthy rate looks like, for the copy beside the number. A bare
497 /// percentage is its own problem: a creator reading "0.3%" has no way to know
498 /// whether that is fine, so the gauge says what normal is on screen.
499 pub const COMPLAINT_RATE_HEALTHY: f64 = 0.0005;
500
501 /// Mail a creator has to have sent in the window before a rate may warn.
502 ///
503 /// A rate over a tiny denominator is noise. A 400-address list needs one
504 /// complaint to read as 0.25%, two and a half times the danger line, and one
505 /// reader pressing the spam button says nothing about how a creator sends. The
506 /// floor is set at the volume where a single complaint can no longer cross the
507 /// line on its own: at 2,000 sends one complaint is 0.05% and it takes two to
508 /// reach 0.1%, so the warning always rests on a repeated signal. A confidence
509 /// interval over three events is machinery that would not change the answer.
510 ///
511 /// Below the floor the rate is still shown, and shown as unmeasured rather than
512 /// as good. Nothing is hidden, nothing fires.
513 pub const COMPLAINT_MIN_SENT: i64 = 2_000;
514
515 /// How long one creator's elevated rate stays quiet after an operator has been
516 /// told. Complaints arrive one webhook at a time, so without this a creator over
517 /// the line mails the operator once per complaint. A day is short enough that a
518 /// worsening rate is heard again while it is still worsening.
519 const COMPLAINT_ALERT_COOLDOWN_HOURS: i64 = 24;
520
521 /// A complaint rate with the context needed to judge it.
522 #[derive(Debug, Clone, Copy)]
523 pub struct ComplaintStanding {
524 /// What went out and what came back, from [`super::mail_attribution`].
525 pub rate: Rate,
526 /// The window it was measured over, in days.
527 pub window_days: i64,
528 }
529
530 impl ComplaintStanding {
531 /// Whether enough mail went out for the rate to mean anything. See
532 /// [`COMPLAINT_MIN_SENT`].
533 #[must_use]
534 pub fn measurable(&self) -> bool {
535 self.rate.sent >= COMPLAINT_MIN_SENT
536 }
537
538 /// Whether the rate is at or above the line a provider acts on, on a
539 /// denominator big enough to believe.
540 #[must_use]
541 pub fn elevated(&self) -> bool {
542 self.measurable() && self.rate.complaint_rate() >= COMPLAINT_RATE_LIMIT
543 }
544
545 /// The rate as a percentage string, e.g. `"0.08%"`.
546 #[must_use]
547 pub fn rate_display(&self) -> String {
548 format_rate(self.rate.complaint_rate())
549 }
550
551 /// What a creator over the line is owed: the number, what it puts at risk,
552 /// what happens next, and who to write to. It names the cap as untouched on
553 /// purpose, because a warning about mail with no such sentence reads as a
554 /// punishment already applied.
555 #[must_use]
556 pub fn warning_message(&self) -> Option<String> {
557 if !self.elevated() {
558 return None;
559 }
560 Some(format!(
561 "Readers marked {complaints} of the {sent} emails you sent in the last {days} days \
562 as spam, a rate of {rate}. At or above {limit} a mail provider can suspend the \
563 account this platform sends through, which would stop transactional mail for every \
564 creator here. Your allowance is untouched and no send is being held. Someone here \
565 has been told and will look. Send only to people who asked for it, and reply to \
566 info@makenot.work so we can work it out with you.",
567 complaints = self.rate.complaints,
568 sent = self.rate.sent,
569 days = self.window_days,
570 rate = self.rate_display(),
571 limit = format_rate(COMPLAINT_RATE_LIMIT),
572 ))
573 }
574 }
575
576 /// A fraction as a percentage, two decimals, which is the resolution the line
577 /// itself is stated at.
578 #[must_use]
579 pub fn format_rate(fraction: f64) -> String {
580 format!("{:.2}%", fraction * 100.0)
581 }
582
583 /// The start of the complaint window.
584 fn complaint_since() -> DateTime<Utc> {
585 Utc::now() - Duration::days(COMPLAINT_WINDOW_DAYS)
586 }
587
588 /// What one creator's sending drew over the window. The read behind the gauge.
589 #[tracing::instrument(skip_all)]
590 pub async fn creator_complaint_standing(
591 pool: &PgPool,
592 creator_id: UserId,
593 ) -> Result<ComplaintStanding> {
594 let rate = mail_attribution::creator_rate(pool, creator_id, complaint_since()).await?;
595 Ok(ComplaintStanding {
596 rate,
597 window_days: COMPLAINT_WINDOW_DAYS,
598 })
599 }
600
601 /// What one list drew over the same window. A creator's rate is the sum of
602 /// their lists and a bad one is usually a single list, so this is what says
603 /// which.
604 #[tracing::instrument(skip_all)]
605 pub async fn list_complaint_standing(pool: &PgPool, list_id: ListId) -> Result<ComplaintStanding> {
606 let rate = mail_attribution::list_rate(pool, list_id, complaint_since()).await?;
607 Ok(ComplaintStanding {
608 rate,
609 window_days: COMPLAINT_WINDOW_DAYS,
610 })
611 }
612
613 /// Tell an operator when a complaint has pushed a creator's rate over the line.
614 ///
615 /// Called from the Postmark webhook after the incident is recorded, because
616 /// that is the moment the rate changes. Reading it off the dashboard instead
617 /// would only ever fire for a creator who went looking, which is the case that
618 /// least needs telling.
619 ///
620 /// Delivery is the path [`crate::security_signals`] already uses: a row in
621 /// `admin_alerts` and mail to `ALERT_EMAIL`. Suppression is the caller's job
622 /// here (migration 169), and it is durable rather than in-process: this
623 /// condition is measured over a month and outlives a restart.
624 ///
625 /// Nothing about the creator's allowance changes. This function only tells
626 /// people.
627 #[tracing::instrument(skip_all)]
628 pub async fn notify_operator_of_complaint_rate(
629 pool: &PgPool,
630 email: &crate::email::EmailClient,
631 attribution: mail_attribution::Attribution,
632 ) -> Result<()> {
633 let creator = creator_complaint_standing(pool, attribution.creator_id).await?;
634 if !creator.elevated() {
635 return Ok(());
636 }
637
638 let dedup_key = format!("mail-complaint-rate:{}", attribution.creator_id);
639 let since = Utc::now() - Duration::hours(COMPLAINT_ALERT_COOLDOWN_HOURS);
640 if super::admin_alerts::alerted_since(pool, &dedup_key, since).await? {
641 return Ok(());
642 }
643
644 let mut body = format!(
645 "Creator {creator_id} is at a {rate} complaint rate: {complaints} complaints against \
646 {sent} emails over the last {days} days. The line is {limit}, which is where Postmark \
647 acts at the account level.\n\nThe cap is untouched and nothing is held. Look at what \
648 they are sending and to whom, and talk to them.\n",
649 creator_id = attribution.creator_id,
650 rate = creator.rate_display(),
651 complaints = creator.rate.complaints,
652 sent = creator.rate.sent,
653 days = creator.window_days,
654 limit = format_rate(COMPLAINT_RATE_LIMIT),
655 );
656
657 if let Some(list_id) = attribution.list_id {
658 let list = list_complaint_standing(pool, list_id).await?;
659 let _ = write!(
660 body,
661 "\nThe complaint came from list {list_id}, which is at {rate} over the same window \
662 ({complaints} of {sent}).\n",
663 rate = list.rate_display(),
664 complaints = list.rate.complaints,
665 sent = list.rate.sent,
666 );
667 }
668
669 let title = format!(
670 "Complaint rate {rate} for creator {creator_id}",
671 rate = creator.rate_display(),
672 creator_id = attribution.creator_id,
673 );
674
675 let id = super::admin_alerts::insert_alert(
676 pool,
677 &super::admin_alerts::NewAlert {
678 source: "mnw",
679 kind: super::admin_alerts::AlertKind::Mail,
680 severity: super::admin_alerts::AlertSeverity::Warning,
681 title: &title,
682 body: &body,
683 dedup_key: Some(&dedup_key),
684 details: None,
685 },
686 )
687 .await?;
688
689 crate::routes::api::internal::alerts::email_alert(
690 pool,
691 email,
692 id,
693 "mnw",
694 super::admin_alerts::AlertKind::Mail,
695 super::admin_alerts::AlertSeverity::Warning,
696 &title,
697 &body,
698 )
699 .await;
700
701 Ok(())
702 }
703
704 #[cfg(test)]
705 mod tests {
706 use super::*;
707 use crate::db::CreatorTier;
708
709 fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
710 Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
711 }
712
713 #[test]
714 fn a_calendar_month_runs_first_to_first() {
715 let w = Window::calendar_month(at(2026, 8, 27));
716 assert_eq!(w.start, Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap());
717 assert_eq!(w.end, Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap());
718 assert!(w.contains(at(2026, 8, 27)));
719 assert!(!w.contains(at(2026, 9, 1)));
720 }
721
722 #[test]
723 fn december_rolls_into_the_next_year() {
724 // The one arithmetic in this module that can be wrong silently: a
725 // December window ending on month 13 would put every December send in
726 // the wrong row.
727 let w = Window::calendar_month(at(2026, 12, 15));
728 assert_eq!(w.end, Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
729 }
730
731 fn usage(sent: i64, cap: i64) -> Usage {
732 Usage {
733 sent,
734 cap,
735 window: Window::calendar_month(at(2026, 8, 27)),
736 }
737 }
738
739 #[test]
740 fn a_lowered_cap_leaves_nothing_rather_than_a_debt() {
741 // An operator can lower an override under a creator who has already
742 // sent past it. Negative remaining would read as owing mail back.
743 let over = usage(9_000, 5_000);
744 assert_eq!(over.remaining(), 0);
745 assert_eq!(over.percent(), 100);
746 }
747
748 #[test]
749 fn the_gauge_reads_the_same_bands_as_storage() {
750 crate::tier_prices::TierPrices::install_test_default();
751 assert_eq!(gauge_tier(&usage(0, 1_000)), "");
752 assert_eq!(gauge_tier(&usage(800, 1_000)), "warn");
753 assert_eq!(gauge_tier(&usage(950, 1_000)), "danger");
754 }
755
756 #[test]
757 fn the_warning_band_arrives_before_the_cap() {
758 crate::tier_prices::TierPrices::install_test_default();
759 let warn_at = crate::tier_prices::TierPrices::global().mail_cap_warn_at;
760 assert!(
761 (0.0..1.0).contains(&warn_at),
762 "a warning band at or above the cap warns nobody: {warn_at}"
763 );
764
765 #[expect(clippy::cast_possible_truncation, reason = "test arithmetic")]
766 let threshold = (1_000.0 * warn_at) as i64;
767 assert!(!usage(threshold - 1, 1_000).in_warning_band());
768 assert!(usage(threshold, 1_000).in_warning_band());
769 assert!(usage(1_000, 1_000).in_warning_band());
770 }
771
772 #[test]
773 fn a_refusal_says_what_to_do_about_it() {
774 crate::tier_prices::TierPrices::install_test_default();
775 let verdict = Verdict::Refused {
776 usage: usage(24_000, 25_000),
777 requested: 4_000,
778 };
779 let message = verdict.refusal_message().expect("a refusal has a message");
780 // The three facts a refused creator needs, and the address that lifts it.
781 assert!(message.contains("4000"), "{message}");
782 assert!(message.contains("1000"), "{message}");
783 assert!(message.contains("September 1, 2026"), "{message}");
784 assert!(message.contains("info@makenot.work"), "{message}");
785
786 assert!(
787 Verdict::Admitted {
788 usage: usage(1, 25_000)
789 }
790 .refusal_message()
791 .is_none()
792 );
793 }
794
795 fn standing(sent: i64, complaints: i64) -> ComplaintStanding {
796 ComplaintStanding {
797 rate: Rate {
798 sent,
799 complaints,
800 bounces: 0,
801 },
802 window_days: COMPLAINT_WINDOW_DAYS,
803 }
804 }
805
806 #[test]
807 fn one_complaint_on_a_small_list_never_warns() {
808 // The false positive this floor exists for. A 400-address list needs one
809 // complaint to read as 0.25%, and one reader pressing the spam button is
810 // not evidence about how a creator sends.
811 let small = standing(400, 1);
812 assert!(small.rate.complaint_rate() > COMPLAINT_RATE_LIMIT);
813 assert!(!small.measurable());
814 assert!(!small.elevated());
815 assert!(small.warning_message().is_none());
816 }
817
818 #[test]
819 fn the_floor_is_where_one_complaint_can_no_longer_cross_the_line() {
820 // The property the number was chosen for, so a later edit to
821 // COMPLAINT_MIN_SENT that loses it fails here rather than in a
822 // creator's inbox.
823 assert!(!standing(COMPLAINT_MIN_SENT, 1).elevated());
824 assert!(standing(COMPLAINT_MIN_SENT, 2).elevated());
825 }
826
827 #[test]
828 fn the_line_is_the_one_a_provider_acts_on() {
829 assert_eq!(format_rate(COMPLAINT_RATE_LIMIT), "0.10%");
830 #[expect(
831 clippy::assertions_on_constants,
832 reason = "both are constants, and an edit that inverts them is the failure"
833 )]
834 {
835 assert!(
836 COMPLAINT_RATE_HEALTHY < COMPLAINT_RATE_LIMIT,
837 "the copy would tell a creator that normal is at or over the danger line"
838 );
839 }
840
841 // At the line, not only past it.
842 assert!(standing(10_000, 10).elevated());
843 assert!(!standing(10_000, 9).elevated());
844 }
845
846 #[test]
847 fn an_elevated_rate_says_what_happens_next_and_who_to_write_to() {
848 let over = standing(10_000, 40);
849 let message = over
850 .warning_message()
851 .expect("an elevated rate has a message");
852 assert!(message.contains("0.40%"), "{message}");
853 assert!(message.contains("0.10%"), "{message}");
854 assert!(message.contains("info@makenot.work"), "{message}");
855 // The cap is not touched, and the copy has to say so: a warning about
856 // mail with no such sentence reads as a punishment already applied.
857 assert!(message.contains("allowance is untouched"), "{message}");
858 }
859
860 #[test]
861 fn an_unmeasured_rate_reads_as_unmeasured_rather_than_good() {
862 let quiet = standing(0, 0);
863 assert!(!quiet.measurable());
864 assert!(!quiet.elevated());
865 assert_eq!(quiet.rate_display(), "0.00%");
866 }
867
868 #[test]
869 fn every_tier_has_an_allowance_a_real_creator_clears() {
870 // The stated bias is against false positives, and the worked example in
871 // the decision is a 2,000-person list mailed weekly. If any tier's
872 // default sits under that, the cap stops legitimate work by default.
873 crate::tier_prices::TierPrices::install_test_default();
874 let prices = crate::tier_prices::TierPrices::global();
875 const WEEKLY_TO_TWO_THOUSAND: i64 = 8_000;
876
877 for tier in [
878 Some(CreatorTier::Basic),
879 Some(CreatorTier::SmallFiles),
880 Some(CreatorTier::BigFiles),
881 Some(CreatorTier::Everything),
882 ] {
883 let cap = prices.monthly_mail_cap_for(tier);
884 assert!(
885 cap > WEEKLY_TO_TWO_THOUSAND,
886 "{tier:?} allows {cap}, under the worked example of {WEEKLY_TO_TWO_THOUSAND}"
887 );
888 }
889
890 // A creator with no subscription gets less, and still gets something.
891 let unsubscribed = prices.monthly_mail_cap_for(None);
892 assert!(unsubscribed > 0);
893 assert!(unsubscribed <= prices.monthly_mail_cap_for(Some(CreatorTier::Basic)));
894 }
895 }
896