@@ -65,10 +65,48 @@ impl Header for AutoSubmitted {
6565/// is shown in the body.
6666const 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+
6879fn 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.
191229fn 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