1use std::fmt::Write as FmtWrite;
17
18use html_escape::encode_safe;
19
20use crate::email::EmailTemplate;
21
22#[derive(Debug, Clone)]
27pub struct EmailBranding {
28 pub app_name: String,
30 pub logo_url: Option<String>,
32 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#[derive(Debug, Clone)]
49pub struct RenderedEmail {
50 pub html: String,
51 pub text: String,
52}
53
54pub 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
72fn 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 #[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 #[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 #[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 #[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 #[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("<script>"));
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("<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 assert!(!r.html.contains("<b>bad</b>"));
390 assert!(r.html.contains("<b"));
391 }
392
393 #[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}