Unsubscribe option available on website

This commit is contained in:
Alphonse Paix
2025-09-22 15:44:02 +02:00
parent 4b5fbc2eb3
commit 6f9d33953c
19 changed files with 397 additions and 91 deletions

View File

@@ -110,6 +110,7 @@ pub struct DatabaseSettings {
pub host: String,
pub database_name: String,
pub require_ssl: bool,
pub timeout_millis: u64,
}
impl DatabaseSettings {

View File

@@ -111,7 +111,7 @@ impl IntoResponse for AppError {
};
Html(template.render().unwrap())
};
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
html.into_response()
}
AppError::FormError(error) => {
let template = MessageTemplate::Error {

View File

@@ -41,28 +41,33 @@ pub async fn subscribe(
.begin()
.await
.context("Failed to acquire a Postgres connection from the pool.")?;
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
if let Some(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber)
.await
.context("Failed to insert new subscriber in the database.")?;
let subscription_token = generate_token();
store_token(&mut transaction, &subscription_token, &subscriber_id)
.context("Failed to insert new subscriber in the database.")
.map_err(AppError::unexpected_message)?
{
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.")
.map_err(AppError::unexpected_message)?;
send_confirmation_email(
&email_client,
&new_subscriber,
&base_url,
&subscription_token,
)
.await
.context("Failed to store the confirmation token for a new subscriber.")?;
send_confirmation_email(
&email_client,
&new_subscriber,
&base_url,
&subscription_token,
)
.await
.context("Failed to send a confirmation email.")?;
.context("Failed to send a confirmation email.")?;
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
}
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
let template = MessageTemplate::Success {
message: "A confirmation email has been sent.".to_string(),
message: "You'll receive a confirmation email shortly.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
}
@@ -74,7 +79,15 @@ pub async fn subscribe(
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> Result<Uuid, sqlx::Error> {
) -> Result<Option<Uuid>, sqlx::Error> {
let query = sqlx::query!(
"SELECT id FROM subscriptions WHERE email = $1",
new_subscriber.email.as_ref()
);
let existing = transaction.fetch_optional(query).await?;
if existing.is_some() {
return Ok(None);
}
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
@@ -86,7 +99,7 @@ pub async fn insert_subscriber(
Utc::now()
);
transaction.execute(query).await?;
Ok(subscriber_id)
Ok(Some(subscriber_id))
}
#[tracing::instrument(

View File

@@ -1,23 +1,109 @@
use crate::{
domain::SubscriberEmail,
email_client::EmailClient,
routes::AppError,
startup::AppState,
templates::{NotFoundTemplate, UnsubscribeTemplate},
templates::{
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
},
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::{Query, State},
response::{Html, IntoResponse, Response},
};
use reqwest::StatusCode;
use sqlx::Executor;
use sqlx::{Executor, PgPool};
#[derive(serde::Deserialize)]
pub struct UnsubQueryParams {
token: String,
}
pub async fn unsubscribe(
pub async fn get_unsubscribe() -> Response {
Html(UnsubscribeTemplate.render().unwrap()).into_response()
}
#[derive(serde::Deserialize)]
pub struct UnsubFormData {
email: String,
}
pub async fn post_unsubscribe(
State(AppState {
connection_pool,
email_client,
base_url,
}): State<AppState>,
Form(UnsubFormData { email }): Form<UnsubFormData>,
) -> Result<Response, AppError> {
let subscriber_email = SubscriberEmail::parse(email)?;
if let Some(token) = fetch_unsubscribe_token(&connection_pool, &subscriber_email)
.await
.context("Could not fetch unsubscribe token.")?
{
send_unsubscribe_email(&email_client, &subscriber_email, &base_url, &token)
.await
.context("Failed to send a confirmation email.")?;
}
let template = MessageTemplate::Success {
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
};
Ok(Html(template.render().unwrap()).into_response())
}
#[tracing::instrument(name = "Fetching unsubscribe token from the database", skip_all)]
async fn fetch_unsubscribe_token(
connection_pool: &PgPool,
subscriber_email: &SubscriberEmail,
) -> Result<Option<String>, sqlx::Error> {
let r = sqlx::query!(
"SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
subscriber_email.as_ref()
)
.fetch_optional(connection_pool)
.await?;
Ok(r.and_then(|r| r.unsubscribe_token))
}
#[tracing::instrument(name = "Send an unsubscribe confirmation email", skip_all)]
pub async fn send_unsubscribe_email(
email_client: &EmailClient,
subscriber_email: &SubscriberEmail,
base_url: &str,
unsubscribe_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/unsubscribe/confirm?token={}",
base_url, unsubscribe_token
);
let html_content = format!(
"You've requested to unsubscribe from my emails. To confirm, please click the link below:<br />\
<a href=\"{}\">Confirm unsubscribe</a><br />\
If you did not request this, you can safely ignore this email.",
confirmation_link
);
let text_content = format!(
"You've requested to unsubscribe from my emails. To confirm, please follow the link below:\
{}\
If you did not request this, you can safely ignore this email.",
confirmation_link
);
email_client
.send_email(
subscriber_email,
"I will miss you",
&html_content,
&text_content,
)
.await
}
#[tracing::instrument(name = "Removing user from database if he exists", skip_all)]
pub async fn unsubscribe_confirm(
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
State(AppState {
connection_pool, ..
@@ -33,12 +119,14 @@ pub async fn unsubscribe(
.context("Could not update subscriptions table.")?;
if result.rows_affected() == 0 {
tracing::info!("Unsubscribe token is not tied to any confirmed user");
Ok((
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response())
} else {
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
tracing::info!("User successfully removed");
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())
}
}

View File

@@ -9,7 +9,7 @@ use axum::{
use axum_server::tls_rustls::RustlsConfig;
use secrecy::ExposeSecret;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{net::TcpListener, sync::Arc};
use std::{net::TcpListener, sync::Arc, time::Duration};
use tower_http::{services::ServeDir, trace::TraceLayer};
use tower_sessions::SessionManagerLayer;
use tower_sessions_redis_store::{
@@ -37,8 +37,9 @@ impl Application {
"{}:{}",
configuration.application.host, configuration.application.port
);
let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let connection_pool = PgPoolOptions::new()
.acquire_timeout(Duration::from_millis(configuration.database.timeout_millis))
.connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap();
let pool = Pool::new(
Config::from_url(configuration.redis_uri.expose_secret())
@@ -81,7 +82,7 @@ impl Application {
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
tracing::debug!("listening on {}", self.local_addr());
tracing::debug!("Listening on {}", self.local_addr());
if let Some(tls_config) = self.tls_config {
axum_server::from_tcp_rustls(self.listener, tls_config)
.serve(self.router.into_make_service())
@@ -127,7 +128,8 @@ pub fn app(
.route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm))
.route("/unsubscribe", get(unsubscribe))
.route("/unsubscribe", get(get_unsubscribe).post(post_unsubscribe))
.route("/unsubscribe/confirm", get(unsubscribe_confirm))
.route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes)

View File

@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
#[template(path = "../templates/404.html")]
pub struct NotFoundTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe_confirm.html")]
pub struct UnsubscribeConfirmTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe.html")]
pub struct UnsubscribeTemplate;
@@ -103,7 +107,7 @@ Alphonse
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
Unsubscribe: {}/unsubscribe/confirm?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,
@@ -124,7 +128,7 @@ impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> {
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
Unsubscribe: {}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN
You're receiving this because you subscribed to the zero2prod newsletter."#,
self.text_content, self.base_url, self.base_url