Skip to main content

allowthem_core/
email_render.rs

1//! Render `EmailTemplate` instances to branded HTML + plain text.
2//!
3//! Uses plain `format!` / `write!` interpolation. HTML interpolations for
4//! user-controlled strings go through [`html_escape::encode_safe`] to prevent
5//! stored XSS in email clients that render rich content (see plan §2.1).
6//! Text output is not escaped — clients render it literally.
7//!
8//! # Exhaustiveness
9//!
10//! The `match` in [`render`] has no wildcard arm. Because this module lives in
11//! the same crate as `EmailTemplate`, `#[non_exhaustive]` does not apply here,
12//! so the compiler enforces that every variant is handled. Adding a new variant
13//! to `EmailTemplate` will cause a compile error in [`render`] — that is
14//! intentional (plan §2.5): every variant must have an explicit branch.
15
16use std::fmt::Write as FmtWrite;
17
18use html_escape::encode_safe;
19
20use crate::email::EmailTemplate;
21
22/// Branding injected into rendered emails at sender-construction time.
23///
24/// Per-tenant resolution is `allowthem-c8m.3`'s responsibility.
25/// Defaults produce plain `allowthem`-branded output.
26#[derive(Debug, Clone)]
27pub struct EmailBranding {
28    /// Name of the application or service shown in the email header.
29    pub app_name: String,
30    /// Optional logo URL. When `Some`, an `<img>` tag is included in HTML.
31    pub logo_url: Option<String>,
32    /// Optional footer line rendered verbatim (e.g. "© 2026 Acme, Inc.").
33    /// Omitted entirely when `None`.
34    pub footer_line: Option<String>,
35}
36
37impl Default for EmailBranding {
38    fn default() -> Self {
39        Self {
40            app_name: "allowthem".to_owned(),
41            logo_url: None,
42            footer_line: None,
43        }
44    }
45}
46
47/// HTML and plain-text output from [`render`].
48#[derive(Debug, Clone)]
49pub struct RenderedEmail {
50    pub html: String,
51    pub text: String,
52}
53
54/// Render `template` to `RenderedEmail` using `branding`.
55pub fn render(template: &EmailTemplate, branding: &EmailBranding) -> RenderedEmail {
56    match template {
57        EmailTemplate::EmailVerification { url, username } => {
58            render_email_verification(url, username, branding)
59        }
60        EmailTemplate::PasswordReset { url, username } => {
61            render_password_reset(url, username, branding)
62        }
63        EmailTemplate::MfaRecovery { codes, username } => {
64            render_mfa_recovery(codes, username, branding)
65        }
66        EmailTemplate::Invitation { url, invited_by } => {
67            render_invitation(url, invited_by, branding)
68        }
69    }
70}
71
72// ─── Private helpers ────────────────────────────────────────────────────────
73
74fn html_header(branding: &EmailBranding) -> String {
75    let app = encode_safe(&branding.app_name);
76    let logo = branding
77        .logo_url
78        .as_deref()
79        .map(|u| format!("<img src=\"{u}\" alt=\"{app}\" style=\"max-height:48px\" /><br/>"))
80        .unwrap_or_default();
81    format!(
82        "<!doctype html><html><body style=\"font-family:sans-serif;max-width:600px;margin:auto\">\
83         <div style=\"padding:24px\">{logo}<h2>{app}</h2>"
84    )
85}
86
87fn html_footer(branding: &EmailBranding) -> String {
88    let footer = branding
89        .footer_line
90        .as_deref()
91        .map(|f| {
92            format!(
93                "<p class=\"footer\" style=\"font-size:12px;color:#888\">{}</p>",
94                encode_safe(f)
95            )
96        })
97        .unwrap_or_default();
98    format!("{footer}</div></body></html>")
99}
100
101fn text_footer(branding: &EmailBranding) -> String {
102    branding
103        .footer_line
104        .as_deref()
105        .map(|f| format!("\n\n---\n{f}"))
106        .unwrap_or_default()
107}
108
109fn render_email_verification(url: &str, username: &str, branding: &EmailBranding) -> RenderedEmail {
110    let app = encode_safe(&branding.app_name);
111    let safe_user = encode_safe(username);
112
113    let mut html = html_header(branding);
114    write!(
115        html,
116        "<p>Hi {safe_user},</p>\
117         <p>Please verify your email address for <strong>{app}</strong>.</p>\
118         <p><a href=\"{url}\" style=\"display:inline-block;padding:10px 20px;\
119         background:#4f46e5;color:#fff;text-decoration:none;border-radius:4px\">\
120         Verify email</a></p>\
121         <p>Or copy this link: <code>{url}</code></p>"
122    )
123    .unwrap();
124    html.push_str(&html_footer(branding));
125
126    let text = format!(
127        "{} — Verify your email\n\nHi {},\n\nVerify your email:\n{}{}\n",
128        branding.app_name,
129        username,
130        url,
131        text_footer(branding)
132    );
133
134    RenderedEmail { html, text }
135}
136
137fn render_password_reset(url: &str, username: &str, branding: &EmailBranding) -> RenderedEmail {
138    let app = encode_safe(&branding.app_name);
139    let safe_user = encode_safe(username);
140
141    let mut html = html_header(branding);
142    write!(
143        html,
144        "<p>Hi {safe_user},</p>\
145         <p>We received a request to reset your <strong>{app}</strong> password.</p>\
146         <p><a href=\"{url}\" style=\"display:inline-block;padding:10px 20px;\
147         background:#4f46e5;color:#fff;text-decoration:none;border-radius:4px\">\
148         Reset password</a></p>\
149         <p>Or copy this link: <code>{url}</code></p>\
150         <p>If you did not request this, you can safely ignore this email.</p>"
151    )
152    .unwrap();
153    html.push_str(&html_footer(branding));
154
155    let text = format!(
156        "{} — Reset your password\n\nHi {},\n\nReset your password:\n{}\n\n\
157         If you did not request this, ignore this email.{}\n",
158        branding.app_name,
159        username,
160        url,
161        text_footer(branding)
162    );
163
164    RenderedEmail { html, text }
165}
166
167fn render_mfa_recovery(
168    codes: &[String],
169    username: &str,
170    branding: &EmailBranding,
171) -> RenderedEmail {
172    let app = encode_safe(&branding.app_name);
173    let safe_user = encode_safe(username);
174
175    let codes_html: String = codes
176        .iter()
177        .map(|c| format!("<li><code>{}</code></li>", encode_safe(c)))
178        .collect();
179    let codes_text = codes.join(", ");
180
181    let mut html = html_header(branding);
182    write!(
183        html,
184        "<p>Hi {safe_user},</p>\
185         <p>Your <strong>{app}</strong> MFA recovery codes:</p>\
186         <ul>{codes_html}</ul>\
187         <p>Store these in a safe place. Each code can only be used once.</p>"
188    )
189    .unwrap();
190    html.push_str(&html_footer(branding));
191
192    let text = format!(
193        "{} — MFA recovery codes\n\nHi {},\n\nYour recovery codes:\n{}\n\n\
194         Each code can only be used once.{}\n",
195        branding.app_name,
196        username,
197        codes_text,
198        text_footer(branding)
199    );
200
201    RenderedEmail { html, text }
202}
203
204fn render_invitation(url: &str, invited_by: &str, branding: &EmailBranding) -> RenderedEmail {
205    let app = encode_safe(&branding.app_name);
206    let safe_inviter = encode_safe(invited_by);
207
208    let mut html = html_header(branding);
209    write!(
210        html,
211        "<p>You have been invited to join <strong>{app}</strong> by \
212         <strong>{safe_inviter}</strong>.</p>\
213         <p><a href=\"{url}\" style=\"display:inline-block;padding:10px 20px;\
214         background:#4f46e5;color:#fff;text-decoration:none;border-radius:4px\">\
215         Accept invitation</a></p>\
216         <p>Or copy this link: <code>{url}</code></p>"
217    )
218    .unwrap();
219    html.push_str(&html_footer(branding));
220
221    let text = format!(
222        "{} — You've been invited\n\n{} has invited you to join {}.\n\
223         Accept here:\n{}{}\n",
224        branding.app_name,
225        invited_by,
226        branding.app_name,
227        url,
228        text_footer(branding)
229    );
230
231    RenderedEmail { html, text }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn branding_default() -> EmailBranding {
239        EmailBranding::default()
240    }
241
242    fn branding_with_logo() -> EmailBranding {
243        EmailBranding {
244            app_name: "MyApp".to_owned(),
245            logo_url: Some("https://example.com/logo.png".to_owned()),
246            footer_line: None,
247        }
248    }
249
250    fn branding_with_footer() -> EmailBranding {
251        EmailBranding {
252            app_name: "MyApp".to_owned(),
253            logo_url: None,
254            footer_line: Some("© 2026 Acme".to_owned()),
255        }
256    }
257
258    // ─── Email verification ───────────────────────────────────────────────
259
260    #[test]
261    fn email_verification_html_contains_url_and_username() {
262        let t = EmailTemplate::EmailVerification {
263            url: "https://example.com/verify?token=abc".to_owned(),
264            username: "alice".to_owned(),
265        };
266        let r = render(&t, &branding_default());
267        assert!(r.html.contains("https://example.com/verify?token=abc"));
268        assert!(r.html.contains("alice"));
269    }
270
271    #[test]
272    fn email_verification_text_contains_url_and_username() {
273        let t = EmailTemplate::EmailVerification {
274            url: "https://example.com/verify?token=abc".to_owned(),
275            username: "alice".to_owned(),
276        };
277        let r = render(&t, &branding_default());
278        assert!(r.text.contains("https://example.com/verify?token=abc"));
279        assert!(r.text.contains("alice"));
280    }
281
282    // ─── Password reset ───────────────────────────────────────────────────
283
284    #[test]
285    fn password_reset_html_contains_url_and_username() {
286        let t = EmailTemplate::PasswordReset {
287            url: "https://example.com/reset?token=xyz".to_owned(),
288            username: "bob".to_owned(),
289        };
290        let r = render(&t, &branding_default());
291        assert!(r.html.contains("https://example.com/reset?token=xyz"));
292        assert!(r.html.contains("bob"));
293    }
294
295    #[test]
296    fn password_reset_text_contains_url_and_username() {
297        let t = EmailTemplate::PasswordReset {
298            url: "https://example.com/reset?token=xyz".to_owned(),
299            username: "bob".to_owned(),
300        };
301        let r = render(&t, &branding_default());
302        assert!(r.text.contains("https://example.com/reset?token=xyz"));
303        assert!(r.text.contains("bob"));
304    }
305
306    // ─── MFA recovery ────────────────────────────────────────────────────
307
308    #[test]
309    fn mfa_recovery_html_contains_codes_and_username() {
310        let t = EmailTemplate::MfaRecovery {
311            codes: vec!["AAAA-1111".to_owned(), "BBBB-2222".to_owned()],
312            username: "carol".to_owned(),
313        };
314        let r = render(&t, &branding_default());
315        assert!(r.html.contains("AAAA-1111"));
316        assert!(r.html.contains("BBBB-2222"));
317        assert!(r.html.contains("carol"));
318    }
319
320    #[test]
321    fn mfa_recovery_text_contains_codes_and_username() {
322        let t = EmailTemplate::MfaRecovery {
323            codes: vec!["AAAA-1111".to_owned(), "BBBB-2222".to_owned()],
324            username: "carol".to_owned(),
325        };
326        let r = render(&t, &branding_default());
327        assert!(r.text.contains("AAAA-1111"));
328        assert!(r.text.contains("BBBB-2222"));
329        assert!(r.text.contains("carol"));
330    }
331
332    // ─── Invitation ───────────────────────────────────────────────────────
333
334    #[test]
335    fn invitation_html_contains_url_and_invited_by() {
336        let t = EmailTemplate::Invitation {
337            url: "https://example.com/invite?token=inv".to_owned(),
338            invited_by: "Dave".to_owned(),
339        };
340        let r = render(&t, &branding_default());
341        assert!(r.html.contains("https://example.com/invite?token=inv"));
342        assert!(r.html.contains("Dave"));
343    }
344
345    #[test]
346    fn invitation_text_contains_url_and_invited_by() {
347        let t = EmailTemplate::Invitation {
348            url: "https://example.com/invite?token=inv".to_owned(),
349            invited_by: "Dave".to_owned(),
350        };
351        let r = render(&t, &branding_default());
352        assert!(r.text.contains("https://example.com/invite?token=inv"));
353        assert!(r.text.contains("Dave"));
354    }
355
356    // ─── XSS escaping ────────────────────────────────────────────────────
357
358    #[test]
359    fn username_xss_is_escaped_in_html() {
360        let t = EmailTemplate::PasswordReset {
361            url: "https://example.com/reset".to_owned(),
362            username: "<script>alert(1)</script>".to_owned(),
363        };
364        let r = render(&t, &branding_default());
365        assert!(r.html.contains("&lt;script&gt;"));
366        assert!(!r.html.contains("<script>"));
367    }
368
369    #[test]
370    fn invited_by_xss_is_escaped_in_html() {
371        let t = EmailTemplate::Invitation {
372            url: "https://example.com/invite".to_owned(),
373            invited_by: "<img src=x onerror=alert(1)>".to_owned(),
374        };
375        let r = render(&t, &branding_default());
376        assert!(!r.html.contains("<img src=x"));
377        assert!(r.html.contains("&lt;img"));
378    }
379
380    #[test]
381    fn mfa_code_xss_is_escaped_in_html() {
382        let t = EmailTemplate::MfaRecovery {
383            codes: vec!["<b>bad</b>".to_owned()],
384            username: "user".to_owned(),
385        };
386        let r = render(&t, &branding_default());
387        // encode_safe escapes '<' and '&' but not '>'; the important
388        // invariant is that '<b>' is not present verbatim.
389        assert!(!r.html.contains("<b>bad</b>"));
390        assert!(r.html.contains("&lt;b"));
391    }
392
393    // ─── Branding ─────────────────────────────────────────────────────────
394
395    #[test]
396    fn logo_url_appears_in_html_when_some() {
397        let t = EmailTemplate::PasswordReset {
398            url: "https://example.com/reset".to_owned(),
399            username: "user".to_owned(),
400        };
401        let r = render(&t, &branding_with_logo());
402        assert!(r.html.contains("<img src=\"https://example.com/logo.png\""));
403    }
404
405    #[test]
406    fn no_img_tag_when_logo_is_none() {
407        let t = EmailTemplate::PasswordReset {
408            url: "https://example.com/reset".to_owned(),
409            username: "user".to_owned(),
410        };
411        let r = render(&t, &branding_default());
412        assert!(!r.html.contains("<img"));
413    }
414
415    #[test]
416    fn footer_appears_in_html_and_text_when_some() {
417        let t = EmailTemplate::PasswordReset {
418            url: "https://example.com/reset".to_owned(),
419            username: "user".to_owned(),
420        };
421        let r = render(&t, &branding_with_footer());
422        assert!(r.html.contains("© 2026 Acme"));
423        assert!(r.text.contains("© 2026 Acme"));
424    }
425
426    #[test]
427    fn no_footer_block_when_none() {
428        let t = EmailTemplate::PasswordReset {
429            url: "https://example.com/reset".to_owned(),
430            username: "user".to_owned(),
431        };
432        let r = render(&t, &branding_default());
433        assert!(!r.html.contains("class=\"footer\""));
434    }
435}