diff --git a/Cargo.lock b/Cargo.lock index 32982b46..924d331a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 1a693bf5..c4b00a0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/src/application/ports/email_sender.rs b/src/application/ports/email_sender.rs new file mode 100644 index 00000000..9f9e3a4b --- /dev/null +++ b/src/application/ports/email_sender.rs @@ -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, +} + +/// 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` (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>; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index c79f248c..a6e756ea 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/common/config.rs b/src/common/config.rs index 3b7d1883..052dfa95 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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 { + 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 `). + 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::() + { + 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 } diff --git a/src/common/di.rs b/src/common/di.rs index bcc17c1f..5f520de1 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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>, + /// 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>, } // 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> { + 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() { "" } else { "" }, + "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 + } + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 5d625379..701d62a5 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/smtp_email_sender.rs b/src/infrastructure/services/smtp_email_sender.rs new file mode 100644 index 00000000..b2aef8de --- /dev/null +++ b/src/infrastructure/services/smtp_email_sender.rs @@ -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, + /// 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 { + 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::::starttls_relay(&cfg.host).map_err(|e| { + DomainError::internal_error( + "SmtpEmailSender", + format!("starttls relay for {}: {}", cfg.host, e), + ) + })? + } + SmtpTlsMode::Tls => { + AsyncSmtpTransport::::relay(&cfg.host).map_err(|e| { + DomainError::internal_error( + "SmtpEmailSender", + format!("tls relay for {}: {}", cfg.host, e), + ) + })? + } + SmtpTlsMode::None => AsyncSmtpTransport::::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(()) + } +}