//! Who was mailing, when an address bounces or complains. //! //! `93f23f00`. [`super::email_suppressions`] records `(email, reason)` and is //! keyed on the address alone: it says an address complained and cannot say //! what it complained about. So no complaint rate was computable at any //! granularity, and complaint rate is the number a mail provider actually //! judges an account on -- the one that predicts reputation damage before the //! shared Postmark IP pool feels it. //! //! # Both halves, because a rate has two //! //! [`record_send`] writes the denominator and [`record_incident`] the //! numerator. Nothing recorded how much mail a list send put on the wire before //! this: `creator_mail_usage` counts what a creator sent, which answers the //! cap's question and not this one -- it is a running counter per billing //! period, so it cannot be windowed to a fortnight and knows nothing about //! lists. //! //! # The finest grain, and the other two derived //! //! A send, carrying its list and its creator. A coarser grain forecloses the //! other two and saves nothing, because the attribution work is identical either //! way. //! //! Bounces are recorded beside complaints. Bounce rate is the other half of //! what a mail provider judges an account on, and it arrives on the same //! webhook for free. use chrono::{DateTime, Utc}; use sqlx::PgPool; use super::id_types::{EmailSendId, ListId, UserId}; use crate::error::Result; /// What a send was. Stored as text for [`super::admin_alerts`]' reason: /// extending the set should be a code change and not a migration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SendKind { /// A creator's broadcast to their followers. Broadcast, /// A release or blog announcement fanned out to a list. Announcement, } impl SendKind { /// Canonical storage token. pub const fn as_str(self) -> &'static str { match self { Self::Broadcast => "broadcast", Self::Announcement => "announcement", } } } /// What happened to one address. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IncidentKind { /// The address does not exist, or refused permanently. HardBounce, /// A reader pressed the spam button. Complaint, } impl IncidentKind { /// Canonical storage token. pub const fn as_str(self) -> &'static str { match self { Self::HardBounce => "hard_bounce", Self::Complaint => "complaint", } } } /// Complaints and bounces over a window, against what was sent in it. /// /// The two counts rather than a computed fraction: a caller that wants a /// percentage has one division to do and a caller that wants "3 of 4,000" has /// the numbers it needs, whereas a fraction alone hides that a rate of 1.0 can /// be one complaint out of one mail. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Rate { /// Mails put on the wire in the window. pub sent: i64, /// Complaints raised in it. pub complaints: i64, /// Hard bounces raised in it. pub bounces: i64, } impl Rate { /// Complaints as a fraction of what was sent. /// /// `0.0` when nothing was sent, which is the honest answer: a rate over an /// empty denominator is not "perfect", it is unmeasured, and the caller /// deciding what to do about that reads [`sent`](Self::sent). #[must_use] pub fn complaint_rate(&self) -> f64 { if self.sent <= 0 { return 0.0; } #[allow(clippy::cast_precision_loss)] { self.complaints as f64 / self.sent as f64 } } /// Hard bounces as a fraction of what was sent. See /// [`complaint_rate`](Self::complaint_rate). #[must_use] pub fn bounce_rate(&self) -> f64 { if self.sent <= 0 { return 0.0; } #[allow(clippy::cast_precision_loss)] { self.bounces as f64 / self.sent as f64 } } } /// Record a fan-out, and hand back the id the mail will carry. /// /// Called before the mail goes out, because the id has to ride on it. A send /// row for mail that then fails to leave overstates the denominator by that /// send, which is the safe direction: it dilutes a rate rather than inflating /// one, and a rate that reads low is a rate nobody acts on. #[tracing::instrument(skip_all)] pub async fn record_send( pool: &PgPool, creator_id: UserId, list_id: Option, kind: SendKind, recipients: i64, ) -> Result { let id = sqlx::query_scalar::<_, EmailSendId>( "INSERT INTO email_sends (creator_id, list_id, kind, recipients) \ VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(creator_id) .bind(list_id) .bind(kind.as_str()) .bind(recipients) .fetch_one(pool) .await?; Ok(id) } /// Who an incident landed on. Handed back by [`record_incident`] so a caller /// that has to react to the incident does not re-derive the attribution the /// insert already resolved, and cannot disagree with it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Attribution { /// The creator whose send drew the incident. pub creator_id: UserId, /// The list it went to, absent for a send that belonged to no list. pub list_id: Option, } /// Record one bounce or complaint, attributed to the send that caused it where /// the mail carried one. /// /// The creator and the list are looked up from the send rather than passed, /// so a webhook cannot attribute an incident to a creator the send did not /// belong to. An unknown send attributes nothing and still counts: dropping it /// would flatter the rate, which is the wrong direction for a number that /// exists to warn. That is the `None` return: the row is written either way. #[tracing::instrument(skip_all)] pub async fn record_incident( pool: &PgPool, send_id: Option, email: &str, kind: IncidentKind, ) -> Result> { let row = sqlx::query_as::<_, (Option, Option)>( "INSERT INTO email_incidents (send_id, creator_id, list_id, email, kind) \ SELECT s.id, s.creator_id, s.list_id, LOWER($2), $3 \ FROM email_sends s WHERE s.id = $1 \ UNION ALL \ SELECT NULL, NULL, NULL, LOWER($2), $3 \ WHERE NOT EXISTS (SELECT 1 FROM email_sends WHERE id = $1) \ LIMIT 1 \ RETURNING creator_id, list_id", ) .bind(send_id) .bind(email) .bind(kind.as_str()) .fetch_optional(pool) .await?; Ok(row.and_then(|(creator_id, list_id)| { creator_id.map(|creator_id| Attribution { creator_id, list_id, }) })) } /// What one creator sent in a window, and what came back. #[tracing::instrument(skip_all)] pub async fn creator_rate(pool: &PgPool, creator_id: UserId, since: DateTime) -> Result { rate_from(pool, "creator_id", creator_id.into(), since).await } /// What one list sent in a window, and what came back. #[tracing::instrument(skip_all)] pub async fn list_rate(pool: &PgPool, list_id: ListId, since: DateTime) -> Result { rate_from(pool, "list_id", list_id.into(), since).await } /// The one query both rates are, with the column named by the caller. /// /// The column is a `&'static str` from exactly two call sites above and never /// reaches this from outside the module, so it is a constant in the query /// rather than a bind: Postgres will not take an identifier as a parameter, and /// the alternative is the same query written twice. async fn rate_from( pool: &PgPool, column: &'static str, id: uuid::Uuid, since: DateTime, ) -> Result { let sent = sqlx::query_scalar::<_, Option>(&format!( "SELECT SUM(recipients) FROM email_sends WHERE {column} = $1 AND created_at >= $2" )) .bind(id) .bind(since) .fetch_one(pool) .await? .unwrap_or(0); let row = sqlx::query_as::<_, (i64, i64)>(&format!( "SELECT \ COUNT(*) FILTER (WHERE kind = 'complaint'), \ COUNT(*) FILTER (WHERE kind = 'hard_bounce') \ FROM email_incidents WHERE {column} = $1 AND created_at >= $2" )) .bind(id) .bind(since) .fetch_one(pool) .await?; Ok(Rate { sent, complaints: row.0, bounces: row.1, }) } #[cfg(test)] mod tests { use super::*; #[test] fn a_rate_over_nothing_sent_is_unmeasured_rather_than_perfect() { let nothing = Rate::default(); assert!((nothing.complaint_rate() - 0.0).abs() < f64::EPSILON); assert!((nothing.bounce_rate() - 0.0).abs() < f64::EPSILON); assert_eq!(nothing.sent, 0); } #[test] fn a_rate_is_the_fraction_of_what_went_out() { let measured = Rate { sent: 4000, complaints: 3, bounces: 12, }; assert!((measured.complaint_rate() - 0.00075).abs() < 1e-9); assert!((measured.bounce_rate() - 0.003).abs() < 1e-9); } /// The tokens are storage, so a rename that did not reach the migration /// would silently stop matching every row already written. #[test] fn the_stored_tokens_are_the_ones_the_queries_filter_on() { assert_eq!(IncidentKind::Complaint.as_str(), "complaint"); assert_eq!(IncidentKind::HardBounce.as_str(), "hard_bounce"); assert_eq!(SendKind::Broadcast.as_str(), "broadcast"); assert_eq!(SendKind::Announcement.as_str(), "announcement"); } }