From a6fa11181d9968d8cecf4a9b1957af916020a308 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 11 May 2026 21:24:17 +0200 Subject: [PATCH] feat: enable cloud smtp services --- Cargo.lock | 168 ++++++++++++++++++++++++++++++-- devenv.lock | 64 +----------- module.nix | 18 +++- server/relay-server/Cargo.toml | 2 +- server/relay-server/src/smtp.rs | 63 ++++++++---- 5 files changed, 224 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebd3764..8e99c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2038,9 +2038,12 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", + "rustls", "socket2", "tokio", + "tokio-rustls", "url", + "webpki-roots", ] [[package]] @@ -2214,7 +2217,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2238,7 +2241,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2904,6 +2907,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rstml" version = "0.12.1" @@ -2934,6 +2951,41 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3230,7 +3282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3500,7 +3552,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3540,6 +3592,16 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -3984,6 +4046,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4262,6 +4330,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "2.1.2" @@ -4281,7 +4358,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -4290,6 +4367,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4299,6 +4385,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -4532,6 +4682,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.1.3" diff --git a/devenv.lock b/devenv.lock index 0f2de9a..991fcf7 100644 --- a/devenv.lock +++ b/devenv.lock @@ -16,62 +16,6 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "owner": "NixOS", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1776796298, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762808025, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { "lastModified": 1776734388, @@ -105,15 +49,11 @@ "root": { "inputs": { "devenv": "devenv", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-cmake3": "nixpkgs-cmake3", - "pre-commit-hooks": [ - "git-hooks" - ] + "nixpkgs-cmake3": "nixpkgs-cmake3" } } }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/module.nix b/module.nix index 2577d13..ec119ab 100644 --- a/module.nix +++ b/module.nix @@ -48,9 +48,14 @@ in description = "SMTP server hostname."; }; port = mkOption { - type = types.port; - default = 1025; - description = "SMTP server port."; + type = types.nullOr types.port; + default = null; + description = "SMTP server port. Defaults to 465 when tls = true, 1025 otherwise."; + }; + tls = mkOption { + type = types.bool; + default = false; + description = "Use TLS (port 465). Required for Resend and other cloud SMTP providers."; }; from = mkOption { type = types.str; @@ -60,7 +65,7 @@ in user = mkOption { type = types.str; default = ""; - description = "SMTP username (leave empty to skip authentication)."; + description = "SMTP username (leave empty to skip authentication). Use \"resend\" for Resend."; }; passwordFile = mkOption { type = types.nullOr types.path; @@ -181,8 +186,11 @@ in DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}"; APP_URL = "${cfg.protocol}://${cfg.hostname}"; SMTP_HOST = cfg.smtp.host; - SMTP_PORT = toString cfg.smtp.port; + SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port + else if cfg.smtp.tls then 465 else 1025); SMTP_FROM = cfg.smtp.from; + } // optionalAttrs cfg.smtp.tls { + SMTP_TLS = "true"; } // optionalAttrs (cfg.smtp.user != "") { SMTP_USER = cfg.smtp.user; }; diff --git a/server/relay-server/Cargo.toml b/server/relay-server/Cargo.toml index b4312d5..ccb97fa 100644 --- a/server/relay-server/Cargo.toml +++ b/server/relay-server/Cargo.toml @@ -25,4 +25,4 @@ axum-login = "0.18" argon2 = "0.5" time = "0.3" thiserror = "1" -lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] } +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname", "tokio1-rustls-tls"] } diff --git a/server/relay-server/src/smtp.rs b/server/relay-server/src/smtp.rs index 37ebaa3..bbcfa6e 100644 --- a/server/relay-server/src/smtp.rs +++ b/server/relay-server/src/smtp.rs @@ -1,15 +1,18 @@ -//! SMTP mailer (plain SMTP, no TLS). +//! SMTP mailer. //! //! Configured via environment variables: -//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev) -//! SMTP_PORT — default: 1025 (mailpit default) +//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev) +//! SMTP_PORT — default: 1025 (mailpit) / 465 when SMTP_TLS=true +//! SMTP_TLS — set to "true" to use TLS (required for Resend and other cloud SMTP) //! SMTP_FROM — default: noreply@trictrac.local -//! SMTP_USER — optional SMTP credentials -//! SMTP_PASSWORD — optional SMTP credentials +//! SMTP_USER — optional SMTP credentials (use "resend" for Resend) +//! SMTP_PASSWORD — optional SMTP credentials (use Resend API key) //! APP_URL — default: http://localhost:9091 (frontend base URL for email links) //! -//! For production with TLS, run a local relay (e.g. Postfix) or use an SMTP -//! service that accepts plain connections on a local port and handles TLS externally. +//! Production (Resend): +//! SMTP_HOST=smtp.resend.com SMTP_TLS=true +//! SMTP_USER=resend SMTP_PASSWORD=re_xxxx +//! SMTP_FROM=noreply@yourdomain.com use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, @@ -26,21 +29,47 @@ pub struct Mailer { impl Mailer { pub fn from_env() -> Self { let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port: u16 = std::env::var("SMTP_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(1025); + let tls = std::env::var("SMTP_TLS").map(|v| v == "true").unwrap_or(false); let from_str = std::env::var("SMTP_FROM") .unwrap_or_else(|_| "noreply@trictrac.local".to_string()); let app_url = std::env::var("APP_URL") .unwrap_or_else(|_| "http://localhost:9091".to_string()); - let mut builder = - AsyncSmtpTransport::::builder_dangerous(&host).port(port); - if let (Ok(user), Ok(pass)) = (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) { - builder = builder.credentials(SmtpCredentials::new(user, pass)); - } - let transport = builder.build(); + let credentials = if let (Ok(user), Ok(pass)) = + (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) + { + Some(SmtpCredentials::new(user, pass)) + } else { + None + }; + + let transport = if tls { + // TLS on port 465 (Resend, SendGrid, etc.) + let default_port = 465u16; + let port: u16 = std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(default_port); + let mut builder = AsyncSmtpTransport::::relay(&host) + .expect("invalid SMTP_HOST for TLS relay") + .port(port); + if let Some(creds) = credentials { + builder = builder.credentials(creds); + } + builder.build() + } else { + // Plain SMTP (Mailpit dev, or local relay) + let port: u16 = std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(1025); + let mut builder = + AsyncSmtpTransport::::builder_dangerous(&host).port(port); + if let Some(creds) = credentials { + builder = builder.credentials(creds); + } + builder.build() + }; let from = from_str .parse()