add support for postmark email backend
This commit is contained in:
parent
7b5b3436e4
commit
b95684a434
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
|
||||
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=
|
||||
|
|
|
@ -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
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 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,
|
||||
|
|
|
@ -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/{}",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue