From 72fa283a6d3c67ced63e6a09e206e5819027d6bd Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Mon, 22 Sep 2025 01:25:36 +0200 Subject: [PATCH] Unsubscribe link in emails sent --- ...f19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json | 14 -- ...d404043b35f44f639b0edde61ed9e1a7f2944.json | 14 ++ ...ubscribe_token_to_issue_delivery_queue.sql | 1 + src/issue_delivery_worker.rs | 46 +++-- src/routes/admin/newsletters.rs | 26 ++- src/routes/admin/posts.rs | 4 +- src/routes/unsubscribe.rs | 12 +- src/templates.rs | 44 ++++- templates/email/base.html | 145 ++++++++++++++ templates/email/new_post.html | 177 ++---------------- templates/email/standalone.html | 2 + tests/api/helpers.rs | 53 +++++- tests/api/unsubscribe.rs | 104 +++++++++- 13 files changed, 431 insertions(+), 211 deletions(-) delete mode 100644 .sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json create mode 100644 .sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json create mode 100644 migrations/20250921230225_add_unsubscribe_token_to_issue_delivery_queue.sql create mode 100644 templates/email/base.html create mode 100644 templates/email/standalone.html diff --git a/.sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json b/.sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json deleted file mode 100644 index 93e684b..0000000 --- a/.sqlx/query-9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email\n )\n SELECT $1, email\n FROM subscriptions\n WHERE status = 'confirmed'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d" -} diff --git a/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json b/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json new file mode 100644 index 0000000..59a6f35 --- /dev/null +++ b/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token\n )\n SELECT $1, email, unsubscribe_token\n FROM subscriptions\n WHERE status = 'confirmed'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944" +} diff --git a/migrations/20250921230225_add_unsubscribe_token_to_issue_delivery_queue.sql b/migrations/20250921230225_add_unsubscribe_token_to_issue_delivery_queue.sql new file mode 100644 index 0000000..1cb136d --- /dev/null +++ b/migrations/20250921230225_add_unsubscribe_token_to_issue_delivery_queue.sql @@ -0,0 +1 @@ +ALTER TABLE issue_delivery_queue ADD COLUMN unsubscribe_token TEXT NOT NULL; diff --git a/src/issue_delivery_worker.rs b/src/issue_delivery_worker.rs index 540c4ee..e90bfa3 100644 --- a/src/issue_delivery_worker.rs +++ b/src/issue_delivery_worker.rs @@ -44,13 +44,14 @@ pub async fn try_execute_task( if task.is_none() { return Ok(ExecutionOutcome::EmptyQueue); } - let (transaction, issue_id, email) = task.unwrap(); + let (transaction, task) = task.unwrap(); Span::current() - .record("newsletter_issue_id", display(issue_id)) - .record("subscriber_email", display(&email)); - match SubscriberEmail::parse(email.clone()) { + .record("newsletter_issue_id", display(task.newsletter_issue_id)) + .record("subscriber_email", display(&task.subscriber_email)); + match SubscriberEmail::parse(task.subscriber_email.clone()) { Ok(email) => { - let issue = get_issue(connection_pool, issue_id).await?; + let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?; + issue.inject_unsubscribe_token(&task.unsubscribe_token); if let Err(e) = email_client .send_email( &email, @@ -73,7 +74,12 @@ pub async fn try_execute_task( ); } } - delete_task(transaction, issue_id, &email).await?; + delete_task( + transaction, + task.newsletter_issue_id, + &task.subscriber_email, + ) + .await?; Ok(ExecutionOutcome::TaskCompleted) } @@ -84,6 +90,13 @@ struct NewsletterIssue { html_content: String, } +impl NewsletterIssue { + fn inject_unsubscribe_token(&mut self, token: &str) { + self.text_content = self.text_content.replace("UNSUBSCRIBE_TOKEN", token); + self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token); + } +} + #[tracing::instrument(skip_all)] async fn get_issue( connection_pool: &PgPool, @@ -103,14 +116,20 @@ async fn get_issue( Ok(issue) } +pub struct Task { + pub newsletter_issue_id: Uuid, + pub subscriber_email: String, + pub unsubscribe_token: String, +} + #[tracing::instrument(skip_all)] async fn dequeue_task( connection_pool: &PgPool, -) -> Result, Uuid, String)>, anyhow::Error> { +) -> Result, Task)>, anyhow::Error> { let mut transaction = connection_pool.begin().await?; let query = sqlx::query!( r#" - SELECT newsletter_issue_id, subscriber_email + SELECT newsletter_issue_id, subscriber_email, unsubscribe_token FROM issue_delivery_queue FOR UPDATE SKIP LOCKED @@ -119,11 +138,12 @@ async fn dequeue_task( ); let r = transaction.fetch_optional(query).await?; if let Some(row) = r { - Ok(Some(( - transaction, - row.get("newsletter_issue_id"), - row.get("subscriber_email"), - ))) + let task = Task { + newsletter_issue_id: row.get("newsletter_issue_id"), + subscriber_email: row.get("subscriber_email"), + unsubscribe_token: row.get("unsubscribe_token"), + }; + Ok(Some((transaction, task))) } else { Ok(None) } diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index b4c8388..53d4a30 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -3,7 +3,7 @@ use crate::{ idempotency::{IdempotencyKey, save_response, try_processing}, routes::{AdminError, AppError}, startup::AppState, - templates::MessageTemplate, + templates::{EmailTemplate, MessageTemplate, StandaloneEmailTemplate}, }; use anyhow::Context; use askama::Template; @@ -27,8 +27,7 @@ pub struct BodyData { pub async fn insert_newsletter_issue( transaction: &mut Transaction<'static, Postgres>, title: &str, - text_content: &str, - html_content: &str, + email_template: &dyn EmailTemplate, ) -> Result { let newsletter_issue_id = Uuid::new_v4(); let query = sqlx::query!( @@ -40,8 +39,8 @@ pub async fn insert_newsletter_issue( "#, newsletter_issue_id, title, - text_content, - html_content + email_template.text(), + email_template.html(), ); transaction.execute(query).await?; Ok(newsletter_issue_id) @@ -56,9 +55,10 @@ pub async fn enqueue_delivery_tasks( r#" INSERT INTO issue_delivery_queue ( newsletter_issue_id, - subscriber_email + subscriber_email, + unsubscribe_token ) - SELECT $1, email + SELECT $1, email, unsubscribe_token FROM subscriptions WHERE status = 'confirmed' "#, @@ -71,7 +71,9 @@ pub async fn enqueue_delivery_tasks( #[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))] pub async fn publish_newsletter( State(AppState { - connection_pool, .. + connection_pool, + base_url, + .. }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, @@ -90,7 +92,13 @@ pub async fn publish_newsletter( } }; - let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html) + let email_template = StandaloneEmailTemplate { + base_url: &base_url, + text_content: &form.text, + html_content: &form.html, + }; + + let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &email_template) .await .context("Failed to store newsletter issue details.")?; diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs index f25fa25..6bfa3bf 100644 --- a/src/routes/admin/posts.rs +++ b/src/routes/admin/posts.rs @@ -119,7 +119,5 @@ pub async fn create_newsletter( post_id, post_excerpt: "", }; - let html_content = template.render().unwrap(); - let text_content = template.text_version(); - insert_newsletter_issue(transaction, post_title, &text_content, &html_content).await + insert_newsletter_issue(transaction, post_title, &template).await } diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index 90f6174..cb10dc1 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -1,4 +1,8 @@ -use crate::{routes::AppError, startup::AppState, templates::UnsubscribeTemplate}; +use crate::{ + routes::AppError, + startup::AppState, + templates::{NotFoundTemplate, UnsubscribeTemplate}, +}; use anyhow::Context; use askama::Template; use axum::{ @@ -29,7 +33,11 @@ pub async fn unsubscribe( .context("Could not update subscriptions table.")?; if result.rows_affected() == 0 { - Ok(StatusCode::NOT_FOUND.into_response()) + Ok(( + StatusCode::NOT_FOUND, + Html(NotFoundTemplate.render().unwrap()), + ) + .into_response()) } else { Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response()) } diff --git a/src/templates.rs b/src/templates.rs index 96660e8..7cd31dd 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -63,8 +63,25 @@ pub struct NewPostEmailTemplate<'a> { pub post_excerpt: &'a str, } -impl<'a> NewPostEmailTemplate<'a> { - pub fn text_version(&self) -> String { +#[derive(Template)] +#[template(path = "../templates/email/standalone.html")] +pub struct StandaloneEmailTemplate<'a> { + pub base_url: &'a str, + pub html_content: &'a str, + pub text_content: &'a str, +} + +pub trait EmailTemplate: Sync { + fn html(&self) -> String; + fn text(&self) -> String; +} + +impl<'a> EmailTemplate for NewPostEmailTemplate<'a> { + fn html(&self) -> String { + self.render().unwrap() + } + + fn text(&self) -> String { format!( r#"New post available! @@ -86,10 +103,31 @@ Alphonse zero2prod - Building better backends with Rust Visit the blog: {} -Unsubscribe: {}/unsubscribe +Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN You're receiving this because you subscribed to the zero2prod newsletter."#, self.post_title, self.base_url, self.post_id, self.base_url, self.base_url, ) } } + +impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> { + fn html(&self) -> String { + self.render().unwrap() + } + + fn text(&self) -> String { + format!( + r#"{} + +--- + +zero2prod - Building better backends with Rust +Visit the blog: {} +Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN + +You're receiving this because you subscribed to the zero2prod newsletter."#, + self.text_content, self.base_url, self.base_url + ) + } +} diff --git a/templates/email/base.html b/templates/email/base.html new file mode 100644 index 0000000..2d3e480 --- /dev/null +++ b/templates/email/base.html @@ -0,0 +1,145 @@ + + + + + + + + + {% block title %}Updates{% endblock %} + - zero2prod + + + +
+
+

Updates from zero2prod

+

Fresh insights on Rust backend development

+
+
+ {% block content %}{% endblock %} +
+
+ + + diff --git a/templates/email/new_post.html b/templates/email/new_post.html index 7878f96..f80b92d 100644 --- a/templates/email/new_post.html +++ b/templates/email/new_post.html @@ -1,157 +1,20 @@ - - - - - - - - {{ post_title }} - - - -
-
-

A new post is available!

-

Fresh insights on Rust backend development

-
-
-

Hello there!

-

I just published a new post that I think you'll find interesting:

-
-

{{ post_title }}

- {% if !post_excerpt.is_empty() %}

{{ post_excerpt }}

{% endif %} -
- Read the full post → -

- This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey. -

-

Thanks for being a subscriber!

-

- Best regards, -
- Alphonse -

-
- -
- - +{% extends "base.html" %} +{% block title %}New post available: {{ post_title }}{% endblock %} +{% block content %} +

Hello there!

+

I just published a new post that I think you'll find interesting:

+
+

{{ post_title }}

+ {% if !post_excerpt.is_empty() %}

{{ post_excerpt }}

{% endif %} +
+ Read the full post → +

+ This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey. +

+

Thanks for being a subscriber!

+

+ Best regards, +
+ Alphonse +

+{% endblock %} diff --git a/templates/email/standalone.html b/templates/email/standalone.html new file mode 100644 index 0000000..b8c6303 --- /dev/null +++ b/templates/email/standalone.html @@ -0,0 +1,2 @@ +{% extends "base.html" %} +{% block content %}{{ html_content }}{% endblock %} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 5206d76..ffa009c 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -3,7 +3,7 @@ use argon2::{ password_hash::{SaltString, rand_core::OsRng}, }; use fake::{Fake, faker::internet::en::SafeEmail}; -use linkify::LinkFinder; +use linkify::{Link, LinkFinder}; use once_cell::sync::Lazy; use sqlx::{Connection, Executor, PgConnection, PgPool}; use uuid::Uuid; @@ -169,16 +169,29 @@ impl TestApp { } } + pub fn get_unsubscribe_links(&self, request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + let get_link = |s: &str| { + let links = get_links(s); + assert!(!links.is_empty()); + let mut confirmation_link = + reqwest::Url::parse(links.last().unwrap().as_str()).unwrap(); + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + confirmation_link.set_port(Some(self.port)).unwrap(); + confirmation_link + }; + + let html = get_link(body["html"].as_str().unwrap()); + let text = get_link(body["text"].as_str().unwrap()); + ConfirmationLinks { html, text } + } + pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks { let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); let get_link = |s: &str| { - let links: Vec<_> = LinkFinder::new() - .links(s) - .filter(|l| *l.kind() == linkify::LinkKind::Url) - .collect(); + let links = get_links(s); assert_eq!(links.len(), 1); - let raw_link = links[0].as_str(); - let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap(); + let mut confirmation_link = reqwest::Url::parse(links[0].as_str()).unwrap(); assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); confirmation_link.set_port(Some(self.port)).unwrap(); confirmation_link @@ -318,3 +331,29 @@ pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { pub fn when_sending_an_email() -> MockBuilder { Mock::given(path("/email")).and(method("POST")) } + +pub fn fake_newsletter_body() -> serde_json::Value { + serde_json::json!({ + "title": "Newsletter title", + "text": "Newsletter body as plain text", + "html": "

Newsletter body as HTML

", + "idempotency_key": Uuid::new_v4().to_string(), + + }) +} + +pub fn fake_post_body() -> serde_json::Value { + serde_json::json!({ + "title": "Post title", + "content": "Post content", + "idempotency_key": Uuid::new_v4().to_string(), + + }) +} + +pub fn get_links(s: &'_ str) -> Vec> { + LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect() +} diff --git a/tests/api/unsubscribe.rs b/tests/api/unsubscribe.rs index fc4db4e..bfc7284 100644 --- a/tests/api/unsubscribe.rs +++ b/tests/api/unsubscribe.rs @@ -1,9 +1,21 @@ -use crate::helpers::TestApp; +use wiremock::ResponseTemplate; + +use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email}; #[tokio::test] -async fn unsubscribe_works_with_a_valid_token() { +async fn subscriber_can_unsubscribe() { let app = TestApp::spawn().await; app.create_confirmed_subscriber().await; + app.admin_login().await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.post_newsletters(&fake_newsletter_body()).await; + app.dispatch_all_pending_emails().await; let record = sqlx::query!("SELECT unsubscribe_token FROM subscriptions") .fetch_one(&app.connection_pool) @@ -18,7 +30,7 @@ async fn unsubscribe_works_with_a_valid_token() { ) .await; - assert!(response.status().is_success()); + assert_eq!(response.status().as_u16(), 200); let html_fragment = response.text().await.unwrap(); assert!(html_fragment.contains("Good bye, old friend")); @@ -28,4 +40,90 @@ async fn unsubscribe_works_with_a_valid_token() { .expect("Failed to fetch subscription table"); assert!(record.is_none()); + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&app.email_server) + .await; + + app.post_newsletters(&fake_newsletter_body()).await; + app.dispatch_all_pending_emails().await; +} + +#[tokio::test] +async fn a_valid_unsubscribe_link_is_present_in_new_post_email_notifications() { + let app = TestApp::spawn().await; + app.create_confirmed_subscriber().await; + app.admin_login().await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.post_create_post(&fake_post_body()).await; + app.dispatch_all_pending_emails().await; + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + let unsubscribe_links = app.get_unsubscribe_links(&email_request); + reqwest::get(unsubscribe_links.html) + .await + .unwrap() + .error_for_status() + .unwrap(); + + let record = sqlx::query!("SELECT email FROM subscriptions") + .fetch_optional(&app.connection_pool) + .await + .expect("Failed to fetch subscription table"); + + assert!(record.is_none()); +} + +#[tokio::test] +async fn a_valid_unsubscribe_link_is_present_in_emails_manually_sent() { + let app = TestApp::spawn().await; + app.create_confirmed_subscriber().await; + app.admin_login().await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.post_newsletters(&fake_newsletter_body()).await; + app.dispatch_all_pending_emails().await; + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + let unsubscribe_links = app.get_unsubscribe_links(&email_request); + reqwest::get(unsubscribe_links.html) + .await + .unwrap() + .error_for_status() + .unwrap(); +} + +#[tokio::test] +async fn an_invalid_unsubscribe_token_is_rejected() { + let app = TestApp::spawn().await; + app.create_confirmed_subscriber().await; + + let response = reqwest::get(format!("{}/unsubscribe?token=invalid", app.address)) + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 404); }