Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conf/config.dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub struct RawCryptifyConfig {
pkg_url: String,
chunk_size: Option<u64>,
session_ttl_secs: Option<u64>,
staging_mode: Option<bool>,
}

#[derive(Debug, Deserialize)]
Expand All @@ -31,6 +32,7 @@ pub struct CryptifyConfig {
pkg_url: String,
chunk_size: u64,
session_ttl_secs: u64,
staging_mode: bool,
}

impl From<RawCryptifyConfig> for CryptifyConfig {
Expand All @@ -51,6 +53,7 @@ impl From<RawCryptifyConfig> 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),
}
}
}
Expand Down Expand Up @@ -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,
}
}
}
101 changes: 101 additions & 0 deletions src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ pub async fn send_email(
state: &FileState,
uuid: &str,
) -> Result<String, Box<dyn std::error::Error>> {
if config.staging_mode() {
return Ok(staging_log_email(config, state, uuid));
}

// setup SMTP connection
log::info!(
"Setting up SMTP: host={}, port={}, tls={}, credentials={}",
Expand Down Expand Up @@ -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("<unknown>");
let lang = match state.mail_lang {
Language::En => "EN",
Language::Nl => "NL",
};
let recipients: Vec<String> = state
.recipients
.iter()
.map(|m| m.email.to_string())
.collect();
let attrs: Vec<String> = 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::*;
Expand Down Expand Up @@ -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::<Mailbox>().unwrap());
mboxes.push("bob@example.com".parse::<Mailbox>().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.
Expand Down