From 97eda86ad41ed10309c583607af504a2d7d69b5a Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Fri, 21 Feb 2025 15:02:23 -0800 Subject: [PATCH] add support for postmark email backend --- example.env | 6 +- src/app_state.rs | 12 +--- src/email.rs | 155 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 35 ++++++----- src/router.rs | 24 +++----- src/settings.rs | 21 +++++-- src/v0_router.rs | 23 ++++--- 7 files changed, 217 insertions(+), 59 deletions(-) create mode 100644 src/email.rs diff --git a/example.env b/example.env index 079c618..90842b3 100644 --- a/example.env +++ b/example.env @@ -8,6 +8,6 @@ AUTH.TOKEN_URL=https://example.com/token AUTH.USERINFO_URL=https://example.com/userinfo EMAIL.VERIFICATION_FROM=no-reply@shout.dev EMAIL.MESSAGE_FROM=no-reply@shout.dev -EMAIL.SMTP_SERVER=smtp.example.com -EMAIL.SMTP_USERNAME= -EMAIL.SMTP_PASSWORD= +EMAIL.SMTP.SERVER=smtp.example.com +EMAIL.SMTP.USERNAME= +EMAIL.SMTP.PASSWORD= diff --git a/src/app_state.rs b/src/app_state.rs index bd95a1b..258e9ce 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -3,10 +3,9 @@ use axum::{ http::request::Parts, }; use deadpool_diesel::postgres::{Connection, Pool}; -use lettre::SmtpTransport; use oauth2::basic::BasicClient; -use crate::{app_error::AppError, sessions::PgStore, settings::Settings}; +use crate::{app_error::AppError, email::Mailer, sessions::PgStore, settings::Settings}; #[derive(Clone)] pub struct AppState { @@ -18,15 +17,6 @@ pub struct AppState { pub settings: Settings, } -#[derive(Clone)] -pub struct Mailer(pub SmtpTransport); - -impl FromRef for Mailer { - fn from_ref(state: &AppState) -> Self { - state.mailer.clone() - } -} - #[derive(Clone)] pub struct ReqwestClient(pub reqwest::Client); diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..19faea8 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,155 @@ +use anyhow::{Context, Result}; +use axum::extract::FromRef; +use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use serde::{Serialize, Serializer}; + +use crate::app_state::AppState; + +const POSTMARK_EMAIL_BATCH_URL: &'static str = "https://api.postmarkapp.com/email/batch"; + +#[derive(Serialize)] +pub struct Message { + #[serde(rename = "From")] + pub from: lettre::message::Mailbox, + #[serde(rename = "To")] + #[serde(serialize_with = "serialize_mailboxes")] + pub to: lettre::message::Mailboxes, + #[serde(rename = "Subject")] + pub subject: String, + #[serde(rename = "TextBody")] + pub text_body: String, +} + +pub trait MailSender: Clone + Sync { + async fn send_batch(&self, emails: Vec) -> Result<(), anyhow::Error>; +} + +#[derive(Clone, Debug)] +pub enum Mailer { + Smtp(SmtpSender), + Postmark(PostmarkSender), +} + +impl Mailer { + pub fn new_smtp(opts: SmtpOptions) -> Result { + Ok(Self::Smtp(SmtpSender::new(opts)?)) + } + + pub fn new_postmark(server_token: String) -> Result { + Ok(Self::Postmark(PostmarkSender::new(server_token)?)) + } + + pub fn with_reqwest_client(self, client: reqwest::Client) -> Self { + match self { + Self::Postmark(sender) => Self::Postmark(sender.with_reqwest_client(client)), + _ => self, + } + } +} + +impl MailSender for Mailer { + async fn send_batch(&self, emails: Vec) -> Result<(), anyhow::Error> { + match self { + Mailer::Smtp(sender) => sender.send_batch(emails).await, + Mailer::Postmark(sender) => sender.send_batch(emails).await, + } + } +} + +#[derive(Clone, Debug)] +struct SmtpSender { + transport: AsyncSmtpTransport, +} + +#[derive(Debug)] +pub struct SmtpOptions { + pub server: String, + pub username: String, + pub password: String, +} + +impl SmtpSender { + fn new(opts: SmtpOptions) -> Result { + let mailer_creds = + lettre::transport::smtp::authentication::Credentials::new(opts.username, opts.password); + let transport = lettre::AsyncSmtpTransport::::starttls_relay(&opts.server) + .context("unable to initialize starttls_relay")? + .credentials(mailer_creds) + .build(); + Ok(Self { transport }) + } +} + +impl TryInto for Message { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let mut builder = lettre::Message::builder() + .from(self.from.clone()) + .subject(self.subject) + .header(lettre::message::header::ContentType::TEXT_PLAIN); + for to_addr in self.to { + builder = builder.to(to_addr); + } + let message = builder.body(self.text_body)?; + Ok(message) + } +} + +fn serialize_mailboxes(t: &lettre::message::Mailboxes, s: S) -> Result +where + S: Serializer, +{ + Ok(s.serialize_str(&t.to_string())?) +} + +impl MailSender for SmtpSender { + async fn send_batch(&self, emails: Vec) -> Result<()> { + for email in emails { + self.transport.send(email.try_into()?).await?; + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct PostmarkSender { + client: reqwest::Client, + server_token: String, +} + +impl PostmarkSender { + fn new(server_token: String) -> Result { + Ok(Self { + client: reqwest::ClientBuilder::new().https_only(true).build()?, + server_token, + }) + } + + pub fn with_reqwest_client(self, client: reqwest::Client) -> Self { + Self { client, ..self } + } +} + +impl MailSender for PostmarkSender { + async fn send_batch(&self, emails: Vec) -> Result<()> { + if emails.len() > 500 { + return Err(anyhow::anyhow!( + "Postmark sends no more than 500 messages per batch" + )); + } + self.client + .post(POSTMARK_EMAIL_BATCH_URL) + .header("X-Postmark-Server-Token", &self.server_token) + .json(&emails) + .send() + .await?; + Ok(()) + } +} + +impl FromRef for Mailer { + fn from_ref(state: &AppState) -> Mailer { + state.mailer.clone() + } +} diff --git a/src/main.rs b/src/main.rs index 4433825..07eb4a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod auth; mod channel_selections; mod channels; mod csrf; +mod email; mod guards; mod messages; mod nav_state; @@ -18,13 +19,13 @@ mod teams; mod users; mod v0_router; +use std::process::exit; + +use email::SmtpOptions; use tracing_subscriber::EnvFilter; use crate::{ - app_state::{AppState, Mailer}, - router::new_router, - sessions::PgStore, - settings::Settings, + app_state::AppState, email::Mailer, router::new_router, sessions::PgStore, settings::Settings, }; #[tokio::main] @@ -42,22 +43,28 @@ async fn main() { .build() .unwrap(); let session_store = PgStore::new(db_pool.clone()); - let mailer_creds = lettre::transport::smtp::authentication::Credentials::new( - settings.email.smtp_username.clone(), - settings.email.smtp_password.clone(), - ); - let mailer = Mailer( - lettre::SmtpTransport::starttls_relay(&settings.email.smtp_server) - .unwrap() - .credentials(mailer_creds) - .build(), - ); let reqwest_client = reqwest::ClientBuilder::new() .https_only(true) .build() .unwrap(); let oauth_client = auth::new_oauth_client(&settings).unwrap(); + let mailer = if let Some(smtp_settings) = settings.email.smtp.clone() { + Mailer::new_smtp(SmtpOptions { + server: smtp_settings.server, + username: smtp_settings.username, + password: smtp_settings.password, + }) + .unwrap() + } else if let Some(postmark_settings) = settings.email.postmark.clone() { + Mailer::new_postmark(postmark_settings.server_token) + .unwrap() + .with_reqwest_client(reqwest_client.clone()) + } else { + tracing::error!("no email backend settings configured"); + exit(1); + }; + let app_state = AppState { db_pool, mailer, diff --git a/src/router.rs b/src/router.rs index 0460a10..ac852f4 100644 --- a/src/router.rs +++ b/src/router.rs @@ -10,7 +10,6 @@ use axum::{ }; use axum_extra::extract::Form; use diesel::{delete, dsl::insert_into, prelude::*, update}; -use lettre::Transport as _; use rand::{distributions::Uniform, Rng}; use regex::Regex; use serde::Deserialize; @@ -25,11 +24,12 @@ use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, - app_state::{AppState, DbConn, Mailer}, + app_state::{AppState, DbConn}, auth, channel_selections::ChannelSelection, channels::Channel, csrf::generate_csrf_token, + email::{MailSender as _, Mailer}, guards, nav_state::{Breadcrumb, NavState}, projects::Project, @@ -487,7 +487,7 @@ async fn update_channel_email_recipient( .. }): State, DbConn(db_conn): DbConn, - State(Mailer(mailer)): State, + State(mailer): State, Path((team_id, channel_id)): Path<(Uuid, Uuid)>, CurrentUser(current_user): CurrentUser, Form(form_body): Form, @@ -557,17 +557,13 @@ async fn update_channel_email_recipient( "Sending email verification code to: {}", form_body.recipient ); - let email = lettre::Message::builder() - .from(email_settings.verification_from.clone().into()) - .reply_to(email_settings.verification_from.clone().into()) - .to(form_body.recipient.parse()?) - .subject("Verify Your Email") - .header(lettre::message::header::ContentType::TEXT_PLAIN) - .body(format!( - "Your email verification code is: {}", - verification_code - ))?; - mailer.send(&email)?; + let email = crate::email::Message { + from: email_settings.verification_from.into(), + to: form_body.recipient.parse()?, + subject: "Verify Your Email".to_string(), + text_body: format!("Your email verification code is: {}", verification_code), + }; + mailer.send_batch(vec![email]).await?; Ok(Redirect::to(&format!( "{}/teams/{}/channels/{}", diff --git a/src/settings.rs b/src/settings.rs index 37cfe36..0418216 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -49,13 +49,24 @@ fn default_cookie_name() -> String { "SHOUT_DOT_DEV_SESSION".to_string() } +#[derive(Clone, Debug, Deserialize)] +pub struct SmtpSettings { + pub server: String, + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PostmarkSettings { + pub server_token: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct EmailSettings { - pub verification_from: lettre::Address, - pub message_from: lettre::Address, - pub smtp_server: String, - pub smtp_username: String, - pub smtp_password: String, + pub verification_from: lettre::message::Mailbox, + pub message_from: lettre::message::Mailbox, + pub smtp: Option, + pub postmark: Option, } pub struct SlackSettings { diff --git a/src/v0_router.rs b/src/v0_router.rs index f49a2b9..96e1bf5 100644 --- a/src/v0_router.rs +++ b/src/v0_router.rs @@ -6,7 +6,6 @@ use axum::{ Router, }; use diesel::{dsl::insert_into, prelude::*, update}; -use lettre::Transport as _; use serde::Deserialize; use serde_json::json; use uuid::Uuid; @@ -14,7 +13,8 @@ use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, - app_state::{AppState, DbConn, Mailer}, + app_state::{AppState, DbConn}, + email::{MailSender as _, Mailer}, projects::Project, schema::{api_keys, projects}, settings::Settings, @@ -36,7 +36,7 @@ async fn say_get( email: email_settings, .. }): State, - State(Mailer(mailer)): State, + State(mailer): State, DbConn(db_conn): DbConn, Query(query): Query, ) -> Result { @@ -89,16 +89,15 @@ async fn say_get( for channel in selected_channels { if let Some(email_data) = channel.email_data { if email_data.verified { - let recipient: lettre::Address = email_data.recipient.parse()?; - let email = lettre::Message::builder() - .from(email_settings.message_from.clone().into()) - .reply_to(email_settings.message_from.clone().into()) - .to(recipient.into()) - .subject("Shout") - .header(lettre::message::header::ContentType::TEXT_PLAIN) - .body(query.message.clone())?; + let recipient: lettre::message::Mailbox = email_data.recipient.parse()?; + let email = crate::email::Message { + from: email_settings.message_from.clone().into(), + to: recipient.into(), + subject: "Shout".to_string(), + text_body: query.message.clone(), + }; tracing::info!("Sending email to recipient for channel {}", channel.id); - mailer.send(&email)?; + mailer.send_batch(vec![email]).await?; } else { tracing::info!("Email recipient for channel {} is not verified", channel.id); }