feat(smtp): add SMTP support to reach MTA

This commit is contained in:
Edouard Vanbelle
2026-06-01 21:14:24 +02:00
parent 772d097e23
commit 2011d19e71
8 changed files with 412 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
//! Outbound email port.
//!
//! Single-recipient transactional mail sending — the entry point for the
//! magic-link invitation flow (PR 9), the login-via-email flow (PR 10), and
//! any future notification mail. Kept deliberately small: one method, one
//! recipient, one body pair (text + optional HTML).
//!
//! The infrastructure-layer implementation lives at
//! `src/infrastructure/services/smtp_email_sender.rs` and is constructed
//! lazily in [`AppServiceFactory`]: when `OXICLOUD_SMTP_HOST` is empty the
//! DI container holds `None`, and endpoints that require email return a
//! clear 503 ("SMTP not configured") rather than silently dropping mail.
//!
//! Future evolution: an in-memory `MemoryEmailSender` for tests (no SMTP
//! round-trip), and a `LoggingEmailSender` decorator that records every
//! send to the audit log. Both are deferred until a concrete consumer
//! needs them.
use async_trait::async_trait;
use crate::common::errors::DomainError;
/// A single outbound message. The `to` address is expected to be a normalised
/// RFC 5321 mailbox (lowercase local-part + punycoded domain); upstream
/// callers handle the normalisation before constructing this struct.
#[derive(Debug, Clone)]
pub struct EmailMessage {
/// RFC 5321 recipient address. Single recipient per send today — the
/// invite flow targets one external user at a time. Multi-recipient
/// (CC/BCC) is intentionally out of scope.
pub to: String,
/// Plain-text subject line. UTF-8 — lettre handles RFC 2047 encoding.
pub subject: String,
/// Plain-text body. Always required; mail clients without HTML
/// rendering fall back to this.
pub text_body: String,
/// Optional HTML body. When present, the message is sent as
/// `multipart/alternative` with both representations.
pub html_body: Option<String>,
}
/// Port for sending transactional email.
///
/// Implementations must:
/// - Be idempotent at the network level (lettre handles connection reuse).
/// - Run the actual SMTP exchange on the existing tokio runtime (no
/// blocking threads).
/// - Return `DomainError` with `ErrorKind::ExternalService` (or the most
/// precise variant available) on permanent failures so handlers can
/// distinguish "couldn't reach SMTP" from validation errors.
///
/// `#[async_trait]` is used so the trait is dyn-compatible — the DI
/// container holds `Arc<dyn EmailSender>` (matches the existing
/// `dyn` patterns at the service boundary).
#[async_trait]
pub trait EmailSender: Send + Sync + 'static {
/// Send one message. Returns `Ok(())` only after the SMTP server has
/// accepted the message (i.e. after the final `.` or LMTP DATA close).
/// Caller may run this fire-and-forget via `tokio::spawn` if response
/// timing matters (e.g. magic-link invite path defending against
/// enumeration via latency).
async fn send(&self, message: EmailMessage) -> Result<(), DomainError>;
}
+1
View File
@@ -8,6 +8,7 @@ pub mod carddav_ports;
pub mod chunked_upload_ports;
pub mod compression_ports;
pub mod dedup_ports;
pub mod email_sender;
pub mod favorites_ports;
pub mod file_lifecycle;
pub mod file_ports;