diff --git a/conf/config.dev.toml b/conf/config.dev.toml index 921a3f5..0e51aaa 100644 --- a/conf/config.dev.toml +++ b/conf/config.dev.toml @@ -15,3 +15,5 @@ usage_db = "/app/data/usage.db" # pkg_url = "https://pkg.staging.yivi.app" pkg_url = "http://postguard-pkg:8087" chunk_size = 5000000 +# When true, finalize logs the email it WOULD have sent and skips SMTP. +# staging_mode = true diff --git a/src/config.rs b/src/config.rs index ad36f0e..b20e4fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct RawCryptifyConfig { pkg_url: String, chunk_size: Option, session_ttl_secs: Option, + staging_mode: Option, } #[derive(Debug, Deserialize)] @@ -31,6 +32,7 @@ pub struct CryptifyConfig { pkg_url: String, chunk_size: u64, session_ttl_secs: u64, + staging_mode: bool, } impl From for CryptifyConfig { @@ -51,6 +53,7 @@ impl From for CryptifyConfig { pkg_url: config.pkg_url, chunk_size: config.chunk_size.unwrap_or(5_000_000), session_ttl_secs: config.session_ttl_secs.unwrap_or(3600), + staging_mode: config.staging_mode.unwrap_or(false), } } } @@ -103,4 +106,27 @@ impl CryptifyConfig { pub fn session_ttl_secs(&self) -> u64 { self.session_ttl_secs } + + pub fn staging_mode(&self) -> bool { + self.staging_mode + } + + #[cfg(test)] + pub(crate) fn for_test(server_url: &str, staging_mode: bool) -> Self { + CryptifyConfig { + server_url: server_url.to_owned(), + data_dir: "/tmp".to_owned(), + email_from: "noreply@test.invalid".parse().unwrap(), + smtp_url: "localhost".to_owned(), + smtp_port: 25, + smtp_username: None, + smtp_password: None, + smtp_tls: false, + allowed_origins: String::new(), + pkg_url: String::new(), + chunk_size: 5_000_000, + session_ttl_secs: 3600, + staging_mode, + } + } } diff --git a/src/email.rs b/src/email.rs index 1f9cd93..3a951cb 100644 --- a/src/email.rs +++ b/src/email.rs @@ -193,6 +193,10 @@ pub async fn send_email( state: &FileState, uuid: &str, ) -> Result> { + if config.staging_mode() { + return Ok(staging_log_email(config, state, uuid)); + } + // setup SMTP connection log::info!( "Setting up SMTP: host={}, port={}, tls={}, credentials={}", @@ -281,6 +285,57 @@ pub async fn send_email( Ok("Email successfully sent".to_owned()) } +/// Staging-mode replacement for actual SMTP delivery. Logs a clearly +/// marked record of the email that *would* have been sent (recipients, +/// sender, attributes, expiry, download URL) so operators of a staging +/// deployment can observe the full flow without contacting an SMTP +/// server. Returns a summary string in the same `Result::Ok` shape as +/// real sends. +fn staging_log_email(config: &CryptifyConfig, state: &FileState, uuid: &str) -> String { + let sender = state.sender.as_deref().unwrap_or(""); + let lang = match state.mail_lang { + Language::En => "EN", + Language::Nl => "NL", + }; + let recipients: Vec = state + .recipients + .iter() + .map(|m| m.email.to_string()) + .collect(); + let attrs: Vec = state + .sender_attributes + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect(); + + let base = Url::parse(config.server_url()).ok(); + let download_url = base + .and_then(|b| b.join("/download").ok()) + .map(|mut u| { + u.query_pairs_mut().append_pair("uuid", uuid); + u.to_string() + }) + .unwrap_or_else(|| format!("(unparseable server_url={})", config.server_url())); + + let summary = format!( + "[STAGING] Email NOT sent (staging_mode=true). Would have notified recipients={:?} \ + from sender={} (attributes=[{}]) lang={} expires={} confirm={} notify_recipients={} \ + download_url={} uuid={}", + recipients, + sender, + attrs.join(", "), + lang, + state.expires, + state.confirm, + state.notify_recipients, + download_url, + uuid, + ); + + log::info!("{}", summary); + summary +} + #[cfg(test)] mod tests { use super::*; @@ -346,6 +401,52 @@ mod tests { assert_eq!(format_file_size(1024_u64.pow(4)), "1.0 TB"); } + fn staging_filestate() -> FileState { + use lettre::message::{Mailbox, Mailboxes}; + let mut mboxes = Mailboxes::new(); + mboxes.push("alice@example.com".parse::().unwrap()); + mboxes.push("bob@example.com".parse::().unwrap()); + FileState { + uploaded: 1234, + cryptify_token: String::new(), + expires: 1_700_000_000, + recipients: mboxes, + mail_content: String::new(), + mail_lang: Language::En, + sender: Some("sender@example.com".to_owned()), + sender_attributes: vec![ + ("orgName".to_owned(), "Acme".to_owned()), + ("phone".to_owned(), "+31123".to_owned()), + ], + confirm: true, + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + } + } + + #[rocket::async_test] + async fn staging_mode_skips_smtp_and_returns_summary() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let res = send_email(&config, &state, "uuid-abc") + .await + .expect("staging mode should return Ok without contacting SMTP"); + assert!(res.starts_with("[STAGING]"), "got: {}", res); + assert!(res.contains("alice@example.com"), "got: {}", res); + assert!(res.contains("bob@example.com"), "got: {}", res); + assert!(res.contains("sender@example.com"), "got: {}", res); + assert!(res.contains("orgName=Acme"), "got: {}", res); + assert!(res.contains("uuid=uuid-abc"), "got: {}", res); + assert!( + res.contains("https://staging.example.com/download?uuid=uuid-abc"), + "got: {}", + res + ); + } + #[test] fn format_file_size_clamps_above_tb() { // u64 max is ~16 EB, far beyond TB — previously UNITS[i] would panic.