Update Woodpecker CI/CD configs and backend: add .woodpecker/ directory, update base/dockerhub/yml configs, Cargo.lock, email handler and crate

This commit is contained in:
Tracewebstudio Dev 2026-05-08 15:34:35 +02:00
parent 614357cfa7
commit 9313f1288c
9 changed files with 402 additions and 109 deletions

View file

@ -10,8 +10,9 @@ steps:
- name: build-base-image - name: build-base-image
image: woodpeckerci/plugin-docker-buildx:5.0.0 image: woodpeckerci/plugin-docker-buildx:5.0.0
settings: settings:
registry: ghcr.io registry:
repo: ghcr.io/traceworks2023/nxtgauge-rust-base from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-base
context: . context: .
dockerfile: Dockerfile.base dockerfile: Dockerfile.base
tags: tags:

View file

@ -87,8 +87,9 @@ steps:
- name: build-docker - name: build-docker
image: woodpeckerci/plugin-docker-buildx:5.0.0 image: woodpeckerci/plugin-docker-buildx:5.0.0
settings: settings:
registry: docker.io registry:
repo: your-dockerhub-username/nxtgauge-rust-${SERVICE} from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-${SERVICE}
dockerfile: Dockerfile.binary dockerfile: Dockerfile.binary
build_args: build_args:
- SERVICE_NAME=${SERVICE} - SERVICE_NAME=${SERVICE}

View file

@ -27,7 +27,8 @@ steps:
- name: build-and-push - name: build-and-push
image: woodpeckerci/plugin-kaniko:2.1.1 image: woodpeckerci/plugin-kaniko:2.1.1
settings: settings:
registry: registry.nxtgauge.internal:5000 registry:
from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-${SERVICE} repo: nxtgauge-rust-${SERVICE}
dockerfile: Dockerfile.simple dockerfile: Dockerfile.simple
build_args: build_args:

18
.woodpecker/README.md Normal file
View file

@ -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.

93
Cargo.lock generated
View file

@ -788,6 +788,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@ -1185,6 +1191,8 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"lettre", "lettre",
"reqwest",
"serde",
"tracing", "tracing",
] ]
@ -1510,9 +1518,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1814,6 +1824,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tower-service", "tower-service",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@ -2202,6 +2213,12 @@ dependencies = [
"hashbrown 0.15.5", "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]] [[package]]
name = "makeup_artists" name = "makeup_artists"
version = "0.1.0" version = "0.1.0"
@ -2677,6 +2694,61 @@ dependencies = [
"cc", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -2868,6 +2940,8 @@ dependencies = [
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@ -2875,6 +2949,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls 0.26.4",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@ -2884,6 +2959,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@ -2931,6 +3007,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -2999,6 +3081,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@ -4276,6 +4359,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"

View file

@ -48,5 +48,6 @@ uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "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"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
async-trait = "0.1" async-trait = "0.1"
bytes = "1" bytes = "1"

View file

@ -14,8 +14,8 @@ pub fn router() -> Router<AppState> {
.route("/templates", get(list_templates)) .route("/templates", get(list_templates))
.route("/templates/:name/preview", get(preview_template)) .route("/templates/:name/preview", get(preview_template))
.route("/templates/:name/test", post(send_test_email)) .route("/templates/:name/test", post(send_test_email))
.route("/smtp-config", get(get_smtp_config).post(update_smtp_config)) .route("/email-config", get(get_email_config).post(update_email_config))
.route("/smtp-test", post(test_smtp_connection)) .route("/email-test", post(test_email_connection))
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -416,16 +416,21 @@ async fn send_test_email(
} }
} }
// ── SMTP Configuration ─────────────────────────────────────────────────────── // ── Email Configuration ───────────────────────────────────────────────────────
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SmtpConfig { #[allow(dead_code)]
host: String, struct EmailConfig {
port: i32, provider: String,
secure: bool, smtp_host: String,
username: String, smtp_port: i32,
smtp_secure: bool,
smtp_username: String,
#[serde(skip_serializing)] #[serde(skip_serializing)]
password: Option<String>, smtp_password: Option<String>,
zeptomail_api_key: String,
zeptomail_from_email: String,
zeptomail_from_name: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
@ -433,65 +438,93 @@ struct SmtpConfig {
} }
#[derive(Serialize)] #[derive(Serialize)]
struct SmtpConfigResponse { struct EmailConfigResponse {
host: String, provider: String,
port: i32, smtp_host: String,
secure: bool, smtp_port: i32,
username: String, smtp_secure: bool,
smtp_username: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
enabled: bool, enabled: bool,
zeptomail_configured: bool,
} }
async fn get_smtp_config() -> impl IntoResponse { async fn get_email_config() -> impl IntoResponse {
// Return current SMTP configuration from environment let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "SMTP".to_string());
let config = SmtpConfigResponse { let zeptomail_configured = std::env::var("ZEPTOMAIL_API_KEY").is_ok();
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), let config = EmailConfigResponse {
secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", provider: provider.clone(),
username: std::env::var("SMTP_USER").unwrap_or_default(), smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(),
from_email: std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()), smtp_port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587),
from_name: std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()), smtp_secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true",
reply_to_email: std::env::var("SMTP_REPLY_TO").ok(), smtp_username: std::env::var("SMTP_USER").unwrap_or_default(),
enabled: std::env::var("SMTP_HOST").is_ok() && !std::env::var("SMTP_HOST").unwrap_or_default().is_empty(), 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)) (StatusCode::OK, Json(config))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct UpdateSmtpConfigRequest { #[allow(dead_code)]
host: String, struct UpdateEmailConfigRequest {
port: i32, provider: String,
secure: bool, smtp_host: String,
username: String, smtp_port: i32,
password: Option<String>, smtp_secure: bool,
smtp_username: String,
smtp_password: Option<String>,
zeptomail_api_key: String,
zeptomail_from_email: String,
zeptomail_from_name: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
reply_to_email: Option<String>, reply_to_email: Option<String>,
enabled: bool, enabled: bool,
} }
async fn update_smtp_config( async fn update_email_config(
Json(req): Json<UpdateSmtpConfigRequest>, Json(req): Json<UpdateEmailConfigRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// In production, this would update the database or secrets manager if req.enabled {
// For now, we just return success (env vars need restart to take effect) if req.provider == "SMTP" && req.smtp_host.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
if req.enabled && req.host.is_empty() { "error": "SMTP host is required when SMTP provider is enabled"
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ })));
"error": "SMTP host is required when 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!({ (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": { "config": {
"host": req.host, "provider": req.provider,
"port": req.port, "smtp_host": req.smtp_host,
"secure": req.secure, "smtp_port": req.smtp_port,
"username": req.username, "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_email": req.from_email,
"from_name": req.from_name, "from_name": req.from_name,
"reply_to_email": req.reply_to_email, "reply_to_email": req.reply_to_email,
@ -501,36 +534,33 @@ async fn update_smtp_config(
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmtpTestRequest { struct EmailTestRequest {
to_email: String, to_email: String,
config: Option<SmtpTestConfig>, provider: Option<String>,
config: Option<EmailTestConfig>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmtpTestConfig { #[allow(dead_code)]
host: String, struct EmailTestConfig {
port: i32, provider: String,
secure: bool, smtp_host: String,
username: String, smtp_port: i32,
password: String, smtp_secure: bool,
smtp_username: String,
smtp_password: String,
zeptomail_api_key: String,
from_email: String, from_email: String,
from_name: String, from_name: String,
} }
async fn test_smtp_connection( async fn test_email_connection(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SmtpTestRequest>, Json(req): Json<EmailTestRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Send a test email using current or provided config // Send a test email using current or provided config
let result = if let Some(test_config) = req.config { let result = state.mail.send_test_email(&req.to_email).await;
// 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
};
match result { match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ Ok(_) => (StatusCode::OK, Json(serde_json::json!({
"message": "Test email sent successfully", "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()
}

View file

@ -8,3 +8,5 @@ lettre = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }

View file

@ -4,9 +4,98 @@ use lettre::{
transport::smtp::authentication::Credentials, transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
}; };
use reqwest::Client;
use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
#[derive(Clone)]
pub enum EmailProvider {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
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<ZeptomailAddress<'a>>,
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 ─────────────────────────────────────────────────────────── // ── Template Engine ───────────────────────────────────────────────────────────
pub struct TemplateEngine; pub struct TemplateEngine;
@ -103,7 +192,7 @@ impl Default for TemplateEngine {
// ── Mailer ──────────────────────────────────────────────────────────────────── // ── Mailer ────────────────────────────────────────────────────────────────────
pub struct Mailer { pub struct Mailer {
transport: Option<AsyncSmtpTransport<Tokio1Executor>>, provider: Option<EmailProvider>,
from_email: String, from_email: String,
from_name: String, from_name: String,
template_engine: TemplateEngine, template_engine: TemplateEngine,
@ -111,42 +200,98 @@ pub struct Mailer {
impl Mailer { impl Mailer {
pub fn new() -> Self { pub fn new() -> Self {
let smtp_host = env::var("SMTP_HOST").ok(); let provider_type = env::var("EMAIL_PROVIDER")
let smtp_user = env::var("SMTP_USER").ok(); .unwrap_or_else(|_| "SMTP".to_string())
let smtp_pass = env::var("SMTP_PASS").ok(); .to_uppercase();
let smtp_port: u16 = env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let from_email = env::var("SMTP_FROM_EMAIL") 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()); .unwrap_or_else(|_| "noreply@nxtgauge.com".to_string());
let from_name = env::var("SMTP_FROM_NAME") let from_name = env::var("SMTP_FROM_NAME")
.or_else(|_| env::var("ZEPTOMAIL_FROM_NAME".to_string()))
.unwrap_or_else(|_| "NXTGAUGE".to_string()); .unwrap_or_else(|_| "NXTGAUGE".to_string());
let transport = match (smtp_host, smtp_user, smtp_pass) { let provider = match provider_type.as_str() {
(Some(host), Some(user), Some(pass)) => { "ZEPTOMAIL_SMTP" | "ZEPTOMAIL" => {
let creds = Credentials::new(user, pass); // Use Zeptomail via SMTP
match AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host) { let smtp_host = env::var("SMTP_HOST").ok();
Ok(builder) => { let smtp_user = env::var("SMTP_USER").ok();
let t = builder.port(smtp_port).credentials(creds).build(); let smtp_pass = env::var("SMTP_PASS").ok();
tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); let smtp_port: u16 = env::var("SMTP_PORT")
Some(t) .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::<Tokio1Executor>::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 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"); // Default to SMTP
None 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::<Tokio1Executor>::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 { Self {
transport, provider,
from_email, from_email,
from_name, from_name,
template_engine: TemplateEngine::new(), template_engine: TemplateEngine::new(),
@ -154,22 +299,29 @@ impl Mailer {
} }
async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> { async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> {
let Some(transport) = &self.transport else { let Some(provider) = &self.provider else {
tracing::debug!("SMTP disabled — skipping email to {}", to); tracing::debug!("No email provider configured — skipping email to {}", to);
return Ok(()); return Ok(());
}; };
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; match provider {
let to: Mailbox = to.parse()?; EmailProvider::Smtp(transport) => {
let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?;
let to: Mailbox = to.parse()?;
let email = Message::builder() let email = Message::builder()
.from(from) .from(from)
.to(to) .to(to)
.subject(subject) .subject(subject)
.header(ContentType::TEXT_HTML) .header(ContentType::TEXT_HTML)
.body(html_body)?; .body(html_body)?;
transport.send(email).await?; transport.send(email).await?;
}
EmailProvider::Zeptomail(transport) => {
transport.send(to, subject, &html_body).await?;
}
}
Ok(()) Ok(())
} }