From 3618c8728e414b0c64601461208ff3f9688651b0 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 09:51:59 +0200 Subject: [PATCH 1/2] feat(email): expose render fns + staging-only /staging/preview/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor `send_email` so the per-recipient and confirmation email bodies are produced by `render_recipient_email` / `render_confirmation_email` (public, pure, no SMTP). `send_email` consumes those and adds the SMTP/MIME plumbing — keeping the email layout in one place. Add `GET /staging/preview/`, gated on `config.staging_mode()` and returning 404 otherwise. Response is JSON `{ recipients: [...], confirmation: ... }` with each entry carrying `recipient`, `subject`, `from`, `reply_to`, `html`, `text`. The staging website calls this so developers can read what cryptify *would* have sent without scraping log lines. --- src/email.rs | 202 ++++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 64 +++++++++++++++- 2 files changed, 229 insertions(+), 37 deletions(-) diff --git a/src/email.rs b/src/email.rs index 17fe162..faf4c37 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,31 @@ 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)?)?; + // `state.confirm` is only set when a sender address was captured, + // so render_confirmation_email returns Some here. Fall through + // silently if that invariant ever loosens. + if let Some(rendered) = render_confirmation_email(state, config, uuid)? { + 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 {}", 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); + 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 +893,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..9d36697 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::{ @@ -972,6 +972,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, + })) +} + #[derive(Debug, PartialEq, Eq)] struct ByteRange { start: u64, @@ -1195,7 +1254,8 @@ pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket()) From 4151321f1a531b465958f97d128ca1bc636c13fd Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 10:41:25 +0200 Subject: [PATCH 2/2] review: restore ByteRange doc, observable confirm-skip, route tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the "Parsed byte range…" doc back above `struct ByteRange`; it had been displaced onto `StagingPreviewResponse` by the previous edit. - `send_email`'s confirmation arm now logs at error level when `state.confirm` is set but `render_confirmation_email` returns `None`, instead of silently dropping the sender's copy if the invariant ever loosens. - Three Rocket local-client tests for `/staging/preview/`: 404 when `staging_mode = false`, 404 on unknown uuid, and a happy-path case asserting the JSON shape (recipients + confirmation). --- src/email.rs | 53 +++++++++++++----------- src/main.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/src/email.rs b/src/email.rs index faf4c37..5cbfea5 100644 --- a/src/email.rs +++ b/src/email.rs @@ -512,30 +512,37 @@ pub async fn send_email( } if state.confirm { - // `state.confirm` is only set when a sender address was captured, - // so render_confirmation_email returns Some here. Fall through - // silently if that invariant ever loosens. - if let Some(rendered) = render_confirmation_email(state, config, uuid)? { - 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, + // `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 - ); - e - })?; - log::info!("Confirmation email sent to {}", rendered.recipient); + })?; + log::info!("Confirmation email sent to {}", rendered.recipient); + } } } diff --git a/src/main.rs b/src/main.rs index 9d36697..5e48209 100644 --- a/src/main.rs +++ b/src/main.rs @@ -970,8 +970,6 @@ fn usage(store: &State, api_key: ApiKey, email: String) -> Json) -> 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