diff --git a/src/email.rs b/src/email.rs index 17fe162..5cbfea5 100644 --- a/src/email.rs +++ b/src/email.rs @@ -262,6 +262,87 @@ fn format_date(date: i64, lang: &Language) -> String { dt.format_localized("%e %B %Y", locale).to_string() } +/// One rendered notification email, in the shape `send_email` would +/// hand to the SMTP layer. Returned by [`render_recipient_email`] and +/// [`render_confirmation_email`]; consumed by `send_email` for real +/// delivery and by the staging `/staging/preview/` endpoint so +/// developers can inspect what cryptify would have sent without +/// reaching for the logs. +#[derive(Serialize, Clone, Debug)] +pub struct RenderedEmail { + /// The recipient address this rendering targets (the per-recipient + /// notification's `To`, or the sender's address for confirmation). + pub recipient: String, + pub subject: String, + /// Formatted `Name ` form of the configured `email_from`. + pub from: String, + /// Set on per-recipient notifications (so replies go to the sender); + /// `None` on the sender's own confirmation copy. + pub reply_to: Option, + pub html: String, + pub text: String, +} + +/// Build the `/download?uuid=…&recipient=…` link cryptify embeds in the +/// notification body. Extracted from `send_email` so the preview endpoint +/// constructs URLs the same way and they cannot drift. +fn build_download_url( + config: &CryptifyConfig, + uuid: &str, + recipient: &str, +) -> Result { + let base = Url::parse(config.server_url())?; + let mut url = base.join("/download")?; + url.query_pairs_mut() + .append_pair("uuid", uuid) + .append_pair("recipient", recipient); + Ok(url.to_string()) +} + +/// Render the per-recipient notification email (subject + HTML + text) +/// for a single recipient on an upload. Pure: no SMTP, no IO beyond URL +/// parsing. +pub fn render_recipient_email( + state: &FileState, + config: &CryptifyConfig, + recipient_email: &str, + uuid: &str, +) -> Result { + let url = build_download_url(config, uuid, recipient_email)?; + let (html, text, subject) = email_templates(state, &url); + Ok(RenderedEmail { + recipient: recipient_email.to_owned(), + subject, + from: config.email_from().to_string(), + reply_to: state.sender.clone(), + html, + text, + }) +} + +/// Render the sender's confirmation copy (only emitted when +/// `state.confirm` is set on upload). Returns `Ok(None)` when no sender +/// address is known — confirmation has nowhere to go. +pub fn render_confirmation_email( + state: &FileState, + config: &CryptifyConfig, + uuid: &str, +) -> Result, url::ParseError> { + let Some(sender_email) = state.sender.clone() else { + return Ok(None); + }; + let url = build_download_url(config, uuid, &sender_email)?; + let (html, text, subject) = email_confirm(state, &url); + Ok(Some(RenderedEmail { + recipient: sender_email, + subject, + from: config.email_from().to_string(), + reply_to: None, + html, + text, + })) +} + fn email_templates(state: &FileState, url: &str) -> (String, String, String) { let strings = match state.mail_lang { Language::En => EN_STRINGS, @@ -392,21 +473,16 @@ pub async fn send_email( if state.notify_recipients { for recipient in state.recipients.iter() { - // combine URL with mail variables into template - let base = Url::parse(config.server_url())?; - let mut url = base.join("/download")?; - url.query_pairs_mut() - .append_pair("uuid", uuid) - .append_pair("recipient", &format!("{}", recipient.email)); - - let (html, text, subject) = email_templates(state, url.as_str()); + let recipient_email = recipient.email.to_string(); + let rendered = render_recipient_email(state, config, &recipient_email, uuid)?; + let mut builder = Message::builder() .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) .header(AutoSubmitted) .from(config.email_from()) // checked in config .to(recipient.clone()) - .subject(subject); - if let Some(sender) = state.sender.as_deref() { + .subject(&rendered.subject); + if let Some(sender) = rendered.reply_to.as_deref() { match sender.parse::() { Ok(mailbox) => builder = builder.reply_to(mailbox), Err(e) => log::warn!( @@ -416,7 +492,7 @@ pub async fn send_email( ), } } - let email = builder.multipart(build_body(html, text)?)?; + let email = builder.multipart(build_body(rendered.html, rendered.text)?)?; // send email log::info!("Sending email to {}", recipient.email); @@ -436,31 +512,38 @@ pub async fn send_email( } if state.confirm { - // also send confirmation email to sender - let sender = state.sender.clone().unwrap(); - - let base = Url::parse(config.server_url())?; - let mut url = base.join("/download")?; - url.query_pairs_mut() - .append_pair("uuid", uuid) - .append_pair("recipient", &sender); - - let (html, text, subject) = email_confirm(state, url.as_str()); - let email = Message::builder() - .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) - .header(AutoSubmitted) - .from(config.email_from()) - .to(sender.parse()?) - .subject(subject) - .multipart(build_body(html, text)?)?; - - log::info!("Sending confirmation email to {}", sender); - let mailer = mailer_builder.build(); - mailer.send(&email).map_err(|e| { - log::error!("Failed to send confirmation email to {}: {}", sender, e); - e - })?; - log::info!("Confirmation email sent to {}", sender); + // `state.confirm` is only set on uploads that captured a sender + // address, so render_confirmation_email returns `Some` here. Log + // loudly on the `None` arm so a future invariant breach surfaces + // instead of silently dropping the sender's confirmation copy. + match render_confirmation_email(state, config, uuid)? { + None => log::error!( + "state.confirm=true but no sender on FileState for upload {} — confirmation email dropped", + uuid + ), + Some(rendered) => { + let to_mailbox: Mailbox = rendered.recipient.parse()?; + let email = Message::builder() + .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) + .header(AutoSubmitted) + .from(config.email_from()) + .to(to_mailbox) + .subject(&rendered.subject) + .multipart(build_body(rendered.html, rendered.text)?)?; + + log::info!("Sending confirmation email to {}", rendered.recipient); + let mailer = mailer_builder.build(); + mailer.send(&email).map_err(|e| { + log::error!( + "Failed to send confirmation email to {}: {}", + rendered.recipient, + e + ); + e + })?; + log::info!("Confirmation email sent to {}", rendered.recipient); + } + } } Ok("Email successfully sent".to_owned()) @@ -817,6 +900,62 @@ mod tests { ); } + #[test] + fn render_recipient_email_embeds_download_url_with_uuid_and_recipient() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let rendered = render_recipient_email(&state, &config, "alice@example.com", "uuid-abc") + .expect("render"); + assert_eq!(rendered.recipient, "alice@example.com"); + assert_eq!( + rendered.reply_to.as_deref(), + Some("sender@example.com"), + "reply_to should mirror state.sender" + ); + // HTML escapes `&` to `&`; the plain-text branch is the + // cleanest place to assert URL composition. + assert!( + rendered.text.contains( + "https://staging.example.com/download?uuid=uuid-abc&recipient=alice%40example.com" + ), + "text missing download URL: {}", + rendered.text + ); + assert!( + rendered.subject.contains("sent you files"), + "subject: {}", + rendered.subject + ); + } + + #[test] + fn render_confirmation_email_targets_sender_and_drops_reply_to() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let rendered = render_confirmation_email(&state, &config, "uuid-xyz") + .expect("render") + .expect("confirmation present when state.sender is Some"); + assert_eq!(rendered.recipient, "sender@example.com"); + assert!( + rendered.reply_to.is_none(), + "confirmation should not set Reply-To" + ); + assert!( + rendered.html.contains("uuid=uuid-xyz"), + "html missing uuid: {}", + rendered.html + ); + } + + #[test] + fn render_confirmation_email_returns_none_without_sender() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let mut state = staging_filestate(); + state.sender = None; + let rendered = render_confirmation_email(&state, &config, "uuid-xyz").expect("render"); + assert!(rendered.is_none()); + } + #[test] fn format_file_size_clamps_above_tb() { // u64 max is ~16 EB, far beyond TB — previously UNITS[i] would panic. diff --git a/src/main.rs b/src/main.rs index 4f14e57..5e48209 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Duration; use crate::config::CryptifyConfig; -use crate::email::send_email; +use crate::email::{render_confirmation_email, render_recipient_email, send_email, RenderedEmail}; use crate::error::{Error, PayloadTooLargeBody}; use crate::metrics::{detect_channel, storage_sampler, Metrics}; use crate::store::{ @@ -970,6 +970,65 @@ fn usage(store: &State, api_key: ApiKey, email: String) -> Json, + confirmation: Option, +} + +#[get("/staging/preview/")] +async fn staging_preview( + config: &State, + store: &State, + uuid: &str, +) -> Result, rocket::http::Status> { + if !config.staging_mode() { + return Err(rocket::http::Status::NotFound); + } + let state_arc = store.get(uuid).ok_or(rocket::http::Status::NotFound)?; + let state = state_arc.lock().await; + + let mut recipients = Vec::with_capacity(state.recipients.iter().count()); + for mailbox in state.recipients.iter() { + let email = mailbox.email.to_string(); + match render_recipient_email(&state, config, &email, uuid) { + Ok(r) => recipients.push(r), + Err(e) => log::warn!( + "staging_preview: failed to render recipient {} for {}: {}", + email, + uuid, + e + ), + } + } + + let confirmation = if state.confirm { + match render_confirmation_email(&state, config, uuid) { + Ok(opt) => opt, + Err(e) => { + log::warn!( + "staging_preview: failed to render confirmation for {}: {}", + uuid, + e + ); + None + } + } + } else { + None + }; + + Ok(Json(StagingPreviewResponse { + recipients, + confirmation, + })) +} + /// Parsed byte range derived from an HTTP `Range` header and the resource's /// total size. Both endpoints are inclusive, per RFC 7233 §2.1. #[derive(Debug, PartialEq, Eq)] @@ -1195,7 +1254,8 @@ pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket()) @@ -2285,6 +2345,114 @@ mod tests { let _ = std::fs::remove_dir_all(&data_dir); let _ = std::fs::remove_dir_all(&secret_dir); } + + /// Build a minimal rocket exposing only the `staging_preview` route, + /// with `staging_mode` controlled by the caller. The returned UUID + /// (when `seed_uuid` is `Some`) is pre-inserted into the store so the + /// happy-path test has something to render. + async fn staging_preview_client(staging_mode: bool, seed_uuid: Option<&str>) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "https://staging.example.com", + "data_dir": std::env::temp_dir().to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + "staging_mode": staging_mode, + }), + )); + + let store = Store::new(Arc::new(Metrics::new())); + if let Some(uuid) = seed_uuid { + let mut mboxes = lettre::message::Mailboxes::new(); + mboxes.push("alice@example.com".parse().unwrap()); + mboxes.push("bob@example.com".parse().unwrap()); + let state = FileState { + uploaded: 1234, + cryptify_token: String::new(), + expires: 1_700_000_000, + recipients: mboxes, + mail_content: String::new(), + mail_lang: email::Language::En, + sender: Some("sender@example.com".to_owned()), + sender_attributes: Vec::new(), + confirm: true, + source_channel: String::new(), + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + }; + store.create(uuid.to_owned(), state); + } + + let rocket = rocket::custom(figment) + .mount("/", routes![staging_preview]) + .attach(AdHoc::config::()) + .manage(store); + + Client::tracked(rocket).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn staging_preview_returns_404_in_production_mode() { + let client = staging_preview_client(false, Some("uuid-known")).await; + let res = client.get("/staging/preview/uuid-known").dispatch().await; + assert_eq!( + res.status(), + Status::NotFound, + "the staging_mode gate must hide the route in production" + ); + } + + #[rocket::async_test] + async fn staging_preview_returns_404_for_unknown_uuid() { + let client = staging_preview_client(true, None).await; + let res = client + .get("/staging/preview/uuid-does-not-exist") + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + } + + #[rocket::async_test] + async fn staging_preview_renders_recipients_and_confirmation() { + let client = staging_preview_client(true, Some("uuid-known")).await; + let res = client.get("/staging/preview/uuid-known").dispatch().await; + assert_eq!(res.status(), Status::Ok); + + let body = res.into_string().await.expect("body"); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + + let recipients = v + .get("recipients") + .and_then(|r| r.as_array()) + .expect("recipients array"); + let emails: Vec<&str> = recipients + .iter() + .filter_map(|r| r.get("recipient").and_then(|s| s.as_str())) + .collect(); + assert_eq!(emails, vec!["alice@example.com", "bob@example.com"]); + for r in recipients { + assert!(r.get("subject").and_then(|s| s.as_str()).is_some()); + assert!(r.get("html").and_then(|s| s.as_str()).is_some()); + assert!(r.get("text").and_then(|s| s.as_str()).is_some()); + } + + let confirmation = v.get("confirmation").expect("confirmation key present"); + assert_eq!( + confirmation + .get("recipient") + .and_then(|s| s.as_str()) + .expect("confirmation.recipient"), + "sender@example.com" + ); + } } /// End-to-end integration tests for the upload pipeline