Files
zero2prod/src/routes/subscriptions.rs
2025-09-22 15:44:02 +02:00

172 lines
4.9 KiB
Rust

use crate::{
domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient,
routes::{AppError, generate_token},
startup::AppState,
templates::MessageTemplate,
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use chrono::Utc;
use serde::Deserialize;
use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid;
#[tracing::instrument(
name = "Adding a new subscriber",
skip(connection_pool, email_client, base_url, form),
fields(
subscriber_email = %form.email,
)
)]
pub async fn subscribe(
State(AppState {
connection_pool,
email_client,
base_url,
..
}): State<AppState>,
Form(form): Form<SubscriptionFormData>,
) -> Result<Response, AppError> {
let new_subscriber = form
.try_into()
.context("Failed to parse subscription form data.")
.map_err(AppError::FormError)?;
let mut transaction = connection_pool
.begin()
.await
.context("Failed to acquire a Postgres connection from the pool.")?;
if let Some(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber)
.await
.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 send a confirmation email.")?;
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
}
let template = MessageTemplate::Success {
message: "You'll receive a confirmation email shortly.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
}
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(transaction, new_subscriber)
)]
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> 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#"
INSERT INTO subscriptions (id, email, subscribed_at, status)
VALUES ($1, $2, $3, 'pending_confirmation')
"#,
subscriber_id,
new_subscriber.email.as_ref(),
Utc::now()
);
transaction.execute(query).await?;
Ok(Some(subscriber_id))
}
#[tracing::instrument(
name = "Store subscription token in the database",
skip(transaction, subscription_token)
)]
async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
subscription_token: &str,
subscriber_id: &Uuid,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO subscription_tokens (subscription_token, subscriber_id)
VALUES ($1, $2)
"#,
subscription_token,
subscriber_id,
);
transaction.execute(query).await?;
Ok(())
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: &NewSubscriber,
base_url: &str,
subscription_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url, subscription_token
);
let html_content = format!(
"Welcome to our newsletter!<br />\
Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
let text_content = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
email_client
.send_email(
&new_subscriber.email,
"Welcome!",
&html_content,
&text_content,
)
.await
}
#[derive(Debug, Deserialize)]
pub struct SubscriptionFormData {
email: String,
}
impl TryFrom<SubscriptionFormData> for NewSubscriber {
type Error = anyhow::Error;
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
let email = SubscriberEmail::parse(value.email)?;
Ok(Self { email })
}
}