add support for postmark email backend

This commit is contained in:
Brent Schroeter 2025-02-21 15:02:23 -08:00
parent d430516675
commit 97eda86ad4
7 changed files with 217 additions and 59 deletions

View file

@ -8,6 +8,6 @@ AUTH.TOKEN_URL=https://example.com/token
AUTH.USERINFO_URL=https://example.com/userinfo AUTH.USERINFO_URL=https://example.com/userinfo
EMAIL.VERIFICATION_FROM=no-reply@shout.dev EMAIL.VERIFICATION_FROM=no-reply@shout.dev
EMAIL.MESSAGE_FROM=no-reply@shout.dev EMAIL.MESSAGE_FROM=no-reply@shout.dev
EMAIL.SMTP_SERVER=smtp.example.com EMAIL.SMTP.SERVER=smtp.example.com
EMAIL.SMTP_USERNAME= EMAIL.SMTP.USERNAME=
EMAIL.SMTP_PASSWORD= EMAIL.SMTP.PASSWORD=

View file

@ -3,10 +3,9 @@ use axum::{
http::request::Parts, http::request::Parts,
}; };
use deadpool_diesel::postgres::{Connection, Pool}; use deadpool_diesel::postgres::{Connection, Pool};
use lettre::SmtpTransport;
use oauth2::basic::BasicClient; 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -18,15 +17,6 @@ pub struct AppState {
pub settings: Settings, pub settings: Settings,
} }
#[derive(Clone)]
pub struct Mailer(pub SmtpTransport);
impl FromRef<AppState> for Mailer {
fn from_ref(state: &AppState) -> Self {
state.mailer.clone()
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ReqwestClient(pub reqwest::Client); pub struct ReqwestClient(pub reqwest::Client);

155
src/email.rs Normal file
View file

@ -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<Message>) -> Result<(), anyhow::Error>;
}
#[derive(Clone, Debug)]
pub enum Mailer {
Smtp(SmtpSender),
Postmark(PostmarkSender),
}
impl Mailer {
pub fn new_smtp(opts: SmtpOptions) -> Result<Self> {
Ok(Self::Smtp(SmtpSender::new(opts)?))
}
pub fn new_postmark(server_token: String) -> Result<Self> {
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<Message>) -> 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<Tokio1Executor>,
}
#[derive(Debug)]
pub struct SmtpOptions {
pub server: String,
pub username: String,
pub password: String,
}
impl SmtpSender {
fn new(opts: SmtpOptions) -> Result<Self> {
let mailer_creds =
lettre::transport::smtp::authentication::Credentials::new(opts.username, opts.password);
let transport = lettre::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&opts.server)
.context("unable to initialize starttls_relay")?
.credentials(mailer_creds)
.build();
Ok(Self { transport })
}
}
impl TryInto<lettre::Message> for Message {
type Error = anyhow::Error;
fn try_into(self) -> Result<lettre::Message, Self::Error> {
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<S>(t: &lettre::message::Mailboxes, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
Ok(s.serialize_str(&t.to_string())?)
}
impl MailSender for SmtpSender {
async fn send_batch(&self, emails: Vec<Message>) -> 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<Self> {
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<Message>) -> 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<AppState> for Mailer {
fn from_ref(state: &AppState) -> Mailer {
state.mailer.clone()
}
}

View file

@ -5,6 +5,7 @@ mod auth;
mod channel_selections; mod channel_selections;
mod channels; mod channels;
mod csrf; mod csrf;
mod email;
mod guards; mod guards;
mod messages; mod messages;
mod nav_state; mod nav_state;
@ -18,13 +19,13 @@ mod teams;
mod users; mod users;
mod v0_router; mod v0_router;
use std::process::exit;
use email::SmtpOptions;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use crate::{ use crate::{
app_state::{AppState, Mailer}, app_state::AppState, email::Mailer, router::new_router, sessions::PgStore, settings::Settings,
router::new_router,
sessions::PgStore,
settings::Settings,
}; };
#[tokio::main] #[tokio::main]
@ -42,22 +43,28 @@ async fn main() {
.build() .build()
.unwrap(); .unwrap();
let session_store = PgStore::new(db_pool.clone()); 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() let reqwest_client = reqwest::ClientBuilder::new()
.https_only(true) .https_only(true)
.build() .build()
.unwrap(); .unwrap();
let oauth_client = auth::new_oauth_client(&settings).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 { let app_state = AppState {
db_pool, db_pool,
mailer, mailer,

View file

@ -10,7 +10,6 @@ use axum::{
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use diesel::{delete, dsl::insert_into, prelude::*, update}; use diesel::{delete, dsl::insert_into, prelude::*, update};
use lettre::Transport as _;
use rand::{distributions::Uniform, Rng}; use rand::{distributions::Uniform, Rng};
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
@ -25,11 +24,12 @@ use uuid::Uuid;
use crate::{ use crate::{
api_keys::ApiKey, api_keys::ApiKey,
app_error::AppError, app_error::AppError,
app_state::{AppState, DbConn, Mailer}, app_state::{AppState, DbConn},
auth, auth,
channel_selections::ChannelSelection, channel_selections::ChannelSelection,
channels::Channel, channels::Channel,
csrf::generate_csrf_token, csrf::generate_csrf_token,
email::{MailSender as _, Mailer},
guards, guards,
nav_state::{Breadcrumb, NavState}, nav_state::{Breadcrumb, NavState},
projects::Project, projects::Project,
@ -487,7 +487,7 @@ async fn update_channel_email_recipient(
.. ..
}): State<Settings>, }): State<Settings>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
State(Mailer(mailer)): State<Mailer>, State(mailer): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>, Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser, CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>, Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
@ -557,17 +557,13 @@ async fn update_channel_email_recipient(
"Sending email verification code to: {}", "Sending email verification code to: {}",
form_body.recipient form_body.recipient
); );
let email = lettre::Message::builder() let email = crate::email::Message {
.from(email_settings.verification_from.clone().into()) from: email_settings.verification_from.into(),
.reply_to(email_settings.verification_from.clone().into()) to: form_body.recipient.parse()?,
.to(form_body.recipient.parse()?) subject: "Verify Your Email".to_string(),
.subject("Verify Your Email") text_body: format!("Your email verification code is: {}", verification_code),
.header(lettre::message::header::ContentType::TEXT_PLAIN) };
.body(format!( mailer.send_batch(vec![email]).await?;
"Your email verification code is: {}",
verification_code
))?;
mailer.send(&email)?;
Ok(Redirect::to(&format!( Ok(Redirect::to(&format!(
"{}/teams/{}/channels/{}", "{}/teams/{}/channels/{}",

View file

@ -49,13 +49,24 @@ fn default_cookie_name() -> String {
"SHOUT_DOT_DEV_SESSION".to_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)] #[derive(Clone, Debug, Deserialize)]
pub struct EmailSettings { pub struct EmailSettings {
pub verification_from: lettre::Address, pub verification_from: lettre::message::Mailbox,
pub message_from: lettre::Address, pub message_from: lettre::message::Mailbox,
pub smtp_server: String, pub smtp: Option<SmtpSettings>,
pub smtp_username: String, pub postmark: Option<PostmarkSettings>,
pub smtp_password: String,
} }
pub struct SlackSettings { pub struct SlackSettings {

View file

@ -6,7 +6,6 @@ use axum::{
Router, Router,
}; };
use diesel::{dsl::insert_into, prelude::*, update}; use diesel::{dsl::insert_into, prelude::*, update};
use lettre::Transport as _;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -14,7 +13,8 @@ use uuid::Uuid;
use crate::{ use crate::{
api_keys::ApiKey, api_keys::ApiKey,
app_error::AppError, app_error::AppError,
app_state::{AppState, DbConn, Mailer}, app_state::{AppState, DbConn},
email::{MailSender as _, Mailer},
projects::Project, projects::Project,
schema::{api_keys, projects}, schema::{api_keys, projects},
settings::Settings, settings::Settings,
@ -36,7 +36,7 @@ async fn say_get(
email: email_settings, email: email_settings,
.. ..
}): State<Settings>, }): State<Settings>,
State(Mailer(mailer)): State<Mailer>, State(mailer): State<Mailer>,
DbConn(db_conn): DbConn, DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>, Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
@ -89,16 +89,15 @@ async fn say_get(
for channel in selected_channels { for channel in selected_channels {
if let Some(email_data) = channel.email_data { if let Some(email_data) = channel.email_data {
if email_data.verified { if email_data.verified {
let recipient: lettre::Address = email_data.recipient.parse()?; let recipient: lettre::message::Mailbox = email_data.recipient.parse()?;
let email = lettre::Message::builder() let email = crate::email::Message {
.from(email_settings.message_from.clone().into()) from: email_settings.message_from.clone().into(),
.reply_to(email_settings.message_from.clone().into()) to: recipient.into(),
.to(recipient.into()) subject: "Shout".to_string(),
.subject("Shout") text_body: query.message.clone(),
.header(lettre::message::header::ContentType::TEXT_PLAIN) };
.body(query.message.clone())?;
tracing::info!("Sending email to recipient for channel {}", channel.id); tracing::info!("Sending email to recipient for channel {}", channel.id);
mailer.send(&email)?; mailer.send_batch(vec![email]).await?;
} else { } else {
tracing::info!("Email recipient for channel {} is not verified", channel.id); tracing::info!("Email recipient for channel {} is not verified", channel.id);
} }