Basic unsubscribe endpoint

This commit is contained in:
Alphonse Paix
2025-09-21 17:49:31 +02:00
parent 0725b87bf2
commit 829f3e4e4f
17 changed files with 292 additions and 43 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -5,6 +5,7 @@ mod login;
mod posts; mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
mod unsubscribe;
pub use admin::*; pub use admin::*;
use askama::Template; use askama::Template;
@@ -16,15 +17,38 @@ pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
pub use posts::*; pub use posts::*;
use rand::{Rng, distr::Alphanumeric};
use reqwest::StatusCode; use reqwest::StatusCode;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
pub use unsubscribe::*;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate}, 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)] #[derive(thiserror::Error)]
pub enum AppError { pub enum AppError {
#[error("An unexpected error was encountered.")] #[error("An unexpected error was encountered.")]

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
domain::{NewSubscriber, SubscriberEmail}, domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient, email_client::EmailClient,
routes::AppError, routes::{AppError, generate_token},
startup::AppState, startup::AppState,
templates::MessageTemplate, templates::MessageTemplate,
}; };
@@ -13,35 +13,10 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc; use chrono::Utc;
use rand::{Rng, distr::Alphanumeric};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{Executor, Postgres, Transaction}; use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid; 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( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(connection_pool, email_client, base_url, form), 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) let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
.await .await
.context("Failed to insert new subscriber in the database.")?; .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) store_token(&mut transaction, &subscription_token, &subscriber_id)
.await .await
.context("Failed to store the confirmation token for a new subscriber.")?; .context("Failed to store the confirmation token for a new subscriber.")?;

View File

@@ -1,4 +1,4 @@
use crate::{startup::AppState, templates::ConfirmTemplate}; use crate::{routes::generate_token, startup::AppState, templates::ConfirmTemplate};
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
@@ -44,7 +44,8 @@ async fn confirm_subscriber(
subscriber_id: &Uuid, subscriber_id: &Uuid,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
sqlx::query!( 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 subscriber_id
) )
.execute(connection_pool) .execute(connection_pool)

36
src/routes/unsubscribe.rs Normal file
View File

@@ -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<UnsubQueryParams>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
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())
}
}

View File

@@ -127,6 +127,7 @@ pub fn app(
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm)) .route("/subscriptions/confirm", get(confirm))
.route("/unsubscribe", get(unsubscribe))
.route("/posts", get(list_posts)) .route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post)) .route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes) .nest("/admin", admin_routes)

View File

@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
#[template(path = "../templates/404.html")] #[template(path = "../templates/404.html")]
pub struct NotFoundTemplate; pub struct NotFoundTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe.html")]
pub struct UnsubscribeTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "../templates/email/new_post.html")] #[template(path = "../templates/email/new_post.html")]
pub struct NewPostEmailTemplate<'a> { pub struct NewPostEmailTemplate<'a> {

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Unsubscribed{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<div class="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<h1 class="text-4xl font-bold text-gray-800 mb-4">Good bye, old friend!</h1>
<h2 class="text-xl font-medium text-gray-600 mb-6">You've successfully unsubscribed</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
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!
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<a href="/posts"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Posts
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -189,6 +189,17 @@ impl TestApp {
ConfirmationLinks { html, text } 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 { pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/admin/dashboard", &self.address)) .get(format!("{}/admin/dashboard", &self.address))

View File

@@ -7,3 +7,4 @@ mod newsletters;
mod posts; mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
mod unsubscribe;

31
tests/api/unsubscribe.rs Normal file
View File

@@ -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());
}