feat(smtp): add SMTP support to reach MTA
This commit is contained in:
Generated
+61
@@ -1732,6 +1732,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -2855,6 +2871,34 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand 2.4.1",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"url",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -3170,6 +3214,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonmax"
|
||||
version = "0.5.5"
|
||||
@@ -3680,6 +3733,7 @@ dependencies = [
|
||||
"infer 0.19.0",
|
||||
"jsonwebtoken",
|
||||
"kamadak-exif",
|
||||
"lettre",
|
||||
"lightningcss",
|
||||
"lru",
|
||||
"md-5 0.11.0",
|
||||
@@ -4279,6 +4333,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -4651,6 +4711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
||||
@@ -69,6 +69,7 @@ aes-gcm = "0.10.3"
|
||||
lru = "0.16.4"
|
||||
fastcdc = "4.0.0"
|
||||
memmap2 = "0.9.10"
|
||||
lettre = { version = "0.11.18", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-native-certs", "builder"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -609,6 +609,81 @@ impl NextcloudConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport encryption mode for the SMTP relay. Picked at startup
|
||||
/// from `OXICLOUD_SMTP_TLS=starttls|tls|none`. The default for an
|
||||
/// unconfigured deployment is `Starttls` (port 587 with `STARTTLS`),
|
||||
/// matching the most common modern submission setup.
|
||||
///
|
||||
/// `None` is allowed for development against MailHog / a local
|
||||
/// netcat trap. Production deployments using `None` get a startup
|
||||
/// `WARN` log so the choice is visible in operational telemetry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SmtpTlsMode {
|
||||
/// Plain submission with `STARTTLS` upgrade (RFC 3207). Standard
|
||||
/// for port 587.
|
||||
Starttls,
|
||||
/// Implicit TLS from the first byte (RFC 8314). Standard for
|
||||
/// port 465.
|
||||
Tls,
|
||||
/// No encryption. Development only.
|
||||
None,
|
||||
}
|
||||
|
||||
impl SmtpTlsMode {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"starttls" => Some(Self::Starttls),
|
||||
"tls" | "implicit" | "smtps" => Some(Self::Tls),
|
||||
"none" | "plain" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outbound SMTP transport configuration. Sourced exclusively from
|
||||
/// `OXICLOUD_SMTP_*` env vars. `host` empty means the feature is
|
||||
/// disabled — every endpoint that needs email returns 503 in that
|
||||
/// state so admins notice misconfiguration immediately rather than
|
||||
/// silently dropping mail.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname or IP. Empty string disables the feature.
|
||||
pub host: String,
|
||||
/// Submission port (typically 587 for STARTTLS, 465 for implicit
|
||||
/// TLS, 25 for relay-to-relay).
|
||||
pub port: u16,
|
||||
/// SASL username. Empty = no authentication (anonymous relay).
|
||||
pub user: String,
|
||||
/// SASL password. Logged as `***` redacted in startup banner.
|
||||
pub pass: String,
|
||||
/// `From:` mailbox. Either a bare address (`noreply@example.com`)
|
||||
/// or RFC 5322 name-address (`OxiCloud <noreply@example.com>`).
|
||||
pub from: String,
|
||||
/// Transport encryption mode. See [`SmtpTlsMode`].
|
||||
pub tls: SmtpTlsMode,
|
||||
}
|
||||
|
||||
impl Default for SmtpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: 587,
|
||||
user: String::new(),
|
||||
pass: String::new(),
|
||||
from: String::new(),
|
||||
tls: SmtpTlsMode::Starttls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
/// `true` iff `OXICLOUD_SMTP_HOST` was set to a non-empty value.
|
||||
/// Used by DI to decide whether to construct an `EmailSender`.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!self.host.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature configuration (feature flags)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
@@ -670,6 +745,8 @@ pub struct AppConfig {
|
||||
pub wopi: WopiConfig,
|
||||
/// Nextcloud compatibility configuration
|
||||
pub nextcloud: NextcloudConfig,
|
||||
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -690,6 +767,7 @@ impl Default for AppConfig {
|
||||
oidc: OidcConfig::default(),
|
||||
wopi: WopiConfig::default(),
|
||||
nextcloud: NextcloudConfig::default(),
|
||||
smtp: SmtpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1230,38 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP configuration. `HOST` empty = feature disabled — every
|
||||
// endpoint that needs email returns 503 in that state.
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_HOST") {
|
||||
config.smtp.host = v.trim().to_string();
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_PORT")
|
||||
&& let Ok(p) = v.parse::<u16>()
|
||||
{
|
||||
config.smtp.port = p;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_USER") {
|
||||
config.smtp.user = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_PASS") {
|
||||
config.smtp.pass = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_FROM") {
|
||||
config.smtp.from = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_TLS")
|
||||
&& let Some(mode) = SmtpTlsMode::parse(&v)
|
||||
{
|
||||
config.smtp.tls = mode;
|
||||
}
|
||||
|
||||
if config.smtp.is_enabled() && config.smtp.tls == SmtpTlsMode::None {
|
||||
tracing::warn!(
|
||||
"OXICLOUD_SMTP_TLS=none — outbound mail will travel in plaintext. \
|
||||
Use 'starttls' or 'tls' for production deployments."
|
||||
);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -898,6 +898,7 @@ impl AppServiceFactory {
|
||||
),
|
||||
),
|
||||
)),
|
||||
email_sender: build_email_sender(&self.config.smtp),
|
||||
};
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -1227,6 +1228,11 @@ pub struct AppState {
|
||||
/// auth subsystem is not configured.
|
||||
pub subject_group_service:
|
||||
Option<Arc<crate::application::services::subject_group_service::SubjectGroupService>>,
|
||||
/// Outbound transactional email — `None` when `OXICLOUD_SMTP_HOST` is
|
||||
/// empty. Endpoints that need email (magic-link invite, login-via-email)
|
||||
/// must return 503 when this is `None` rather than silently dropping
|
||||
/// the message.
|
||||
pub email_sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
@@ -1256,3 +1262,43 @@ fn build_authorization_engine(
|
||||
}
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
/// Construct the SMTP email sender from config, or return `None` when
|
||||
/// SMTP is disabled (`OXICLOUD_SMTP_HOST` empty). Construction errors
|
||||
/// (unparseable `From:` mailbox, bad TLS settings) downgrade to `None`
|
||||
/// with a `WARN` log — the server still starts, but every magic-link
|
||||
/// endpoint will return 503 until the operator fixes the config.
|
||||
fn build_email_sender(
|
||||
cfg: &crate::common::config::SmtpConfig,
|
||||
) -> Option<Arc<dyn crate::application::ports::email_sender::EmailSender>> {
|
||||
if !cfg.is_enabled() {
|
||||
tracing::info!(
|
||||
"SMTP disabled (OXICLOUD_SMTP_HOST empty); magic-link endpoints will return 503"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match crate::infrastructure::services::smtp_email_sender::SmtpEmailSender::new(cfg) {
|
||||
Ok(sender) => {
|
||||
tracing::info!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.configured",
|
||||
host = %cfg.host,
|
||||
port = cfg.port,
|
||||
tls = ?cfg.tls,
|
||||
from = %cfg.from,
|
||||
user = if cfg.user.is_empty() { "<anon>" } else { "<set>" },
|
||||
"SMTP sender configured",
|
||||
);
|
||||
Some(Arc::new(sender))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.config_invalid",
|
||||
error = %e,
|
||||
"SMTP configuration is invalid; magic-link endpoints will return 503",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod pg_acl_engine;
|
||||
pub mod retry_blob_backend;
|
||||
pub mod s3_blob_backend;
|
||||
pub mod share_unlock_cookie;
|
||||
pub mod smtp_email_sender;
|
||||
pub mod thumbnail_service;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_service_test;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 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.
|
||||
|
||||
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, 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<(), 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))
|
||||
})?;
|
||||
|
||||
self.transport
|
||||
.send(built)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("SmtpEmailSender", format!("send: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user