add support for postmark email backend
This commit is contained in:
parent
d430516675
commit
97eda86ad4
7 changed files with 217 additions and 59 deletions
|
@ -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=
|
||||||
|
|
|
@ -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
155
src/email.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
35
src/main.rs
35
src/main.rs
|
@ -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,
|
||||||
|
|
|
@ -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/{}",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue