diff --git a/.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json b/.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json new file mode 100644 index 0000000..c76eae3 --- /dev/null +++ b/.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e" +} diff --git a/.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json b/.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json new file mode 100644 index 0000000..ad9301e --- /dev/null +++ b/.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "post_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "author", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "published_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123" +} diff --git a/.sqlx/query-a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b.json b/.sqlx/query-a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b.json deleted file mode 100644 index aea5ffd..0000000 --- a/.sqlx/query-a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b" -} diff --git a/.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json b/.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json new file mode 100644 index 0000000..e05802a --- /dev/null +++ b/.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf" +} diff --git a/.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json b/.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json new file mode 100644 index 0000000..865badc --- /dev/null +++ b/.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "post_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "author", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "published_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f" +} diff --git a/migrations/20250921113345_add_unsubscribe_token_to_subscriptions.sql b/migrations/20250921113345_add_unsubscribe_token_to_subscriptions.sql new file mode 100644 index 0000000..500d55e --- /dev/null +++ b/migrations/20250921113345_add_unsubscribe_token_to_subscriptions.sql @@ -0,0 +1,5 @@ +ALTER TABLE subscriptions ADD COLUMN unsubscribe_token TEXT UNIQUE; + +UPDATE subscriptions +SET unsubscribe_token = left(md5(random()::text), 25) +WHERE status = 'confirmed' AND unsubscribe_token IS NULL; diff --git a/migrations/20250921152839_add_constraint_to_subscription_tokens_subscriber_id_fkey.sql b/migrations/20250921152839_add_constraint_to_subscription_tokens_subscriber_id_fkey.sql new file mode 100644 index 0000000..72139ef --- /dev/null +++ b/migrations/20250921152839_add_constraint_to_subscription_tokens_subscriber_id_fkey.sql @@ -0,0 +1,8 @@ +ALTER TABLE subscription_tokens +DROP CONSTRAINT subscription_tokens_subscriber_id_fkey; + +ALTER TABLE subscription_tokens +ADD CONSTRAINT subscription_tokens_subscriber_id_fkey + FOREIGN KEY (subscriber_id) + REFERENCES subscriptions (id) + ON DELETE CASCADE; diff --git a/src/routes.rs b/src/routes.rs index ae2f594..911e771 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -5,6 +5,7 @@ mod login; mod posts; mod subscriptions; mod subscriptions_confirm; +mod unsubscribe; pub use admin::*; use askama::Template; @@ -16,15 +17,38 @@ pub use health_check::*; pub use home::*; pub use login::*; pub use posts::*; +use rand::{Rng, distr::Alphanumeric}; use reqwest::StatusCode; pub use subscriptions::*; pub use subscriptions_confirm::*; +pub use unsubscribe::*; use crate::{ authentication::AuthError, templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate}, }; +pub fn generate_token() -> String { + let mut rng = rand::rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() +} + +fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter) -> std::fmt::Result { + writeln!(f, "{}", e)?; + let mut current = e.source(); + while let Some(cause) = current { + write!(f, "Caused by:\n\t{}", cause)?; + current = cause.source(); + if current.is_some() { + writeln!(f)?; + } + } + Ok(()) +} + #[derive(thiserror::Error)] pub enum AppError { #[error("An unexpected error was encountered.")] diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 789bac7..e79f096 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,7 +1,7 @@ use crate::{ domain::{NewSubscriber, SubscriberEmail}, email_client::EmailClient, - routes::AppError, + routes::{AppError, generate_token}, startup::AppState, templates::MessageTemplate, }; @@ -13,35 +13,10 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use chrono::Utc; -use rand::{Rng, distr::Alphanumeric}; use serde::Deserialize; use sqlx::{Executor, Postgres, Transaction}; use uuid::Uuid; -fn generate_subscription_token() -> String { - let mut rng = rand::rng(); - std::iter::repeat_with(|| rng.sample(Alphanumeric)) - .map(char::from) - .take(25) - .collect() -} - -pub fn error_chain_fmt( - e: &impl std::error::Error, - f: &mut std::fmt::Formatter, -) -> std::fmt::Result { - writeln!(f, "{}", e)?; - let mut current = e.source(); - while let Some(cause) = current { - write!(f, "Caused by:\n\t{}", cause)?; - current = cause.source(); - if current.is_some() { - writeln!(f)?; - } - } - Ok(()) -} - #[tracing::instrument( name = "Adding a new subscriber", skip(connection_pool, email_client, base_url, form), @@ -69,7 +44,7 @@ pub async fn subscribe( let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber) .await .context("Failed to insert new subscriber in the database.")?; - let subscription_token = generate_subscription_token(); + let subscription_token = generate_token(); store_token(&mut transaction, &subscription_token, &subscriber_id) .await .context("Failed to store the confirmation token for a new subscriber.")?; diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs index 9534063..ec6942b 100644 --- a/src/routes/subscriptions_confirm.rs +++ b/src/routes/subscriptions_confirm.rs @@ -1,4 +1,4 @@ -use crate::{startup::AppState, templates::ConfirmTemplate}; +use crate::{routes::generate_token, startup::AppState, templates::ConfirmTemplate}; use askama::Template; use axum::{ extract::{Query, State}, @@ -44,7 +44,8 @@ async fn confirm_subscriber( subscriber_id: &Uuid, ) -> Result<(), sqlx::Error> { sqlx::query!( - "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1", + "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2", + generate_token(), subscriber_id ) .execute(connection_pool) diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs new file mode 100644 index 0000000..90f6174 --- /dev/null +++ b/src/routes/unsubscribe.rs @@ -0,0 +1,36 @@ +use crate::{routes::AppError, startup::AppState, templates::UnsubscribeTemplate}; +use anyhow::Context; +use askama::Template; +use axum::{ + extract::{Query, State}, + response::{Html, IntoResponse, Response}, +}; +use reqwest::StatusCode; +use sqlx::Executor; + +#[derive(serde::Deserialize)] +pub struct UnsubQueryParams { + token: String, +} + +pub async fn unsubscribe( + Query(UnsubQueryParams { token }): Query, + State(AppState { + connection_pool, .. + }): State, +) -> Result { + let query = sqlx::query!( + "DELETE FROM subscriptions WHERE unsubscribe_token = $1", + token + ); + let result = connection_pool + .execute(query) + .await + .context("Could not update subscriptions table.")?; + + if result.rows_affected() == 0 { + Ok(StatusCode::NOT_FOUND.into_response()) + } else { + Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response()) + } +} diff --git a/src/startup.rs b/src/startup.rs index ec649dc..e2365bd 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -127,6 +127,7 @@ pub fn app( .route("/health_check", get(health_check)) .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) + .route("/unsubscribe", get(unsubscribe)) .route("/posts", get(list_posts)) .route("/posts/{post_id}", get(see_post)) .nest("/admin", admin_routes) diff --git a/src/templates.rs b/src/templates.rs index 5cbee1f..96660e8 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -50,6 +50,10 @@ pub struct ConfirmTemplate; #[template(path = "../templates/404.html")] pub struct NotFoundTemplate; +#[derive(Template)] +#[template(path = "../templates/unsubscribe.html")] +pub struct UnsubscribeTemplate; + #[derive(Template)] #[template(path = "../templates/email/new_post.html")] pub struct NewPostEmailTemplate<'a> { diff --git a/templates/unsubscribe.html b/templates/unsubscribe.html new file mode 100644 index 0000000..e0aa612 --- /dev/null +++ b/templates/unsubscribe.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Unsubscribed{% endblock %} +{% block content %} +
+
+
+
+ + + +
+

Good bye, old friend!

+

You've successfully unsubscribed

+

+ Your email has been removed from the database. You won't receive emails anymore. If you change your mind, you are welcome back any time! +

+ +
+
+
+{% endblock %} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index c087b6f..5206d76 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -189,6 +189,17 @@ impl TestApp { ConfirmationLinks { html, text } } + pub async fn get_unsubscribe(&self, unsubscribe_token: String) -> reqwest::Response { + self.api_client + .get(format!( + "{}/unsubscribe?token={}", + &self.address, unsubscribe_token + )) + .send() + .await + .expect("Failed to execute request") + } + pub async fn get_admin_dashboard(&self) -> reqwest::Response { self.api_client .get(format!("{}/admin/dashboard", &self.address)) diff --git a/tests/api/main.rs b/tests/api/main.rs index 0ff7f26..2d045bb 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -7,3 +7,4 @@ mod newsletters; mod posts; mod subscriptions; mod subscriptions_confirm; +mod unsubscribe; diff --git a/tests/api/unsubscribe.rs b/tests/api/unsubscribe.rs new file mode 100644 index 0000000..fc4db4e --- /dev/null +++ b/tests/api/unsubscribe.rs @@ -0,0 +1,31 @@ +use crate::helpers::TestApp; + +#[tokio::test] +async fn unsubscribe_works_with_a_valid_token() { + let app = TestApp::spawn().await; + app.create_confirmed_subscriber().await; + + let record = sqlx::query!("SELECT unsubscribe_token FROM subscriptions") + .fetch_one(&app.connection_pool) + .await + .expect("Failed to fetch saved token"); + + let response = app + .get_unsubscribe( + record + .unsubscribe_token + .expect("Confirmed subscriber should have a valid unsubscribe token"), + ) + .await; + + assert!(response.status().is_success()); + let html_fragment = response.text().await.unwrap(); + assert!(html_fragment.contains("Good bye, old friend")); + + let record = sqlx::query!("SELECT email FROM subscriptions") + .fetch_optional(&app.connection_pool) + .await + .expect("Failed to fetch subscription table"); + + assert!(record.is_none()); +}