Skip to main content

max / makenotwork

20.2 KB · 583 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, and nothing bounded that: [`super::lists::resolve_audience`]
7 //! caps one audience at 10,000 and `users.last_broadcast_at` allows one
8 //! broadcast per 24 hours, so a creator with several projects could mail without
9 //! any bound on the count over a month. The count is the number reputation
10 //! follows.
11 //!
12 //! # Soft, and the softness is the design
13 //!
14 //! Max, 2026-08-27: protect the commons through soft maximums with
15 //! application-based exceptions. Four things follow from that, and none of them
16 //! is polish:
17 //!
18 //! - The window is the creator's own **billing period**, not a calendar month,
19 //! so the cap is legible next to what they pay for rather than being a second
20 //! calendar to track. A creator with no subscription falls back to the
21 //! calendar month, since they still send.
22 //! - A send that would cross the cap is **refused with a message**, never
23 //! throttled. The alternative shapes were a draining queue and a silent
24 //! swallow; a silent throttle reads as the platform losing mail, which is
25 //! worse than a refusal, and a partial fan-out leaves half a list mailed.
26 //! - The count, the cap and the reset date are **visible before** either is met,
27 //! which is the half that makes the cap acceptable.
28 //! - The number is set where a real creator never meets it, and the
29 //! [application path](create_request) carries the rest. A cap that stops
30 //! legitimate sends is a worse failure than one that lets a marginal send
31 //! through.
32 //!
33 //! # Reserved up front, per send
34 //!
35 //! [`reserve`] takes the whole audience before any mail leaves. Counting each
36 //! mail as it went would be 8,000 writes for one announcement, and it would
37 //! decide the question halfway through a fan-out, which is exactly the
38 //! half-mailed list the refusal exists to avoid.
39 //!
40 //! Deliberately out of scope, filed rather than dropped: feeding a list's
41 //! complaint rate back into the cap. It predicts reputation damage better than
42 //! volume does, and it is a second mechanism with its own failure modes.
43
44 use chrono::{DateTime, Datelike as _, TimeZone as _, Utc};
45 use sqlx::PgPool;
46
47 use crate::db::UserId;
48 use crate::error::Result;
49
50 /// The billing month a count belongs to.
51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 pub struct Window {
53 pub start: DateTime<Utc>,
54 pub end: DateTime<Utc>,
55 }
56
57 impl Window {
58 /// The calendar month containing `now`, in UTC.
59 ///
60 /// The fallback for a creator with no subscription period to align to. Also
61 /// the fallback when the stored period does not contain `now`, which is a
62 /// Stripe update that has not landed yet rather than a period that really
63 /// ran out: rolling the stale window forward would count this month's mail
64 /// against last month's row.
65 #[must_use]
66 pub fn calendar_month(now: DateTime<Utc>) -> Self {
67 let start = Utc
68 .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
69 .single()
70 .unwrap_or(now);
71 let (next_year, next_month) = if now.month() == 12 {
72 (now.year() + 1, 1)
73 } else {
74 (now.year(), now.month() + 1)
75 };
76 let end = Utc
77 .with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
78 .single()
79 .unwrap_or(now);
80 Self { start, end }
81 }
82
83 fn contains(&self, at: DateTime<Utc>) -> bool {
84 at >= self.start && at < self.end
85 }
86 }
87
88 /// What one creator has sent this window, against what they may.
89 #[derive(Debug, Clone, Copy)]
90 pub struct Usage {
91 pub sent: i64,
92 pub cap: i64,
93 pub window: Window,
94 }
95
96 impl Usage {
97 /// How much of the allowance is left. Never negative: a cap lowered under a
98 /// creator who has already sent past it reads as nothing left rather than as
99 /// a debt.
100 #[must_use]
101 pub fn remaining(&self) -> i64 {
102 (self.cap - self.sent).max(0)
103 }
104
105 /// Percent of the allowance used, clamped to 100 for the gauge.
106 #[must_use]
107 pub fn percent(&self) -> i32 {
108 if self.cap <= 0 {
109 return 100;
110 }
111 #[expect(
112 clippy::cast_possible_truncation,
113 clippy::cast_precision_loss,
114 reason = "clamped to 0..=100 before the cast"
115 )]
116 let pct = ((self.sent as f64 / self.cap as f64) * 100.0).clamp(0.0, 100.0) as i32;
117 pct
118 }
119
120 /// Whether the creator is inside the warning band and should be told before
121 /// they meet the cap rather than when they hit it.
122 #[must_use]
123 pub fn in_warning_band(&self) -> bool {
124 let warn_at = crate::tier_prices::TierPrices::global().mail_cap_warn_at;
125 #[expect(clippy::cast_precision_loss, reason = "counts, not currency")]
126 let threshold = self.cap as f64 * warn_at;
127 #[expect(clippy::cast_precision_loss, reason = "counts, not currency")]
128 let sent = self.sent as f64;
129 sent >= threshold
130 }
131 }
132
133 /// What [`reserve`] decided.
134 #[derive(Debug, Clone, Copy)]
135 pub enum Verdict {
136 /// The send may go. `usage` already counts it.
137 Admitted { usage: Usage },
138 /// The send may not go, and nothing was reserved. `usage` is the state that
139 /// refused it, so the caller can say how much room is left rather than only
140 /// that there is none.
141 Refused { usage: Usage, requested: i64 },
142 }
143
144 impl Verdict {
145 #[must_use]
146 pub fn is_admitted(&self) -> bool {
147 matches!(self, Self::Admitted { .. })
148 }
149
150 #[must_use]
151 pub fn usage(&self) -> Usage {
152 match *self {
153 Self::Admitted { usage } | Self::Refused { usage, .. } => usage,
154 }
155 }
156
157 /// The sentence a creator is owed when a send is refused. Says what the cap
158 /// is, when it resets and what to do about it, because a refusal that only
159 /// says no is the failure this whole mechanism is trying not to be.
160 #[must_use]
161 pub fn refusal_message(&self) -> Option<String> {
162 let Self::Refused { usage, requested } = self else {
163 return None;
164 };
165 Some(format!(
166 "This send would reach {requested} recipients, and you have {remaining} of your \
167 {cap} monthly emails left. The allowance resets on {reset}. Reply to \
168 info@makenot.work with what you need and why, and we will raise it.",
169 remaining = usage.remaining(),
170 cap = usage.cap,
171 reset = usage.window.end.format("%B %-d, %Y"),
172 ))
173 }
174 }
175
176 /// The window this creator's count belongs to.
177 ///
178 /// Their Stripe billing period when there is one covering now, the calendar
179 /// month otherwise. See [`Window::calendar_month`] for why a stale period does
180 /// not roll forward.
181 #[tracing::instrument(skip_all)]
182 pub async fn window_for(pool: &PgPool, user_id: UserId) -> Result<Window> {
183 let now = Utc::now();
184 let period = sqlx::query_as::<_, (Option<DateTime<Utc>>, Option<DateTime<Utc>>)>(
185 "SELECT current_period_start, current_period_end \
186 FROM creator_subscriptions WHERE user_id = $1",
187 )
188 .bind(user_id)
189 .fetch_optional(pool)
190 .await?;
191
192 if let Some((Some(start), Some(end))) = period {
193 let window = Window { start, end };
194 if window.contains(now) {
195 return Ok(window);
196 }
197 }
198 Ok(Window::calendar_month(now))
199 }
200
201 /// This creator's monthly allowance: their per-account override if an operator
202 /// has granted one, otherwise their tier's default from `assumptions.toml`.
203 ///
204 /// The override is nullable rather than defaulted to the tier number on purpose.
205 /// A defaulted column would freeze today's number onto every row and stop a
206 /// config change from reaching an account that never asked for anything.
207 #[tracing::instrument(skip_all)]
208 pub async fn effective_cap(pool: &PgPool, user_id: UserId) -> Result<i64> {
209 let override_cap = sqlx::query_scalar::<_, Option<i32>>(
210 "SELECT monthly_mail_cap_override FROM users WHERE id = $1",
211 )
212 .bind(user_id)
213 .fetch_optional(pool)
214 .await?
215 .flatten();
216
217 if let Some(cap) = override_cap {
218 return Ok(i64::from(cap));
219 }
220
221 let tier = super::creator_tiers::get_active_creator_tier(pool, user_id).await?;
222 Ok(crate::tier_prices::TierPrices::global().monthly_mail_cap_for(tier))
223 }
224
225 /// What this creator has sent this window, without reserving anything. The read
226 /// behind the dashboard gauge.
227 #[tracing::instrument(skip_all)]
228 pub async fn usage(pool: &PgPool, user_id: UserId) -> Result<Usage> {
229 let window = window_for(pool, user_id).await?;
230 let cap = effective_cap(pool, user_id).await?;
231 let sent = sent_in_window(pool, user_id, window).await?;
232 Ok(Usage { sent, cap, window })
233 }
234
235 async fn sent_in_window(pool: &PgPool, user_id: UserId, window: Window) -> Result<i64> {
236 let sent = sqlx::query_scalar::<_, Option<i64>>(
237 "SELECT sent_count FROM creator_mail_usage WHERE user_id = $1 AND period_start = $2",
238 )
239 .bind(user_id)
240 .bind(window.start)
241 .fetch_optional(pool)
242 .await?
243 .flatten()
244 .unwrap_or(0);
245 Ok(sent)
246 }
247
248 /// Claim `mails` against this creator's allowance, all or nothing.
249 ///
250 /// Atomic: the guard rides on the `ON CONFLICT` update, so two sends racing
251 /// cannot both see room that only one of them has. The single-send-larger-than-
252 /// the-whole-cap case is checked before the statement, because the insert branch
253 /// of an upsert has no `WHERE` to fail and would otherwise admit it.
254 #[tracing::instrument(skip_all, fields(mails))]
255 pub async fn reserve(pool: &PgPool, user_id: UserId, mails: i64) -> Result<Verdict> {
256 let window = window_for(pool, user_id).await?;
257 let cap = effective_cap(pool, user_id).await?;
258
259 // Nothing to reserve, and no reason to make a row for a send with no
260 // recipients.
261 if mails <= 0 {
262 let sent = sent_in_window(pool, user_id, window).await?;
263 return Ok(Verdict::Admitted {
264 usage: Usage { sent, cap, window },
265 });
266 }
267
268 if mails > cap {
269 let sent = sent_in_window(pool, user_id, window).await?;
270 return Ok(Verdict::Refused {
271 usage: Usage { sent, cap, window },
272 requested: mails,
273 });
274 }
275
276 let reserved = sqlx::query_scalar::<_, i64>(
277 r"
278 INSERT INTO creator_mail_usage (user_id, period_start, period_end, sent_count)
279 VALUES ($1, $2, $3, $4)
280 ON CONFLICT (user_id, period_start) DO UPDATE
281 SET sent_count = creator_mail_usage.sent_count + EXCLUDED.sent_count,
282 period_end = EXCLUDED.period_end,
283 updated_at = now()
284 WHERE creator_mail_usage.sent_count + EXCLUDED.sent_count <= $5
285 RETURNING sent_count
286 ",
287 )
288 .bind(user_id)
289 .bind(window.start)
290 .bind(window.end)
291 .bind(mails)
292 .bind(cap)
293 .fetch_optional(pool)
294 .await?;
295
296 match reserved {
297 Some(sent) => Ok(Verdict::Admitted {
298 usage: Usage { sent, cap, window },
299 }),
300 None => {
301 let sent = sent_in_window(pool, user_id, window).await?;
302 Ok(Verdict::Refused {
303 usage: Usage { sent, cap, window },
304 requested: mails,
305 })
306 }
307 }
308 }
309
310 /// Hand back a reservation that never turned into mail.
311 ///
312 /// A caller that reserved and then failed to send owes the allowance back;
313 /// keeping it would charge a creator for a send that did not happen. Saturates
314 /// at zero rather than going negative.
315 #[tracing::instrument(skip_all)]
316 pub async fn release(pool: &PgPool, user_id: UserId, mails: i64) -> Result<()> {
317 if mails <= 0 {
318 return Ok(());
319 }
320 let window = window_for(pool, user_id).await?;
321 sqlx::query(
322 "UPDATE creator_mail_usage \
323 SET sent_count = GREATEST(sent_count - $3, 0), updated_at = now() \
324 WHERE user_id = $1 AND period_start = $2",
325 )
326 .bind(user_id)
327 .bind(window.start)
328 .bind(mails)
329 .execute(pool)
330 .await?;
331 Ok(())
332 }
333
334 // --- The application path ---
335
336 /// One creator's ask for a bigger allowance.
337 #[derive(Debug, Clone, sqlx::FromRow)]
338 pub struct MailCapRequest {
339 pub id: uuid::Uuid,
340 pub user_id: UserId,
341 pub requested_cap: i32,
342 pub reason: String,
343 pub status: String,
344 pub granted_cap: Option<i32>,
345 pub created_at: DateTime<Utc>,
346 pub decided_at: Option<DateTime<Utc>>,
347 }
348
349 /// File an increase request. Returns `false` when one is already open: a second
350 /// ask is an amendment rather than a queue, and two identical pending rows means
351 /// an operator grants one and leaves the other to be granted again later.
352 #[tracing::instrument(skip_all)]
353 pub async fn create_request(
354 pool: &PgPool,
355 user_id: UserId,
356 requested_cap: i32,
357 reason: &str,
358 ) -> Result<bool> {
359 let inserted = sqlx::query(
360 "INSERT INTO mail_cap_requests (user_id, requested_cap, reason) \
361 VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
362 )
363 .bind(user_id)
364 .bind(requested_cap)
365 .bind(reason)
366 .execute(pool)
367 .await?;
368 Ok(inserted.rows_affected() > 0)
369 }
370
371 /// This creator's most recent request, for the dashboard to show them where
372 /// their ask got to.
373 #[tracing::instrument(skip_all)]
374 pub async fn latest_request(pool: &PgPool, user_id: UserId) -> Result<Option<MailCapRequest>> {
375 let row = sqlx::query_as::<_, MailCapRequest>(
376 "SELECT id, user_id, requested_cap, reason, status, granted_cap, created_at, decided_at \
377 FROM mail_cap_requests WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1",
378 )
379 .bind(user_id)
380 .fetch_optional(pool)
381 .await?;
382 Ok(row)
383 }
384
385 /// The operator queue: open requests, oldest first.
386 #[tracing::instrument(skip_all)]
387 pub async fn pending_requests(pool: &PgPool) -> Result<Vec<MailCapRequest>> {
388 let rows = sqlx::query_as::<_, MailCapRequest>(
389 "SELECT id, user_id, requested_cap, reason, status, granted_cap, created_at, decided_at \
390 FROM mail_cap_requests WHERE status = 'pending' ORDER BY created_at LIMIT 200",
391 )
392 .fetch_all(pool)
393 .await?;
394 Ok(rows)
395 }
396
397 /// Grant a request at `granted_cap`, writing the per-account override in the
398 /// same transaction as the decision. The two are one act: a granted request
399 /// whose override never landed is a creator still being refused.
400 #[tracing::instrument(skip_all)]
401 pub async fn grant_request(
402 pool: &PgPool,
403 request_id: uuid::Uuid,
404 granted_cap: i32,
405 decided_by: UserId,
406 ) -> Result<Option<UserId>> {
407 let mut tx = pool.begin().await?;
408
409 let user_id = sqlx::query_scalar::<_, UserId>(
410 "UPDATE mail_cap_requests \
411 SET status = 'granted', granted_cap = $2, decided_at = now(), decided_by = $3 \
412 WHERE id = $1 AND status = 'pending' \
413 RETURNING user_id",
414 )
415 .bind(request_id)
416 .bind(granted_cap)
417 .bind(decided_by)
418 .fetch_optional(&mut *tx)
419 .await?;
420
421 let Some(user_id) = user_id else {
422 tx.rollback().await?;
423 return Ok(None);
424 };
425
426 sqlx::query("UPDATE users SET monthly_mail_cap_override = $2 WHERE id = $1")
427 .bind(user_id)
428 .bind(granted_cap)
429 .execute(&mut *tx)
430 .await?;
431
432 tx.commit().await?;
433 Ok(Some(user_id))
434 }
435
436 /// Deny a request. Leaves the override alone, so the tier default keeps
437 /// applying.
438 #[tracing::instrument(skip_all)]
439 pub async fn deny_request(
440 pool: &PgPool,
441 request_id: uuid::Uuid,
442 decided_by: UserId,
443 ) -> Result<Option<UserId>> {
444 let user_id = sqlx::query_scalar::<_, UserId>(
445 "UPDATE mail_cap_requests \
446 SET status = 'denied', decided_at = now(), decided_by = $2 \
447 WHERE id = $1 AND status = 'pending' \
448 RETURNING user_id",
449 )
450 .bind(request_id)
451 .bind(decided_by)
452 .fetch_optional(pool)
453 .await?;
454 Ok(user_id)
455 }
456
457 /// The tier a cap belongs to, for the dashboard gauge. Same three bands the
458 /// storage gauge uses, read from the same helper so the two cannot drift.
459 #[must_use]
460 pub fn gauge_tier(usage: &Usage) -> &'static str {
461 crate::types::gauge_tier(usage.percent())
462 }
463
464 #[cfg(test)]
465 mod tests {
466 use super::*;
467 use crate::db::CreatorTier;
468
469 fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
470 Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
471 }
472
473 #[test]
474 fn a_calendar_month_runs_first_to_first() {
475 let w = Window::calendar_month(at(2026, 8, 27));
476 assert_eq!(w.start, Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap());
477 assert_eq!(w.end, Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap());
478 assert!(w.contains(at(2026, 8, 27)));
479 assert!(!w.contains(at(2026, 9, 1)));
480 }
481
482 #[test]
483 fn december_rolls_into_the_next_year() {
484 // The one arithmetic in this module that can be wrong silently: a
485 // December window ending on month 13 would put every December send in
486 // the wrong row.
487 let w = Window::calendar_month(at(2026, 12, 15));
488 assert_eq!(w.end, Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
489 }
490
491 fn usage(sent: i64, cap: i64) -> Usage {
492 Usage {
493 sent,
494 cap,
495 window: Window::calendar_month(at(2026, 8, 27)),
496 }
497 }
498
499 #[test]
500 fn a_lowered_cap_leaves_nothing_rather_than_a_debt() {
501 // An operator can lower an override under a creator who has already
502 // sent past it. Negative remaining would read as owing mail back.
503 let over = usage(9_000, 5_000);
504 assert_eq!(over.remaining(), 0);
505 assert_eq!(over.percent(), 100);
506 }
507
508 #[test]
509 fn the_gauge_reads_the_same_bands_as_storage() {
510 crate::tier_prices::TierPrices::install_test_default();
511 assert_eq!(gauge_tier(&usage(0, 1_000)), "");
512 assert_eq!(gauge_tier(&usage(800, 1_000)), "warn");
513 assert_eq!(gauge_tier(&usage(950, 1_000)), "danger");
514 }
515
516 #[test]
517 fn the_warning_band_arrives_before_the_cap() {
518 crate::tier_prices::TierPrices::install_test_default();
519 let warn_at = crate::tier_prices::TierPrices::global().mail_cap_warn_at;
520 assert!(
521 (0.0..1.0).contains(&warn_at),
522 "a warning band at or above the cap warns nobody: {warn_at}"
523 );
524
525 #[expect(clippy::cast_possible_truncation, reason = "test arithmetic")]
526 let threshold = (1_000.0 * warn_at) as i64;
527 assert!(!usage(threshold - 1, 1_000).in_warning_band());
528 assert!(usage(threshold, 1_000).in_warning_band());
529 assert!(usage(1_000, 1_000).in_warning_band());
530 }
531
532 #[test]
533 fn a_refusal_says_what_to_do_about_it() {
534 crate::tier_prices::TierPrices::install_test_default();
535 let verdict = Verdict::Refused {
536 usage: usage(24_000, 25_000),
537 requested: 4_000,
538 };
539 let message = verdict.refusal_message().expect("a refusal has a message");
540 // The three facts a refused creator needs, and the address that lifts it.
541 assert!(message.contains("4000"), "{message}");
542 assert!(message.contains("1000"), "{message}");
543 assert!(message.contains("September 1, 2026"), "{message}");
544 assert!(message.contains("info@makenot.work"), "{message}");
545
546 assert!(
547 Verdict::Admitted {
548 usage: usage(1, 25_000)
549 }
550 .refusal_message()
551 .is_none()
552 );
553 }
554
555 #[test]
556 fn every_tier_has_an_allowance_a_real_creator_clears() {
557 // The stated bias is against false positives, and the worked example in
558 // the decision is a 2,000-person list mailed weekly. If any tier's
559 // default sits under that, the cap stops legitimate work by default.
560 crate::tier_prices::TierPrices::install_test_default();
561 let prices = crate::tier_prices::TierPrices::global();
562 const WEEKLY_TO_TWO_THOUSAND: i64 = 8_000;
563
564 for tier in [
565 Some(CreatorTier::Basic),
566 Some(CreatorTier::SmallFiles),
567 Some(CreatorTier::BigFiles),
568 Some(CreatorTier::Everything),
569 ] {
570 let cap = prices.monthly_mail_cap_for(tier);
571 assert!(
572 cap > WEEKLY_TO_TWO_THOUSAND,
573 "{tier:?} allows {cap}, under the worked example of {WEEKLY_TO_TWO_THOUSAND}"
574 );
575 }
576
577 // A creator with no subscription gets less, and still gets something.
578 let unsubscribed = prices.monthly_mail_cap_for(None);
579 assert!(unsubscribed > 0);
580 assert!(unsubscribed <= prices.monthly_mail_cap_for(Some(CreatorTier::Basic)));
581 }
582 }
583