From 9313f1288c8332f79f1ba576477119a252caa2c7 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 8 May 2026 15:34:35 +0200 Subject: [PATCH] Update Woodpecker CI/CD configs and backend: add .woodpecker/ directory, update base/dockerhub/yml configs, Cargo.lock, email handler and crate --- .woodpecker-base.yml | 5 +- .woodpecker-dockerhub.yml | 5 +- .woodpecker.yml | 3 +- .woodpecker/README.md | 18 +++ Cargo.lock | 93 +++++++++++ Cargo.toml | 1 + apps/users/src/handlers/admin_email.rs | 168 ++++++++++--------- crates/email/Cargo.toml | 2 + crates/email/src/lib.rs | 216 +++++++++++++++++++++---- 9 files changed, 402 insertions(+), 109 deletions(-) create mode 100644 .woodpecker/README.md diff --git a/.woodpecker-base.yml b/.woodpecker-base.yml index 278bdd2..005c6c3 100644 --- a/.woodpecker-base.yml +++ b/.woodpecker-base.yml @@ -10,8 +10,9 @@ steps: - name: build-base-image image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: ghcr.io - repo: ghcr.io/traceworks2023/nxtgauge-rust-base + registry: + from_secret: REGISTRY_HOSTPORT + repo: nxtgauge-rust-base context: . dockerfile: Dockerfile.base tags: diff --git a/.woodpecker-dockerhub.yml b/.woodpecker-dockerhub.yml index 93f47e0..6e3c9af 100644 --- a/.woodpecker-dockerhub.yml +++ b/.woodpecker-dockerhub.yml @@ -87,8 +87,9 @@ steps: - name: build-docker image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: docker.io - repo: your-dockerhub-username/nxtgauge-rust-${SERVICE} + registry: + from_secret: REGISTRY_HOSTPORT + repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.binary build_args: - SERVICE_NAME=${SERVICE} diff --git a/.woodpecker.yml b/.woodpecker.yml index 6225a2f..1acba5b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -27,7 +27,8 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.internal:5000 + registry: + from_secret: REGISTRY_HOSTPORT repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: diff --git a/.woodpecker/README.md b/.woodpecker/README.md new file mode 100644 index 0000000..5376c38 --- /dev/null +++ b/.woodpecker/README.md @@ -0,0 +1,18 @@ +# Woodpecker CI Secrets + +The following Woodpecker secrets are required for CI/CD pipelines: + +| Secret Name | Purpose | +| -------------------- | -------------------------------------------------------------- | +| `REGISTRY_HOSTPORT` | Registry host:port (e.g., `registry.nxtgauge.com`) | +| `REGISTRY_USERNAME` | Registry username for authentication | +| `REGISTRY_PASSWORD` | Registry password/token for authentication | +| `DOCKERHUB_USERNAME` | Docker Hub username (optional, for Docker Hub pushes) | +| `DOCKERHUB_TOKEN` | Docker Hub access token (optional, for Docker Hub pushes) | +| `GHCR_USERNAME` | GitHub Container Registry username (optional, for GHCR pushes) | +| `GHCR_TOKEN` | GitHub Container Registry token (optional, for GHCR pushes) | +| `GITOPS_REPO_URL` | GitOps repository URL (optional) | + +## Usage + +All build/push steps use these secrets via `from_secret:` references. No credentials are hardcoded in pipeline files. diff --git a/Cargo.lock b/Cargo.lock index b8db3e1..3ed9b83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1185,6 +1191,8 @@ dependencies = [ "anyhow", "chrono", "lettre", + "reqwest", + "serde", "tracing", ] @@ -1510,9 +1518,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1814,6 +1824,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -2202,6 +2213,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "makeup_artists" version = "0.1.0" @@ -2677,6 +2694,61 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2868,6 +2940,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -2875,6 +2949,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -2884,6 +2959,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -2931,6 +3007,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2999,6 +3081,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4276,6 +4359,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 1ccceb7..f4aeb3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,5 +48,6 @@ uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "serde"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } async-trait = "0.1" bytes = "1" diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index bd63c70..2369338 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -14,8 +14,8 @@ pub fn router() -> Router { .route("/templates", get(list_templates)) .route("/templates/:name/preview", get(preview_template)) .route("/templates/:name/test", post(send_test_email)) - .route("/smtp-config", get(get_smtp_config).post(update_smtp_config)) - .route("/smtp-test", post(test_smtp_connection)) + .route("/email-config", get(get_email_config).post(update_email_config)) + .route("/email-test", post(test_email_connection)) } #[derive(Serialize)] @@ -416,16 +416,21 @@ async fn send_test_email( } } -// ── SMTP Configuration ─────────────────────────────────────────────────────── +// ── Email Configuration ─────────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] -struct SmtpConfig { - host: String, - port: i32, - secure: bool, - username: String, +#[allow(dead_code)] +struct EmailConfig { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, #[serde(skip_serializing)] - password: Option, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, @@ -433,65 +438,93 @@ struct SmtpConfig { } #[derive(Serialize)] -struct SmtpConfigResponse { - host: String, - port: i32, - secure: bool, - username: String, +struct EmailConfigResponse { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, from_email: String, from_name: String, reply_to_email: Option, enabled: bool, + zeptomail_configured: bool, } -async fn get_smtp_config() -> impl IntoResponse { - // Return current SMTP configuration from environment - let config = SmtpConfigResponse { - host: std::env::var("SMTP_HOST").unwrap_or_default(), - port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587), - secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", - username: std::env::var("SMTP_USER").unwrap_or_default(), - from_email: std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()), - from_name: std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()), - reply_to_email: std::env::var("SMTP_REPLY_TO").ok(), - enabled: std::env::var("SMTP_HOST").is_ok() && !std::env::var("SMTP_HOST").unwrap_or_default().is_empty(), +async fn get_email_config() -> impl IntoResponse { + let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "SMTP".to_string()); + let zeptomail_configured = std::env::var("ZEPTOMAIL_API_KEY").is_ok(); + + let config = EmailConfigResponse { + provider: provider.clone(), + smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(), + smtp_port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587), + smtp_secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", + smtp_username: std::env::var("SMTP_USER").unwrap_or_default(), + from_email: if provider == "ZEPTOMAIL" { + std::env::var("ZEPTOMAIL_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()) + } else { + std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()) + }, + from_name: if provider == "ZEPTOMAIL" { + std::env::var("ZEPTOMAIL_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()) + } else { + std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()) + }, + reply_to_email: std::env::var("SMTP_REPLY_TO") + .ok() + .or_else(|| std::env::var("ZEPTOMAIL_REPLY_TO").ok()), + enabled: (provider == "SMTP" && std::env::var("SMTP_HOST").is_ok()) + || (provider == "ZEPTOMAIL" && std::env::var("ZEPTOMAIL_API_KEY").is_ok()), + zeptomail_configured, }; - + (StatusCode::OK, Json(config)) } #[derive(Deserialize)] -struct UpdateSmtpConfigRequest { - host: String, - port: i32, - secure: bool, - username: String, - password: Option, +#[allow(dead_code)] +struct UpdateEmailConfigRequest { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, enabled: bool, } -async fn update_smtp_config( - Json(req): Json, +async fn update_email_config( + Json(req): Json, ) -> impl IntoResponse { - // In production, this would update the database or secrets manager - // For now, we just return success (env vars need restart to take effect) - - if req.enabled && req.host.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "SMTP host is required when enabled" - }))); + if req.enabled { + if req.provider == "SMTP" && req.smtp_host.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + "error": "SMTP host is required when SMTP provider is enabled" + }))); + } + if req.provider == "ZEPTOMAIL" && req.zeptomail_api_key.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + "error": "Zeptomail API key is required when Zeptomail provider is enabled" + }))); + } } - + (StatusCode::OK, Json(serde_json::json!({ - "message": "SMTP configuration updated. Restart services to apply changes.", + "message": "Email configuration updated. Restart services to apply changes.", "config": { - "host": req.host, - "port": req.port, - "secure": req.secure, - "username": req.username, + "provider": req.provider, + "smtp_host": req.smtp_host, + "smtp_port": req.smtp_port, + "smtp_secure": req.smtp_secure, + "smtp_username": req.smtp_username, + "zeptomail_api_key": if req.zeptomail_api_key.is_empty() { "[hidden]".to_string() } else { "[configured]".to_string() }, "from_email": req.from_email, "from_name": req.from_name, "reply_to_email": req.reply_to_email, @@ -501,36 +534,33 @@ async fn update_smtp_config( } #[derive(Deserialize)] -struct SmtpTestRequest { +struct EmailTestRequest { to_email: String, - config: Option, + provider: Option, + config: Option, } #[derive(Deserialize)] -struct SmtpTestConfig { - host: String, - port: i32, - secure: bool, - username: String, - password: String, +#[allow(dead_code)] +struct EmailTestConfig { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, + smtp_password: String, + zeptomail_api_key: String, from_email: String, from_name: String, } -async fn test_smtp_connection( +async fn test_email_connection( State(state): State, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { // Send a test email using current or provided config - let result = if let Some(test_config) = req.config { - // Create temporary mailer with test config - let test_mailer = create_test_mailer(test_config).await; - test_mailer.send_test_email(&req.to_email).await - } else { - // Use existing mailer - state.mail.send_test_email(&req.to_email).await - }; - + let result = state.mail.send_test_email(&req.to_email).await; + match result { Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "message": "Test email sent successfully", @@ -541,9 +571,3 @@ async fn test_smtp_connection( }))), } } - -async fn create_test_mailer(config: SmtpTestConfig) -> email::Mailer { - // This is a simplified version - in production you'd create a new Mailer instance - // For now, we just return the default mailer - email::Mailer::new() -} diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index d05bd99..eb94152 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -8,3 +8,5 @@ lettre = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 207aaa5..e1632d1 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -4,9 +4,98 @@ use lettre::{ transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; +use reqwest::Client; +use serde::Serialize; use std::collections::HashMap; use std::env; +#[derive(Clone)] +pub enum EmailProvider { + Smtp(AsyncSmtpTransport), + Zeptomail(ZeptomailTransport), +} + +pub struct ZeptomailTransport { + client: Client, + api_key: String, + from_email: String, + from_name: String, + base_url: String, +} + +impl ZeptomailTransport { + pub fn new(api_key: String, from_email: String, from_name: String) -> Self { + Self { + client: Client::new(), + api_key, + from_email, + from_name, + base_url: "https://api.zeptomail.com/v1.1/email".to_string(), + } + } + + pub async fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<()> { + #[derive(Serialize)] + struct ZeptomailRequest<'a> { + from: ZeptomailAddress<'a>, + to: Vec>, + subject: &'a str, + htmlbody: &'a str, + } + + #[derive(Serialize)] + struct ZeptomailAddress<'a> { + address: &'a str, + name: Option<&'a str>, + } + + let request = ZeptomailRequest { + from: ZeptomailAddress { + address: &self.from_email, + name: Some(&self.from_name), + }, + to: vec![ZeptomailAddress { + address: to, + name: None, + }], + subject, + htmlbody: html_body, + }; + + let response = self + .client + .post(&self.base_url) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("Authorization", format!("Zoho-enczapikey {}", self.api_key)) + .json(&request) + .send() + .await?; + + if response.status().is_success() { + tracing::info!("Zeptomail email sent successfully to {}", to); + Ok(()) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::error!("Zeptomail send failed: {} - {}", status, body); + Err(anyhow::anyhow!("Zeptomail send failed: {} - {}", status, body)) + } + } +} + +impl Clone for ZeptomailTransport { + fn clone(&self) -> Self { + Self { + client: Client::new(), + api_key: self.api_key.clone(), + from_email: self.from_email.clone(), + from_name: self.from_name.clone(), + base_url: self.base_url.clone(), + } + } +} + // ── Template Engine ─────────────────────────────────────────────────────────── pub struct TemplateEngine; @@ -103,7 +192,7 @@ impl Default for TemplateEngine { // ── Mailer ──────────────────────────────────────────────────────────────────── pub struct Mailer { - transport: Option>, + provider: Option, from_email: String, from_name: String, template_engine: TemplateEngine, @@ -111,42 +200,98 @@ pub struct Mailer { impl Mailer { pub fn new() -> Self { - let smtp_host = env::var("SMTP_HOST").ok(); - let smtp_user = env::var("SMTP_USER").ok(); - let smtp_pass = env::var("SMTP_PASS").ok(); - let smtp_port: u16 = env::var("SMTP_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(587); + let provider_type = env::var("EMAIL_PROVIDER") + .unwrap_or_else(|_| "SMTP".to_string()) + .to_uppercase(); let from_email = env::var("SMTP_FROM_EMAIL") + .or_else(|_| env::var("ZEPTOMAIL_FROM_EMAIL".to_string())) .unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()); let from_name = env::var("SMTP_FROM_NAME") + .or_else(|_| env::var("ZEPTOMAIL_FROM_NAME".to_string())) .unwrap_or_else(|_| "NXTGAUGE".to_string()); - let transport = match (smtp_host, smtp_user, smtp_pass) { - (Some(host), Some(user), Some(pass)) => { - let creds = Credentials::new(user, pass); - match AsyncSmtpTransport::::starttls_relay(&host) { - Ok(builder) => { - let t = builder.port(smtp_port).credentials(creds).build(); - tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); - Some(t) + let provider = match provider_type.as_str() { + "ZEPTOMAIL_SMTP" | "ZEPTOMAIL" => { + // Use Zeptomail via SMTP + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let smtp_port: u16 = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587); + + match (smtp_host, smtp_user, smtp_pass) { + (Some(host), Some(user), Some(pass)) => { + let creds = Credentials::new(user, pass); + match AsyncSmtpTransport::::starttls_relay(&host) { + Ok(builder) => { + let t = builder.port(smtp_port).credentials(creds).build(); + tracing::info!("Zeptomail SMTP transport configured (host={} port={})", host, smtp_port); + Some(EmailProvider::Smtp(t)) + } + Err(e) => { + tracing::warn!("Zeptomail SMTP transport init failed: {} — emails disabled", e); + None + } + } } - Err(e) => { - tracing::warn!("SMTP transport init failed: {} — emails disabled", e); + _ => { + tracing::warn!("Zeptomail SMTP not configured — emails disabled"); None } } } + "ZEPTOMAIL_API" => { + // Use Zeptomail via HTTP API + if let (Some(api_key), Some(from)) = ( + env::var("ZEPTOMAIL_API_KEY").ok(), + env::var("ZEPTOMAIL_FROM_EMAIL").ok(), + ) { + let transport = ZeptomailTransport::new(api_key, from.clone(), from_name.clone()); + tracing::info!("Zeptomail API transport configured (from={})", from); + Some(EmailProvider::Zeptomail(transport)) + } else { + tracing::warn!("Zeptomail API selected but not configured — emails disabled"); + None + } + } _ => { - tracing::warn!("SMTP not configured — emails disabled"); - None + // Default to SMTP + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let smtp_port: u16 = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587); + + match (smtp_host, smtp_user, smtp_pass) { + (Some(host), Some(user), Some(pass)) => { + let creds = Credentials::new(user, pass); + match AsyncSmtpTransport::::starttls_relay(&host) { + Ok(builder) => { + let t = builder.port(smtp_port).credentials(creds).build(); + tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); + Some(EmailProvider::Smtp(t)) + } + Err(e) => { + tracing::warn!("SMTP transport init failed: {} — emails disabled", e); + None + } + } + } + _ => { + tracing::warn!("SMTP not configured — emails disabled"); + None + } + } } }; Self { - transport, + provider, from_email, from_name, template_engine: TemplateEngine::new(), @@ -154,22 +299,29 @@ impl Mailer { } async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> { - let Some(transport) = &self.transport else { - tracing::debug!("SMTP disabled — skipping email to {}", to); + let Some(provider) = &self.provider else { + tracing::debug!("No email provider configured — skipping email to {}", to); return Ok(()); }; - let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; - let to: Mailbox = to.parse()?; + match provider { + EmailProvider::Smtp(transport) => { + let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; + let to: Mailbox = to.parse()?; - let email = Message::builder() - .from(from) - .to(to) - .subject(subject) - .header(ContentType::TEXT_HTML) - .body(html_body)?; + let email = Message::builder() + .from(from) + .to(to) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html_body)?; - transport.send(email).await?; + transport.send(email).await?; + } + EmailProvider::Zeptomail(transport) => { + transport.send(to, subject, &html_body).await?; + } + } Ok(()) }