make Mailer::send_batch() return a result for each message
This commit is contained in:
parent
ac056c0aa3
commit
e7d4eaaf81
3 changed files with 127 additions and 58 deletions
149
src/email.rs
149
src/email.rs
|
@ -21,9 +21,11 @@ pub struct Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MailSender: Clone + Sync {
|
pub trait MailSender: Clone + Sync {
|
||||||
// TODO: Document a consistent contract for how partial successes should
|
/**
|
||||||
// behave
|
* Attempt to send all messages defined by the input Vec. Send as many as
|
||||||
async fn send_batch(&self, emails: Vec<Message>) -> Result<(), anyhow::Error>;
|
* possible, returning exactly one Result<()> for each message.
|
||||||
|
*/
|
||||||
|
async fn send_batch(&self, emails: Vec<Message>) -> Vec<Result<()>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
@ -50,7 +52,7 @@ impl Mailer {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailSender for Mailer {
|
impl MailSender for Mailer {
|
||||||
async fn send_batch(&self, emails: Vec<Message>) -> Result<(), anyhow::Error> {
|
async fn send_batch(&self, emails: Vec<Message>) -> Vec<Result<()>> {
|
||||||
match self {
|
match self {
|
||||||
Mailer::Smtp(sender) => sender.send_batch(emails).await,
|
Mailer::Smtp(sender) => sender.send_batch(emails).await,
|
||||||
Mailer::Postmark(sender) => sender.send_batch(emails).await,
|
Mailer::Postmark(sender) => sender.send_batch(emails).await,
|
||||||
|
@ -106,11 +108,25 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailSender for SmtpSender {
|
impl MailSender for SmtpSender {
|
||||||
async fn send_batch(&self, emails: Vec<Message>) -> Result<()> {
|
async fn send_batch(&self, emails: Vec<Message>) -> Vec<Result<()>> {
|
||||||
|
let mut results: Vec<Result<()>> = Vec::with_capacity(emails.len());
|
||||||
for email in emails {
|
for email in emails {
|
||||||
self.transport.send(email.try_into()?).await?;
|
match TryInto::<lettre::Message>::try_into(email) {
|
||||||
|
Ok(email) => {
|
||||||
|
results.push(
|
||||||
|
self.transport
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(Into::<anyhow::Error>::into),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Err(err) => {
|
||||||
|
results.push(Err(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,73 +150,112 @@ impl PostmarkSender {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailSender for PostmarkSender {
|
impl MailSender for PostmarkSender {
|
||||||
async fn send_batch(&self, mut emails: Vec<Message>) -> Result<()> {
|
/**
|
||||||
// https://postmarkapp.com/support/article/1056-what-are-the-attachment-and-email-size-limits
|
* Recursively attempts to send messages, breaking them into smaller and
|
||||||
const POSTMARK_MAX_BATCH_ENTRIES: usize = 500;
|
* smaller batches as needed.
|
||||||
const POSTMARK_MAX_REQUEST_BYTES: usize = 50 * 1000 * 1000;
|
*/
|
||||||
// TODO: Check email subject and body size against Postmark limits
|
async fn send_batch(&self, mut emails: Vec<Message>) -> Vec<Result<()>> {
|
||||||
let count = emails.len();
|
/**
|
||||||
|
* Constructs a Vec with Ok(()) repeated n times.
|
||||||
|
*/
|
||||||
|
macro_rules! all_ok {
|
||||||
|
() => {{
|
||||||
|
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
|
||||||
|
for _ in 0..emails.len() {
|
||||||
|
collection.push(Ok(()));
|
||||||
|
}
|
||||||
|
collection
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a Vec with a single specific error, followed by n-1
|
||||||
|
* generic errors referring back to it.
|
||||||
|
*/
|
||||||
|
macro_rules! cascade_err {
|
||||||
|
($err:expr) => {{
|
||||||
|
let mut collection: Vec<Result<_>> = Vec::with_capacity(emails.len());
|
||||||
|
collection.push(Err($err));
|
||||||
|
for _ in 1..emails.len() {
|
||||||
|
collection.push(Err(anyhow::anyhow!("could not send due to previous error")));
|
||||||
|
}
|
||||||
|
collection
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: This may be more readable as a closure
|
|
||||||
/**
|
/**
|
||||||
* Recursively splits the email batch in half and tries to send each
|
* Recursively splits the email batch in half and tries to send each
|
||||||
* half independently, allowing both to run to completion and then
|
* half independently, allowing both to run to completion and then
|
||||||
* returning the first error of the two results, if present. Panics if
|
* returning the first error of the two results, if present.
|
||||||
* the batch is too small to split, so the caller is responsible for
|
*
|
||||||
* handling that case before invoking the macro.
|
* This is implemented as a macro in order to avoid unstable async
|
||||||
|
* closures.
|
||||||
*/
|
*/
|
||||||
macro_rules! split_and_retry {
|
macro_rules! split_and_retry {
|
||||||
() => {
|
() => {
|
||||||
|
if emails.len() < 2 {
|
||||||
|
tracing::warn!("Postmark send batch cannot be split any further");
|
||||||
|
vec![Err(anyhow::anyhow!(
|
||||||
|
"unable to split Postmark batch into smaller slices"
|
||||||
|
))]
|
||||||
|
} else {
|
||||||
tracing::debug!("retrying Postmark send with smaller batches");
|
tracing::debug!("retrying Postmark send with smaller batches");
|
||||||
assert!(count > 1);
|
let mut results =
|
||||||
let res1 = Box::pin(self.send_batch(emails.split_off(count / 2))).await;
|
Box::pin(self.send_batch(emails.split_off(emails.len() / 2))).await;
|
||||||
let res2 = Box::pin(self.send_batch(emails)).await;
|
results.extend(Box::pin(self.send_batch(emails)).await);
|
||||||
match (res1, res2) {
|
results
|
||||||
(Err(err), _) => {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
(_, Err(err)) => {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if count == 0 {
|
// https://postmarkapp.com/support/article/1056-what-are-the-attachment-and-email-size-limits
|
||||||
|
const POSTMARK_MAX_BATCH_ENTRIES: usize = 500;
|
||||||
|
const POSTMARK_MAX_REQUEST_BYTES: usize = 50 * 1000 * 1000;
|
||||||
|
// TODO: Check email subject and body size against Postmark limits
|
||||||
|
|
||||||
|
if emails.len() == 0 {
|
||||||
tracing::debug!("no Postmark messages to send");
|
tracing::debug!("no Postmark messages to send");
|
||||||
Ok(())
|
vec![Ok(())]
|
||||||
} else if count > POSTMARK_MAX_BATCH_ENTRIES {
|
} else if emails.len() > POSTMARK_MAX_BATCH_ENTRIES {
|
||||||
split_and_retry!();
|
split_and_retry!()
|
||||||
Ok(())
|
|
||||||
} else {
|
} else {
|
||||||
let body = serde_json::to_string(&emails)?;
|
let body = match serde_json::to_string(&emails) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(err) => return cascade_err!(err.into()),
|
||||||
|
};
|
||||||
if body.len() > POSTMARK_MAX_REQUEST_BYTES {
|
if body.len() > POSTMARK_MAX_REQUEST_BYTES {
|
||||||
if count > 1 {
|
if emails.len() > 1 {
|
||||||
split_and_retry!();
|
split_and_retry!()
|
||||||
Ok(())
|
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow::anyhow!(
|
vec![Err(anyhow::anyhow!(
|
||||||
"Postmark requests may not exceed {} bytes",
|
"Postmark requests may not exceed {} bytes",
|
||||||
POSTMARK_MAX_REQUEST_BYTES
|
POSTMARK_MAX_REQUEST_BYTES
|
||||||
))
|
))]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("sending Postmark batch of {} messages", count);
|
tracing::debug!("sending Postmark batch of {} messages", emails.len());
|
||||||
let resp = self
|
let resp = match self
|
||||||
.client
|
.client
|
||||||
.post(POSTMARK_EMAIL_BATCH_URL)
|
.post(POSTMARK_EMAIL_BATCH_URL)
|
||||||
.header("X-Postmark-Server-Token", &self.server_token)
|
.header("X-Postmark-Server-Token", &self.server_token)
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await
|
||||||
if resp.status().is_client_error() && count > 1 {
|
{
|
||||||
split_and_retry!();
|
Ok(resp) => resp,
|
||||||
|
Err(err) => return cascade_err!(err.into()),
|
||||||
|
};
|
||||||
|
if resp.status().is_client_error() && emails.len() > 1 {
|
||||||
|
split_and_retry!()
|
||||||
|
} else {
|
||||||
|
if let Err(err) = resp.error_for_status() {
|
||||||
|
cascade_err!(err.into())
|
||||||
|
} else {
|
||||||
|
tracing::debug!("sent Postmark batch of {} messages", emails.len());
|
||||||
|
all_ok!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resp.error_for_status()?;
|
|
||||||
tracing::debug!("sent Postmark batch of {} messages", count);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -593,7 +593,7 @@ async fn update_channel_email_recipient(
|
||||||
subject: "Verify Your Email".to_string(),
|
subject: "Verify Your Email".to_string(),
|
||||||
text_body: format!("Your email verification code is: {}", verification_code),
|
text_body: format!("Your email verification code is: {}", verification_code),
|
||||||
};
|
};
|
||||||
mailer.send_batch(vec![email]).await?;
|
mailer.send_batch(vec![email]).await.remove(0)?;
|
||||||
|
|
||||||
Ok(Redirect::to(&format!(
|
Ok(Redirect::to(&format!(
|
||||||
"{}/teams/{}/channels/{}",
|
"{}/teams/{}/channels/{}",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use tracing::Instrument as _;
|
use tracing::Instrument as _;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_state::AppState,
|
app_state::AppState,
|
||||||
|
@ -19,6 +20,11 @@ pub async fn run_worker(state: AppState) -> Result<()> {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process messages from the queue in the `messages` table. Insertions to the
|
||||||
|
* queue are rate limited per team and per project, so no effort should be
|
||||||
|
* needed here to enforce fairness.
|
||||||
|
*/
|
||||||
async fn process_messages(state: AppState) -> Result<()> {
|
async fn process_messages(state: AppState) -> Result<()> {
|
||||||
async move {
|
async move {
|
||||||
const MESSAGE_QUEUE_LIMIT: i64 = 250;
|
const MESSAGE_QUEUE_LIMIT: i64 = 250;
|
||||||
|
@ -38,7 +44,7 @@ async fn process_messages(state: AppState) -> Result<()> {
|
||||||
.unwrap()?;
|
.unwrap()?;
|
||||||
// Dispatch email messages together to take advantage of Postmark's
|
// Dispatch email messages together to take advantage of Postmark's
|
||||||
// batch send API
|
// batch send API
|
||||||
let emails: Vec<crate::email::Message> = queued_messages
|
let emails: Vec<(Uuid, crate::email::Message)> = queued_messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(message, channel)| {
|
.filter_map(|(message, channel)| {
|
||||||
if let Ok(backend_config) =
|
if let Ok(backend_config) =
|
||||||
|
@ -60,7 +66,7 @@ async fn process_messages(state: AppState) -> Result<()> {
|
||||||
text_body: message.message.clone(),
|
text_body: message.message.clone(),
|
||||||
};
|
};
|
||||||
tracing::debug!("Sending email to recipient for channel {}", channel.id);
|
tracing::debug!("Sending email to recipient for channel {}", channel.id);
|
||||||
Some(email)
|
Some((message.id.clone(), email))
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Email recipient for channel {} is not verified",
|
"Email recipient for channel {} is not verified",
|
||||||
|
@ -74,16 +80,24 @@ async fn process_messages(state: AppState) -> Result<()> {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if !emails.is_empty() {
|
if !emails.is_empty() {
|
||||||
state.mailer.send_batch(emails).await?;
|
let message_ids: Vec<Uuid> = emails.iter().map(|(id, _)| id.clone()).collect();
|
||||||
}
|
let results = state
|
||||||
{
|
.mailer
|
||||||
|
.send_batch(emails.into_iter().map(|(_, email)| email).collect())
|
||||||
|
.await;
|
||||||
|
assert!(results.len() == message_ids.len());
|
||||||
|
let results_by_id = message_ids.into_iter().zip(results.into_iter());
|
||||||
db_conn
|
db_conn
|
||||||
.interact::<_, Result<_>>(move |conn| {
|
.interact::<_, Result<_>>(move |conn| {
|
||||||
for (message, _) in queued_messages {
|
for (id, result) in results_by_id {
|
||||||
diesel::update(messages::table.filter(messages::id.eq(message.id)))
|
if let Err(err) = result {
|
||||||
|
tracing::error!("error sending message {}: {:?}", id, err);
|
||||||
|
} else {
|
||||||
|
diesel::update(messages::table.filter(messages::id.eq(id)))
|
||||||
.set(messages::sent_at.eq(diesel::dsl::now))
|
.set(messages::sent_at.eq(diesel::dsl::now))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
Loading…
Add table
Reference in a new issue