Skip to main content

max / makenotwork

Show the complaint rate beside the mail allowance, and warn above the threshold The rate travels with the context needed to read it: the healthy figure, the line a provider acts on, and the volume below which nothing is judged. It never moves the cap. An elevated rate also raises an operator alert, deduped on a 24-hour cooldown.
Author: Max Johnson <me@maxj.phd> · 2026-08-31 11:38 UTC
Signed with PGP, not checked
Commit: ed28fb7e4c81603527748248157774794406d82d
Parent: 19009e5
7 files changed, +487 insertions, -31 deletions
@@ -4,6 +4,7 @@
4 4 //! monitoring agents (PoM, MT) push ops alerts here, each persisted as one row
5 5 //! and emailed to the operator. Admin-routed only. Never creator-facing.
6 6
7 + use chrono::{DateTime, Utc};
7 8 use serde::Deserialize;
8 9 use sqlx::PgPool;
9 10
@@ -38,6 +39,11 @@
38 39 /// [`AlertSeverity`], exactly the way `Tls` already folds three PoM
39 40 /// categories. See [`crate::security_signals`].
40 41 Security,
42 + /// Deliverability of this platform's own sending: a creator's complaint
43 + /// rate crossing the line a mail provider acts on. One domain rather than a
44 + /// variant per condition, following the rule above. See
45 + /// [`crate::db::mail_caps`].
46 + Mail,
41 47 }
42 48
43 49 impl AlertKind {
@@ -56,6 +62,7 @@
56 62 Self::Scan => "scan",
57 63 Self::Monitoring => "monitoring",
58 64 Self::Security => "security",
65 + Self::Mail => "mail",
59 66 }
60 67 }
61 68 }
@@ -122,3 +129,27 @@
122 129 .await?;
123 130 Ok(())
124 131 }
132 +
133 + /// Whether an alert carrying `dedup_key` has already landed since `since`.
134 + ///
135 + /// The table deliberately has no unique constraint on `dedup_key` (migration
136 + /// 169: deduplication is the sending agent's job), so a caller that fires on a
137 + /// repeating condition has to suppress its own repeats. This is the durable way
138 + /// to do that: an in-process window, the way [`crate::security_signals`]
139 + /// suppresses, forgets everything on restart, which is fine for a five-minute
140 + /// counter and not for a condition measured over a month.
141 + #[tracing::instrument(skip_all)]
142 + pub async fn alerted_since(
143 + pool: &PgPool,
144 + dedup_key: &str,
145 + since: DateTime<Utc>,
146 + ) -> Result<bool, sqlx::Error> {
147 + let exists = sqlx::query_scalar::<_, bool>(
148 + "SELECT EXISTS(SELECT 1 FROM admin_alerts WHERE dedup_key = $1 AND received_at >= $2)",
149 + )
150 + .bind(dedup_key)
151 + .bind(since)
152 + .fetch_one(pool)
153 + .await?;
154 + Ok(exists)
155 + }
@@ -18,9 +18,9 @@
18 18 //!
19 19 //! # The finest grain, and the other two derived
20 20 //!
21 - //! A send, carrying its list and its creator. Settled 2026-08-30 rather than
22 - //! asked: a coarser grain forecloses the other two and saves nothing, because
23 - //! the attribution work is identical either way.
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 24 //!
25 25 //! Bounces are recorded beside complaints. Bounce rate is the other half of
26 26 //! what a mail provider judges an account on, and it arrives on the same
@@ -145,6 +145,17 @@
145 145 Ok(id)
146 146 }
147 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 +
148 159 /// Record one bounce or complaint, attributed to the send that caused it where
149 160 /// the mail carried one.
150 161 ///
@@ -152,29 +163,36 @@
152 163 /// so a webhook cannot attribute an incident to a creator the send did not
153 164 /// belong to. An unknown send attributes nothing and still counts: dropping it
154 165 /// would flatter the rate, which is the wrong direction for a number that
155 - /// exists to warn.
166 + /// exists to warn. That is the `None` return: the row is written either way.
156 167 #[tracing::instrument(skip_all)]
157 168 pub async fn record_incident(
158 169 pool: &PgPool,
159 170 send_id: Option<EmailSendId>,
160 171 email: &str,
161 172 kind: IncidentKind,
162 - ) -> Result<()> {
163 - sqlx::query(
173 + ) -> Result<Option<Attribution>> {
174 + let row = sqlx::query_as::<_, (Option<UserId>, Option<ListId>)>(
164 175 "INSERT INTO email_incidents (send_id, creator_id, list_id, email, kind) \
165 176 SELECT s.id, s.creator_id, s.list_id, LOWER($2), $3 \
166 177 FROM email_sends s WHERE s.id = $1 \
167 178 UNION ALL \
168 179 SELECT NULL, NULL, NULL, LOWER($2), $3 \
169 180 WHERE NOT EXISTS (SELECT 1 FROM email_sends WHERE id = $1) \
170 - LIMIT 1",
181 + LIMIT 1 \
182 + RETURNING creator_id, list_id",
171 183 )
172 184 .bind(send_id)
173 185 .bind(email)
174 186 .bind(kind.as_str())
175 - .execute(pool)
187 + .fetch_optional(pool)
176 188 .await?;
177 - Ok(())
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 + }))
178 196 }
179 197
180 198 /// What one creator sent in a window, and what came back.
@@ -3,15 +3,14 @@
3 3 //! # What it protects
4 4 //!
5 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.
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.
11 10 //!
12 11 //! # Soft, and the softness is the design
13 12 //!
14 - //! Max, 2026-08-27: protect the commons through soft maximums with
13 + //! The rule is to protect the commons through soft maximums with
15 14 //! application-based exceptions. Four things follow from that, and none of them
16 15 //! is polish:
17 16 //!
@@ -20,9 +19,9 @@
20 19 //! calendar to track. A creator with no subscription falls back to the
21 20 //! calendar month, since they still send.
22 21 //! - 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.
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.
26 25 //! - The count, the cap and the reset date are **visible before** either is met,
27 26 //! which is the half that makes the cap acceptable.
28 27 //! - The number is set where a real creator never meets it, and the
@@ -37,14 +36,30 @@
37 36 //! decide the question halfway through a fan-out, which is exactly the
38 37 //! half-mailed list the refusal exists to avoid.
39 38 //!
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.
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.
43 54
44 - use chrono::{DateTime, Datelike as _, TimeZone as _, Utc};
55 + use std::fmt::Write as _;
56 +
57 + use chrono::{DateTime, Datelike as _, Duration, TimeZone as _, Utc};
45 58 use sqlx::PgPool;
46 59
60 + use super::mail_attribution::{self, Rate};
47 61 use crate::db::UserId;
62 + use crate::db::id_types::ListId;
48 63 use crate::error::Result;
49 64
50 65 /// The billing month a count belongs to.
@@ -461,6 +476,231 @@
461 476 crate::types::gauge_tier(usage.percent())
462 477 }
463 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 +
464 704 #[cfg(test)]
465 705 mod tests {
466 706 use super::*;
@@ -552,6 +792,79 @@
552 792 );
553 793 }
554 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 +
555 868 #[test]
556 869 fn every_tier_has_an_allowance_a_real_creator_clears() {
557 870 // The stated bias is against false positives, and the worked example in
@@ -82,8 +82,8 @@
82 82 pub steps: Vec<OnboardingStep>,
83 83 pub completed: usize,
84 84 pub total: usize,
85 - /// Completion percentage, computed in Rust with a divide-by-zero guard
86 - /// (ultra-fuzz Run #1 UX LOW). The template renders this directly rather
85 + /// Completion percentage, computed in Rust with a divide-by-zero guard.
86 + /// The template renders this directly rather
87 87 /// than doing `completed * 100 / total`, which would panic the render, a
88 88 /// 500, if `total` ever became 0.
89 89 pub progress_pct: u32,
@@ -247,6 +247,39 @@
247 247 /// The creator's most recent increase request, so the form can show them
248 248 /// where their ask got to instead of offering a second one.
249 249 pub request: Option<MailCapRequestView>,
250 + /// How much of that mail was marked as spam, and what a normal rate is.
251 + pub complaints: MailComplaintView,
252 + }
253 +
254 + /// The complaint rate as the same section shows it, beside the volume counter.
255 + ///
256 + /// Carries its own context rather than only the number. A creator reading a
257 + /// bare percentage cannot tell whether it is fine, so the healthy figure, the
258 + /// line a provider acts on and the volume below which nothing is judged all
259 + /// travel with it (`db::mail_caps`).
260 + #[derive(Clone)]
261 + pub struct MailComplaintView {
262 + /// Mails sent in the complaint window.
263 + pub sent: i64,
264 + /// Complaints raised against them.
265 + pub complaints: i64,
266 + /// The window, in days.
267 + pub window_days: i64,
268 + /// The rate as a percentage, e.g. "0.08%".
269 + pub rate_display: String,
270 + /// Whether enough mail went out for the rate to mean anything.
271 + pub measurable: bool,
272 + /// Whether the rate is at or above the line, on a denominator big enough to
273 + /// believe.
274 + pub elevated: bool,
275 + /// The line a mail provider acts on, formatted.
276 + pub limit_display: String,
277 + /// What a healthy rate looks like, formatted.
278 + pub healthy_display: String,
279 + /// Sends needed in the window before the rate is judged.
280 + pub min_sent: i64,
281 + /// What an elevated rate means and who to write to. `None` below the line.
282 + pub warning: Option<String>,
250 283 }
251 284
252 285 /// One increase request, as the creator sees their own.
@@ -19,6 +19,7 @@
19 19 config::Config,
20 20 csrf::{CsrfRouter, post_csrf_skip},
21 21 db::{self, mail_attribution::IncidentKind},
22 + email::EmailClient,
22 23 };
23 24 use sqlx::PgPool;
24 25
@@ -137,6 +138,7 @@
137 138 #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")]
138 139 async fn postmark_webhook(
139 140 State(db): State<PgPool>,
141 + State(email): State<EmailClient>,
140 142 State(config): State<Config>,
141 143 headers: HeaderMap,
142 144 Json(payload): Json<PostmarkWebhookPayload>,
@@ -179,7 +181,7 @@
179 181 anyhow::Error::new(e).context("add hard-bounce suppression"),
180 182 );
181 183 }
182 - record_incident(&db, &payload, IncidentKind::HardBounce).await;
184 + record_incident(&db, &email, &payload, IncidentKind::HardBounce).await;
183 185 } else {
184 186 tracing::info!(
185 187 email = %payload.email,
@@ -197,7 +199,7 @@
197 199 anyhow::Error::new(e).context("add spam-complaint suppression"),
198 200 );
199 201 }
200 - record_incident(&db, &payload, IncidentKind::Complaint).await;
202 + record_incident(&db, &email, &payload, IncidentKind::Complaint).await;
201 203 }
202 204 other => {
203 205 tracing::debug!(record_type = %other, "Postmark webhook: unhandled record type");
@@ -218,13 +220,39 @@
218 220 /// which is idempotent -- and then this one, which is not: the second attempt
219 221 /// would count the same complaint twice and inflate the rate. An
220 222 /// under-counted incident is the safer failure.
221 - async fn record_incident(db: &PgPool, payload: &PostmarkWebhookPayload, kind: IncidentKind) {
223 + ///
224 + /// A complaint is also the moment a creator's complaint rate changes, so it is
225 + /// where the rate is judged and an operator told (`db::mail_caps`). Nothing
226 + /// about the creator's allowance moves; the check only notifies.
227 + async fn record_incident(
228 + db: &PgPool,
229 + email: &EmailClient,
230 + payload: &PostmarkWebhookPayload,
231 + kind: IncidentKind,
232 + ) {
233 + let attribution =
234 + match db::mail_attribution::record_incident(db, payload.send(), &payload.email, kind).await
235 + {
236 + Ok(attribution) => attribution,
237 + Err(error) => {
238 + tracing::warn!(
239 + error = ?error, email = %payload.email, kind = kind.as_str(),
240 + "suppressed the address but could not attribute the incident"
241 + );
242 + return;
243 + }
244 + };
245 +
246 + let Some(attribution) = attribution.filter(|_| kind == IncidentKind::Complaint) else {
247 + return;
248 + };
249 +
222 250 if let Err(error) =
223 - db::mail_attribution::record_incident(db, payload.send(), &payload.email, kind).await
251 + db::mail_caps::notify_operator_of_complaint_rate(db, email, attribution).await
224 252 {
225 253 tracing::warn!(
226 - error = ?error, email = %payload.email, kind = kind.as_str(),
227 - "suppressed the address but could not attribute the incident"
254 + error = ?error, creator_id = %attribution.creator_id,
255 + "recorded the complaint but could not review the creator's complaint rate"
228 256 );
229 257 }
230 258 }
@@ -110,7 +110,7 @@
110 110 </details>
111 111
112 112 {% if let Some(mail) = mail_allowance %}
113 - <details class="form-section creator-form-section"{% if mail.warning %} open{% endif %}>
113 + <details class="form-section creator-form-section"{% if mail.warning || mail.complaints.elevated %} open{% endif %}>
114 114 <summary><h2 class="creator-h2 creator-h2--sm">Email allowance ({{ mail.sent }} / {{ mail.cap }})</h2></summary>
115 115 <div class="storage-box mb-section">
116 116 <div class="storage-row">
@@ -129,6 +129,23 @@
129 129 <p class="muted text-sm">
130 130 The allowance covers announcements to your subscribers and broadcasts to your followers. It exists so one large send cannot degrade delivery for everyone on the shared sending pool, and it is set where ordinary use never meets it.
131 131 </p>
132 + <div class="storage-row mt-section">
133 + <span>
134 + Marked as spam:
135 + {% if mail.complaints.measurable %}
136 + {{ mail.complaints.rate_display }}
137 + {% else %}
138 + too little sending to measure
139 + {% endif %}
140 + </span>
141 + <span class="muted">{{ mail.complaints.complaints }} of {{ mail.complaints.sent }} in the last {{ mail.complaints.window_days }} days</span>
142 + </div>
143 + <p class="muted text-sm mt-peer">
144 + A healthy list stays under {{ mail.complaints.healthy_display }}. {{ mail.complaints.limit_display }} is the line mail providers act on, and it is the one we watch. Under {{ mail.complaints.min_sent }} emails in the window the number is too small a sample to say anything, so we do not read it as good or bad.
145 + </p>
146 + {% if let Some(complaint_warning) = mail.complaints.warning %}
147 + <div class="alert alert-warning">{{ complaint_warning }}</div>
148 + {% endif %}
132 149 </div>
133 150
134 151 {% match mail.request %}
@@ -158,6 +158,10 @@
158 158 granted_cap: r.granted_cap,
159 159 created_at: r.created_at.format("%B %-d, %Y").to_string(),
160 160 });
161 + // The complaint rate sits beside the volume counter: volume is what the
162 + // cap bounds, and the rate is what a mail provider judges the shared
163 + // account on. It never moves the cap (`db::mail_caps`).
164 + let standing = db::mail_caps::creator_complaint_standing(db, session_user.id).await?;
161 165 Some(crate::types::MailAllowanceView {
162 166 sent: usage.sent,
163 167 cap: usage.cap,
@@ -167,6 +171,18 @@
167 171 resets_on: usage.window.end.format("%B %-d, %Y").to_string(),
168 172 warning: usage.in_warning_band(),
169 173 request,
174 + complaints: crate::types::MailComplaintView {
175 + sent: standing.rate.sent,
176 + complaints: standing.rate.complaints,
177 + window_days: standing.window_days,
178 + rate_display: standing.rate_display(),
179 + measurable: standing.measurable(),
180 + elevated: standing.elevated(),
181 + limit_display: db::mail_caps::format_rate(db::mail_caps::COMPLAINT_RATE_LIMIT),
182 + healthy_display: db::mail_caps::format_rate(db::mail_caps::COMPLAINT_RATE_HEALTHY),
183 + min_sent: db::mail_caps::COMPLAINT_MIN_SENT,
184 + warning: standing.warning_message(),
185 + },
170 186 })
171 187 } else {
172 188 None