Skip to content

Commit 2516329

Browse files
committed
fix(email): address postguard#197 deliverability + show disclosed signer 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
1 parent 2af3ba0 commit 2516329

5 files changed

Lines changed: 265 additions & 34 deletions

File tree

build.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
fn main() {
2+
println!("cargo:rerun-if-changed=Cargo.lock");
3+
4+
let lock = std::fs::read_to_string("Cargo.lock").expect("Cargo.lock not readable");
5+
let version = lock
6+
.split("[[package]]")
7+
.find_map(|block| {
8+
let mut name = None;
9+
let mut ver = None;
10+
for line in block.lines() {
11+
if let Some(rest) = line.strip_prefix("name = \"") {
12+
name = rest.strip_suffix('"');
13+
}
14+
if let Some(rest) = line.strip_prefix("version = \"") {
15+
ver = rest.strip_suffix('"');
16+
}
17+
}
18+
if name == Some("pg-core") {
19+
ver
20+
} else {
21+
None
22+
}
23+
})
24+
.expect("pg-core entry not found in Cargo.lock");
25+
26+
println!("cargo:rustc-env=PG_CORE_VERSION={}", version);
27+
}

src/email.rs

Lines changed: 212 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use askama::Template;
66
use chrono::{format::Locale, TimeZone};
77

88
use 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

3670
use serde::{Deserialize, Serialize};
3771
use 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+
108198
fn 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

191318
pub 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();

templates/email/email.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<div style="background:#F2F8FD;background-color:#F2F8FD;padding:1em;">
1010
<div style="background:#F2F8FD;width:100%;max-width:600px;margin-left:auto;margin-right:auto;text-align:center;">
1111
<div style="margin:50px 0 20px 0">
12-
<img src="https://postguard.eu/pg_logo.png" alt="PostGuard" width="200" height="109" style="display:block;margin:0 auto;" />
12+
<img src="cid:pg-logo" alt="PostGuard" width="200" height="109" style="display:block;margin:0 auto;" />
1313
</div>
1414
<div style="background:#FFFFFF;padding:60px 50px;border-radius:8px;text-align:center;">
1515
<p style="font-size:22px;font-weight:700;color:#030E17;margin:0 0 5px 0;line-height:30px;">

0 commit comments

Comments
 (0)