1use std::fmt;
36use std::sync::Arc;
37
38use chrono::{DateTime, Utc};
39
40#[derive(Debug, Clone)]
44pub struct Mail {
45 pub to: String,
46 pub subject: String,
47 pub text_body: String,
48 pub html_body: Option<String>,
49 pub headers: Vec<(String, String)>,
50}
51
52impl Mail {
53 pub fn framework_envelope(
63 to: impl Into<String>,
64 subject: impl Into<String>,
65 body: impl Into<String>,
66 system_name: &str,
67 request_ip: Option<&str>,
68 ua_summary: Option<&str>,
69 when: DateTime<Utc>,
70 ) -> Self {
71 let mut text = body.into();
72 text.push_str("\n\n— — —\n");
73 text.push_str(&format!("System: {system_name}\n"));
74 text.push_str(&format!("When: {} UTC\n", when.format("%Y-%m-%d %H:%M")));
75 if let Some(ip) = request_ip {
76 text.push_str(&format!("From IP: {ip}\n"));
77 }
78 if let Some(ua) = ua_summary {
79 text.push_str(&format!("Device: {ua}\n"));
80 }
81 text.push_str(
82 "\nIf this was not you, sign in and visit /admin/account/sessions to revoke \
83 sessions, then ask your administrator to reset your account.\n",
84 );
85 Mail {
86 to: to.into(),
87 subject: subject.into(),
88 text_body: text,
89 html_body: None,
90 headers: Vec::new(),
91 }
92 }
93
94 pub fn with_html(mut self, html: impl Into<String>) -> Self {
101 self.html_body = Some(html.into());
102 self
103 }
104}
105
106pub fn render_recovery_html(parts: RecoveryEmailParts<'_>) -> String {
120 let RecoveryEmailParts {
121 app_name,
122 app_tagline,
123 title,
124 greeting_name,
125 intro,
126 cta_label,
127 cta_url,
128 fine_print,
129 when,
130 request_ip,
131 ua_summary,
132 correlation_id,
133 signature_primary,
134 signature_title,
135 support_email,
136 show_powered_by,
137 } = parts;
138
139 let cta_url_safe = html_attr_escape(cta_url);
140 let cta_url_text = html_text_escape(cta_url);
141 let app_name_text = html_text_escape(app_name);
142 let tagline_text = html_text_escape(app_tagline.unwrap_or("Account security notification"));
143 let title_text = html_text_escape(title);
144 let greeting_text = html_text_escape(greeting_name);
145 let intro_text = html_text_escape(intro);
146 let fine_print_text = html_text_escape(fine_print);
147 let cta_label_text = html_text_escape(cta_label);
148
149 let when_str = when.format("%Y-%m-%d %H:%M UTC").to_string();
150 let ip_row = match request_ip {
151 Some(ip) => format!(
152 "<tr><td style=\"padding:6px 0;color:#6B7280;font-size:13px;\
153 width:90px;vertical-align:top;\">From IP</td>\
154 <td style=\"padding:6px 0;color:#1F2937;font-size:13px;\
155 font-variant-numeric:tabular-nums;\">{}</td></tr>",
156 html_text_escape(ip)
157 ),
158 None => String::new(),
159 };
160 let ua_row = match ua_summary {
161 Some(ua) => format!(
162 "<tr><td style=\"padding:6px 0;color:#6B7280;font-size:13px;\
163 width:90px;vertical-align:top;\">Device</td>\
164 <td style=\"padding:6px 0;color:#1F2937;font-size:13px;\
165 word-break:break-word;\">{}</td></tr>",
166 html_text_escape(ua)
167 ),
168 None => String::new(),
169 };
170
171 let reference_panel = match correlation_id {
175 Some(cid) => {
176 let stripped: String = cid.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
177 let take_from = stripped.len().saturating_sub(6);
178 let code = stripped[take_from..].to_ascii_uppercase();
179 format!(
180 r##"
181 <!-- Verification reference: derived from the per-request correlation id.
182 Operators can match this against the audit log row. -->
183 <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
184 border="0" style="margin:0 0 28px 0;">
185 <tr><td style="padding:18px 20px;background:#F7F9FC;
186 border:1px solid #DEE3EC;border-radius:6px;">
187 <div style="color:#6B7280;font-size:11px;font-weight:600;
188 letter-spacing:0.08em;text-transform:uppercase;margin:0 0 6px 0;">
189 Verification reference
190 </div>
191 <div style="color:#111827;font-family:'SFMono-Regular',Menlo,Consolas,
192 'Liberation Mono',monospace;font-size:22px;font-weight:600;
193 letter-spacing:0.18em;font-variant-numeric:tabular-nums;
194 line-height:1.2;">{}</div>
195 <div style="color:#6B7280;font-size:12px;line-height:1.5;
196 margin:8px 0 0 0;">
197 Keep this for your security records. It identifies this reset
198 attempt in the audit log; you don't need to type it anywhere.
199 </div>
200 </td></tr>
201 </table>"##,
202 html_text_escape(&code)
203 )
204 }
205 None => String::new(),
206 };
207
208 let signature_block = match signature_primary {
212 Some(primary) => {
213 let primary_safe = html_text_escape(primary);
214 let title_line = match signature_title {
215 Some(t) => format!(
216 r##"<div style="color:#6B7280;font-size:13px;line-height:1.5;">{}</div>"##,
217 html_text_escape(t)
218 ),
219 None => String::new(),
220 };
221 format!(
222 r##"
223 <!-- Account-owner signature. Hidden when profile fields are unset. -->
224 <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
225 border="0" style="margin:0 0 8px 0;">
226 <tr><td style="padding-top:8px;">
227 <div style="color:#6B7280;font-size:11px;font-weight:600;
228 letter-spacing:0.08em;text-transform:uppercase;margin:0 0 6px 0;">
229 Account owner
230 </div>
231 <div style="color:#111827;font-size:14px;font-weight:600;
232 line-height:1.4;">{primary_safe}</div>
233 {title_line}
234 <div style="color:#6B7280;font-size:13px;line-height:1.5;">{app_name_text}</div>
235 </td></tr>
236 </table>"##
237 )
238 }
239 None => String::new(),
240 };
241
242 let support_line = match support_email {
245 Some(addr) => {
246 let addr_safe = html_attr_escape(addr);
247 let addr_text = html_text_escape(addr);
248 format!(
249 r##"<p style="margin:6px 0 0 0;color:#9CA3AF;font-size:11px;line-height:1.5;">
250 Need help? Contact <a href="mailto:{addr_safe}" style="color:#6B7280;text-decoration:none;">{addr_text}</a>.
251 </p>"##
252 )
253 }
254 None => String::new(),
255 };
256
257 let powered_by_line = if show_powered_by {
259 r##"<p style="margin:10px 0 0 0;color:#D1D5DB;font-size:10px;line-height:1.5;letter-spacing:0.02em;">
260 Powered by RustIO
261 </p>"##.to_string()
262 } else {
263 String::new()
264 };
265
266 let preheader = format!("{title_text} — {fine_print_text}");
268 let preheader_safe = html_text_escape(&preheader);
269
270 format!(
271 r##"<!DOCTYPE html>
272<html lang="en">
273<head>
274<meta charset="utf-8">
275<meta name="viewport" content="width=device-width,initial-scale=1">
276<meta name="color-scheme" content="light">
277<meta name="supported-color-schemes" content="light">
278<title>{title_text}</title>
279<style>
280 /* Mobile readability bump — Apple Mail, Gmail mobile, Outlook iOS */
281 @media only screen and (max-width: 600px) {{
282 .rio-mail-shell {{ padding: 24px 16px !important; }}
283 .rio-mail-card {{ padding: 32px 24px !important; }}
284 .rio-mail-title {{ font-size: 22px !important; }}
285 .rio-mail-cta a {{ padding: 14px 24px !important; }}
286 }}
287</style>
288</head>
289<body style="margin:0;padding:0;background:#F7F9FC;color:#1F2937;
290 font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,
291 Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;
292 -webkit-text-size-adjust:100%;">
293
294<!-- Preheader: inbox preview text, hidden in the body itself. -->
295<div style="display:none;font-size:1px;color:#F7F9FC;line-height:1px;
296 max-height:0;max-width:0;opacity:0;overflow:hidden;">{preheader_safe}</div>
297
298<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
299 border="0" style="background:#F7F9FC;">
300<tr>
301<td align="center" class="rio-mail-shell" style="padding:48px 24px;">
302
303 <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
304 border="0" style="max-width:560px;width:100%;
305 background:#FFFFFF;border:1px solid #DEE3EC;border-radius:10px;
306 box-shadow:0 1px 2px rgba(17,24,39,0.04);">
307 <tr>
308 <td class="rio-mail-card" style="padding:40px 40px 32px 40px;">
309
310 <!-- Wordmark + operational descriptor. App identity owns the
311 wordmark; framework name is intentionally absent here. -->
312 <div style="margin:0 0 28px 0;">
313 <div style="font-size:14px;font-weight:700;letter-spacing:-0.005em;
314 color:#0B0F19;line-height:1.3;">
315 {app_name_text}
316 </div>
317 <div style="font-size:11px;font-weight:500;letter-spacing:0.10em;
318 color:#6B7280;text-transform:uppercase;margin-top:4px;">
319 {tagline_text}
320 </div>
321 </div>
322
323 <!-- Title -->
324 <h1 class="rio-mail-title" style="margin:0 0 14px 0;color:#0B0F19;
325 font-size:28px;line-height:1.2;font-weight:700;
326 letter-spacing:-0.018em;">
327 {title_text}
328 </h1>
329
330 <!-- Greeting + intro -->
331 <p style="margin:0 0 12px 0;color:#111827;font-size:15px;
332 line-height:1.65;font-weight:500;">
333 Hello {greeting_text},
334 </p>
335 <p style="margin:0 0 32px 0;color:#374151;font-size:15px;
336 line-height:1.65;">
337 {intro_text}
338 </p>
339
340 <!-- CTA Button: single point of emphasis. Full-width on the
341 card, generous padding, drop shadow for click-confidence. -->
342 <table role="presentation" class="rio-mail-cta" cellpadding="0"
343 cellspacing="0" border="0" width="100%" style="margin:0 0 18px 0;">
344 <tr>
345 <td align="center" style="border-radius:8px;background:#0F8C7E;
346 box-shadow:0 1px 3px rgba(15,140,126,0.30),
347 0 1px 2px rgba(15,140,126,0.18);">
348 <a href="{cta_url_safe}"
349 style="display:block;padding:18px 32px;
350 font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,
351 Helvetica,Arial,sans-serif;font-size:16px;font-weight:600;
352 color:#FFFFFF;text-decoration:none;letter-spacing:-0.005em;
353 border-radius:8px;text-align:center;">
354 {cta_label_text}
355 </a>
356 </td>
357 </tr>
358 </table>
359
360 <!-- URL fallback for clients that strip buttons -->
361 <p style="margin:0 0 8px 0;color:#6B7280;font-size:13px;line-height:1.5;">
362 Or paste this link into your browser:
363 </p>
364 <p style="margin:0 0 28px 0;font-size:13px;line-height:1.5;
365 word-break:break-all;font-family:'SFMono-Regular',Menlo,Consolas,
366 'Liberation Mono',monospace;">
367 <a href="{cta_url_safe}" style="color:#0F8C7E;text-decoration:none;">{cta_url_text}</a>
368 </p>
369
370 <!-- Fine print: TTL -->
371 <p style="margin:0 0 32px 0;color:#6B7280;font-size:13px;line-height:1.5;">
372 {fine_print_text}
373 </p>
374{reference_panel}
375 <!-- Divider -->
376 <hr style="border:none;border-top:1px solid #ECEFF4;margin:0 0 24px 0;">
377
378 <!-- Security envelope — system / when / IP / device -->
379 <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
380 border="0" style="margin:0 0 28px 0;">
381 <tr><td style="padding:6px 0;color:#6B7280;font-size:13px;
382 width:90px;vertical-align:top;">System</td>
383 <td style="padding:6px 0;color:#1F2937;font-size:13px;">{app_name_text}</td></tr>
384 <tr><td style="padding:6px 0;color:#6B7280;font-size:13px;
385 width:90px;vertical-align:top;">When</td>
386 <td style="padding:6px 0;color:#1F2937;font-size:13px;
387 font-variant-numeric:tabular-nums;">{when_str}</td></tr>
388 {ip_row}
389 {ua_row}
390 </table>
391
392 <!-- Warning panel: if not you -->
393 <div style="padding:18px 20px;background:#FFF8EB;border:1px solid #F2D9A7;
394 border-radius:6px;margin:0 0 24px 0;">
395 <p style="margin:0;color:#6B4F12;font-size:13px;line-height:1.55;">
396 <strong style="color:#4F3B0A;font-weight:600;">If this wasn't you</strong>
397 — ignore this email. Your password stays unchanged, and the link
398 above will expire on its own. You can also sign in and revoke open
399 sessions from the Sessions page.
400 </p>
401 </div>
402{signature_block}
403 </td>
404 </tr>
405 </table>
406
407 <!-- Footer — operational tone, no marketing. App identity speaks;
408 framework name appears only when explicitly opted-in. -->
409 <table role="presentation" cellpadding="0" cellspacing="0" border="0"
410 style="max-width:560px;width:100%;margin:18px auto 0 auto;">
411 <tr><td align="center" style="padding:0 8px;">
412 <p style="margin:0;color:#9CA3AF;font-size:12px;line-height:1.6;">
413 Session-aware authentication · Audit-logged ·
414 <span style="font-variant-numeric:tabular-nums;">{when_str}</span>
415 </p>
416 <p style="margin:6px 0 0 0;color:#9CA3AF;font-size:11px;line-height:1.5;">
417 You are receiving this because a password reset was requested
418 for your account on {app_name_text}. If that wasn't you,
419 no action is required.
420 </p>
421 {support_line}
422 {powered_by_line}
423 </td></tr>
424 </table>
425
426</td>
427</tr>
428</table>
429
430</body>
431</html>"##,
432 )
433}
434
435#[non_exhaustive]
444pub struct RecoveryEmailParts<'a> {
445 pub app_name: &'a str,
450 pub app_tagline: Option<&'a str>,
454 pub title: &'a str,
455 pub greeting_name: &'a str,
459 pub intro: &'a str,
460 pub cta_label: &'a str,
461 pub cta_url: &'a str,
462 pub fine_print: &'a str,
463 pub when: DateTime<Utc>,
464 pub request_ip: Option<&'a str>,
465 pub ua_summary: Option<&'a str>,
466 pub correlation_id: Option<&'a str>,
473 pub signature_primary: Option<&'a str>,
476 pub signature_title: Option<&'a str>,
478 pub support_email: Option<&'a str>,
480 pub show_powered_by: bool,
483}
484
485impl<'a> RecoveryEmailParts<'a> {
486 pub fn new(
496 app_name: &'a str,
497 title: &'a str,
498 greeting_name: &'a str,
499 intro: &'a str,
500 cta_url: &'a str,
501 fine_print: &'a str,
502 when: DateTime<Utc>,
503 ) -> Self {
504 Self {
505 app_name,
506 app_tagline: None,
507 title,
508 greeting_name,
509 intro,
510 cta_label: "Set a new password",
511 cta_url,
512 fine_print,
513 when,
514 request_ip: None,
515 ua_summary: None,
516 correlation_id: None,
517 signature_primary: None,
518 signature_title: None,
519 support_email: None,
520 show_powered_by: false,
521 }
522 }
523}
524
525fn html_text_escape(s: &str) -> String {
529 let mut out = String::with_capacity(s.len() + 8);
530 for ch in s.chars() {
531 match ch {
532 '&' => out.push_str("&"),
533 '<' => out.push_str("<"),
534 '>' => out.push_str(">"),
535 '"' => out.push_str("""),
536 '\'' => out.push_str("'"),
537 _ => out.push(ch),
538 }
539 }
540 out
541}
542
543fn html_attr_escape(s: &str) -> String {
548 let mut out = String::with_capacity(s.len() + 8);
549 for ch in s.chars() {
550 match ch {
551 '&' => out.push_str("&"),
552 '"' => out.push_str("""),
553 '\'' => out.push_str("'"),
554 '<' => out.push_str("<"),
555 '>' => out.push_str(">"),
556 c if (c as u32) < 0x20 || (c as u32) == 0x7f => { }
558 c => out.push(c),
559 }
560 }
561 out
562}
563
564#[derive(Debug)]
572#[non_exhaustive]
573pub enum MailerError {
574 ConfigurationMissing(String),
577 Transient(String),
584 Permanent(String),
587}
588
589impl fmt::Display for MailerError {
590 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591 match self {
592 Self::ConfigurationMissing(m) => write!(f, "mailer configuration missing: {m}"),
593 Self::Transient(m) => write!(f, "mailer transient failure: {m}"),
594 Self::Permanent(m) => write!(f, "mailer permanent failure: {m}"),
595 }
596 }
597}
598
599impl std::error::Error for MailerError {}
600
601pub trait Mailer: Send + Sync {
610 fn send<'a>(
612 &'a self,
613 msg: Mail,
614 ) -> std::pin::Pin<
615 Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
616 >;
617}
618
619#[derive(Debug, Default, Clone)]
630pub struct LogMailer;
631
632impl LogMailer {
633 pub fn new() -> Self {
635 Self
636 }
637}
638
639impl Mailer for LogMailer {
640 fn send<'a>(
641 &'a self,
642 msg: Mail,
643 ) -> std::pin::Pin<
644 Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
645 > {
646 Box::pin(async move {
647 let body_preview: String = msg.text_body.chars().take(200).collect();
648 let redacted = redact_likely_tokens(&body_preview);
651 log::info!(
652 target: "rustio_admin::mailer::log",
653 "[LogMailer] to={} subject={:?} body_preview={:?}",
654 msg.to,
655 msg.subject,
656 redacted,
657 );
658 Ok(())
659 })
660 }
661}
662
663fn redact_likely_tokens(s: &str) -> String {
667 s.split_whitespace()
671 .map(|w| {
672 if w.len() >= 32 && w.chars().all(is_token_url_char) {
673 "<redacted-link>"
674 } else {
675 w
676 }
677 })
678 .collect::<Vec<_>>()
679 .join(" ")
680}
681
682fn is_token_url_char(c: char) -> bool {
683 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | ':' | '.' | '?' | '&' | '=' | '#')
684}
685
686pub type SharedMailer = Arc<dyn Mailer>;
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use chrono::TimeZone;
696
697 #[test]
698 fn framework_envelope_appends_security_footer() {
699 let m = Mail::framework_envelope(
700 "user@example.com",
701 "Test",
702 "Body line.",
703 "Bosphorus & Sham · Stockholm",
704 Some("198.51.100.42"),
705 Some("macOS · Safari 18"),
706 Utc::now(),
707 );
708 assert!(m.text_body.contains("Body line."));
709 assert!(m.text_body.contains("System: Bosphorus & Sham · Stockholm"));
710 assert!(m.text_body.contains("From IP: 198.51.100.42"));
711 assert!(m.text_body.contains("Device: macOS · Safari 18"));
712 assert!(m.text_body.contains("If this was not you"));
713 }
714
715 #[test]
716 fn framework_envelope_omits_missing_fields() {
717 let m = Mail::framework_envelope(
718 "user@example.com",
719 "Test",
720 "Body.",
721 "ACME",
722 None,
723 None,
724 Utc::now(),
725 );
726 assert!(!m.text_body.contains("From IP:"));
727 assert!(!m.text_body.contains("Device:"));
728 assert!(m.text_body.contains("If this was not you"));
729 }
730
731 #[tokio::test]
732 async fn log_mailer_send_is_ok() {
733 let m = LogMailer::new();
734 let mail = Mail {
735 to: "user@example.com".into(),
736 subject: "Hi".into(),
737 text_body: "test body".into(),
738 html_body: None,
739 headers: Vec::new(),
740 };
741 assert!(m.send(mail).await.is_ok());
742 }
743
744 #[test]
745 fn redact_likely_tokens_redacts_long_alnum_strings() {
746 let s = "Click http://example.com/admin/reset-password/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa to reset.";
747 let r = redact_likely_tokens(s);
748 assert!(r.contains("<redacted-link>"));
749 assert!(!r.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
750 }
751
752 #[test]
753 fn redact_likely_tokens_keeps_short_words() {
754 let s = "Hello user, your account is fine.";
755 let r = redact_likely_tokens(s);
756 assert_eq!(r, s);
757 }
758
759 #[test]
760 fn mailer_error_display() {
761 let e = MailerError::ConfigurationMissing("no SMTP host".into());
762 assert!(format!("{e}").contains("configuration missing"));
763 let e = MailerError::Transient("timeout".into());
764 assert!(format!("{e}").contains("transient"));
765 let e = MailerError::Permanent("blocked".into());
766 assert!(format!("{e}").contains("permanent"));
767 }
768
769 #[test]
770 fn with_html_attaches_alternative_body() {
771 let m = Mail::framework_envelope(
772 "user@example.com",
773 "Test",
774 "Plain.",
775 "ACME",
776 None,
777 None,
778 Utc::now(),
779 )
780 .with_html("<p>Rich</p>");
781 assert!(m.text_body.contains("Plain."));
782 assert_eq!(m.html_body.as_deref(), Some("<p>Rich</p>"));
783 }
784
785 #[test]
786 fn recovery_html_contains_required_markers_and_escapes() {
787 let when = Utc.with_ymd_and_hms(2026, 5, 13, 14, 30, 0).unwrap();
788 let html = render_recovery_html(RecoveryEmailParts {
789 app_name: "Library Circulation",
790 app_tagline: Some("Operational library management"),
791 title: "Reset your password",
792 greeting_name: "Abdulwahed",
793 intro: "We received a request to reset the password for your \
794 Library Circulation account. Choose a new password to continue.",
795 cta_label: "Set a new password",
796 cta_url: "http://127.0.0.1:3000/admin/reset-password/abc123",
797 fine_print: "This link expires in 30 minutes.",
798 when,
799 request_ip: Some("127.0.0.1"),
800 ua_summary: Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15"),
801 correlation_id: Some("019e212b-9f63-7512-be44-daaa8e6267e2"),
802 signature_primary: Some("Abdulwahed Mansour"),
803 signature_title: Some("Principal Administrator"),
804 support_email: Some("support@library.example.com"),
805 show_powered_by: false,
806 });
807 assert!(html.starts_with("<!DOCTYPE html>"));
809 assert!(html.contains("viewport"));
810 assert!(!html.contains("multipart")); assert!(html.contains("Library Circulation"));
813 assert!(!html.contains("RustIO Admin"));
814 assert!(html.contains("Operational library management"));
816 assert!(!html.contains("Account security notification"));
817 assert!(html.contains("Reset your password"));
819 assert!(html.contains("Hello Abdulwahed,"));
820 assert!(html.contains("Set a new password"));
821 assert!(html.contains("http://127.0.0.1:3000/admin/reset-password/abc123"));
822 assert!(html.contains("This link expires in 30 minutes."));
823 assert!(html.contains("2026-05-13 14:30 UTC"));
824 assert!(html.contains("127.0.0.1"));
825 assert!(html.contains("Mozilla/5.0"));
826 assert!(html.contains("Verification reference"));
828 assert!(html.contains("6267E2"));
829 assert!(html.contains("Session-aware authentication"));
831 assert!(html.contains("Account owner"));
833 assert!(html.contains("Abdulwahed Mansour"));
834 assert!(html.contains("Principal Administrator"));
835 assert!(html.contains("support@library.example.com"));
837 assert!(!html.contains("Powered by RustIO"));
839 assert!(html.contains("If this wasn"));
841 assert!(html.contains("#0F8C7E"));
843
844 let _ = std::fs::write("/tmp/rustio-recovery-email-preview.html", &html);
847 }
848
849 #[test]
850 fn recovery_html_powered_by_appears_only_when_opted_in() {
851 let when = Utc.with_ymd_and_hms(2026, 5, 13, 14, 30, 0).unwrap();
852 let html = render_recovery_html(RecoveryEmailParts {
853 app_name: "Library Circulation",
854 app_tagline: None,
855 title: "Reset your password",
856 greeting_name: "there",
857 intro: "We received a request.",
858 cta_label: "Set a new password",
859 cta_url: "http://example/x",
860 fine_print: "Expires soon.",
861 when,
862 request_ip: None,
863 ua_summary: None,
864 correlation_id: None,
865 signature_primary: None,
866 signature_title: None,
867 support_email: None,
868 show_powered_by: true,
869 });
870 assert!(html.contains("Account security notification"));
872 assert!(html.contains("Powered by RustIO"));
874 assert!(!html.contains("Account owner"));
876 }
877
878 #[test]
879 fn recovery_html_escapes_html_in_inputs() {
880 let html = render_recovery_html(RecoveryEmailParts {
881 app_name: "<script>alert(1)</script>",
882 app_tagline: Some("<b>raw</b>"),
883 title: "Title & co",
884 greeting_name: "Alice<script>",
885 intro: "Body <em>x</em>",
886 cta_label: "Click >>",
887 cta_url: "http://example.com/?a=1&b=2",
888 fine_print: "Expires in <30> minutes",
889 when: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
890 request_ip: Some("<bad>"),
891 ua_summary: Some("\"chrome\""),
892 correlation_id: None,
893 signature_primary: Some("<sig>"),
894 signature_title: Some("<title>"),
895 support_email: Some("a@<b>"),
896 show_powered_by: false,
897 });
898 assert!(!html.contains("<script>alert(1)</script>"));
900 assert!(html.contains("<script>alert(1)</script>"));
902 assert!(html.contains("Title & co"));
903 assert!(html.contains("Body <em>x</em>"));
904 assert!(html.contains("Click >>"));
905 assert!(html.contains("?a=1&b=2"));
907 assert!(html.contains("<bad>"));
908 assert!(html.contains(""chrome""));
909 }
910}