1
0
Fork 0
forked from 2sys/shoutdotdev

add support for postmark email backend

This commit is contained in:
Brent Schroeter 2025-02-26 13:10:43 -08:00
parent 7b5b3436e4
commit b95684a434
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
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=

View file

@ -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<AppState> for Mailer {
fn from_ref(state: &AppState) -> Self {
state.mailer.clone()
}
}
#[derive(Clone)]
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 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,

View file

@ -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<Settings>,
DbConn(db_conn): DbConn,
State(Mailer(mailer)): State<Mailer>,
State(mailer): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
@ -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/{}",

View file

@ -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<SmtpSettings>,
pub postmark: Option<PostmarkSettings>,
}
pub struct SlackSettings {

View file

@ -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<Settings>,
State(Mailer(mailer)): State<Mailer>,
State(mailer): State<Mailer>,
DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> {
@ -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);
}