Skip to content

Commit 7d3aac0

Browse files
committed
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 `<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.
1 parent 64aea4d commit 7d3aac0

1 file changed

Lines changed: 165 additions & 1 deletion

File tree

src/email.rs

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,48 @@ impl Header for AutoSubmitted {
6565
/// is shown in the body.
6666
const FULLNAME_ATYPE_SUFFIX: &str = ".gemeente.personalData.fullname";
6767

68+
/// Per-credential suffixes for the `(firstName, lastName)` pairs the
69+
/// signer may disclose instead of the gemeente fullname (postguard#239
70+
/// follow-up). Each entry's `.firstName` / `.lastName` pair, when both
71+
/// are present and non-empty, is concatenated into a single display name.
72+
/// Suffix-matching catches both `pbdf.pbdf.*` and `irma-demo.pbdf.*`.
73+
const NAME_PAIR_CREDENTIAL_SUFFIXES: &[&str] = &[
74+
".pbdf.passport",
75+
".pbdf.idcard",
76+
".pbdf.drivinglicence",
77+
];
78+
6879
fn is_fullname_atype(atype: &str) -> bool {
6980
atype.ends_with(FULLNAME_ATYPE_SUFFIX)
7081
}
7182

83+
/// If `attrs` contains `<cred>.firstName` and `<cred>.lastName` for one of
84+
/// the supported credentials and both are non-empty, remove them and
85+
/// return `"<firstName> <lastName>"`. Otherwise leave `attrs` untouched.
86+
fn take_firstname_lastname_pair(attrs: &mut Vec<(String, String)>) -> Option<String> {
87+
for cred in NAME_PAIR_CREDENTIAL_SUFFIXES {
88+
let first_suffix = format!("{}.firstName", cred);
89+
let last_suffix = format!("{}.lastName", cred);
90+
91+
let first_idx = attrs.iter().position(|(t, _)| t.ends_with(&first_suffix));
92+
let last_idx = attrs.iter().position(|(t, _)| t.ends_with(&last_suffix));
93+
94+
if let (Some(fi), Some(li)) = (first_idx, last_idx) {
95+
let first_val = attrs[fi].1.clone();
96+
let last_val = attrs[li].1.clone();
97+
if !first_val.is_empty() && !last_val.is_empty() {
98+
// Remove the higher index first so the second remove is
99+
// still valid.
100+
let (hi, lo) = if fi > li { (fi, li) } else { (li, fi) };
101+
attrs.remove(hi);
102+
attrs.remove(lo);
103+
return Some(format!("{} {}", first_val, last_val));
104+
}
105+
}
106+
}
107+
None
108+
}
109+
72110
/// Embedded PostGuard logo, served inline via a `Content-ID: <pg-logo>`
73111
/// MIME part rather than fetched from postguard.eu. Removes the
74112
/// HTML-only-plus-remote-image spam signal flagged in postguard#197.
@@ -190,11 +228,17 @@ fn build_body(html: String, text: String) -> Result<MultiPart, Box<dyn std::erro
190228
/// fall through to the email instead of rendering a blank.
191229
fn sender_display(state: &FileState) -> (String, Vec<(String, String)>) {
192230
let mut attrs = state.sender_attributes.clone();
231+
232+
// 1. Prefer gemeente.personalData.fullname (Dutch municipality credential).
193233
let name = attrs
194234
.iter()
195235
.position(|(t, _)| is_fullname_atype(t))
196236
.map(|i| attrs.remove(i).1)
197-
.filter(|n| !n.is_empty());
237+
.filter(|n| !n.is_empty())
238+
// 2. Otherwise concatenate firstName + lastName from passport / id /
239+
// driving licence (postguard#239 follow-up).
240+
.or_else(|| take_firstname_lastname_pair(&mut attrs));
241+
198242
let display = name
199243
.or_else(|| state.sender.clone())
200244
.unwrap_or_else(|| "Someone".to_string());
@@ -548,6 +592,126 @@ mod tests {
548592
assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]);
549593
}
550594

595+
#[test]
596+
fn sender_display_concatenates_firstname_lastname_from_passport() {
597+
let state = filestate_with_attrs(vec![
598+
(
599+
"pbdf.pbdf.passport.firstName".to_owned(),
600+
"Jan".to_owned(),
601+
),
602+
("pbdf.pbdf.passport.lastName".to_owned(), "Jansen".to_owned()),
603+
("orgName".to_owned(), "Acme".to_owned()),
604+
]);
605+
let (display, remaining) = sender_display(&state);
606+
assert_eq!(display, "Jan Jansen");
607+
assert_eq!(
608+
remaining,
609+
vec![("orgName".to_owned(), "Acme".to_owned())],
610+
"both name attrs consumed; unrelated attrs kept"
611+
);
612+
}
613+
614+
#[test]
615+
fn sender_display_concatenates_firstname_lastname_from_idcard() {
616+
let state = filestate_with_attrs(vec![
617+
("pbdf.pbdf.idcard.firstName".to_owned(), "Jan".to_owned()),
618+
("pbdf.pbdf.idcard.lastName".to_owned(), "Jansen".to_owned()),
619+
]);
620+
let (display, remaining) = sender_display(&state);
621+
assert_eq!(display, "Jan Jansen");
622+
assert!(remaining.is_empty());
623+
}
624+
625+
#[test]
626+
fn sender_display_concatenates_firstname_lastname_from_drivinglicence() {
627+
let state = filestate_with_attrs(vec![
628+
(
629+
"pbdf.pbdf.drivinglicence.firstName".to_owned(),
630+
"Jan".to_owned(),
631+
),
632+
(
633+
"pbdf.pbdf.drivinglicence.lastName".to_owned(),
634+
"Jansen".to_owned(),
635+
),
636+
]);
637+
let (display, _) = sender_display(&state);
638+
assert_eq!(display, "Jan Jansen");
639+
}
640+
641+
#[test]
642+
fn sender_display_concatenates_firstname_lastname_from_demo_scheme() {
643+
let state = filestate_with_attrs(vec![
644+
(
645+
"irma-demo.pbdf.passport.firstName".to_owned(),
646+
"Jan".to_owned(),
647+
),
648+
(
649+
"irma-demo.pbdf.passport.lastName".to_owned(),
650+
"Jansen".to_owned(),
651+
),
652+
]);
653+
let (display, _) = sender_display(&state);
654+
assert_eq!(display, "Jan Jansen");
655+
}
656+
657+
#[test]
658+
fn sender_display_prefers_gemeente_fullname_over_passport_pair() {
659+
// If both are disclosed (unlikely in practice), gemeente wins
660+
// because that path runs first.
661+
let state = filestate_with_attrs(vec![
662+
(
663+
"pbdf.gemeente.personalData.fullname".to_owned(),
664+
"Marie Smit".to_owned(),
665+
),
666+
(
667+
"pbdf.pbdf.passport.firstName".to_owned(),
668+
"Jan".to_owned(),
669+
),
670+
(
671+
"pbdf.pbdf.passport.lastName".to_owned(),
672+
"Jansen".to_owned(),
673+
),
674+
]);
675+
let (display, _) = sender_display(&state);
676+
assert_eq!(display, "Marie Smit");
677+
}
678+
679+
#[test]
680+
fn sender_display_falls_through_when_firstname_present_without_lastname() {
681+
let state = filestate_with_attrs(vec![(
682+
"pbdf.pbdf.passport.firstName".to_owned(),
683+
"Jan".to_owned(),
684+
)]);
685+
let (display, remaining) = sender_display(&state);
686+
// No lastName → no concatenation; the orphan firstName stays as a
687+
// pill so the recipient at least sees it instead of having it
688+
// silently dropped.
689+
assert_eq!(display, "sender@example.com");
690+
assert_eq!(
691+
remaining,
692+
vec![(
693+
"pbdf.pbdf.passport.firstName".to_owned(),
694+
"Jan".to_owned()
695+
)]
696+
);
697+
}
698+
699+
#[test]
700+
fn sender_display_treats_empty_firstname_lastname_as_not_disclosed() {
701+
let state = filestate_with_attrs(vec![
702+
(
703+
"pbdf.pbdf.passport.firstName".to_owned(),
704+
String::new(),
705+
),
706+
(
707+
"pbdf.pbdf.passport.lastName".to_owned(),
708+
"Jansen".to_owned(),
709+
),
710+
]);
711+
let (display, _) = sender_display(&state);
712+
assert_eq!(display, "sender@example.com");
713+
}
714+
551715
#[test]
552716
fn sender_display_uses_someone_when_no_sender_and_no_name() {
553717
let mut state = filestate_with_attrs(vec![]);

0 commit comments

Comments
 (0)