Files
Oxicloud/src/infrastructure/services/smtp_email_sender.rs
T
2026-06-03 00:31:59 +02:00

155 lines
6.1 KiB
Rust

//! Lettre-backed implementation of [`EmailSender`].
//!
//! Wraps an [`AsyncSmtpTransport`] configured from [`SmtpConfig`] at
//! application startup. The transport itself is internally connection-
//! pooled, so a single instance is shared across the whole app via the
//! DI container.
//!
//! On startup the `From:` mailbox is parsed once and cached. Bad config
//! (unparseable `from`, missing `host`) is reported during construction
//! so the server fails fast rather than at first send.
//!
//! # No retry / no spool — by design
//!
//! `send()` makes a single attempt against the configured relay. If the
//! relay is unreachable, slow, or returns a transient error, the call
//! returns `Err` and the message is gone. There is no in-process queue,
//! no exponential backoff, no dead-letter handling.
//!
//! Operators who need durability across upstream relay outages should
//! point `OXICLOUD_SMTP_HOST` at a local MTA (Postfix, OpenSMTPD,
//! msmtp-mta, …) configured as a smarthost — the local MTA owns the
//! retry queue. See `docs/config/env.md` → "Reliability and retries"
//! for the recipe.
use async_trait::async_trait;
use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncTransport, Message, Tokio1Executor};
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
use crate::common::config::{SmtpConfig, SmtpTlsMode};
use crate::common::errors::DomainError;
pub struct SmtpEmailSender {
transport: AsyncSmtpTransport<Tokio1Executor>,
/// Parsed once at construction so every send reuses the same
/// `Mailbox` value (and any RFC 5322 name-address parsing errors
/// surface during startup instead of at first send).
from: Mailbox,
}
impl SmtpEmailSender {
/// Build a sender from an SMTP config block. Returns an `Err` when
/// `from` is unparseable or the transport's TLS parameters can't be
/// constructed — both surface at startup so misconfiguration never
/// silently drops mail.
pub fn new(cfg: &SmtpConfig) -> Result<Self, DomainError> {
if cfg.host.is_empty() {
return Err(DomainError::internal_error(
"SmtpEmailSender",
"OXICLOUD_SMTP_HOST is empty — refusing to construct a no-op sender",
));
}
let from: Mailbox = cfg.from.parse().map_err(|e| {
DomainError::internal_error(
"SmtpEmailSender",
format!("invalid OXICLOUD_SMTP_FROM mailbox '{}': {}", cfg.from, e),
)
})?;
let builder = match cfg.tls {
SmtpTlsMode::Starttls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host).map_err(|e| {
DomainError::internal_error(
"SmtpEmailSender",
format!("starttls relay for {}: {}", cfg.host, e),
)
})?
}
SmtpTlsMode::Tls => {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|e| {
DomainError::internal_error(
"SmtpEmailSender",
format!("tls relay for {}: {}", cfg.host, e),
)
})?
}
SmtpTlsMode::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
};
let mut builder = builder.port(cfg.port);
if !cfg.user.is_empty() {
builder = builder.credentials(Credentials::new(cfg.user.clone(), cfg.pass.clone()));
}
let transport = builder.build();
Ok(Self { transport, from })
}
}
#[async_trait]
impl EmailSender for SmtpEmailSender {
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
let to: Mailbox = message.to.parse().map_err(|e| {
DomainError::new(
crate::common::errors::ErrorKind::InvalidInput,
"SmtpEmailSender",
format!("invalid recipient '{}': {}", message.to, e),
)
})?;
let builder = Message::builder()
.from(self.from.clone())
.to(to)
.subject(message.subject.clone());
// multipart/alternative when an HTML body is supplied — old text
// clients see the text part, modern clients render the HTML.
let built = match message.html_body {
Some(html) => builder.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(message.text_body),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
),
None => builder.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(message.text_body),
),
}
.map_err(|e| {
DomainError::internal_error("SmtpEmailSender", format!("build message: {}", e))
})?;
let response =
self.transport.send(built).await.map_err(|e| {
DomainError::internal_error("SmtpEmailSender", format!("send: {}", e))
})?;
// Lettre's `Response::code()` returns a structured `Code`; its
// `Display` impl is the three-digit form ("250", "451", …).
let code: u16 = response.code().to_string().parse().unwrap_or(0);
// `message()` is `Iterator<Item = &String>`; take the first
// line (the rest are typically multi-line EHLO continuations,
// not interesting for a confirmation).
let message = response
.message()
.next()
.map(str::to_string)
.unwrap_or_default();
Ok(EmailSendOutcome { code, message })
}
}