Skip to main content

max / makenotwork

Add the unified list, subscription and consent tables Step 2 of wiki [[mnw-mailing-lists]]. Five mechanisms currently decide who gets email about what and none knows about the others: project mailing_lists, email_signups, six users.notify_* bools, follows, and email_suppressions. Same act, three standards of care. These three tables are the one model the rest collapses into. A list is anything somebody can be subscribed to, scoped to the platform, a project, a repo or a creator. A subscription is one recipient's relationship to one list. A consent event is why we believe we may mail them. Three constraints make the states that caused the mess unrepresentable: - A subscriber is an account or a bare address, never both. The old mailing_list_subscribers allowed both at once and left "which one is authoritative" to whoever read the row next. - scope_id is NULL exactly for platform lists, so a project list cannot exist without a project. - consent_events rejects UPDATE via trigger. Every consent claim rests on the log not having been edited after the fact, so that is enforced rather than documented. DELETE still cascades: erasure has to be able to remove the person, and that is the one way these rows should go. `required` marks the lists nobody may leave (receipts, security, account lifecycle). It is a property of the list rather than of the sender, which is what structurally keeps a marketing line out of a receipt. The backfill carries every existing subscriber over as `imported` with an import event naming the table it came from. `imported` is its own state deliberately. Those rows predate any consent record: calling them `confirmed` would manufacture evidence we do not have, and `pending` would assert a double opt-in is in flight when none is. Whether an imported subscriber may be mailed is an open decision (GoingsOn 04a882b4), and until it is answered the state says what is known and no more. Signups that had already unsubscribed stay unsubscribed, since that opt-out is the one piece of consent history the old table did hold. Nothing reads these yet. The old tables stay authoritative until the send resolver lands in step 3, so this is additive and reverts by dropping what it creates.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 01:57 UTC
Signed with PGP, not checked
Commit: 825e744ca7ad4474efd072af6f4c1efb890594c9
Parent: 9b97b59
7 files changed, +804 insertions, -0 deletions
@@ -1161,6 +1161,120 @@
1161 1161 }
1162 1162 }
1163 1163
1164 + // ── Mailing lists (wiki: mnw-mailing-lists) ──
1165 +
1166 + /// What a list is attached to. `Platform` lists have no `scope_id`; every other
1167 + /// scope requires one, and the database enforces the pairing.
1168 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1169 + pub enum ListScope {
1170 + Platform,
1171 + Project,
1172 + Repo,
1173 + Creator,
1174 + }
1175 +
1176 + impl_str_enum!(ListScope {
1177 + Platform => "platform",
1178 + Project => "project",
1179 + Repo => "repo",
1180 + Creator => "creator",
1181 + });
1182 +
1183 + /// What the list carries. Kinds are deliberately coarse: the unsubscribe page
1184 + /// shows one row per list, and a subscriber who has to reason about fifteen
1185 + /// near-identical kinds will unsubscribe from all of them.
1186 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1187 + pub enum ListKind {
1188 + Content,
1189 + Devlog,
1190 + Patches,
1191 + Releases,
1192 + Issues,
1193 + Announce,
1194 + Marketing,
1195 + }
1196 +
1197 + impl_str_enum!(ListKind {
1198 + Content => "content",
1199 + Devlog => "devlog",
1200 + Patches => "patches",
1201 + Releases => "releases",
1202 + Issues => "issues",
1203 + Announce => "announce",
1204 + Marketing => "marketing",
1205 + });
1206 +
1207 + /// Where a subscription stands.
1208 + ///
1209 + /// `Imported` is its own state on purpose. Everything the step-2 backfill
1210 + /// carried over predates any consent record: calling it `Confirmed` would
1211 + /// manufacture evidence we do not have, and calling it `Pending` would assert a
1212 + /// double-opt-in is in flight when none is. Whether an imported subscriber may
1213 + /// be mailed is an open decision, and until it is answered the state says
1214 + /// exactly what is known and no more.
1215 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1216 + pub enum SubscriptionState {
1217 + Pending,
1218 + Confirmed,
1219 + Imported,
1220 + Unsubscribed,
1221 + Bounced,
1222 + }
1223 +
1224 + impl_str_enum!(SubscriptionState {
1225 + Pending => "pending",
1226 + Confirmed => "confirmed",
1227 + Imported => "imported",
1228 + Unsubscribed => "unsubscribed",
1229 + Bounced => "bounced",
1230 + });
1231 +
1232 + /// How the subscription was created. Recorded so a later re-confirmation pass
1233 + /// can tell a page subscribe from a bulk import, which the table this replaces
1234 + /// could not.
1235 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1236 + pub enum SubscriptionSource {
1237 + LandingForm,
1238 + ProjectPage,
1239 + Checkout,
1240 + Import,
1241 + Admin,
1242 + Api,
1243 + }
1244 +
1245 + impl_str_enum!(SubscriptionSource {
1246 + LandingForm => "landing_form",
1247 + ProjectPage => "project_page",
1248 + Checkout => "checkout",
1249 + Import => "import",
1250 + Admin => "admin",
1251 + Api => "api",
1252 + });
1253 +
1254 + /// An entry in a subscription's consent history. The table is append-only: an
1255 + /// opt-out adds a row rather than editing the opt-in that came before, and a
1256 + /// database trigger rejects `UPDATE` so that stays true.
1257 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1258 + pub enum ConsentEvent {
1259 + OptIn,
1260 + Confirm,
1261 + OptOut,
1262 + Bounce,
1263 + Complaint,
1264 + AdminRemoval,
1265 + Import,
1266 + }
1267 +
1268 + impl_str_enum!(ConsentEvent {
1269 + OptIn => "opt_in",
1270 + Confirm => "confirm",
1271 + OptOut => "opt_out",
1272 + Bounce => "bounce",
1273 + Complaint => "complaint",
1274 + AdminRemoval => "admin_removal",
1275 + Import => "import",
1276 + });
1277 +
1164 1278 #[cfg(test)]
1165 1279 mod tests {
1166 1280 use super::*;
@@ -177,6 +177,9 @@
177 177 BuildId,
178 178 MailingListId,
179 179 MailingListSubscriberId,
180 + ListId,
181 + ListSubscriptionId,
182 + ConsentEventId,
180 183 CustomDomainId,
181 184 ItemSectionId,
182 185 ProjectSectionId,
@@ -37,6 +37,7 @@
37 37 pub(crate) mod item_sections;
38 38 pub mod items;
39 39 pub mod license_keys;
40 + pub mod lists;
40 41 pub mod mailing_lists;
41 42 pub(crate) mod media_files;
42 43 mod models;
@@ -71,6 +71,7 @@
71 71 mod item_sections;
72 72 mod license_keys;
73 73 mod lifecycle;
74 + mod lists;
74 75 mod mailing_lists;
75 76 mod media_library;
76 77 mod mock_payment_flows;
@@ -1,0 +1,190 @@
1 + -- One subscription model for every list-like thing.
2 + --
3 + -- Step 2 of wiki [[mnw-mailing-lists]]. Today five mechanisms decide who gets
4 + -- email about what and none knows about the others: project mailing_lists,
5 + -- email_signups, six users.notify_* bools, follows, and email_suppressions.
6 + -- Same act, different standards of care. These three tables are the one model
7 + -- the rest collapses into.
8 + --
9 + -- Nothing reads them yet. Step 3 routes sends through a resolver, step 4 builds
10 + -- the unsubscribe surface, step 5 migrates users.notify_*. The old tables stay
11 + -- authoritative until then, so this migration is additive and reversible by
12 + -- dropping what it creates.
13 +
14 + -- A list is anything somebody can be subscribed to.
15 + CREATE TABLE IF NOT EXISTS lists (
16 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17 + scope TEXT NOT NULL CHECK (scope IN ('platform', 'project', 'repo', 'creator')),
18 + -- NULL exactly when the list is platform-wide. The CHECK ties the two
19 + -- together so a project list cannot exist without a project, and a platform
20 + -- list cannot accidentally acquire one.
21 + scope_id UUID,
22 + kind TEXT NOT NULL CHECK (kind IN (
23 + 'content', 'devlog', 'patches', 'releases',
24 + 'issues', 'announce', 'marketing'
25 + )),
26 + title TEXT NOT NULL,
27 + -- Transactional lists nobody may leave: receipts, security, account
28 + -- lifecycle. This is a property of the LIST rather than of the sender, and
29 + -- it is what structurally keeps a marketing line out of a receipt: if the
30 + -- content is marketing it goes on a marketing list, which is opt-out-able
31 + -- by construction.
32 + required BOOLEAN NOT NULL DEFAULT FALSE,
33 + owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
34 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
35 + CONSTRAINT chk_lists_scope_id CHECK ((scope = 'platform') = (scope_id IS NULL))
36 + );
37 +
38 + -- One list per (scope, scope_id, kind). Split in two because scope_id is NULL
39 + -- for platform lists and NULL never equals NULL in a unique index.
40 + CREATE UNIQUE INDEX IF NOT EXISTS idx_lists_scoped
41 + ON lists (scope, scope_id, kind) WHERE scope_id IS NOT NULL;
42 + CREATE UNIQUE INDEX IF NOT EXISTS idx_lists_platform
43 + ON lists (kind) WHERE scope_id IS NULL;
44 +
45 + -- A subscription is one recipient's relationship to one list.
46 + CREATE TABLE IF NOT EXISTS list_subscriptions (
47 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
48 + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
49 + -- Exactly one of these. A subscriber is either an account or a bare
50 + -- address; the old mailing_list_subscribers allowed both at once, which
51 + -- left "which one is authoritative" to whoever read the row next.
52 + user_id UUID REFERENCES users(id) ON DELETE CASCADE,
53 + email TEXT,
54 + -- 'imported' is the honest state for everything this migration backfills.
55 + -- Those rows predate any consent record, so calling them 'confirmed' would
56 + -- manufacture evidence we do not have, and calling them 'pending' would
57 + -- assert they are mid-double-opt-in, which they are not. Whether an
58 + -- imported subscriber may be mailed is a live decision (GoingsOn
59 + -- 04a882b4); until it is answered, the state says exactly what we know.
60 + state TEXT NOT NULL CHECK (state IN (
61 + 'pending', 'confirmed', 'imported', 'unsubscribed', 'bounced'
62 + )),
63 + source TEXT NOT NULL CHECK (source IN (
64 + 'landing_form', 'project_page', 'checkout',
65 + 'import', 'admin', 'api'
66 + )),
67 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
68 + confirmed_at TIMESTAMPTZ,
69 + unsubscribed_at TIMESTAMPTZ,
70 + CONSTRAINT chk_subscription_identity CHECK ((user_id IS NULL) <> (email IS NULL))
71 + );
72 +
73 + CREATE UNIQUE INDEX IF NOT EXISTS idx_list_subscriptions_user
74 + ON list_subscriptions (list_id, user_id) WHERE user_id IS NOT NULL;
75 + CREATE UNIQUE INDEX IF NOT EXISTS idx_list_subscriptions_email
76 + ON list_subscriptions (list_id, email) WHERE email IS NOT NULL;
77 + -- The query a send draws from: one list, the states that may receive mail.
78 + CREATE INDEX IF NOT EXISTS idx_list_subscriptions_sendable
79 + ON list_subscriptions (list_id, state);
80 +
81 + -- Why we believe we are allowed to mail this person. Append-only.
82 + CREATE TABLE IF NOT EXISTS consent_events (
83 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
84 + subscription_id UUID NOT NULL REFERENCES list_subscriptions(id) ON DELETE CASCADE,
85 + event TEXT NOT NULL CHECK (event IN (
86 + 'opt_in', 'confirm', 'opt_out', 'bounce',
87 + 'complaint', 'admin_removal', 'import'
88 + )),
89 + at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
90 + ip INET,
91 + user_agent TEXT,
92 + -- The exact copy shown at opt-in, or the provenance of an import. This is
93 + -- the column that makes consent evidenceable rather than asserted: without
94 + -- it we can say someone subscribed but not what they were told they were
95 + -- subscribing to.
96 + evidence TEXT
97 + );
98 +
99 + CREATE INDEX IF NOT EXISTS idx_consent_events_subscription
100 + ON consent_events (subscription_id, at DESC);
101 +
102 + -- Append-only, enforced rather than documented. An unsubscribe adds an opt_out
103 + -- row; it never edits the opt_in that came before, because the record of what
104 + -- was agreed to is the whole point of the table.
105 + --
106 + -- DELETE is deliberately allowed: erasure cascades from list_subscriptions, and
107 + -- a right-to-erasure request has to be able to remove the person.
108 + CREATE OR REPLACE FUNCTION consent_events_no_update() RETURNS TRIGGER AS $$
109 + BEGIN
110 + RAISE EXCEPTION 'consent_events is append-only; record a new event instead';
111 + END;
112 + $$ LANGUAGE plpgsql;
113 +
114 + DROP TRIGGER IF EXISTS trg_consent_events_no_update ON consent_events;
115 + CREATE TRIGGER trg_consent_events_no_update
116 + BEFORE UPDATE ON consent_events
117 + FOR EACH ROW EXECUTE FUNCTION consent_events_no_update();
118 +
119 + -- ── Backfill ──
120 + --
121 + -- Every existing subscriber lands as 'imported' with an import consent event
122 + -- stating where it came from. None of these rows carries evidence of an opt-in,
123 + -- and the backfill says so rather than inventing one.
124 +
125 + -- Project mailing lists.
126 + INSERT INTO lists (scope, scope_id, kind, title, required, owner_id)
127 + SELECT 'project', ml.project_id, ml.list_type, ml.name, FALSE, p.user_id
128 + FROM mailing_lists ml
129 + JOIN projects p ON p.id = ml.project_id
130 + ON CONFLICT DO NOTHING;
131 +
132 + -- The platform marketing list behind the landing "notify me" form.
133 + INSERT INTO lists (scope, scope_id, kind, title, required, owner_id)
134 + VALUES ('platform', NULL, 'marketing', 'Makenotwork updates', FALSE, NULL)
135 + ON CONFLICT DO NOTHING;
136 +
137 + -- Project subscribers. user_id wins where a legacy row carried both, since an
138 + -- account is the stronger identity and the new CHECK permits only one.
139 + INSERT INTO list_subscriptions (list_id, user_id, email, state, source, created_at)
140 + SELECT l.id,
141 + mls.user_id,
142 + CASE WHEN mls.user_id IS NULL THEN LOWER(mls.email) END,
143 + 'imported',
144 + 'import',
145 + mls.subscribed_at
146 + FROM mailing_list_subscribers mls
147 + JOIN mailing_lists ml ON ml.id = mls.list_id
148 + JOIN lists l ON l.scope = 'project' AND l.scope_id = ml.project_id AND l.kind = ml.list_type
149 + ON CONFLICT DO NOTHING;
150 +
151 + -- Landing signups. An already-unsubscribed row stays unsubscribed: the opt-out
152 + -- is the one piece of consent history this table does have, and losing it in
153 + -- the migration would re-subscribe people who asked to leave.
154 + INSERT INTO list_subscriptions (list_id, email, state, source, created_at, unsubscribed_at)
155 + SELECT l.id,
156 + LOWER(es.email),
157 + CASE WHEN es.unsubscribed_at IS NULL THEN 'imported' ELSE 'unsubscribed' END,
158 + 'import',
159 + es.created_at,
160 + es.unsubscribed_at
161 + FROM email_signups es
162 + CROSS JOIN lists l
163 + WHERE l.scope = 'platform' AND l.kind = 'marketing'
164 + ON CONFLICT DO NOTHING;
165 +
166 + -- One import event per backfilled subscription, naming the table it came from
167 + -- so a later re-confirmation pass can tell the two provenances apart. The
168 + -- project rows came from a subscribe action of unknown shape (the old table
169 + -- kept no source, so a page subscribe and a creator's CSV import are
170 + -- indistinguishable); the signup rows came from the landing form.
171 + INSERT INTO consent_events (subscription_id, event, at, evidence)
172 + SELECT ls.id,
173 + 'import',
174 + ls.created_at,
175 + CASE l.scope
176 + WHEN 'platform' THEN
177 + 'Backfilled from email_signups (landing form, single opt-in, '
178 + || 'no record of the copy shown at signup).'
179 + ELSE
180 + 'Backfilled from mailing_list_subscribers. The source table kept '
181 + || 'no provenance, so a page subscribe and a creator CSV import '
182 + || 'are indistinguishable in this row.'
183 + END
184 + FROM list_subscriptions ls
185 + JOIN lists l ON l.id = ls.list_id
186 + WHERE ls.source = 'import'
187 + AND NOT EXISTS (
188 + SELECT 1 FROM consent_events ce
189 + WHERE ce.subscription_id = ls.id AND ce.event = 'import'
190 + );
@@ -1,0 +1,204 @@
1 + //! One subscription model for every list-like thing.
2 + //!
3 + //! Step 2 of the plan in the maintainer wiki (`mnw-mailing-lists`). Three
4 + //! tables: a `list` is anything somebody can be subscribed to, a
5 + //! `list_subscription` is one recipient's relationship to one list, and a
6 + //! `consent_event` is why we believe we are allowed to mail them.
7 + //!
8 + //! **Nothing sends through this yet.** `mailing_lists` and `email_signups`
9 + //! remain authoritative until the resolver lands (step 3). This module exists
10 + //! so the schema has typed access and the backfill has tests, not so callers
11 + //! can start using it piecemeal, which is how the five parallel mechanisms this
12 + //! replaces came to exist in the first place.
13 + //! <!-- wiki: mnw-mailing-lists -->
14 +
15 + use sqlx::PgPool;
16 +
17 + use super::enums::{ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState};
18 + use super::id_types::{ListId, ListSubscriptionId, UserId};
19 + use crate::error::Result;
20 +
21 + /// A subscriber: an account or a bare address, never both. The old
22 + /// `mailing_list_subscribers` allowed both at once and left "which one is
23 + /// authoritative" to whoever read the row next; the new table's CHECK makes
24 + /// that unrepresentable, and this type carries the same rule into Rust.
25 + #[derive(Debug, Clone)]
26 + pub enum Subscriber {
27 + User(UserId),
28 + Email(String),
29 + }
30 +
31 + /// Find a list by what it is attached to.
32 + ///
33 + /// `scope_id` must be `None` for [`ListScope::Platform`] and `Some` otherwise;
34 + /// the database CHECK rejects the other combinations, so a mismatch here
35 + /// returns no row rather than the wrong one.
36 + #[tracing::instrument(skip_all)]
37 + pub async fn find_list(
38 + pool: &PgPool,
39 + scope: ListScope,
40 + scope_id: Option<uuid::Uuid>,
41 + kind: ListKind,
42 + ) -> Result<Option<ListId>> {
43 + let id = sqlx::query_scalar::<_, ListId>(
44 + "SELECT id FROM lists \
45 + WHERE scope = $1 AND kind = $2 \
46 + AND (scope_id = $3 OR ($3::uuid IS NULL AND scope_id IS NULL))",
47 + )
48 + .bind(scope.to_string())
49 + .bind(kind.to_string())
50 + .bind(scope_id)
51 + .fetch_optional(pool)
52 + .await?;
53 + Ok(id)
54 + }
55 +
56 + /// Record a subscription and the consent event that justifies it, in one
57 + /// transaction.
58 + ///
59 + /// The two are written together because a subscription without its consent
60 + /// event is exactly the state this whole model exists to eliminate: someone on
61 + /// a list with no record of why. Re-subscribing an address that had left moves
62 + /// it back and appends a fresh event rather than editing the old one.
63 + #[tracing::instrument(skip_all)]
64 + pub async fn subscribe(
65 + pool: &PgPool,
66 + list_id: ListId,
67 + subscriber: &Subscriber,
68 + state: SubscriptionState,
69 + source: SubscriptionSource,
70 + event: ConsentEvent,
71 + evidence: Option<&str>,
72 + ) -> Result<ListSubscriptionId> {
73 + let (user_id, email) = match subscriber {
74 + Subscriber::User(id) => (Some(*id), None),
75 + Subscriber::Email(addr) => (None, Some(addr.to_lowercase())),
76 + };
77 + let confirmed_at = (state == SubscriptionState::Confirmed).then(chrono::Utc::now);
78 +
79 + let mut tx = pool.begin().await?;
80 +
81 + // The uniqueness of a subscriber is enforced by two partial indexes, one
82 + // per identity kind, and ON CONFLICT has to name the one that applies.
83 + // A single statement cannot cover both, so the arm is chosen here rather
84 + // than left to the database to guess.
85 + let conflict_target = match subscriber {
86 + Subscriber::User(_) => "(list_id, user_id) WHERE user_id IS NOT NULL",
87 + Subscriber::Email(_) => "(list_id, email) WHERE email IS NOT NULL",
88 + };
89 + let sql = format!(
90 + "INSERT INTO list_subscriptions (list_id, user_id, email, state, source, confirmed_at) \
91 + VALUES ($1, $2, $3, $4, $5, $6) \
92 + ON CONFLICT {conflict_target} \
93 + DO UPDATE SET state = EXCLUDED.state, \
94 + confirmed_at = EXCLUDED.confirmed_at, \
95 + unsubscribed_at = NULL \
96 + RETURNING id"
97 + );
98 +
99 + let subscription_id = sqlx::query_scalar::<_, ListSubscriptionId>(&sql)
100 + .bind(list_id)
101 + .bind(user_id)
102 + .bind(email.as_deref())
103 + .bind(state.to_string())
104 + .bind(source.to_string())
105 + .bind(confirmed_at)
106 + .fetch_one(&mut *tx)
107 + .await?;
108 +
109 + sqlx::query(
110 + "INSERT INTO consent_events (subscription_id, event, evidence) VALUES ($1, $2, $3)",
111 + )
112 + .bind(subscription_id)
113 + .bind(event.to_string())
114 + .bind(evidence)
115 + .execute(&mut *tx)
116 + .await?;
117 +
118 + tx.commit().await?;
119 + Ok(subscription_id)
120 + }
121 +
122 + /// Mark a subscription unsubscribed and append the opt-out event.
123 + ///
124 + /// Returns whether a subscription moved. Idempotent: unsubscribing twice
125 + /// reports `false` the second time and is not an error, which matters because
126 + /// RFC 8058 one-click POSTs get retried.
127 + #[tracing::instrument(skip_all)]
128 + pub async fn unsubscribe(
129 + pool: &PgPool,
130 + subscription_id: ListSubscriptionId,
131 + event: ConsentEvent,
132 + ) -> Result<bool> {
133 + let mut tx = pool.begin().await?;
134 +
135 + let moved = sqlx::query(
136 + "UPDATE list_subscriptions SET state = 'unsubscribed', unsubscribed_at = NOW() \
137 + WHERE id = $1 AND state <> 'unsubscribed'",
138 + )
139 + .bind(subscription_id)
140 + .execute(&mut *tx)
141 + .await?
142 + .rows_affected()
143 + > 0;
144 +
145 + if moved {
146 + sqlx::query("INSERT INTO consent_events (subscription_id, event) VALUES ($1, $2)")
147 + .bind(subscription_id)
148 + .bind(event.to_string())
149 + .execute(&mut *tx)
150 + .await?;
151 + }
152 +
153 + tx.commit().await?;
154 + Ok(moved)
155 + }
156 +
157 + /// Count subscriptions on a list in a given state. Exists for the backfill
158 + /// tests and the admin view; the send path gets its own resolver in step 3.
159 + #[tracing::instrument(skip_all)]
160 + pub async fn count_in_state(
161 + pool: &PgPool,
162 + list_id: ListId,
163 + state: SubscriptionState,
164 + ) -> Result<i64> {
165 + let count = sqlx::query_scalar::<_, i64>(
166 + "SELECT COUNT(*) FROM list_subscriptions WHERE list_id = $1 AND state = $2",
167 + )
168 + .bind(list_id)
169 + .bind(state.to_string())
170 + .fetch_one(pool)
171 + .await?;
172 + Ok(count)
173 + }
174 +
175 + #[cfg(test)]
176 + mod tests {
177 + use super::*;
178 +
179 + #[test]
180 + fn scope_and_kind_round_trip() {
181 + for s in [
182 + ListScope::Platform,
183 + ListScope::Project,
184 + ListScope::Repo,
185 + ListScope::Creator,
186 + ] {
187 + assert_eq!(s.to_string().parse::<ListScope>().unwrap(), s);
188 + }
189 + for k in [ListKind::Content, ListKind::Marketing, ListKind::Issues] {
190 + assert_eq!(k.to_string().parse::<ListKind>().unwrap(), k);
191 + }
192 + }
193 +
194 + /// The strings are a database CHECK constraint, so a rename here that is
195 + /// not matched by a migration fails at insert rather than at compile time.
196 + #[test]
197 + fn state_and_event_strings_match_the_check_constraints() {
198 + assert_eq!(SubscriptionState::Imported.to_string(), "imported");
199 + assert_eq!(SubscriptionState::Unsubscribed.to_string(), "unsubscribed");
200 + assert_eq!(SubscriptionSource::LandingForm.to_string(), "landing_form");
201 + assert_eq!(ConsentEvent::AdminRemoval.to_string(), "admin_removal");
202 + assert_eq!(ConsentEvent::Import.to_string(), "import");
203 + }
204 + }
@@ -1,0 +1,291 @@
1 + //! The unified subscription model (step 2 of wiki `mnw-mailing-lists`).
2 + //!
3 + //! Nothing sends through these tables yet, so what is worth pinning is the
4 + //! shape: the constraints that make bad states unrepresentable, and the
5 + //! append-only guarantee the consent log rests on. Those are the parts a later
6 + //! step will lean on without re-checking.
7 +
8 + use crate::harness::TestHarness;
9 + use makenotwork::db::{
10 + ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
11 + };
12 +
13 + /// The backfill creates the platform marketing list unconditionally, so the
14 + /// landing form has somewhere to point once step 3 wires it up.
15 + #[tokio::test]
16 + async fn migration_creates_the_platform_marketing_list() {
17 + let h = TestHarness::new().await;
18 +
19 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
20 + .await
21 + .expect("query")
22 + .expect("platform marketing list should exist after the backfill");
23 +
24 + let count = lists::count_in_state(&h.db, list, SubscriptionState::Imported)
25 + .await
26 + .expect("count");
27 + assert_eq!(count, 0, "a fresh database has nothing to import");
28 + }
29 +
30 + /// A subscriber is an account or a bare address, never both and never neither.
31 + /// The old mailing_list_subscribers allowed both at once, which left "which is
32 + /// authoritative" to whoever read the row next.
33 + #[tokio::test]
34 + async fn a_subscription_cannot_have_both_identities_or_neither() {
35 + let h = TestHarness::new().await;
36 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
37 + .await
38 + .unwrap()
39 + .unwrap();
40 +
41 + let both = sqlx::query(
42 + "INSERT INTO list_subscriptions (list_id, user_id, email, state, source) \
43 + VALUES ($1, gen_random_uuid(), 'both@example.com', 'confirmed', 'api')",
44 + )
45 + .bind(list)
46 + .execute(&h.db)
47 + .await;
48 + assert!(both.is_err(), "a row with both identities was accepted");
49 +
50 + let neither = sqlx::query(
51 + "INSERT INTO list_subscriptions (list_id, state, source) VALUES ($1, 'confirmed', 'api')",
52 + )
53 + .bind(list)
54 + .execute(&h.db)
55 + .await;
56 + assert!(neither.is_err(), "a row with no identity was accepted");
57 + }
58 +
59 + /// scope_id is NULL exactly for platform lists. A project list without a
60 + /// project, or a platform list that acquired one, is not a representable state.
61 + #[tokio::test]
62 + async fn list_scope_and_scope_id_must_agree() {
63 + let h = TestHarness::new().await;
64 +
65 + let platform_with_id = sqlx::query(
66 + "INSERT INTO lists (scope, scope_id, kind, title) \
67 + VALUES ('platform', gen_random_uuid(), 'announce', 'bad')",
68 + )
69 + .execute(&h.db)
70 + .await;
71 + assert!(platform_with_id.is_err());
72 +
73 + let project_without_id =
74 + sqlx::query("INSERT INTO lists (scope, kind, title) VALUES ('project', 'content', 'bad')")
75 + .execute(&h.db)
76 + .await;
77 + assert!(project_without_id.is_err());
78 + }
79 +
80 + /// Subscribing writes the subscription and its consent event together. A
81 + /// subscription with no recorded reason is the exact state this model exists to
82 + /// eliminate.
83 + #[tokio::test]
84 + async fn subscribing_records_the_consent_event_with_it() {
85 + let h = TestHarness::new().await;
86 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
87 + .await
88 + .unwrap()
89 + .unwrap();
90 +
91 + let sub = lists::subscribe(
92 + &h.db,
93 + list,
94 + &lists::Subscriber::Email("consent@example.com".to_string()),
95 + SubscriptionState::Confirmed,
96 + SubscriptionSource::LandingForm,
97 + ConsentEvent::OptIn,
98 + Some("Get notified when something ships."),
99 + )
100 + .await
101 + .expect("subscribe");
102 +
103 + let (event, evidence): (String, Option<String>) =
104 + sqlx::query_as("SELECT event, evidence FROM consent_events WHERE subscription_id = $1")
105 + .bind(sub)
106 + .fetch_one(&h.db)
107 + .await
108 + .expect("consent event");
109 + assert_eq!(event, "opt_in");
110 + assert_eq!(
111 + evidence.as_deref(),
112 + Some("Get notified when something ships."),
113 + "the copy shown at opt-in is what makes the consent evidenceable"
114 + );
115 + }
116 +
117 + /// Unsubscribing appends rather than edits, and is idempotent because one-click
118 + /// POSTs get retried.
119 + #[tokio::test]
120 + async fn unsubscribing_appends_an_event_and_is_idempotent() {
121 + let h = TestHarness::new().await;
122 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
123 + .await
124 + .unwrap()
125 + .unwrap();
126 +
127 + let sub = lists::subscribe(
128 + &h.db,
129 + list,
130 + &lists::Subscriber::Email("leaving@example.com".to_string()),
131 + SubscriptionState::Confirmed,
132 + SubscriptionSource::LandingForm,
133 + ConsentEvent::OptIn,
134 + None,
135 + )
136 + .await
137 + .unwrap();
138 +
139 + assert!(
140 + lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
141 + .await
142 + .unwrap()
143 + );
144 + assert!(
145 + !lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
146 + .await
147 + .unwrap(),
148 + "a retried unsubscribe must report no change rather than erroring"
149 + );
150 +
151 + // The opt_in survives the opt_out: the record of what was agreed to is the
152 + // point of the table.
153 + let events: Vec<String> = sqlx::query_scalar(
154 + "SELECT event FROM consent_events WHERE subscription_id = $1 ORDER BY at",
155 + )
156 + .bind(sub)
157 + .fetch_all(&h.db)
158 + .await
159 + .unwrap();
160 + assert_eq!(events, vec!["opt_in".to_string(), "opt_out".to_string()]);
161 + }
162 +
163 + /// Re-subscribing moves the row back and appends a fresh event, rather than
164 + /// rewriting the history that says they once left.
165 + #[tokio::test]
166 + async fn resubscribing_restores_the_row_and_keeps_the_history() {
167 + let h = TestHarness::new().await;
168 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
169 + .await
170 + .unwrap()
171 + .unwrap();
172 + let subscriber = lists::Subscriber::Email("returning@example.com".to_string());
173 +
174 + let sub = lists::subscribe(
175 + &h.db,
176 + list,
177 + &subscriber,
178 + SubscriptionState::Confirmed,
179 + SubscriptionSource::LandingForm,
180 + ConsentEvent::OptIn,
181 + None,
182 + )
183 + .await
184 + .unwrap();
185 + lists::unsubscribe(&h.db, sub, ConsentEvent::OptOut)
186 + .await
187 + .unwrap();
188 +
189 + let again = lists::subscribe(
190 + &h.db,
191 + list,
192 + &subscriber,
193 + SubscriptionState::Confirmed,
194 + SubscriptionSource::LandingForm,
195 + ConsentEvent::OptIn,
196 + None,
197 + )
198 + .await
199 + .expect("re-subscribe should update rather than conflict");
200 + assert_eq!(again, sub, "re-subscribing should reuse the row");
201 +
202 + assert_eq!(
203 + lists::count_in_state(&h.db, list, SubscriptionState::Confirmed)
204 + .await
205 + .unwrap(),
206 + 1
207 + );
208 + let events: i64 =
209 + sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1")
210 + .bind(sub)
211 + .fetch_one(&h.db)
212 + .await
213 + .unwrap();
214 + assert_eq!(events, 3, "opt_in, opt_out, opt_in");
215 + }
216 +
217 + /// The append-only guarantee is enforced by the database, not by convention.
218 + /// Every consent claim rests on the log not having been edited after the fact.
219 + #[tokio::test]
220 + async fn consent_events_reject_updates() {
221 + let h = TestHarness::new().await;
222 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
223 + .await
224 + .unwrap()
225 + .unwrap();
226 + let sub = lists::subscribe(
227 + &h.db,
228 + list,
229 + &lists::Subscriber::Email("immutable@example.com".to_string()),
230 + SubscriptionState::Confirmed,
231 + SubscriptionSource::LandingForm,
232 + ConsentEvent::OptIn,
233 + Some("original copy"),
234 + )
235 + .await
236 + .unwrap();
237 +
238 + let rewrite =
239 + sqlx::query("UPDATE consent_events SET evidence = 'rewritten' WHERE subscription_id = $1")
240 + .bind(sub)
241 + .execute(&h.db)
242 + .await;
243 + assert!(rewrite.is_err(), "consent history was editable");
244 +
245 + let evidence: String =
246 + sqlx::query_scalar("SELECT evidence FROM consent_events WHERE subscription_id = $1")
247 + .bind(sub)
248 + .fetch_one(&h.db)
249 + .await
250 + .unwrap();
251 + assert_eq!(evidence, "original copy");
252 + }
253 +
254 + /// Erasure has to be able to remove the person, so DELETE cascades even though
255 + /// UPDATE is blocked. This is the one way consent rows legitimately go away.
256 + #[tokio::test]
257 + async fn erasing_a_subscription_takes_its_consent_history_with_it() {
258 + let h = TestHarness::new().await;
259 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
260 + .await
261 + .unwrap()
262 + .unwrap();
263 + let sub = lists::subscribe(
264 + &h.db,
265 + list,
266 + &lists::Subscriber::Email("erase@example.com".to_string()),
267 + SubscriptionState::Confirmed,
268 + SubscriptionSource::LandingForm,
269 + ConsentEvent::OptIn,
270 + None,
271 + )
272 + .await
273 + .unwrap();
274 +
275 + sqlx::query("DELETE FROM list_subscriptions WHERE id = $1")
276 + .bind(sub)
277 + .execute(&h.db)
278 + .await
279 + .expect("erasure must be possible");
280 +
281 + let left: i64 =
282 + sqlx::query_scalar("SELECT COUNT(*) FROM consent_events WHERE subscription_id = $1")
283 + .bind(sub)
284 + .fetch_one(&h.db)
285 + .await
286 + .unwrap();
287 + assert_eq!(
288 + left, 0,
289 + "consent rows outlived the subscription they described"
290 + );
291 + }