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