From 2516329e090f26ffdcfc9bfe46195a2fb143e12d Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Mon, 1 Jun 2026 13:37:55 +0200 Subject: [PATCH 1/6] fix(email): address postguard#197 deliverability + show disclosed signer name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-PostGuard header now tracks the pg-core version via a new build.rs that parses Cargo.lock, instead of the stale hardcoded "0.1.0". When pg-core releases 1.0, the header auto-bumps on the next build. - Add Reply-To header pointing to the disclosed sender on recipient mail so replies reach the human sender, not the noreply From-address. - Add Auto-Submitted: auto-generated (RFC 3834) on both notification and confirmation mail to suppress responder loops and signal "transactional" to receiving MTAs. - Send multipart/alternative with a hand-authored plain-text branch in addition to HTML (new templates/email/email.txt). - Embed the PostGuard logo as a multipart/related CID inline part instead of fetching https://postguard.eu/pg_logo.png at render time. Kills the HTML-only + remote-image spam fingerprint and keeps the logo rendering even with images-blocked-by-default. - Show the sender's disclosed full name (pbdf.gemeente.personalData.fullname) wherever the bare email used to appear in the body; filter it from the attribute pill list so it doesn't render twice. Deliberately not added: List-Unsubscribe — wrong semantic fit for person-to-person mail, would misrepresent the sender. Refs encryption4all/postguard#197 --- build.rs | 27 ++++ src/email.rs | 245 +++++++++++++++++++++++++++++++----- templates/email/email.html | 2 +- templates/email/email.txt | 25 ++++ templates/email/pg_logo.png | Bin 0 -> 41157 bytes 5 files changed, 265 insertions(+), 34 deletions(-) create mode 100644 build.rs create mode 100644 templates/email/email.txt create mode 100644 templates/email/pg_logo.png diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9cee129 --- /dev/null +++ b/build.rs @@ -0,0 +1,27 @@ +fn main() { + println!("cargo:rerun-if-changed=Cargo.lock"); + + let lock = std::fs::read_to_string("Cargo.lock").expect("Cargo.lock not readable"); + let version = lock + .split("[[package]]") + .find_map(|block| { + let mut name = None; + let mut ver = None; + for line in block.lines() { + if let Some(rest) = line.strip_prefix("name = \"") { + name = rest.strip_suffix('"'); + } + if let Some(rest) = line.strip_prefix("version = \"") { + ver = rest.strip_suffix('"'); + } + } + if name == Some("pg-core") { + ver + } else { + None + } + }) + .expect("pg-core entry not found in Cargo.lock"); + + println!("cargo:rustc-env=PG_CORE_VERSION={}", version); +} diff --git a/src/email.rs b/src/email.rs index 1cf2988..005a2c0 100644 --- a/src/email.rs +++ b/src/email.rs @@ -6,7 +6,10 @@ use askama::Template; use chrono::{format::Locale, TimeZone}; use lettre::{ - message::header::{ContentType, Header, HeaderName, HeaderValue}, + message::{ + header::{ContentType, Header, HeaderName, HeaderValue}, + Attachment, Mailbox, MultiPart, SinglePart, + }, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport, }; @@ -31,7 +34,38 @@ impl Header for XPostGuard { } } -const X_POSTGUARD_VERSION: &str = "0.1.0"; +const X_POSTGUARD_VERSION: &str = env!("PG_CORE_VERSION"); + +/// `Auto-Submitted: auto-generated` per RFC 3834. Signals to receiving MTAs +/// and mail clients that this is a machine-generated transactional message, +/// suppresses vacation-responder loops, and is one of the deliverability +/// signals Gmail's bulk-sender heuristics look for. +#[derive(Clone, Debug)] +struct AutoSubmitted; + +impl Header for AutoSubmitted { + fn name() -> HeaderName { + HeaderName::new_from_ascii_str("Auto-Submitted") + } + + fn parse(_s: &str) -> Result> { + Ok(AutoSubmitted) + } + + fn display(&self) -> HeaderValue { + HeaderValue::new(Self::name(), "auto-generated".to_owned()) + } +} + +/// IRMA/Yivi attribute identifier for the signer's full name. When this +/// attribute appears in `FileState.sender_attributes` we render the name +/// in place of the bare email everywhere the sender is shown in the body. +const FULLNAME_ATYPE: &str = "pbdf.gemeente.personalData.fullname"; + +/// Embedded PostGuard logo, served inline via a `Content-ID: ` +/// MIME part rather than fetched from postguard.eu. Removes the +/// HTML-only-plus-remote-image spam signal flagged in postguard#197. +const LOGO_PNG: &[u8] = include_bytes!("../templates/email/pg_logo.png"); use serde::{Deserialize, Serialize}; use url::Url; @@ -105,6 +139,62 @@ struct EmailTemplate<'a> { sender_attributes: &'a [(String, String)], } +#[derive(Template)] +#[template(path = "email/email.txt", escape = "none")] +struct EmailTextTemplate<'a> { + header: &'a str, + subheader: &'a str, + expires_str: &'a str, + download_str: &'a str, + link_str: &'a str, + file_size: &'a str, + expiry_date: &'a str, + html_content: &'a str, + url: &'a str, + confirm: &'a str, + files_from: &'a str, + sender_email: &'a str, + sender_attributes: &'a [(String, String)], +} + +/// Resolve the display string and remaining attribute pills for the +/// sender. When the signer disclosed their full name, the name takes the +/// place of the bare email everywhere it would otherwise appear in the +/// body, and is removed from the attribute pill list so it doesn't render +/// twice. +/// Assemble the MIME body: a `multipart/alternative` whose HTML branch is +/// itself a `multipart/related` carrying the HTML part plus the PostGuard +/// logo as an inline image referenced via `cid:pg-logo`. This shape avoids +/// the HTML-only + remote-image spam signal flagged in postguard#197 while +/// keeping graceful degradation for text-only clients. +fn build_body( + html: String, + text: String, +) -> Result> { + let logo = Attachment::new_inline("pg-logo".to_string()) + .body(LOGO_PNG.to_vec(), "image/png".parse::()?); + + let related = MultiPart::related() + .singlepart(SinglePart::html(html)) + .singlepart(logo); + + Ok(MultiPart::alternative() + .singlepart(SinglePart::plain(text)) + .multipart(related)) +} + +fn sender_display(state: &FileState) -> (String, Vec<(String, String)>) { + let mut attrs = state.sender_attributes.clone(); + let name = attrs + .iter() + .position(|(t, _)| t == FULLNAME_ATYPE) + .map(|i| attrs.remove(i).1); + let display = name + .or_else(|| state.sender.clone()) + .unwrap_or_else(|| "Someone".to_string()); + (display, attrs) +} + fn format_file_size(size: u64) -> String { const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; if size == 0 { @@ -128,55 +218,92 @@ fn format_date(date: i64, lang: &Language) -> String { dt.format_localized("%e %B %Y", locale).to_string() } -fn email_templates(state: &FileState, url: &str) -> (String, String) { +fn email_templates(state: &FileState, url: &str) -> (String, String, String) { let strings = match state.mail_lang { Language::En => EN_STRINGS, Language::Nl => NL_STRINGS, }; - let sender_str = state.sender.clone().unwrap_or("Someone".to_string()); - let email = EmailTemplate { - header: &sender_str, + let (display, attrs) = sender_display(state); + let file_size = format_file_size(state.uploaded); + let expiry_date = format_date(state.expires, &state.mail_lang); + + let html = EmailTemplate { + header: &display, + subheader: strings.sender_str, + expires_str: strings.expires_str, + download_str: strings.download_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + confirm: "", + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + let text = EmailTextTemplate { + header: &display, subheader: strings.sender_str, expires_str: strings.expires_str, download_str: strings.download_str, link_str: strings.link_str, - file_size: &format_file_size(state.uploaded), - expiry_date: &format_date(state.expires, &state.mail_lang), + file_size: &file_size, + expiry_date: &expiry_date, html_content: &state.mail_content, confirm: "", files_from: strings.files_from, - sender_email: &sender_str, - sender_attributes: &state.sender_attributes, + sender_email: &display, + sender_attributes: &attrs, url, }; let subject = SubjectTemplate { subject_str: strings.subject_str, - sender: &sender_str, + sender: &display, }; - (email.to_string(), subject.to_string()) + (html.to_string(), text.to_string(), subject.to_string()) } -fn email_confirm(state: &FileState, url: &str) -> (String, String) { +fn email_confirm(state: &FileState, url: &str) -> (String, String, String) { let strings = match state.mail_lang { Language::En => EN_STRINGS, Language::Nl => NL_STRINGS, }; - let sender_str = state.sender.clone().unwrap_or("Someone".to_string()); - let email = EmailTemplate { + let (display, attrs) = sender_display(state); + let file_size = format_file_size(state.uploaded); + let expiry_date = format_date(state.expires, &state.mail_lang); + let recipients = state.recipients.to_string(); + + let html = EmailTemplate { header: strings.header_confirm, - subheader: &state.recipients.to_string(), + subheader: &recipients, expires_str: strings.expires_str, link_str: strings.link_str, - file_size: &format_file_size(state.uploaded), - expiry_date: &format_date(state.expires, &state.mail_lang), + file_size: &file_size, + expiry_date: &expiry_date, html_content: &state.mail_content, download_str: strings.download_str, confirm: strings.confirm, files_from: strings.files_from, - sender_email: &sender_str, - sender_attributes: &state.sender_attributes, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + let text = EmailTextTemplate { + header: strings.header_confirm, + subheader: &recipients, + expires_str: strings.expires_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + download_str: strings.download_str, + confirm: strings.confirm, + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, url, }; @@ -185,7 +312,7 @@ fn email_confirm(state: &FileState, url: &str) -> (String, String) { sender: "", }; - (email.to_string(), subject.to_string()) + (html.to_string(), text.to_string(), subject.to_string()) } pub async fn send_email( @@ -228,14 +355,21 @@ pub async fn send_email( .append_pair("uuid", uuid) .append_pair("recipient", &format!("{}", recipient.email)); - let (email, subject) = email_templates(state, url.as_str()); - let email = Message::builder() - .header(ContentType::TEXT_HTML) + let (html, text, subject) = email_templates(state, url.as_str()); + 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) - .body(email)?; + .subject(subject); + if let Some(reply_to) = state + .sender + .as_deref() + .and_then(|s| s.parse::().ok()) + { + builder = builder.reply_to(reply_to); + } + let email = builder.multipart(build_body(html, text)?)?; // send email log::info!("Sending email to {}", recipient.email); @@ -264,14 +398,14 @@ pub async fn send_email( .append_pair("uuid", uuid) .append_pair("recipient", &sender); - let (email, subject) = email_confirm(state, url.as_str()); + let (html, text, subject) = email_confirm(state, url.as_str()); let email = Message::builder() - .header(ContentType::TEXT_HTML) .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) + .header(AutoSubmitted) .from(config.email_from()) .to(sender.parse()?) .subject(subject) - .body(email)?; + .multipart(build_body(html, text)?)?; log::info!("Sending confirmation email to {}", sender); let mailer = mailer_builder.build(); @@ -345,10 +479,47 @@ mod tests { assert_eq!(format!("{}", XPostGuard::name()), "X-PostGuard"); } + #[test] + fn auto_submitted_header_emits_auto_generated() { + use lettre::message::Mailbox; + let msg = Message::builder() + .from("noreply@example.com".parse::().unwrap()) + .to("to@example.com".parse::().unwrap()) + .subject("t") + .header(AutoSubmitted) + .body(String::from("hi")) + .expect("build"); + let raw = String::from_utf8(msg.formatted()).expect("utf8"); + assert!( + raw.contains("Auto-Submitted: auto-generated"), + "expected Auto-Submitted header, got: {}", + raw + ); + } + + #[test] + fn sender_display_promotes_disclosed_name() { + let state = filestate_with_attrs(vec![ + (FULLNAME_ATYPE.to_owned(), "Jan Jansen".to_owned()), + ("orgName".to_owned(), "Acme".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); + } + + #[test] + fn sender_display_falls_back_to_email_when_no_name_disclosed() { + let state = filestate_with_attrs(vec![("orgName".to_owned(), "Acme".to_owned())]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "sender@example.com"); + assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); + } + #[test] fn x_postguard_header_round_trips() { - let parsed = XPostGuard::parse("0.1.0").expect("parse"); - assert_eq!(parsed.0, "0.1.0"); + let parsed = XPostGuard::parse(X_POSTGUARD_VERSION).expect("parse"); + assert_eq!(parsed.0, X_POSTGUARD_VERSION); } #[test] @@ -362,9 +533,11 @@ mod tests { .body(String::from("hi")) .expect("build"); let raw = String::from_utf8(msg.formatted()).expect("utf8"); + let expected = format!("X-PostGuard: {}", X_POSTGUARD_VERSION); assert!( - raw.contains("X-PostGuard: 0.1.0"), - "expected X-PostGuard header in message, got: {}", + raw.contains(&expected), + "expected `{}` header in message, got: {}", + expected, raw ); } @@ -401,6 +574,12 @@ mod tests { assert_eq!(format_file_size(1024_u64.pow(4)), "1.0 TB"); } + fn filestate_with_attrs(attrs: Vec<(String, String)>) -> FileState { + let mut state = staging_filestate(); + state.sender_attributes = attrs; + state + } + fn staging_filestate() -> FileState { use lettre::message::{Mailbox, Mailboxes}; let mut mboxes = Mailboxes::new(); diff --git a/templates/email/email.html b/templates/email/email.html index 4bf7fa5..3859648 100644 --- a/templates/email/email.html +++ b/templates/email/email.html @@ -9,7 +9,7 @@
- PostGuard + PostGuard

diff --git a/templates/email/email.txt b/templates/email/email.txt new file mode 100644 index 0000000..fe12a32 --- /dev/null +++ b/templates/email/email.txt @@ -0,0 +1,25 @@ +{{header}} {{subheader}} +{{file_size}} - {{expires_str}} {{expiry_date}} +{% if html_content != "" %} + +{{html_content}} +{% endif %} + +{{download_str}}: +{{url}} + +{{link_str}}: +{{url}} +{% if confirm != "" %} + +{{confirm}} +{% endif %} +{% if sender_email != "" %} + +--- +{{files_from}} {{sender_email}} +{% if !sender_attributes.is_empty() %} +{% for attr in sender_attributes %}- {{attr.1}} +{% endfor %} +{% endif %} +{% endif %} diff --git a/templates/email/pg_logo.png b/templates/email/pg_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0f59be11bcb107fa68fda7227cbdb59a0d77da21 GIT binary patch literal 41157 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 mJ Date: Mon, 1 Jun 2026 13:43:00 +0200 Subject: [PATCH 2/6] build: copy build.rs in Dockerfile and apply rustfmt CI fallout from the previous commit: - The Docker build couldn't run build.rs because it wasn't COPY'd into the planner / builder stages, so env!("PG_CORE_VERSION") had nothing to resolve and the release build failed. Add `COPY build.rs ./build.rs` to both stages. - rustfmt wanted `build_body(...)`'s signature on a single line. Apply. --- Dockerfile | 2 ++ src/email.rs | 5 +---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index bfe1d4a..f56dd9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ WORKDIR /app FROM chef AS planner COPY Cargo.toml ./Cargo.toml COPY Cargo.lock ./Cargo.lock +COPY build.rs ./build.rs COPY src ./src COPY templates ./templates RUN cargo chef prepare --recipe-path recipe.json @@ -25,6 +26,7 @@ RUN cargo chef cook --profile ${CARGO_PROFILE} --recipe-path recipe.json # Copy sources and build the application binary COPY Cargo.toml ./Cargo.toml COPY Cargo.lock ./Cargo.lock +COPY build.rs ./build.rs COPY src ./src COPY templates ./templates RUN cargo build --profile ${CARGO_PROFILE} --bin cryptify diff --git a/src/email.rs b/src/email.rs index 005a2c0..9dc4068 100644 --- a/src/email.rs +++ b/src/email.rs @@ -167,10 +167,7 @@ struct EmailTextTemplate<'a> { /// logo as an inline image referenced via `cid:pg-logo`. This shape avoids /// the HTML-only + remote-image spam signal flagged in postguard#197 while /// keeping graceful degradation for text-only clients. -fn build_body( - html: String, - text: String, -) -> Result> { +fn build_body(html: String, text: String) -> Result> { let logo = Attachment::new_inline("pg-logo".to_string()) .body(LOGO_PNG.to_vec(), "image/png".parse::()?); From 64aea4d8b3759f04dbe0a72a74ad27077760e9e4 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Mon, 1 Jun 2026 13:59:47 +0200 Subject: [PATCH 3/6] fix(email): address dobby review on disclosed-name flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat empty disclosed fullname as not disclosed; fall through to the email instead of rendering a blank where the sender appears. - Match the fullname attribute by `.gemeente.personalData.fullname` suffix so the `irma-demo` scheme used in staging also routes through this path. (Previously only `pbdf.gemeente.personalData.fullname` matched.) - Log a warning when `state.sender` is `Some(...)` but doesn't parse as a `Mailbox` — the Reply-To deliverability fix shouldn't silently drop. - Move the `sender_display` docstring from above `build_body` to above `sender_display`. - Extend the `pg-core not found in Cargo.lock` panic in build.rs to mention that PG_CORE_VERSION feeds the X-PostGuard header consumed by the Outlook add-in's OnMessageRead filter. - New tests: demo-scheme name detection, empty-name fallback, and the `Someone` fallback (sender=None, no name). --- build.rs | 6 +++- src/email.rs | 80 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/build.rs b/build.rs index 9cee129..18ac834 100644 --- a/build.rs +++ b/build.rs @@ -21,7 +21,11 @@ fn main() { None } }) - .expect("pg-core entry not found in Cargo.lock"); + .expect( + "pg-core entry not found in Cargo.lock — PG_CORE_VERSION feeds the \ + X-PostGuard mail header that the Outlook add-in's OnMessageRead \ + launch event filters on (see src/email.rs::XPostGuard).", + ); println!("cargo:rustc-env=PG_CORE_VERSION={}", version); } diff --git a/src/email.rs b/src/email.rs index 9dc4068..cf844b0 100644 --- a/src/email.rs +++ b/src/email.rs @@ -57,10 +57,17 @@ impl Header for AutoSubmitted { } } -/// IRMA/Yivi attribute identifier for the signer's full name. When this -/// attribute appears in `FileState.sender_attributes` we render the name -/// in place of the bare email everywhere the sender is shown in the body. -const FULLNAME_ATYPE: &str = "pbdf.gemeente.personalData.fullname"; +/// Suffix that identifies the signer's full-name attribute across IRMA +/// schemes — prod (`pbdf.gemeente.personalData.fullname`) and demo +/// (`irma-demo.gemeente.personalData.fullname`) both end with this. When +/// such an attribute appears in `FileState.sender_attributes` we render +/// the disclosed name in place of the bare email everywhere the sender +/// is shown in the body. +const FULLNAME_ATYPE_SUFFIX: &str = ".gemeente.personalData.fullname"; + +fn is_fullname_atype(atype: &str) -> bool { + atype.ends_with(FULLNAME_ATYPE_SUFFIX) +} /// Embedded PostGuard logo, served inline via a `Content-ID: ` /// MIME part rather than fetched from postguard.eu. Removes the @@ -157,11 +164,6 @@ struct EmailTextTemplate<'a> { sender_attributes: &'a [(String, String)], } -/// Resolve the display string and remaining attribute pills for the -/// sender. When the signer disclosed their full name, the name takes the -/// place of the bare email everywhere it would otherwise appear in the -/// body, and is removed from the attribute pill list so it doesn't render -/// twice. /// Assemble the MIME body: a `multipart/alternative` whose HTML branch is /// itself a `multipart/related` carrying the HTML part plus the PostGuard /// logo as an inline image referenced via `cid:pg-logo`. This shape avoids @@ -180,12 +182,19 @@ fn build_body(html: String, text: String) -> Result (String, Vec<(String, String)>) { let mut attrs = state.sender_attributes.clone(); let name = attrs .iter() - .position(|(t, _)| t == FULLNAME_ATYPE) - .map(|i| attrs.remove(i).1); + .position(|(t, _)| is_fullname_atype(t)) + .map(|i| attrs.remove(i).1) + .filter(|n| !n.is_empty()); let display = name .or_else(|| state.sender.clone()) .unwrap_or_else(|| "Someone".to_string()); @@ -359,12 +368,15 @@ pub async fn send_email( .from(config.email_from()) // checked in config .to(recipient.clone()) .subject(subject); - if let Some(reply_to) = state - .sender - .as_deref() - .and_then(|s| s.parse::().ok()) - { - builder = builder.reply_to(reply_to); + if let Some(sender) = state.sender.as_deref() { + match sender.parse::() { + Ok(mailbox) => builder = builder.reply_to(mailbox), + Err(e) => log::warn!( + "Skipping Reply-To: sender `{}` did not parse as Mailbox: {}", + sender, + e + ), + } } let email = builder.multipart(build_body(html, text)?)?; @@ -497,7 +509,10 @@ mod tests { #[test] fn sender_display_promotes_disclosed_name() { let state = filestate_with_attrs(vec![ - (FULLNAME_ATYPE.to_owned(), "Jan Jansen".to_owned()), + ( + "pbdf.gemeente.personalData.fullname".to_owned(), + "Jan Jansen".to_owned(), + ), ("orgName".to_owned(), "Acme".to_owned()), ]); let (display, remaining) = sender_display(&state); @@ -505,6 +520,26 @@ mod tests { assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); } + #[test] + fn sender_display_promotes_disclosed_name_from_demo_scheme() { + let state = filestate_with_attrs(vec![( + "irma-demo.gemeente.personalData.fullname".to_owned(), + "Jan Jansen".to_owned(), + )]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_treats_empty_disclosed_name_as_not_disclosed() { + let state = filestate_with_attrs(vec![( + "pbdf.gemeente.personalData.fullname".to_owned(), + String::new(), + )]); + let (display, _) = sender_display(&state); + assert_eq!(display, "sender@example.com"); + } + #[test] fn sender_display_falls_back_to_email_when_no_name_disclosed() { let state = filestate_with_attrs(vec![("orgName".to_owned(), "Acme".to_owned())]); @@ -513,6 +548,15 @@ mod tests { assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); } + #[test] + fn sender_display_uses_someone_when_no_sender_and_no_name() { + let mut state = filestate_with_attrs(vec![]); + state.sender = None; + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Someone"); + assert!(remaining.is_empty()); + } + #[test] fn x_postguard_header_round_trips() { let parsed = XPostGuard::parse(X_POSTGUARD_VERSION).expect("parse"); From 7d3aac09921ea4881bd01c9b5d53a6ffe47cb8f3 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Mon, 1 Jun 2026 15:46:48 +0200 Subject: [PATCH 4/6] feat(email): render firstName+lastName from passport/idcard/drivinglicence Extend `sender_display` so the recipient mail can show a real sender name when the signer's Yivi disclosure came from an ID credential instead of the Dutch municipality credential. `FULLNAME_ATYPE_SUFFIX` still wins when present. If absent, the new `take_firstname_lastname_pair` helper scans the disclosed attributes for a `.firstName` + `.lastName` pair from one of `pbdf.pbdf.{passport,idcard,drivinglicence}` (and matching `irma-demo.*` variants), and concatenates them. Both attributes are removed from the pill list once consumed, so the recipient doesn't see them rendered twice. Empty values fall through to the bare email, as they already did for the gemeente fullname path. Companion to the postguard-website condiscon change: senders can now satisfy the mandatory name disclosure from any of four credentials, not just gemeente. Without this rendering update, non-Dutch senders would still appear as their bare email in the recipient mail. Tests: 7 new `sender_display_*` cases (per-credential, demo-scheme, preference-order, firstname-only fallback, empty-value fallback). All 24 email tests pass, full `cargo test`: 113/113. --- src/email.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/src/email.rs b/src/email.rs index cf844b0..8b038ce 100644 --- a/src/email.rs +++ b/src/email.rs @@ -65,10 +65,48 @@ impl Header for AutoSubmitted { /// is shown in the body. const FULLNAME_ATYPE_SUFFIX: &str = ".gemeente.personalData.fullname"; +/// Per-credential suffixes for the `(firstName, lastName)` pairs the +/// signer may disclose instead of the gemeente fullname (postguard#239 +/// follow-up). Each entry's `.firstName` / `.lastName` pair, when both +/// are present and non-empty, is concatenated into a single display name. +/// Suffix-matching catches both `pbdf.pbdf.*` and `irma-demo.pbdf.*`. +const NAME_PAIR_CREDENTIAL_SUFFIXES: &[&str] = &[ + ".pbdf.passport", + ".pbdf.idcard", + ".pbdf.drivinglicence", +]; + fn is_fullname_atype(atype: &str) -> bool { atype.ends_with(FULLNAME_ATYPE_SUFFIX) } +/// If `attrs` contains `.firstName` and `.lastName` for one of +/// the supported credentials and both are non-empty, remove them and +/// return `" "`. Otherwise leave `attrs` untouched. +fn take_firstname_lastname_pair(attrs: &mut Vec<(String, String)>) -> Option { + for cred in NAME_PAIR_CREDENTIAL_SUFFIXES { + let first_suffix = format!("{}.firstName", cred); + let last_suffix = format!("{}.lastName", cred); + + let first_idx = attrs.iter().position(|(t, _)| t.ends_with(&first_suffix)); + let last_idx = attrs.iter().position(|(t, _)| t.ends_with(&last_suffix)); + + if let (Some(fi), Some(li)) = (first_idx, last_idx) { + let first_val = attrs[fi].1.clone(); + let last_val = attrs[li].1.clone(); + if !first_val.is_empty() && !last_val.is_empty() { + // Remove the higher index first so the second remove is + // still valid. + let (hi, lo) = if fi > li { (fi, li) } else { (li, fi) }; + attrs.remove(hi); + attrs.remove(lo); + return Some(format!("{} {}", first_val, last_val)); + } + } + } + None +} + /// Embedded PostGuard logo, served inline via a `Content-ID: ` /// MIME part rather than fetched from postguard.eu. Removes the /// HTML-only-plus-remote-image spam signal flagged in postguard#197. @@ -190,11 +228,17 @@ fn build_body(html: String, text: String) -> Result (String, Vec<(String, String)>) { let mut attrs = state.sender_attributes.clone(); + + // 1. Prefer gemeente.personalData.fullname (Dutch municipality credential). let name = attrs .iter() .position(|(t, _)| is_fullname_atype(t)) .map(|i| attrs.remove(i).1) - .filter(|n| !n.is_empty()); + .filter(|n| !n.is_empty()) + // 2. Otherwise concatenate firstName + lastName from passport / id / + // driving licence (postguard#239 follow-up). + .or_else(|| take_firstname_lastname_pair(&mut attrs)); + let display = name .or_else(|| state.sender.clone()) .unwrap_or_else(|| "Someone".to_string()); @@ -548,6 +592,126 @@ mod tests { assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); } + #[test] + fn sender_display_concatenates_firstname_lastname_from_passport() { + let state = filestate_with_attrs(vec![ + ( + "pbdf.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + ), + ("pbdf.pbdf.passport.lastName".to_owned(), "Jansen".to_owned()), + ("orgName".to_owned(), "Acme".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert_eq!( + remaining, + vec![("orgName".to_owned(), "Acme".to_owned())], + "both name attrs consumed; unrelated attrs kept" + ); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_idcard() { + let state = filestate_with_attrs(vec![ + ("pbdf.pbdf.idcard.firstName".to_owned(), "Jan".to_owned()), + ("pbdf.pbdf.idcard.lastName".to_owned(), "Jansen".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert!(remaining.is_empty()); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_drivinglicence() { + let state = filestate_with_attrs(vec![ + ( + "pbdf.pbdf.drivinglicence.firstName".to_owned(), + "Jan".to_owned(), + ), + ( + "pbdf.pbdf.drivinglicence.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_demo_scheme() { + let state = filestate_with_attrs(vec![ + ( + "irma-demo.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + ), + ( + "irma-demo.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_prefers_gemeente_fullname_over_passport_pair() { + // If both are disclosed (unlikely in practice), gemeente wins + // because that path runs first. + let state = filestate_with_attrs(vec![ + ( + "pbdf.gemeente.personalData.fullname".to_owned(), + "Marie Smit".to_owned(), + ), + ( + "pbdf.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + ), + ( + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Marie Smit"); + } + + #[test] + fn sender_display_falls_through_when_firstname_present_without_lastname() { + let state = filestate_with_attrs(vec![( + "pbdf.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + )]); + let (display, remaining) = sender_display(&state); + // No lastName → no concatenation; the orphan firstName stays as a + // pill so the recipient at least sees it instead of having it + // silently dropped. + assert_eq!(display, "sender@example.com"); + assert_eq!( + remaining, + vec![( + "pbdf.pbdf.passport.firstName".to_owned(), + "Jan".to_owned() + )] + ); + } + + #[test] + fn sender_display_treats_empty_firstname_lastname_as_not_disclosed() { + let state = filestate_with_attrs(vec![ + ( + "pbdf.pbdf.passport.firstName".to_owned(), + String::new(), + ), + ( + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "sender@example.com"); + } + #[test] fn sender_display_uses_someone_when_no_sender_and_no_name() { let mut state = filestate_with_attrs(vec![]); From 824c9f3761f520e5035403df73df5cb067213cab Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 2 Jun 2026 09:25:38 +0200 Subject: [PATCH 5/6] fix(email): use 'PostGuard' as sender display when no name is disclosed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the signer discloses no name, the recipient email now reads 'PostGuard sent you files' instead of showing the raw sender email. Also drop 'via PostGuard' from the subject — it would be redundant when the sender is already 'PostGuard', and is cleaner for named senders too. Renames the two tests that previously asserted the email fallback. --- src/email.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/email.rs b/src/email.rs index 8b038ce..ac748a3 100644 --- a/src/email.rs +++ b/src/email.rs @@ -136,7 +136,7 @@ struct MailStrings<'a> { } const NL_STRINGS: MailStrings = MailStrings { - subject_str: "heeft je een bestand gestuurd via PostGuard", + subject_str: "heeft je bestanden gestuurd", sender_str: "heeft je bestanden gestuurd", expires_str: "Verloopt op", download_str: "Download jouw bestanden", @@ -148,7 +148,7 @@ const NL_STRINGS: MailStrings = MailStrings { }; const EN_STRINGS: MailStrings = MailStrings { - subject_str: "sent you files via PostGuard", + subject_str: "sent you files", sender_str: "sent you files", expires_str: "Expires on", download_str: "Download your files", @@ -221,11 +221,10 @@ fn build_body(html: String, text: String) -> Result (String, Vec<(String, String)>) { let mut attrs = state.sender_attributes.clone(); @@ -239,9 +238,7 @@ fn sender_display(state: &FileState) -> (String, Vec<(String, String)>) { // driving licence (postguard#239 follow-up). .or_else(|| take_firstname_lastname_pair(&mut attrs)); - let display = name - .or_else(|| state.sender.clone()) - .unwrap_or_else(|| "Someone".to_string()); + let display = name.unwrap_or_else(|| "PostGuard".to_string()); (display, attrs) } @@ -581,14 +578,14 @@ mod tests { String::new(), )]); let (display, _) = sender_display(&state); - assert_eq!(display, "sender@example.com"); + assert_eq!(display, "PostGuard"); } #[test] - fn sender_display_falls_back_to_email_when_no_name_disclosed() { + fn sender_display_falls_back_to_postguard_when_no_name_disclosed() { let state = filestate_with_attrs(vec![("orgName".to_owned(), "Acme".to_owned())]); let (display, remaining) = sender_display(&state); - assert_eq!(display, "sender@example.com"); + assert_eq!(display, "PostGuard"); assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); } @@ -686,7 +683,7 @@ mod tests { // No lastName → no concatenation; the orphan firstName stays as a // pill so the recipient at least sees it instead of having it // silently dropped. - assert_eq!(display, "sender@example.com"); + assert_eq!(display, "PostGuard"); assert_eq!( remaining, vec![( @@ -709,15 +706,15 @@ mod tests { ), ]); let (display, _) = sender_display(&state); - assert_eq!(display, "sender@example.com"); + assert_eq!(display, "PostGuard"); } #[test] - fn sender_display_uses_someone_when_no_sender_and_no_name() { + fn sender_display_uses_postguard_when_no_name_disclosed() { let mut state = filestate_with_attrs(vec![]); state.sender = None; let (display, remaining) = sender_display(&state); - assert_eq!(display, "Someone"); + assert_eq!(display, "PostGuard"); assert!(remaining.is_empty()); } From a6912229fa9a00a1f38c300520167e698879e160 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 2 Jun 2026 09:29:16 +0200 Subject: [PATCH 6/6] style: cargo fmt --- src/email.rs | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/email.rs b/src/email.rs index ac748a3..17fe162 100644 --- a/src/email.rs +++ b/src/email.rs @@ -70,11 +70,8 @@ const FULLNAME_ATYPE_SUFFIX: &str = ".gemeente.personalData.fullname"; /// follow-up). Each entry's `.firstName` / `.lastName` pair, when both /// are present and non-empty, is concatenated into a single display name. /// Suffix-matching catches both `pbdf.pbdf.*` and `irma-demo.pbdf.*`. -const NAME_PAIR_CREDENTIAL_SUFFIXES: &[&str] = &[ - ".pbdf.passport", - ".pbdf.idcard", - ".pbdf.drivinglicence", -]; +const NAME_PAIR_CREDENTIAL_SUFFIXES: &[&str] = + &[".pbdf.passport", ".pbdf.idcard", ".pbdf.drivinglicence"]; fn is_fullname_atype(atype: &str) -> bool { atype.ends_with(FULLNAME_ATYPE_SUFFIX) @@ -592,11 +589,11 @@ mod tests { #[test] fn sender_display_concatenates_firstname_lastname_from_passport() { let state = filestate_with_attrs(vec![ + ("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned()), ( - "pbdf.pbdf.passport.firstName".to_owned(), - "Jan".to_owned(), + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), ), - ("pbdf.pbdf.passport.lastName".to_owned(), "Jansen".to_owned()), ("orgName".to_owned(), "Acme".to_owned()), ]); let (display, remaining) = sender_display(&state); @@ -660,10 +657,7 @@ mod tests { "pbdf.gemeente.personalData.fullname".to_owned(), "Marie Smit".to_owned(), ), - ( - "pbdf.pbdf.passport.firstName".to_owned(), - "Jan".to_owned(), - ), + ("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned()), ( "pbdf.pbdf.passport.lastName".to_owned(), "Jansen".to_owned(), @@ -686,20 +680,14 @@ mod tests { assert_eq!(display, "PostGuard"); assert_eq!( remaining, - vec![( - "pbdf.pbdf.passport.firstName".to_owned(), - "Jan".to_owned() - )] + vec![("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned())] ); } #[test] fn sender_display_treats_empty_firstname_lastname_as_not_disclosed() { let state = filestate_with_attrs(vec![ - ( - "pbdf.pbdf.passport.firstName".to_owned(), - String::new(), - ), + ("pbdf.pbdf.passport.firstName".to_owned(), String::new()), ( "pbdf.pbdf.passport.lastName".to_owned(), "Jansen".to_owned(),