feat(comms): EmailSender, permanent/transient routing

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-05-02 22:28:59 +03:00
committed by Tangled
parent 2462d0ab3b
commit eee6fb9ff4
13 changed files with 537 additions and 158 deletions
+194
View File
@@ -0,0 +1,194 @@
pub mod dkim;
pub mod message;
mod mx;
pub mod transport;
pub mod types;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use hickory_resolver::TokioAsyncResolver;
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::PoolConfig;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::extension::ClientId;
use tokio::sync::Semaphore;
use tracing::{info, warn};
pub use self::dkim::DkimSigner;
pub use self::transport::SendMode;
use self::types::{
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
SmtpUsername, TlsMode,
};
use crate::sender::{CommsSender, SendError};
use crate::types::{CommsChannel, QueuedComms};
pub struct EmailSender {
from: Mailbox,
mode: SendMode,
dkim: Option<DkimSigner>,
}
impl EmailSender {
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
Self { from, mode, dkim }
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
return Ok(None);
};
let from = build_from(&cfg.email.from_name, from_address)?;
let dkim = build_dkim(&cfg.email.dkim)?;
let mode = match cfg
.email
.smarthost
.host
.as_deref()
.filter(|h| !h.is_empty())
{
Some(host) => build_smarthost(cfg, host)?,
None => build_direct_mx(cfg)?,
};
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
Ok(Some(Self { from, mode, dkim }))
}
}
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
SendError::ConfigInvalid(format!("{field}: {error}"))
}
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
let raw = match from_name.is_empty() {
true => from_address.to_string(),
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
};
raw.parse::<Mailbox>()
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
}
fn build_smarthost(
cfg: &tranquil_config::TranquilConfig,
host_raw: &str,
) -> Result<SendMode, SendError> {
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
let port = SmtpPort::parse(cfg.email.smarthost.port)
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
let helo = resolve_helo(cfg)?;
let pool = PoolConfig::new()
.max_size(cfg.email.smarthost.pool_size)
.idle_timeout(Duration::from_secs(60));
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
let builder = match tls {
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
TlsMode::Starttls => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?
}
TlsMode::None => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
}
};
let builder = builder
.port(port.as_u16())
.hello_name(ClientId::Domain(helo.into_inner()))
.timeout(Some(command_timeout))
.pool_config(pool);
let builder = match (
cfg.email.smarthost.username.as_deref(),
cfg.email.smarthost.password.as_deref(),
) {
(Some(u), Some(p)) => {
let username =
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
let password =
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
builder.credentials(Credentials::new(
username.into_inner(),
password.expose().to_string(),
))
}
_ => builder,
};
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
})
}
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
let helo = resolve_helo(cfg)?;
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map(Arc::new)
.map_err(|e| config_invalid("system DNS configuration", e))?;
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
Ok(SendMode::DirectMx {
resolver,
helo,
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
require_tls: cfg.email.direct_mx.require_tls,
inflight: Arc::new(Semaphore::new(max_concurrent)),
})
}
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
let raw = cfg
.email
.helo_name
.clone()
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
}
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
let selector = match cfg.selector.as_deref() {
Some(s) => s,
None => return Ok(None),
};
let domain = cfg
.domain
.as_deref()
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
})?;
let selector = DkimSelector::parse(selector)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
let domain = EmailDomain::parse(domain)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
let path = DkimKeyPath::parse(key_path)
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
DkimSigner::load(selector, domain, path).map(Some)
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
match transport::dispatch(&self.mode, message).await {
Ok(()) => Ok(()),
Err(e) => {
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
Err(e)
}
}
}
}
+4 -2
View File
@@ -1,13 +1,15 @@
pub mod email;
mod locale;
mod sender;
mod types;
pub use email::EmailSender;
pub use locale::{
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
validate_locale,
};
pub use sender::{
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
is_valid_signal_username,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+60 -113
View File
@@ -1,11 +1,7 @@
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use reqwest::Client;
use serde_json::json;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::types::{CommsChannel, QueuedComms};
@@ -21,25 +17,51 @@ pub trait CommsSender: Send + Sync {
#[derive(Debug, thiserror::Error)]
pub enum SendError {
#[error("Failed to spawn {command}: {source}")]
ProcessSpawn {
command: String,
source: std::io::Error,
},
#[error("{command} exited with non-zero status: {detail}")]
ProcessFailed { command: String, detail: String },
#[error("Channel not configured: {0:?}")]
NotConfigured(CommsChannel),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Email configuration invalid: {0}")]
ConfigInvalid(String),
#[error("Invalid recipient format: {0}")]
InvalidRecipient(String),
#[error("Message construction failed: {0}")]
MessageBuild(String),
#[error("transient DNS lookup failure: {0}")]
DnsTransient(String),
#[error("permanent DNS lookup failure: {0}")]
DnsPermanent(String),
#[error("SMTP transient error: {0}")]
SmtpTransient(String),
#[error("SMTP permanent error: {0}")]
SmtpPermanent(String),
#[error("DKIM signing failed: {0}")]
DkimSign(String),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Request timeout")]
Timeout,
#[error("Max retries exceeded: {0}")]
MaxRetriesExceeded(String),
}
impl SendError {
pub fn is_permanent(&self) -> bool {
match self {
Self::SmtpPermanent(_)
| Self::DnsPermanent(_)
| Self::InvalidRecipient(_)
| Self::MessageBuild(_)
| Self::DkimSign(_)
| Self::ConfigInvalid(_) => true,
Self::SmtpTransient(_)
| Self::DnsTransient(_)
| Self::Timeout
| Self::ExternalService(_)
| Self::MaxRetriesExceeded(_)
| Self::NotConfigured(_) => false,
}
}
}
fn create_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
@@ -100,19 +122,6 @@ where
))
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], " ").trim().to_string()
}
pub fn mime_encode_header(value: &str) -> String {
if value.is_ascii() {
sanitize_header_value(value)
} else {
let sanitized = sanitize_header_value(value);
format!("=?UTF-8?B?{}?=", BASE64.encode(sanitized.as_bytes()))
}
}
pub fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -135,93 +144,6 @@ pub fn is_valid_signal_username(username: &str) -> bool {
tranquil_signal::SignalUsername::parse(username).is_ok()
}
pub struct EmailSender {
from_address: String,
from_name: String,
sendmail_path: String,
}
impl EmailSender {
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
Self {
from_address,
from_name,
sendmail_path,
}
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let from_address = cfg.email.from_address.clone()?;
let from_name = cfg.email.from_name.clone();
let sendmail_path = cfg.email.sendmail_path.clone();
Some(Self::new(from_address, from_name, sendmail_path))
}
pub fn format_email(&self, notification: &QueuedComms) -> String {
let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification"));
let recipient = sanitize_header_value(&notification.recipient);
let from_header = if self.from_name.is_empty() {
self.from_address.clone()
} else {
format!(
"{} <{}>",
sanitize_header_value(&self.from_name),
self.from_address
)
};
format!(
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
from_header, recipient, subject, notification.body
)
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let email_content = self.format_email(notification);
let mut child = Command::new(&self.sendmail_path)
.arg("-t")
.arg("-oi")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(email_content.as_bytes())
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ProcessFailed {
command: self.sendmail_path.clone(),
detail: stderr.to_string(),
});
}
Ok(())
}
}
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)]
@@ -610,3 +532,28 @@ impl CommsSender for SignalSender {
))
}
}
#[cfg(test)]
mod is_permanent_matrix {
use super::{CommsChannel, SendError};
#[test]
fn permanent_variants_are_permanent() {
assert!(SendError::SmtpPermanent("x".into()).is_permanent());
assert!(SendError::DnsPermanent("x".into()).is_permanent());
assert!(SendError::InvalidRecipient("x".into()).is_permanent());
assert!(SendError::MessageBuild("x".into()).is_permanent());
assert!(SendError::DkimSign("x".into()).is_permanent());
assert!(SendError::ConfigInvalid("x".into()).is_permanent());
}
#[test]
fn transient_variants_are_not_permanent() {
assert!(!SendError::SmtpTransient("x".into()).is_permanent());
assert!(!SendError::DnsTransient("x".into()).is_permanent());
assert!(!SendError::Timeout.is_permanent());
assert!(!SendError::ExternalService("x".into()).is_permanent());
assert!(!SendError::MaxRetriesExceeded("x".into()).is_permanent());
assert!(!SendError::NotConfigured(CommsChannel::Email).is_permanent());
}
}