Skip to main content

max / makenotwork

Build the email preferences page and one-click unsubscribe Step 4 of wiki [[mnw-mailing-lists]]. Unsubscribe links now key on a subscription in the unified tables, and one token does both jobs the surface needs: a POST unsubscribes exactly the list the mail came from, which is what List-Unsubscribe-Post: One-Click promises Gmail and Yahoo, and a GET opens a page showing every list that subscriber is on. Showing all of them is the point. Somebody who wants out is rarely asking about the one list that happened to prompt them, and making them hunt for the rest is how "unsubscribe" turns into "mark as spam". GET only ever renders. A mail client or link scanner prefetching the URL must not unsubscribe anyone, so every mutation is a POST, and a test pins it. The page is sessionless, authorised by the signed token rather than a login. Asking somebody to remember a password before they can stop receiving email is the same failure as having no unsubscribe at all. The token authorises one subscriber, not any subscription, so each action re-resolves the subscriber's own rows and refuses a target outside them; a test aims a valid token at a stranger's row and is rejected. Required lists are listed but carry no toggle, and unsubscribe-from-all skips them: "everything" means everything on offer, and a receipt was never on offer. The page is an honest inventory of what we send rather than only the parts you can leave. Every action writes a consent event. Unsubscribing appends an opt_out next to the opt_in rather than replacing it, and resubscribing appends again, because the history is what makes the consent evidenceable. The two announcement senders now take the resolver's Audience directly and carry the new link, so db::mailing_lists::get_subscribers and MailingSubscriber are gone. Their last two callers were tests, and keeping production code alive only for tests is how the old parallel mechanisms survived as long as they did.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 03:06 UTC
Signed with PGP, not checked
Commit: ca3ed2919efa55aae6d038b63bba404e6e1227da
Parent: d983cb7
11 files changed, +664 insertions, -84 deletions
@@ -279,6 +279,124 @@
279 279 Ok(count)
280 280 }
281 281
282 + /// One row on the unsubscribe page.
283 + #[derive(Debug, Clone, sqlx::FromRow)]
284 + pub struct SubscriptionRow {
285 + pub subscription_id: ListSubscriptionId,
286 + pub list_id: ListId,
287 + pub title: String,
288 + /// Transactional list. Shown so the page is an honest inventory of what we
289 + /// send, but it carries no toggle: there is no opting out of a receipt.
290 + pub required: bool,
291 + pub state: String,
292 + }
293 +
294 + impl SubscriptionRow {
295 + /// Whether this row is currently receiving mail.
296 + pub fn subscribed(&self) -> bool {
297 + SENDABLE_STATES.contains(&self.state.as_str())
298 + }
299 + }
300 +
301 + /// Every list the subscriber behind `subscription_id` is on.
302 + ///
303 + /// The page is reached from a link in one email, but it shows all of them:
304 + /// somebody who wants out is rarely asking about the single list that happened
305 + /// to prompt them, and making them hunt for the rest is how "unsubscribe" turns
306 + /// into "mark as spam".
307 + #[tracing::instrument(skip_all)]
308 + pub async fn subscriptions_for_peer(
309 + pool: &PgPool,
310 + subscription_id: ListSubscriptionId,
311 + ) -> Result<Vec<SubscriptionRow>> {
312 + let rows = sqlx::query_as::<_, SubscriptionRow>(
313 + r"
314 + WITH peer AS (
315 + SELECT user_id, email FROM list_subscriptions WHERE id = $1
316 + )
317 + SELECT ls.id AS subscription_id, l.id AS list_id, l.title, l.required, ls.state
318 + FROM list_subscriptions ls
319 + JOIN lists l ON l.id = ls.list_id
320 + CROSS JOIN peer
321 + WHERE (peer.user_id IS NOT NULL AND ls.user_id = peer.user_id)
322 + OR (peer.email IS NOT NULL AND LOWER(ls.email) = LOWER(peer.email))
323 + ORDER BY l.required DESC, l.title
324 + ",
325 + )
326 + .bind(subscription_id)
327 + .fetch_all(pool)
328 + .await?;
329 + Ok(rows)
330 + }
331 +
332 + /// Unsubscribe the peer from every list they may leave.
333 + ///
334 + /// Required lists are skipped rather than refused: "unsubscribe from
335 + /// everything" means everything on offer, and a receipt was never on offer.
336 + /// Returns how many moved.
337 + #[tracing::instrument(skip_all)]
338 + pub async fn unsubscribe_peer_from_all(
339 + pool: &PgPool,
340 + subscription_id: ListSubscriptionId,
341 + ) -> Result<usize> {
342 + let rows = subscriptions_for_peer(pool, subscription_id).await?;
343 + let mut moved = 0;
344 + for row in rows {
345 + if row.required {
346 + continue;
347 + }
348 + if unsubscribe(pool, row.subscription_id, ConsentEvent::OptOut).await? {
349 + moved += 1;
350 + }
351 + }
352 + Ok(moved)
353 + }
354 +
355 + /// Re-subscribe a row the page had toggled off. Appends an `opt_in`; the
356 + /// `opt_out` before it stays, because the history is the point.
357 + #[tracing::instrument(skip_all)]
358 + pub async fn resubscribe(pool: &PgPool, subscription_id: ListSubscriptionId) -> Result<bool> {
359 + let mut tx = pool.begin().await?;
360 + let moved = sqlx::query(
361 + "UPDATE list_subscriptions \
362 + SET state = 'confirmed', confirmed_at = NOW(), unsubscribed_at = NULL \
363 + WHERE id = $1 AND state = 'unsubscribed'",
364 + )
365 + .bind(subscription_id)
366 + .execute(&mut *tx)
367 + .await?
368 + .rows_affected()
369 + > 0;
370 +
371 + if moved {
372 + sqlx::query(
373 + "INSERT INTO consent_events (subscription_id, event, evidence) VALUES ($1, 'opt_in', $2)",
374 + )
375 + .bind(subscription_id)
376 + .bind("Re-subscribed from the email preferences page.")
377 + .execute(&mut *tx)
378 + .await?;
379 + }
380 + tx.commit().await?;
381 + Ok(moved)
382 + }
383 +
384 + /// Whether a subscription's list may be left at all.
385 + #[tracing::instrument(skip_all)]
386 + pub async fn subscription_is_required(
387 + pool: &PgPool,
388 + subscription_id: ListSubscriptionId,
389 + ) -> Result<bool> {
390 + let required = sqlx::query_scalar::<_, bool>(
391 + "SELECT l.required FROM list_subscriptions ls \
392 + JOIN lists l ON l.id = ls.list_id WHERE ls.id = $1",
393 + )
394 + .bind(subscription_id)
395 + .fetch_optional(pool)
396 + .await?;
397 + Ok(required.unwrap_or(false))
398 + }
399 +
282 400 // ── Mirroring the legacy tables ──
283 401 //
284 402 // `mailing_lists` / `mailing_list_subscribers` are still what the product
@@ -124,53 +124,6 @@
124 124 Ok(result.rows_affected())
125 125 }
126 126
127 - /// A mailing-list recipient: either an MNW user (has `user_id`) or an
128 - /// email-only subscriber imported from another platform (`user_id` is `None`).
129 - /// The two need different unsubscribe links, user-keyed vs email-keyed.
130 - #[derive(Debug, Clone, sqlx::FromRow)]
131 - pub struct MailingSubscriber {
132 - pub user_id: Option<UserId>,
133 - pub email: String,
134 - pub display_name: Option<String>,
135 - }
136 -
137 - /// Get all deliverable subscribers on a list.
138 - ///
139 - /// An adapter over [`crate::db::lists::resolve_audience`], which is now the one
140 - /// place the delivery rules live. It used to hold its own copy of them, which
141 - /// is how the rules came to differ per send: suppression was applied
142 - /// everywhere because it sat in `send_email_inner`, and nothing else was.
143 - ///
144 - /// The shape is unchanged so the two announcement senders did not have to move
145 - /// in the same commit. They move to [`crate::db::lists::Audience`] in step 4,
146 - /// when the unsubscribe link changes form and this function goes away.
147 - #[tracing::instrument(skip_all)]
148 - pub async fn get_subscribers(
149 - pool: &PgPool,
150 - list_id: MailingListId,
151 - ) -> Result<Vec<MailingSubscriber>> {
152 - let Some(unified) = super::lists::list_for_legacy(pool, list_id.into()).await? else {
153 - // Every legacy list is mirrored, by the step-2 backfill or by
154 - // create_list. Missing means the mirror was skipped, and silently
155 - // sending to nobody would hide that until a creator asked why their
156 - // announcement never arrived.
157 - return Err(crate::error::AppError::Internal(anyhow::anyhow!(
158 - "legacy mailing list {list_id} has no unified list"
159 - )));
160 - };
161 -
162 - let audience = super::lists::resolve_audience(pool, unified).await?;
163 - Ok(audience
164 - .recipients
165 - .into_iter()
166 - .map(|r| MailingSubscriber {
167 - user_id: r.user_id,
168 - email: r.email,
169 - display_name: r.display_name,
170 - })
171 - .collect())
172 - }
173 -
174 127 /// Unsubscribe an email-only subscriber (no MNW account) from a list. Removes
175 128 /// the `(list_id, email)` row. Idempotent. Backs the email-keyed unsubscribe
176 129 /// link carried in emails to imported subscribers.
@@ -252,6 +252,45 @@
252 252 )
253 253 }
254 254
255 + /// Unsubscribe URL keyed on a subscription in the unified tables.
256 + ///
257 + /// One token does both jobs the surface needs. A POST unsubscribes exactly the
258 + /// list the mail came from, which is what `List-Unsubscribe-Post: One-Click`
259 + /// promises. A GET opens the preferences page, because the subscription
260 + /// identifies its subscriber and so reaches every other list they are on.
261 + ///
262 + /// Domain-separated from the user-keyed and email-keyed forms, so a token
263 + /// minted here cannot be replayed as one of those.
264 + pub fn generate_subscription_unsubscribe_url(
265 + host_url: &str,
266 + subscription_id: uuid::Uuid,
267 + secret: &str,
268 + ) -> String {
269 + let sig = subscription_unsubscribe_signature(subscription_id, secret);
270 + format!("{host_url}/unsubscribe?sub={subscription_id}&sig={sig}")
271 + }
272 +
273 + /// Verify a subscription-keyed unsubscribe signature.
274 + pub fn verify_subscription_unsubscribe_signature(
275 + subscription_id: uuid::Uuid,
276 + signature: &str,
277 + secret: &str,
278 + ) -> bool {
279 + let expected = subscription_unsubscribe_signature(subscription_id, secret);
280 + crate::helpers::constant_time_compare(&expected, signature)
281 + }
282 +
283 + fn subscription_unsubscribe_signature(subscription_id: uuid::Uuid, secret: &str) -> String {
284 + use hmac::{Hmac, KeyInit, Mac};
285 + use sha2::Sha256;
286 +
287 + let message = format!("unsub_sub:{subscription_id}");
288 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
289 + .expect("HMAC-SHA256 accepts any key length");
290 + mac.update(message.as_bytes());
291 + hex::encode(mac.finalize().into_bytes())
292 + }
293 +
255 294 /// Unsubscribe URL for the landing "notify me" list.
256 295 ///
257 296 /// Thin wrapper over [`generate_unsubscribe_url_for_email`] that pins the
@@ -10,29 +10,24 @@
10 10 /// Build the mailing-list unsubscribe URL for one subscriber: user-keyed for an
11 11 /// MNW account, email-keyed for an imported email-only subscriber (which has no
12 12 /// user id and would otherwise get no working unsubscribe link, a CAN-SPAM gap).
13 - fn mailing_list_unsub_url(
13 + /// The unsubscribe link carried by an announcement.
14 + ///
15 + /// Keyed on the subscription rather than the recipient's identity, so one token
16 + /// serves both jobs the surface needs: a POST unsubscribes exactly this list
17 + /// (RFC 8058 one-click), and a GET opens the preferences page for everything
18 + /// else they are on. It replaces the user-keyed and email-keyed forms, which
19 + /// needed a different shape per recipient kind and could only ever act on the
20 + /// one list.
21 + fn announcement_unsub_url(
14 22 host_url: &str,
15 - subscriber: &db::mailing_lists::MailingSubscriber,
16 - list_id_str: &str,
23 + recipient: &db::lists::Recipient,
17 24 signing_secret: &str,
18 25 ) -> String {
19 - use crate::email::UnsubscribeAction::MailingList;
20 - match subscriber.user_id {
21 - Some(uid) => crate::email::generate_unsubscribe_url(
22 - host_url,
23 - uid,
24 - MailingList,
25 - list_id_str,
26 - signing_secret,
27 - ),
28 - None => crate::email::generate_unsubscribe_url_for_email(
29 - host_url,
30 - &subscriber.email,
31 - MailingList,
32 - list_id_str,
33 - signing_secret,
34 - ),
35 - }
26 + crate::email::generate_subscription_unsubscribe_url(
27 + host_url,
28 + *recipient.subscription_id.as_uuid(),
29 + signing_secret,
30 + )
36 31 }
37 32
38 33 /// Spawn a bounded email fan-out off the caller's (possibly advisory-lock-held)
@@ -105,9 +100,14 @@
105 100 else {
106 101 return;
107 102 };
108 - let Ok(subscribers) = db::mailing_lists::get_subscribers(db, list.id).await else {
103 + let Ok(Some(unified)) = db::lists::list_for_legacy(db, list.id.into()).await else {
104 + tracing::error!(list_id = %list.id, "mailing list has no unified list; skipping send");
109 105 return;
110 106 };
107 + let Ok(audience) = db::lists::resolve_audience(db, unified).await else {
108 + return;
109 + };
110 + let subscribers = audience.recipients;
111 111
112 112 let creator_name = creator
113 113 .display_name
@@ -119,7 +119,6 @@
119 119 let email_client = mailer.clone();
120 120 let host_url = config.host_url.clone();
121 121 let signing_secret = config.signing_secret.clone();
122 - let list_id_str = list.id.to_string();
123 122
124 123 spawn_bounded_fanout(subscribers, move |subscriber| {
125 124 let email_client = email_client.clone();
@@ -128,10 +127,8 @@
128 127 let creator_name = creator_name.clone();
129 128 let item_title = item_title.clone();
130 129 let item_url = item_url.clone();
131 - let list_id_str = list_id_str.clone();
132 130 async move {
133 - let unsub_url =
134 - mailing_list_unsub_url(&host_url, &subscriber, &list_id_str, &signing_secret);
131 + let unsub_url = announcement_unsub_url(&host_url, &subscriber, &signing_secret);
135 132 if let Err(e) = email_client
136 133 .send_release_announcement(
137 134 &subscriber.email,
@@ -189,9 +186,14 @@
189 186 else {
190 187 return;
191 188 };
192 - let Ok(subscribers) = db::mailing_lists::get_subscribers(db, list.id).await else {
189 + let Ok(Some(unified)) = db::lists::list_for_legacy(db, list.id.into()).await else {
190 + tracing::error!(list_id = %list.id, "mailing list has no unified list; skipping send");
193 191 return;
194 192 };
193 + let Ok(audience) = db::lists::resolve_audience(db, unified).await else {
194 + return;
195 + };
196 + let subscribers = audience.recipients;
195 197
196 198 let creator_name = creator
197 199 .display_name
@@ -203,7 +205,6 @@
203 205 let email_client = mailer.clone();
204 206 let host_url = config.host_url.clone();
205 207 let signing_secret = config.signing_secret.clone();
206 - let list_id_str = list.id.to_string();
207 208
208 209 spawn_bounded_fanout(subscribers, move |subscriber| {
209 210 let email_client = email_client.clone();
@@ -212,10 +213,8 @@
212 213 let creator_name = creator_name.clone();
213 214 let post_title = post_title.clone();
214 215 let post_url = post_url.clone();
215 - let list_id_str = list_id_str.clone();
216 216 async move {
217 - let unsub_url =
218 - mailing_list_unsub_url(&host_url, &subscriber, &list_id_str, &signing_secret);
217 + let unsub_url = announcement_unsub_url(&host_url, &subscriber, &signing_secret);
219 218 if let Err(e) = email_client
220 219 .send_blog_post_announcement(
221 220 &subscriber.email,
@@ -160,6 +160,7 @@
160 160 CreatorsTemplate,
161 161 // Email & account
162 162 EmailResultTemplate,
163 + EmailPreferencesTemplate,
163 164 ConfirmDeleteTemplate,
164 165 AccountDeletedTemplate,
165 166 // Health
@@ -529,3 +529,219 @@
529 529 audience.recipients
530 530 );
531 531 }
532 +
533 + // ── Step 4: the unsubscribe surface ──
534 +
535 + fn prefs_url(subscription: makenotwork::db::ListSubscriptionId) -> String {
536 + makenotwork::email::generate_subscription_unsubscribe_url(
537 + "",
538 + *subscription.as_uuid(),
539 + "test-signing-secret-for-integration-tests",
540 + )
541 + }
542 +
543 + /// Subscribe an address to the platform marketing list and return the row.
544 + async fn marketing_subscription(
545 + h: &TestHarness,
546 + addr: &str,
547 + ) -> makenotwork::db::ListSubscriptionId {
548 + let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
549 + .await
550 + .unwrap()
551 + .unwrap();
552 + lists::subscribe(
553 + &h.db,
554 + list,
555 + &lists::Subscriber::Email(addr.to_string()),
556 + SubscriptionState::Confirmed,
557 + SubscriptionSource::LandingForm,
558 + ConsentEvent::OptIn,
559 + None,
560 + )
561 + .await
562 + .unwrap()
563 + }
564 +
565 + /// GET renders the page and changes nothing. A mail client or link scanner
566 + /// prefetching the URL must not unsubscribe anyone.
567 + #[tokio::test]
568 + async fn the_preferences_page_does_not_mutate_on_get() {
569 + let mut h = TestHarness::new().await;
570 + let sub = marketing_subscription(&h, "prefs@example.com").await;
571 +
572 + let resp = h.client.get(&prefs_url(sub)).await;
573 + assert_eq!(resp.status, 200);
574 + assert!(resp.text.contains("Email preferences"));
575 +
576 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
577 + .bind(sub)
578 + .fetch_one(&h.db)
579 + .await
580 + .unwrap();
581 + assert_eq!(state, "confirmed", "a GET unsubscribed somebody");
582 + }
583 +
584 + /// RFC 8058: a POST to the same URL unsubscribes that one list with no
585 + /// confirmation step, and a retry still reports success.
586 + #[tokio::test]
587 + async fn one_click_post_unsubscribes_that_list_and_retries_cleanly() {
588 + let mut h = TestHarness::new().await;
589 + let sub = marketing_subscription(&h, "oneclick2@example.com").await;
590 + let url = prefs_url(sub);
591 +
592 + let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
593 + assert_eq!(first.status, 200);
594 +
595 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
596 + .bind(sub)
597 + .fetch_one(&h.db)
598 + .await
599 + .unwrap();
600 + assert_eq!(state, "unsubscribed");
601 +
602 + let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await;
603 + assert_eq!(second.status, 200, "a retried one-click must not fail");
604 + }
605 +
606 + /// The page lists every list the subscriber is on, not only the one whose mail
607 + /// brought them there. Making somebody hunt for the rest is how "unsubscribe"
608 + /// becomes "mark as spam".
609 + #[tokio::test]
610 + async fn the_page_shows_every_list_the_subscriber_is_on() {
611 + let mut h = TestHarness::new().await;
612 + let marketing = marketing_subscription(&h, "many@example.com").await;
613 +
614 + // A second list for the same address.
615 + sqlx::query(
616 + "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Product announcements', false)",
617 + )
618 + .execute(&h.db)
619 + .await
620 + .unwrap();
621 + let announce = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
622 + .await
623 + .unwrap()
624 + .unwrap();
625 + lists::subscribe(
626 + &h.db,
627 + announce,
628 + &lists::Subscriber::Email("many@example.com".to_string()),
629 + SubscriptionState::Confirmed,
630 + SubscriptionSource::LandingForm,
631 + ConsentEvent::OptIn,
632 + None,
633 + )
634 + .await
635 + .unwrap();
636 +
637 + let resp = h.client.get(&prefs_url(marketing)).await;
638 + assert!(resp.text.contains("Makenotwork updates"));
639 + assert!(
640 + resp.text.contains("Product announcements"),
641 + "the page showed only the originating list"
642 + );
643 + }
644 +
645 + /// Required lists appear but carry no toggle. There is no opting out of a
646 + /// receipt, and "unsubscribe from everything" means everything on offer.
647 + #[tokio::test]
648 + async fn required_lists_are_shown_but_cannot_be_left() {
649 + let mut h = TestHarness::new().await;
650 + let marketing = marketing_subscription(&h, "receipts@example.com").await;
651 +
652 + sqlx::query(
653 + "INSERT INTO lists (scope, kind, title, required) VALUES ('platform', 'announce', 'Receipts', true)",
654 + )
655 + .execute(&h.db)
656 + .await
657 + .unwrap();
658 + let receipts = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Announce)
659 + .await
660 + .unwrap()
661 + .unwrap();
662 + let receipt_sub = lists::subscribe(
663 + &h.db,
664 + receipts,
665 + &lists::Subscriber::Email("receipts@example.com".to_string()),
666 + SubscriptionState::Confirmed,
667 + SubscriptionSource::Admin,
668 + ConsentEvent::OptIn,
669 + None,
670 + )
671 + .await
672 + .unwrap();
673 +
674 + let page = h.client.get(&prefs_url(marketing)).await;
675 + assert!(
676 + page.text.contains("Always sent"),
677 + "required list had a toggle"
678 + );
679 +
680 + // Unsubscribe-from-all leaves it alone.
681 + let url = prefs_url(marketing);
682 + let token = url.split("sub=").nth(1).unwrap();
683 + let (sub, sig) = token.split_once("&sig=").unwrap();
684 + h.client
685 + .post_form("/unsubscribe/all", &format!("sub={sub}&sig={sig}"))
686 + .await;
687 +
688 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
689 + .bind(receipt_sub)
690 + .fetch_one(&h.db)
691 + .await
692 + .unwrap();
693 + assert_eq!(state, "confirmed", "a required list was unsubscribed");
694 +
695 + let marketing_state: String =
696 + sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
697 + .bind(marketing)
698 + .fetch_one(&h.db)
699 + .await
700 + .unwrap();
701 + assert_eq!(marketing_state, "unsubscribed");
702 + }
703 +
704 + /// A valid token authorises one subscriber, not any subscription. Retargeting
705 + /// it at somebody else's row is refused.
706 + #[tokio::test]
707 + async fn a_token_cannot_be_retargeted_at_another_subscriber() {
708 + let mut h = TestHarness::new().await;
709 + let mine = marketing_subscription(&h, "mine@example.com").await;
710 + let theirs = marketing_subscription(&h, "theirs@example.com").await;
711 +
712 + let url = prefs_url(mine);
713 + let token = url.split("sub=").nth(1).unwrap();
714 + let (sub, sig) = token.split_once("&sig=").unwrap();
715 +
716 + let resp = h
717 + .client
718 + .post_form(
719 + "/unsubscribe/list",
720 + &format!("sub={sub}&sig={sig}&target={theirs}&action=unsubscribe"),
721 + )
722 + .await;
723 + assert!(resp.status.is_client_error(), "a token was retargeted");
724 +
725 + let state: String = sqlx::query_scalar("SELECT state FROM list_subscriptions WHERE id = $1")
726 + .bind(theirs)
727 + .fetch_one(&h.db)
728 + .await
729 + .unwrap();
730 + assert_eq!(state, "confirmed", "somebody else was unsubscribed");
731 + }
732 +
733 + /// A forged signature does nothing.
734 + #[tokio::test]
735 + async fn the_preferences_page_rejects_a_bad_signature() {
736 + let mut h = TestHarness::new().await;
737 + let sub = marketing_subscription(&h, "forged@example.com").await;
738 +
739 + let resp = h
740 + .client
741 + .get(&format!("/unsubscribe?sub={sub}&sig=deadbeef"))
742 + .await;
743 + assert!(
744 + !resp.text.contains("Email preferences"),
745 + "a forged signature opened the page"
746 + );
747 + }
@@ -275,11 +275,16 @@
275 275 .unwrap();
276 276 assert_eq!(inserted, 1);
277 277
278 - // get_subscribers must include it, an INNER JOIN on users used to drop
279 - // email-only rows so imported subscribers were never emailed (Run 21).
280 - let subs = makenotwork::db::mailing_lists::get_subscribers(&h.db, list_id)
278 + // The resolver must include it. An INNER JOIN on users used to drop
279 + // email-only rows, so imported subscribers were never emailed (Run 21).
280 + let unified = makenotwork::db::lists::list_for_legacy(&h.db, list_id.into())
281 281 .await
282 - .unwrap();
282 + .unwrap()
283 + .expect("legacy list is mirrored");
284 + let subs = makenotwork::db::lists::resolve_audience(&h.db, unified)
285 + .await
286 + .unwrap()
287 + .recipients;
283 288 let imported = subs
284 289 .iter()
285 290 .find(|s| s.email == "imported@example.com")
@@ -299,9 +304,10 @@
299 304 .unwrap();
300 305 assert!(removed);
301 306
302 - let subs_after = makenotwork::db::mailing_lists::get_subscribers(&h.db, list_id)
307 + let subs_after = makenotwork::db::lists::resolve_audience(&h.db, unified)
303 308 .await
304 - .unwrap();
309 + .unwrap()
310 + .recipients;
305 311 assert!(
306 312 !subs_after.iter().any(|s| s.email == "imported@example.com"),
307 313 "unsubscribed email-only subscriber must be gone"