use crate::{ domain::{NewSubscriber, SubscriberEmail, SubscriberName}, email_client::EmailClient, startup::AppState, }; use anyhow::Context; use axum::{ Form, Json, extract::State, http::StatusCode, response::{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(()) } #[derive(thiserror::Error)] pub enum SubscribeError { #[error(transparent)] UnexpectedError(#[from] anyhow::Error), #[error("{0}")] ValidationError(String), } impl std::fmt::Debug for SubscribeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { error_chain_fmt(self, f) } } impl IntoResponse for SubscribeError { fn into_response(self) -> Response { #[derive(serde::Serialize)] struct ErrorResponse<'a> { message: &'a str, } tracing::error!("{:?}", self); let status = match self { SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST, }; let message = "An internal server error occured."; (status, Json(ErrorResponse { message })).into_response() } } #[tracing::instrument( name = "Adding a new subscriber", skip(connection_pool, email_client, base_url, form), fields( subscriber_email = %form.email, subscriber_name = %form.name ) )] pub async fn subscribe( State(AppState { connection_pool, email_client, base_url, }): State, Form(form): Form, ) -> Result { let mut transaction = connection_pool .begin() .await .context("Failed to acquire a Postgres connection from the pool.")?; let new_subscriber = form.try_into().map_err(SubscribeError::ValidationError)?; 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(); store_token(&mut transaction, &subscription_token, &subscriber_id) .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.")?; transaction .commit() .await .context("Failed to commit the database transaction to store a new subscriber.")?; Ok(StatusCode::OK.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 { let subscriber_id = Uuid::new_v4(); let query = sqlx::query!( r#" INSERT INTO subscriptions (id, email, name, subscribed_at, status) VALUES ($1, $2, $3, $4, 'pending_confirmation') "#, subscriber_id, new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), Utc::now() ); transaction.execute(query).await?; Ok(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!
\ Click here 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)] #[allow(dead_code)] pub struct FormData { name: String, email: String, } impl TryFrom for NewSubscriber { type Error = String; fn try_from(value: FormData) -> Result { let name = SubscriberName::parse(value.name)?; let email = SubscriberEmail::parse(value.email)?; Ok(Self { name, email }) } }