From 6a963a8c0d94ec477900a39799e0ba17f6d9493c Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Sun, 21 Sep 2025 03:45:29 +0200 Subject: [PATCH] HTML and plain text for new post mail notifications --- src/email_client.rs | 17 +--- src/routes/admin/newsletters.rs | 2 +- src/routes/admin/posts.rs | 27 ++++-- src/templates.rs | 44 ++++++++- templates/email/new_post.html | 157 ++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 templates/email/new_post.html diff --git a/src/email_client.rs b/src/email_client.rs index c539ca9..d5036c6 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -33,12 +33,8 @@ impl EmailClient { ) -> Result<(), reqwest::Error> { let url = self.base_url.join("email").unwrap(); let request_body = SendEmailRequest { - from: EmailField { - email: self.sender.as_ref(), - }, - to: vec![EmailField { - email: recipient.as_ref(), - }], + from: self.sender.as_ref(), + to: recipient.as_ref(), subject, text: text_content, html: html_content, @@ -61,18 +57,13 @@ impl EmailClient { #[derive(serde::Serialize)] struct SendEmailRequest<'a> { - from: EmailField<'a>, - to: Vec>, + from: &'a str, + to: &'a str, subject: &'a str, text: &'a str, html: &'a str, } -#[derive(serde::Serialize)] -struct EmailField<'a> { - email: &'a str, -} - #[cfg(test)] mod tests { use crate::{ diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index ce45515..b4c8388 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -112,7 +112,7 @@ pub async fn publish_newsletter( fn validate_form(form: &BodyData) -> Result<(), &'static str> { if form.title.is_empty() { - return Err("The title was empty"); + return Err("The title was empty."); } if form.html.is_empty() || form.text.is_empty() { return Err("The content was empty."); diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs index bcfe834..f25fa25 100644 --- a/src/routes/admin/posts.rs +++ b/src/routes/admin/posts.rs @@ -3,7 +3,7 @@ use crate::{ idempotency::{IdempotencyKey, save_response, try_processing}, routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue}, startup::AppState, - templates::MessageTemplate, + templates::{MessageTemplate, NewPostEmailTemplate}, }; use anyhow::Context; use askama::Template; @@ -34,7 +34,9 @@ fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> { #[tracing::instrument(name = "Creating a post", skip(connection_pool, form))] pub async fn create_post( State(AppState { - connection_pool, .. + connection_pool, + base_url, + .. }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, @@ -57,7 +59,7 @@ pub async fn create_post( .await .context("Failed to insert new post in the database.")?; - let newsletter_uuid = create_newsletter(&mut transaction, &form.title, &form.content, &post_id) + let newsletter_uuid = create_newsletter(&mut transaction, &base_url, &form.title, &post_id) .await .context("Failed to create newsletter.")?; @@ -103,14 +105,21 @@ pub async fn insert_post( #[tracing::instrument( name = "Creating newsletter for new post", - skip(transaction, title, content, _post_id) + skip(transaction, post_title, post_id) )] pub async fn create_newsletter( transaction: &mut Transaction<'static, Postgres>, - title: &str, - content: &str, - _post_id: &Uuid, + base_url: &str, + post_title: &str, + post_id: &Uuid, ) -> Result { - // We need to send a special link with a unique ID to determine if the user clicked it or not. - insert_newsletter_issue(transaction, title, content, content).await + let template = NewPostEmailTemplate { + base_url, + post_title, + post_id, + post_excerpt: "", + }; + let html_content = template.render().unwrap(); + let text_content = template.text_version(); + insert_newsletter_issue(transaction, post_title, &text_content, &html_content).await } diff --git a/src/templates.rs b/src/templates.rs index 20d0ec2..5cbee1f 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,6 +1,6 @@ -use askama::Template; - use crate::domain::PostEntry; +use askama::Template; +use uuid::Uuid; #[derive(Template)] pub enum MessageTemplate { @@ -49,3 +49,43 @@ pub struct ConfirmTemplate; #[derive(Template)] #[template(path = "../templates/404.html")] pub struct NotFoundTemplate; + +#[derive(Template)] +#[template(path = "../templates/email/new_post.html")] +pub struct NewPostEmailTemplate<'a> { + pub base_url: &'a str, + pub post_title: &'a str, + pub post_id: &'a Uuid, + pub post_excerpt: &'a str, +} + +impl<'a> NewPostEmailTemplate<'a> { + pub fn text_version(&self) -> String { + format!( + r#"New post available! + +Hello there! +I just published a new post that I think you'll find interesting: + +"{}" + +Read the full post: {}/posts/{} + +This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey. + +Thanks for being a subscriber! + +Best regards, +Alphonse + +--- + +zero2prod - Building better backends with Rust +Visit the blog: {} +Unsubscribe: {}/unsubscribe + +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, + ) + } +} diff --git a/templates/email/new_post.html b/templates/email/new_post.html new file mode 100644 index 0000000..7878f96 --- /dev/null +++ b/templates/email/new_post.html @@ -0,0 +1,157 @@ + + + + + + + + {{ post_title }} + + + +
+
+

A new post is available!

+

Fresh insights on Rust backend development

+
+
+

Hello there!

+

I just published a new post that I think you'll find interesting:

+
+

{{ post_title }}

+ {% if !post_excerpt.is_empty() %}

{{ post_excerpt }}

{% endif %} +
+ Read the full post → +

+ This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey. +

+

Thanks for being a subscriber!

+

+ Best regards, +
+ Alphonse +

+
+ +
+ +