Skip to content

fix(email): address postguard#197 deliverability + show disclosed signer name#170

Merged
rubenhensen merged 6 commits into
mainfrom
fix/postguard-197-email-deliverability
Jun 2, 2026
Merged

fix(email): address postguard#197 deliverability + show disclosed signer name#170
rubenhensen merged 6 commits into
mainfrom
fix/postguard-197-email-deliverability

Conversation

@rubenhensen
Copy link
Copy Markdown
Contributor

@rubenhensen rubenhensen commented Jun 1, 2026

Summary

Addresses encryption4all/postguard#197: PostGuard notification mail lands in Gmail spam. The mailer hit three signals at once — a stale X-PostGuard: 0.1.0 header, no Reply-To / Auto-Submitted, and an HTML-only body with a remote <img>. This PR removes all three. While in the email path I also surfaced the signer's disclosed name in the body.

Deliverability

  • X-PostGuard tracks pg-core. Added build.rs that parses Cargo.lock, exports PG_CORE_VERSION as a compile-time env var, and X_POSTGUARD_VERSION now reads env!("PG_CORE_VERSION"). Resolves to 0.6.1 today; auto-bumps the next time pg-core does. No new build deps — stdlib string scanning. Rationale and supersession of the previous "semantic token" recommendation captured in encryption4all/dobby memory note.
  • Reply-To on recipient mail points to the disclosed sender (state.sender) so human replies reach a human, not noreply@. When the sender string fails to parse as a Mailbox we now emit a log::warn! rather than silently dropping the header (Dobby review). Not set on the confirmation mail (recipient is the sender themself).
  • Auto-Submitted: auto-generated (RFC 3834) on both notification and confirmation mail — suppresses vacation-responder loops and gives receiving MTAs a clean "transactional" signal.
  • multipart/alternative with plain-text branch. New hand-authored templates/email/email.txt mirrors the HTML template's fields. email_templates() / email_confirm() now return (html, text, subject).
  • Logo embedded as inline CID. pg_logo.png is include_bytes!'d at build, served as a Content-ID: <pg-logo> part inside a multipart/related HTML branch. The HTML now references cid:pg-logo instead of https://postguard.eu/pg_logo.png. Removes the HTML-only-plus-remote-image spam fingerprint and lets the logo render even with images-blocked-by-default.

Show disclosed sender name in body

When the signer disclosed a fullname attribute matching the .gemeente.personalData.fullname suffix (covers both pbdf prod scheme and irma-demo staging scheme), the name takes the place of the bare email everywhere it used to appear in the body ({{header}} and {{sender_email}} in email.html / email.txt). The name is also removed from the attribute pill list so it doesn't render twice. Empty disclosed values are treated as not disclosed and fall through to the email. New helper sender_display(state) -> (String, Vec<(String, String)>); covered by five unit tests.

Deliberately not done

  • List-Unsubscribe — wrong semantic fit. PostGuard mail is person-to-person, not a list; recipients can't meaningfully opt out of a friend sending a file. Adding it would misrepresent the sender.

Test plan

  • cargo test106 passing. New tests covering: auto_submitted_header_emits_auto_generated, sender_display_promotes_disclosed_name, sender_display_promotes_disclosed_name_from_demo_scheme, sender_display_treats_empty_disclosed_name_as_not_disclosed, sender_display_falls_back_to_email_when_no_name_disclosed, sender_display_uses_someone_when_no_sender_and_no_name. Updated: x_postguard_header_round_trips, x_postguard_header_serialises_into_message (no longer pin literal "0.1.0").
  • cargo clean && cargo buildbuild.rs runs, PG_CORE_VERSION resolves to 0.6.1 per Cargo.lock.
  • Docker build (amd64 + arm64) passes — build.rs is COPY'd into the planner / builder stages.
  • Real SMTP smoke test against Gmail (staging deploy): use "Show original" to verify X-PostGuard: 0.6.1, Reply-To: <sender>, Auto-Submitted: auto-generated, Content-Type: multipart/alternative with multipart/related HTML branch containing the inline image/png; Content-ID: <pg-logo>.
  • Visual: confirm logo renders in Gmail/Outlook with images-off-by-default.
  • With sender name disclosed: confirm "Jan Jansen" replaces the email in the header and "files from" attestation, and does not also appear as a pill below. Without name: behaviour unchanged.

Companion change

The matching frontend change — making the signer's fullname attribute mandatory in the Yivi disclosure session, so this mail path is the default rather than the exception — is in postguard-website (PR #239).

…ner name

- 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
@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented Jun 1, 2026

Dobby has received the request! Routing to the right specialist now...

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.
Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Addresses postguard#197 correctly and the disclosed-name surfacing works. Blocking: the doc-comment block above build_body actually describes sender_display, leaving the latter undocumented; no test asserts the produced Message contains multipart/alternative / multipart/related / Content-ID: <pg-logo> — the whole point of the PR — so a future refactor can silently re-introduce the spam shape; and an empty disclosed fullname renders as a blank everywhere the email used to appear. Minor: pbdf-only fullname match excludes irma-demo (staging/dev), build.rs panic message could explain the header coupling, and a state.sender that fails to parse as Mailbox silently drops Reply-To without a log line. (Stage 1's cargo fmt finding was fixed in the second commit; verified locally.)

Rule compliance

Memory note repos/cryptify/x-postguard-header.md says cryptify's X-PostGuard value convention should be a semantic token (e.g. notification) over a version string; this PR keeps the value as a version string and now auto-tracks pg-core. Non-blocking, but a follow-up to switch to a semantic token would align the three PostGuard surfaces (tb-addon decrypted, cryptify notification).

Comment thread src/email.rs Outdated
sender_attributes: &'a [(String, String)],
}

/// Resolve the display string and remaining attribute pills for the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] The first /// paragraph (lines 160-164) describes sender_display, not build_body. As written, sender_display at line 183 is undocumented and build_body carries a misleading dual description. Move lines 160-164 above fn sender_display.

Comment thread src/email.rs Outdated
.position(|(t, _)| t == FULLNAME_ATYPE)
.map(|i| attrs.remove(i).1);
let display = name
.or_else(|| state.sender.clone())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] An empty disclosed fullname ((FULLNAME_ATYPE, "")) produces display == "" and renders as a blank in the subject and body where the email used to appear. Treat empty disclosed values as not disclosed, e.g. .map(|i| attrs.remove(i).1).filter(|n| !n.is_empty()).

Comment thread src/email.rs Outdated
.map(|i| attrs.remove(i).1);
let display = name
.or_else(|| state.sender.clone())
.unwrap_or_else(|| "Someone".to_string());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] PR description claims the helper is covered by two unit tests, but the three-branch fallback's Someone case (sender=None, no fullname) has no test. Add one or correct the PR body.

Comment thread src/email.rs Outdated
/// 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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] Only pbdf.gemeente.personalData.fullname is recognised; irma-demo.gemeente.personalData.fullname (demo scheme used in staging/dev) is silently ignored, so staging won't exercise this path. Match on the suffix .gemeente.personalData.fullname or maintain a small allowlist — or add a comment if prod-only is intentional.

Comment thread src/email.rs Outdated
.subject(subject)
.body(email)?;
.subject(subject);
if let Some(reply_to) = state
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] When state.sender is Some(...) but doesn't parse as a Mailbox, the recipient mail silently ships with no Reply-To — one of the three deliverability fixes this PR is meant to guarantee. At least log::warn! on the parse failure so a malformed sender is observable.

Comment thread build.rs Outdated
None
}
})
.expect("pg-core entry not found in Cargo.lock");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] Acceptable as a tripwire, but the panic message could mention that PG_CORE_VERSION drives the X-PostGuard header used by the Outlook add-in filter — saves the next reader a grep.

Comment thread src/email.rs
}

const X_POSTGUARD_VERSION: &str = "0.1.0";
const X_POSTGUARD_VERSION: &str = env!("PG_CORE_VERSION");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Rule: cryptify/x-postguard-header] Repo memory note says cryptify's X-PostGuard value convention should be a semantic token (e.g. notification), not a version string. The existing 0.1.0 was already a version string and this PR doubles down by auto-tracking pg-core. Non-blocking — but worth a follow-up to switch the value to a semantic token.

- 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).
@rubenhensen
Copy link
Copy Markdown
Contributor Author

/dobby review

@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented Jun 1, 2026

Dobby has received the request! Routing to the right specialist now...

Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Postguard#197 deliverability fixes (X-PostGuard from build.rs, Reply-To, Auto-Submitted, multipart/alternative + inline CID logo) and disclosed-name display. 106 tests pass and clippy is clean; correct overall. Minor non-blocking notes inline.

Rule compliance

Two PR-body nits, not blocking:

  • [Rule: pr-close-issue-keywords] Body uses Addresses [encryption4all/postguard#197] — won't auto-close on merge. If this PR alone closes the issue, switch to Closes encryption4all/postguard#197; otherwise leaving it as a soft reference (perhaps as Refs) is deliberate and fine.
  • [Rule: writing-rules] PR body has em-dash overuse and bold lead-ins on every bullet — both are flagged AI-slop tells. Consider commas/parens for the dashes and drop the mechanical bolding.

Comment thread src/email.rs
/// 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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] FULLNAME_ATYPE_SUFFIX matches any scheme ending in .gemeente.personalData.fullname, including a hypothetical malicious third-party scheme. Safer to allow-list explicit prefixes (pbdf., irma-demo.) so the trust boundary lives in this module.

Comment thread src/email.rs
/// Embedded PostGuard logo, served inline via a `Content-ID: <pg-logo>`
/// 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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] LOGO_PNG.to_vec() clones the 41 KB embedded PNG on every send. Keep it as &'static [u8] and only convert at the Attachment::body(...) call site — same allocation, but the rest of the module avoids paying for it.

Comment thread src/email.rs
/// 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<MultiPart, Box<dyn std::error::Error>> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] No test asserts the MIME shape produced by build_body (outer multipart/alternative, HTML branch multipart/related with Content-ID: <pg-logo> matching cid:pg-logo in email.html). A small integration test that renders the message and greps the bytes would lock this in against a CID drift.

Comment thread src/email.rs
/// body, and is removed from the attribute pill list so it doesn't render
/// twice. An empty disclosed value is treated as not disclosed, so we
/// fall through to the email instead of rendering a blank.
fn sender_display(state: &FileState) -> (String, Vec<(String, String)>) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] If sender_attributes ever contains more than one fullname-suffixed entry (e.g. both pbdf and demo simultaneously disclosed), only the first is removed and the others render as pills. Use retain or a loop to strip all matches before picking the display name.

Comment thread src/email.rs
.subject(subject)
.body(email)?;
.subject(subject);
if let Some(sender) = state.sender.as_deref() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] The Reply-To parse-failure warn branch has no test — the explicit match was added so failures are observable. A unit test that feeds a malformed state.sender and asserts the message still builds (without Reply-To) would pin the behaviour.

Comment thread templates/email/email.txt
{{file_size}} - {{expires_str}} {{expiry_date}}
{% if html_content != "" %}

{{html_content}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] mail_content is rendered raw into the plain-text part via escape = "none". Not a security issue in text/plain, but pasted HTML markup shows up verbatim with angle brackets. Consider stripping tags or documenting the field as plain text.

Comment thread build.rs

let lock = std::fs::read_to_string("Cargo.lock").expect("Cargo.lock not readable");
let version = lock
.split("[[package]]")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] Splitting Cargo.lock on the literal [[package]] is fragile if a future lockfile format ever includes that substring inside a quoted string (no such case today). A tiny TOML parse (or cargo_metadata) would be safer; not blocking.

…cence

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 `<cred>.firstName` + `<cred>.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.
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.
@rubenhensen
Copy link
Copy Markdown
Contributor Author

/dobby review

@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented Jun 2, 2026

Dobby has received the request! Routing to the right specialist now...

Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Round 3 follow-ups (firstName+lastName helper for passport/idcard/drivinglicence; 'PostGuard' fallback when no name disclosed; drop 'via PostGuard' from subject) are well-tested and clippy-clean. All prior critical/major findings resolved.

Rule compliance

No issues found. cargo fmt --check and cargo clippy -D warnings clean; PR title conventional; body claims all delivered; no AI-slop tells in prose or comments; email HTML alt text and embedded-CID change does not regress WCAG AA; build.rs approach matches the cryptify x-postguard-header convention.

Comment thread src/email.rs
/// If `attrs` contains `<cred>.firstName` and `<cred>.lastName` for one of
/// the supported credentials and both are non-empty, remove them and
/// return `"<firstName> <lastName>"`. Otherwise leave `attrs` untouched.
fn take_firstname_lastname_pair(attrs: &mut Vec<(String, String)>) -> Option<String> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code review] Orphan attribute UX nit (non-blocking): when only firstName (or only lastName) is disclosed, or one value is empty, the orphan stays in the pill list — recipient sees a 'PostGuard sent you files' header with a lone 'Jan' pill below. The test at line 671 documents this as intentional, so flagging only in case the pill looks odd in production.

@rubenhensen rubenhensen merged commit 7816df8 into main Jun 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant