use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use boatramp_core::access::RateLimit;
use boatramp_core::email_config::{EmailProfile, EmailProfileStore};
use boatramp_core::messaging::Messaging;
use boatramp_core::project::ProjectRef;
use boatramp_handlers::{EmailSpool, OutboundEmail, SmtpBackend};
use crate::ratelimit::RateLimiter;
const DURABLE_TOPIC: &str = "_boatramp/email/outbound";
const EMAIL_SENDS_PER_SEC: u32 = 5;
const EMAIL_SEND_BURST: u32 = 50;
const EMAIL_RL_IP: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
const BEST_EFFORT_CAPACITY: usize = 1024;
const BEST_EFFORT_ATTEMPTS: u32 = 3;
const DURABLE_LEASE: Duration = Duration::from_secs(60);
const DURABLE_POLL: Duration = Duration::from_secs(2);
const DURABLE_BATCH: usize = 16;
const DURABLE_MAX_ATTEMPTS: u32 = 5;
pub struct NodeEmailSpool {
tx: tokio::sync::mpsc::Sender<(EmailProfile, OutboundEmail)>,
messaging: Option<Arc<dyn Messaging>>,
rate: RateLimiter,
limit: RateLimit,
}
impl NodeEmailSpool {
pub fn spawn(
backend: Arc<dyn SmtpBackend>,
messaging: Option<Arc<dyn Messaging>>,
store: Arc<EmailProfileStore>,
) -> Arc<dyn EmailSpool> {
let (tx, mut rx) =
tokio::sync::mpsc::channel::<(EmailProfile, OutboundEmail)>(BEST_EFFORT_CAPACITY);
{
let backend = backend.clone();
tokio::spawn(async move {
while let Some((profile, msg)) = rx.recv().await {
deliver_with_retry(backend.as_ref(), &profile, &msg, BEST_EFFORT_ATTEMPTS)
.await;
}
});
}
if let Some(messaging) = messaging.clone() {
let backend = backend.clone();
let store = store.clone();
tokio::spawn(async move { durable_worker(backend, messaging, store).await });
}
Arc::new(Self {
tx,
messaging,
rate: RateLimiter::new(),
limit: RateLimit {
rps: EMAIL_SENDS_PER_SEC,
burst: EMAIL_SEND_BURST,
},
})
}
}
#[async_trait::async_trait]
impl EmailSpool for NodeEmailSpool {
async fn enqueue(&self, profile: EmailProfile, message: OutboundEmail) -> Result<(), String> {
if !self.rate.check(&message.project, EMAIL_RL_IP, &self.limit) {
return Err(format!(
"per-project email send rate exceeded (limit {} msg/s, burst {}); slow down",
self.limit.rps, self.limit.burst
));
}
if message.durable {
let Some(messaging) = &self.messaging else {
return Err(
"durable email requested but this node has no messaging backend configured"
.to_string(),
);
};
let payload = serde_json::to_vec(&message)
.map_err(|e| format!("serializing durable email failed: {e}"))?;
messaging
.publish(DURABLE_TOPIC, &payload)
.await
.map_err(|e| format!("enqueuing durable email failed: {e}"))
} else {
self.tx.try_send((profile, message)).map_err(|e| match e {
tokio::sync::mpsc::error::TrySendError::Full(_) => {
"email spool is full (best-effort queue saturated)".to_string()
}
tokio::sync::mpsc::error::TrySendError::Closed(_) => {
"email spool is shut down".to_string()
}
})
}
}
}
async fn deliver_with_retry(
backend: &dyn SmtpBackend,
profile: &EmailProfile,
msg: &OutboundEmail,
attempts: u32,
) {
let mut last = String::new();
for attempt in 1..=attempts {
match backend.send(profile, msg).await {
Ok(()) => return,
Err(e) => {
last = e;
tracing::warn!(
project = %msg.project,
profile = %msg.profile,
attempt,
error = %last,
"best-effort email delivery attempt failed"
);
if attempt < attempts {
tokio::time::sleep(Duration::from_millis(500 * u64::from(attempt))).await;
}
}
}
}
tracing::error!(
project = %msg.project,
profile = %msg.profile,
error = %last,
"best-effort email dropped after retries"
);
}
async fn durable_worker(
backend: Arc<dyn SmtpBackend>,
messaging: Arc<dyn Messaging>,
store: Arc<EmailProfileStore>,
) {
loop {
let claimed = match messaging
.claim(
DURABLE_TOPIC,
DURABLE_LEASE,
DURABLE_BATCH,
DURABLE_MAX_ATTEMPTS,
)
.await
{
Ok(claimed) => claimed,
Err(err) => {
tracing::warn!(%err, "durable email claim failed");
tokio::time::sleep(DURABLE_POLL).await;
continue;
}
};
if claimed.is_empty() {
tokio::time::sleep(DURABLE_POLL).await;
continue;
}
for msg in claimed {
let outbound: OutboundEmail = match serde_json::from_slice(&msg.payload) {
Ok(o) => o,
Err(err) => {
tracing::warn!(id = %msg.id, %err, "dropping unparsable durable email");
let _ = messaging.ack(&msg).await;
continue;
}
};
let profile = match store
.get(
ProjectRef::new(outbound.project.as_str()),
&outbound.profile,
)
.await
{
Ok(Some(p)) => p,
Ok(None) => {
tracing::warn!(
project = %outbound.project,
profile = %outbound.profile,
"durable email profile no longer exists; dropping"
);
let _ = messaging.ack(&msg).await;
continue;
}
Err(err) => {
tracing::warn!(%err, "resolving durable email profile failed; will retry");
let _ = messaging.nack(&msg).await;
continue;
}
};
match backend.send(&profile, &outbound).await {
Ok(()) => {
let _ = messaging.ack(&msg).await;
}
Err(err) => {
tracing::warn!(
id = %msg.id,
attempts = msg.attempts,
%err,
"durable email delivery failed; redelivering (dead-letters after max attempts)"
);
let _ = messaging.nack(&msg).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::email_config::SmtpSecurity;
use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
use boatramp_core::kv::MemoryKv;
use std::sync::Mutex;
struct NoopEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for NoopEnvelope {
async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(p.to_vec())
}
async fn unwrap(&self, c: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(c.to_vec())
}
}
#[derive(Default)]
struct CountingBackend {
sent: Mutex<usize>,
}
#[async_trait::async_trait]
impl SmtpBackend for CountingBackend {
async fn send(&self, _p: &EmailProfile, _m: &OutboundEmail) -> Result<(), String> {
*self.sent.lock().unwrap() += 1;
Ok(())
}
}
fn outbound(project: &str) -> OutboundEmail {
OutboundEmail {
project: project.into(),
profile: "default".into(),
to: vec!["d@example.org".into()],
cc: vec![],
bcc: vec![],
from: "no-reply@example.com".into(),
reply_to: None,
subject: "hi".into(),
text: Some("x".into()),
html: None,
durable: false,
}
}
fn profile() -> EmailProfile {
EmailProfile {
host: "localhost".into(),
port: 25,
security: SmtpSecurity::Plaintext,
username: None,
password: None,
from: "no-reply@example.com".into(),
durable: false,
}
}
#[tokio::test]
async fn per_project_send_rate_is_enforced_and_is_per_project() {
let store = Arc::new(EmailProfileStore::new(
Arc::new(MemoryKv::new()),
Arc::new(NoopEnvelope),
));
let spool = NodeEmailSpool::spawn(Arc::new(CountingBackend::default()), None, store);
let mut ok = 0usize;
let mut limited = false;
for _ in 0..(EMAIL_SEND_BURST + 5) {
match spool.enqueue(profile(), outbound("acme")).await {
Ok(()) => ok += 1,
Err(e) => {
assert!(e.contains("rate exceeded"), "unexpected error: {e}");
limited = true;
}
}
}
assert!(
(EMAIL_SEND_BURST as usize..=EMAIL_SEND_BURST as usize + 1).contains(&ok),
"expected ~{EMAIL_SEND_BURST} to pass, got {ok}"
);
assert!(
limited,
"a runaway send loop must eventually be rate-limited"
);
assert!(
spool.enqueue(profile(), outbound("globex")).await.is_ok(),
"the rate limit must be per-project, not global"
);
}
}