max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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" |