@@ -6,7 +6,10 @@ use askama::Template;
66use chrono:: { format:: Locale , TimeZone } ;
77
88use lettre:: {
9- message:: header:: { ContentType , Header , HeaderName , HeaderValue } ,
9+ message:: {
10+ header:: { ContentType , Header , HeaderName , HeaderValue } ,
11+ Attachment , Mailbox , MultiPart , SinglePart ,
12+ } ,
1013 transport:: smtp:: authentication:: Credentials ,
1114 Message , SmtpTransport , Transport ,
1215} ;
@@ -31,7 +34,38 @@ impl Header for XPostGuard {
3134 }
3235}
3336
34- const X_POSTGUARD_VERSION : & str = "0.1.0" ;
37+ const X_POSTGUARD_VERSION : & str = env ! ( "PG_CORE_VERSION" ) ;
38+
39+ /// `Auto-Submitted: auto-generated` per RFC 3834. Signals to receiving MTAs
40+ /// and mail clients that this is a machine-generated transactional message,
41+ /// suppresses vacation-responder loops, and is one of the deliverability
42+ /// signals Gmail's bulk-sender heuristics look for.
43+ #[ derive( Clone , Debug ) ]
44+ struct AutoSubmitted ;
45+
46+ impl Header for AutoSubmitted {
47+ fn name ( ) -> HeaderName {
48+ HeaderName :: new_from_ascii_str ( "Auto-Submitted" )
49+ }
50+
51+ fn parse ( _s : & str ) -> Result < Self , Box < dyn std:: error:: Error + Send + Sync > > {
52+ Ok ( AutoSubmitted )
53+ }
54+
55+ fn display ( & self ) -> HeaderValue {
56+ HeaderValue :: new ( Self :: name ( ) , "auto-generated" . to_owned ( ) )
57+ }
58+ }
59+
60+ /// IRMA/Yivi attribute identifier for the signer's full name. When this
61+ /// attribute appears in `FileState.sender_attributes` we render the name
62+ /// in place of the bare email everywhere the sender is shown in the body.
63+ const FULLNAME_ATYPE : & str = "pbdf.gemeente.personalData.fullname" ;
64+
65+ /// Embedded PostGuard logo, served inline via a `Content-ID: <pg-logo>`
66+ /// MIME part rather than fetched from postguard.eu. Removes the
67+ /// HTML-only-plus-remote-image spam signal flagged in postguard#197.
68+ const LOGO_PNG : & [ u8 ] = include_bytes ! ( "../templates/email/pg_logo.png" ) ;
3569
3670use serde:: { Deserialize , Serialize } ;
3771use url:: Url ;
@@ -105,6 +139,62 @@ struct EmailTemplate<'a> {
105139 sender_attributes : & ' a [ ( String , String ) ] ,
106140}
107141
142+ #[ derive( Template ) ]
143+ #[ template( path = "email/email.txt" , escape = "none" ) ]
144+ struct EmailTextTemplate < ' a > {
145+ header : & ' a str ,
146+ subheader : & ' a str ,
147+ expires_str : & ' a str ,
148+ download_str : & ' a str ,
149+ link_str : & ' a str ,
150+ file_size : & ' a str ,
151+ expiry_date : & ' a str ,
152+ html_content : & ' a str ,
153+ url : & ' a str ,
154+ confirm : & ' a str ,
155+ files_from : & ' a str ,
156+ sender_email : & ' a str ,
157+ sender_attributes : & ' a [ ( String , String ) ] ,
158+ }
159+
160+ /// Resolve the display string and remaining attribute pills for the
161+ /// sender. When the signer disclosed their full name, the name takes the
162+ /// place of the bare email everywhere it would otherwise appear in the
163+ /// body, and is removed from the attribute pill list so it doesn't render
164+ /// twice.
165+ /// Assemble the MIME body: a `multipart/alternative` whose HTML branch is
166+ /// itself a `multipart/related` carrying the HTML part plus the PostGuard
167+ /// logo as an inline image referenced via `cid:pg-logo`. This shape avoids
168+ /// the HTML-only + remote-image spam signal flagged in postguard#197 while
169+ /// keeping graceful degradation for text-only clients.
170+ fn build_body (
171+ html : String ,
172+ text : String ,
173+ ) -> Result < MultiPart , Box < dyn std:: error:: Error > > {
174+ let logo = Attachment :: new_inline ( "pg-logo" . to_string ( ) )
175+ . body ( LOGO_PNG . to_vec ( ) , "image/png" . parse :: < ContentType > ( ) ?) ;
176+
177+ let related = MultiPart :: related ( )
178+ . singlepart ( SinglePart :: html ( html) )
179+ . singlepart ( logo) ;
180+
181+ Ok ( MultiPart :: alternative ( )
182+ . singlepart ( SinglePart :: plain ( text) )
183+ . multipart ( related) )
184+ }
185+
186+ fn sender_display ( state : & FileState ) -> ( String , Vec < ( String , String ) > ) {
187+ let mut attrs = state. sender_attributes . clone ( ) ;
188+ let name = attrs
189+ . iter ( )
190+ . position ( |( t, _) | t == FULLNAME_ATYPE )
191+ . map ( |i| attrs. remove ( i) . 1 ) ;
192+ let display = name
193+ . or_else ( || state. sender . clone ( ) )
194+ . unwrap_or_else ( || "Someone" . to_string ( ) ) ;
195+ ( display, attrs)
196+ }
197+
108198fn format_file_size ( size : u64 ) -> String {
109199 const UNITS : [ & str ; 5 ] = [ "B" , "kB" , "MB" , "GB" , "TB" ] ;
110200 if size == 0 {
@@ -128,55 +218,92 @@ fn format_date(date: i64, lang: &Language) -> String {
128218 dt. format_localized ( "%e %B %Y" , locale) . to_string ( )
129219}
130220
131- fn email_templates ( state : & FileState , url : & str ) -> ( String , String ) {
221+ fn email_templates ( state : & FileState , url : & str ) -> ( String , String , String ) {
132222 let strings = match state. mail_lang {
133223 Language :: En => EN_STRINGS ,
134224 Language :: Nl => NL_STRINGS ,
135225 } ;
136226
137- let sender_str = state. sender . clone ( ) . unwrap_or ( "Someone" . to_string ( ) ) ;
138- let email = EmailTemplate {
139- header : & sender_str,
227+ let ( display, attrs) = sender_display ( state) ;
228+ let file_size = format_file_size ( state. uploaded ) ;
229+ let expiry_date = format_date ( state. expires , & state. mail_lang ) ;
230+
231+ let html = EmailTemplate {
232+ header : & display,
233+ subheader : strings. sender_str ,
234+ expires_str : strings. expires_str ,
235+ download_str : strings. download_str ,
236+ link_str : strings. link_str ,
237+ file_size : & file_size,
238+ expiry_date : & expiry_date,
239+ html_content : & state. mail_content ,
240+ confirm : "" ,
241+ files_from : strings. files_from ,
242+ sender_email : & display,
243+ sender_attributes : & attrs,
244+ url,
245+ } ;
246+ let text = EmailTextTemplate {
247+ header : & display,
140248 subheader : strings. sender_str ,
141249 expires_str : strings. expires_str ,
142250 download_str : strings. download_str ,
143251 link_str : strings. link_str ,
144- file_size : & format_file_size ( state . uploaded ) ,
145- expiry_date : & format_date ( state . expires , & state . mail_lang ) ,
252+ file_size : & file_size ,
253+ expiry_date : & expiry_date ,
146254 html_content : & state. mail_content ,
147255 confirm : "" ,
148256 files_from : strings. files_from ,
149- sender_email : & sender_str ,
150- sender_attributes : & state . sender_attributes ,
257+ sender_email : & display ,
258+ sender_attributes : & attrs ,
151259 url,
152260 } ;
153261 let subject = SubjectTemplate {
154262 subject_str : strings. subject_str ,
155- sender : & sender_str ,
263+ sender : & display ,
156264 } ;
157- ( email . to_string ( ) , subject. to_string ( ) )
265+ ( html . to_string ( ) , text . to_string ( ) , subject. to_string ( ) )
158266}
159267
160- fn email_confirm ( state : & FileState , url : & str ) -> ( String , String ) {
268+ fn email_confirm ( state : & FileState , url : & str ) -> ( String , String , String ) {
161269 let strings = match state. mail_lang {
162270 Language :: En => EN_STRINGS ,
163271 Language :: Nl => NL_STRINGS ,
164272 } ;
165273
166- let sender_str = state. sender . clone ( ) . unwrap_or ( "Someone" . to_string ( ) ) ;
167- let email = EmailTemplate {
274+ let ( display, attrs) = sender_display ( state) ;
275+ let file_size = format_file_size ( state. uploaded ) ;
276+ let expiry_date = format_date ( state. expires , & state. mail_lang ) ;
277+ let recipients = state. recipients . to_string ( ) ;
278+
279+ let html = EmailTemplate {
168280 header : strings. header_confirm ,
169- subheader : & state . recipients . to_string ( ) ,
281+ subheader : & recipients,
170282 expires_str : strings. expires_str ,
171283 link_str : strings. link_str ,
172- file_size : & format_file_size ( state . uploaded ) ,
173- expiry_date : & format_date ( state . expires , & state . mail_lang ) ,
284+ file_size : & file_size ,
285+ expiry_date : & expiry_date ,
174286 html_content : & state. mail_content ,
175287 download_str : strings. download_str ,
176288 confirm : strings. confirm ,
177289 files_from : strings. files_from ,
178- sender_email : & sender_str,
179- sender_attributes : & state. sender_attributes ,
290+ sender_email : & display,
291+ sender_attributes : & attrs,
292+ url,
293+ } ;
294+ let text = EmailTextTemplate {
295+ header : strings. header_confirm ,
296+ subheader : & recipients,
297+ expires_str : strings. expires_str ,
298+ link_str : strings. link_str ,
299+ file_size : & file_size,
300+ expiry_date : & expiry_date,
301+ html_content : & state. mail_content ,
302+ download_str : strings. download_str ,
303+ confirm : strings. confirm ,
304+ files_from : strings. files_from ,
305+ sender_email : & display,
306+ sender_attributes : & attrs,
180307 url,
181308 } ;
182309
@@ -185,7 +312,7 @@ fn email_confirm(state: &FileState, url: &str) -> (String, String) {
185312 sender : "" ,
186313 } ;
187314
188- ( email . to_string ( ) , subject. to_string ( ) )
315+ ( html . to_string ( ) , text . to_string ( ) , subject. to_string ( ) )
189316}
190317
191318pub async fn send_email (
@@ -228,14 +355,21 @@ pub async fn send_email(
228355 . append_pair ( "uuid" , uuid)
229356 . append_pair ( "recipient" , & format ! ( "{}" , recipient. email) ) ;
230357
231- let ( email, subject) = email_templates ( state, url. as_str ( ) ) ;
232- let email = Message :: builder ( )
233- . header ( ContentType :: TEXT_HTML )
358+ let ( html, text, subject) = email_templates ( state, url. as_str ( ) ) ;
359+ let mut builder = Message :: builder ( )
234360 . header ( XPostGuard ( X_POSTGUARD_VERSION . to_owned ( ) ) )
361+ . header ( AutoSubmitted )
235362 . from ( config. email_from ( ) ) // checked in config
236363 . to ( recipient. clone ( ) )
237- . subject ( subject)
238- . body ( email) ?;
364+ . subject ( subject) ;
365+ if let Some ( reply_to) = state
366+ . sender
367+ . as_deref ( )
368+ . and_then ( |s| s. parse :: < Mailbox > ( ) . ok ( ) )
369+ {
370+ builder = builder. reply_to ( reply_to) ;
371+ }
372+ let email = builder. multipart ( build_body ( html, text) ?) ?;
239373
240374 // send email
241375 log:: info!( "Sending email to {}" , recipient. email) ;
@@ -264,14 +398,14 @@ pub async fn send_email(
264398 . append_pair ( "uuid" , uuid)
265399 . append_pair ( "recipient" , & sender) ;
266400
267- let ( email , subject) = email_confirm ( state, url. as_str ( ) ) ;
401+ let ( html , text , subject) = email_confirm ( state, url. as_str ( ) ) ;
268402 let email = Message :: builder ( )
269- . header ( ContentType :: TEXT_HTML )
270403 . header ( XPostGuard ( X_POSTGUARD_VERSION . to_owned ( ) ) )
404+ . header ( AutoSubmitted )
271405 . from ( config. email_from ( ) )
272406 . to ( sender. parse ( ) ?)
273407 . subject ( subject)
274- . body ( email ) ?;
408+ . multipart ( build_body ( html , text ) ? ) ?;
275409
276410 log:: info!( "Sending confirmation email to {}" , sender) ;
277411 let mailer = mailer_builder. build ( ) ;
@@ -345,10 +479,47 @@ mod tests {
345479 assert_eq ! ( format!( "{}" , XPostGuard :: name( ) ) , "X-PostGuard" ) ;
346480 }
347481
482+ #[ test]
483+ fn auto_submitted_header_emits_auto_generated ( ) {
484+ use lettre:: message:: Mailbox ;
485+ let msg = Message :: builder ( )
486+ . from ( "noreply@example.com" . parse :: < Mailbox > ( ) . unwrap ( ) )
487+ . to ( "to@example.com" . parse :: < Mailbox > ( ) . unwrap ( ) )
488+ . subject ( "t" )
489+ . header ( AutoSubmitted )
490+ . body ( String :: from ( "hi" ) )
491+ . expect ( "build" ) ;
492+ let raw = String :: from_utf8 ( msg. formatted ( ) ) . expect ( "utf8" ) ;
493+ assert ! (
494+ raw. contains( "Auto-Submitted: auto-generated" ) ,
495+ "expected Auto-Submitted header, got: {}" ,
496+ raw
497+ ) ;
498+ }
499+
500+ #[ test]
501+ fn sender_display_promotes_disclosed_name ( ) {
502+ let state = filestate_with_attrs ( vec ! [
503+ ( FULLNAME_ATYPE . to_owned( ) , "Jan Jansen" . to_owned( ) ) ,
504+ ( "orgName" . to_owned( ) , "Acme" . to_owned( ) ) ,
505+ ] ) ;
506+ let ( display, remaining) = sender_display ( & state) ;
507+ assert_eq ! ( display, "Jan Jansen" ) ;
508+ assert_eq ! ( remaining, vec![ ( "orgName" . to_owned( ) , "Acme" . to_owned( ) ) ] ) ;
509+ }
510+
511+ #[ test]
512+ fn sender_display_falls_back_to_email_when_no_name_disclosed ( ) {
513+ let state = filestate_with_attrs ( vec ! [ ( "orgName" . to_owned( ) , "Acme" . to_owned( ) ) ] ) ;
514+ let ( display, remaining) = sender_display ( & state) ;
515+ assert_eq ! ( display, "sender@example.com" ) ;
516+ assert_eq ! ( remaining, vec![ ( "orgName" . to_owned( ) , "Acme" . to_owned( ) ) ] ) ;
517+ }
518+
348519 #[ test]
349520 fn x_postguard_header_round_trips ( ) {
350- let parsed = XPostGuard :: parse ( "0.1.0" ) . expect ( "parse" ) ;
351- assert_eq ! ( parsed. 0 , "0.1.0" ) ;
521+ let parsed = XPostGuard :: parse ( X_POSTGUARD_VERSION ) . expect ( "parse" ) ;
522+ assert_eq ! ( parsed. 0 , X_POSTGUARD_VERSION ) ;
352523 }
353524
354525 #[ test]
@@ -362,9 +533,11 @@ mod tests {
362533 . body ( String :: from ( "hi" ) )
363534 . expect ( "build" ) ;
364535 let raw = String :: from_utf8 ( msg. formatted ( ) ) . expect ( "utf8" ) ;
536+ let expected = format ! ( "X-PostGuard: {}" , X_POSTGUARD_VERSION ) ;
365537 assert ! (
366- raw. contains( "X-PostGuard: 0.1.0" ) ,
367- "expected X-PostGuard header in message, got: {}" ,
538+ raw. contains( & expected) ,
539+ "expected `{}` header in message, got: {}" ,
540+ expected,
368541 raw
369542 ) ;
370543 }
@@ -401,6 +574,12 @@ mod tests {
401574 assert_eq ! ( format_file_size( 1024_u64 . pow( 4 ) ) , "1.0 TB" ) ;
402575 }
403576
577+ fn filestate_with_attrs ( attrs : Vec < ( String , String ) > ) -> FileState {
578+ let mut state = staging_filestate ( ) ;
579+ state. sender_attributes = attrs;
580+ state
581+ }
582+
404583 fn staging_filestate ( ) -> FileState {
405584 use lettre:: message:: { Mailbox , Mailboxes } ;
406585 let mut mboxes = Mailboxes :: new ( ) ;
0 commit comments