| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 10 | pub enum Purpose { |
| 11 | |
| 12 | Activate, |
| 13 | |
| 14 | Reset, |
| 15 | |
| 16 | VerifyEmail, |
| 17 | } |
| 18 | |
| 19 | impl Purpose { |
| 20 | |
| 21 | #[must_use] |
| 22 | pub fn as_str(self) -> &'static str { |
| 23 | match self { |
| 24 | Self::Activate => "activate", |
| 25 | Self::Reset => "reset", |
| 26 | Self::VerifyEmail => "verify-email", |
| 27 | } |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | |
| 32 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 33 | pub struct Rendered { |
| 34 | |
| 35 | pub subject: String, |
| 36 | |
| 37 | pub plain: String, |
| 38 | |
| 39 | pub html: String, |
| 40 | } |
| 41 | |
| 42 | |
| 43 | |
| 44 | #[must_use] |
| 45 | pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered { |
| 46 | let (subject, lead, action) = match purpose { |
| 47 | Purpose::Activate => ( |
| 48 | format!("Activate your {instance_name} account"), |
| 49 | format!("An account has been created for you on {instance_name}."), |
| 50 | "set your password and activate your account", |
| 51 | ), |
| 52 | Purpose::Reset => ( |
| 53 | format!("Reset your {instance_name} password"), |
| 54 | format!("A password reset was requested for your {instance_name} account."), |
| 55 | "choose a new password", |
| 56 | ), |
| 57 | Purpose::VerifyEmail => ( |
| 58 | format!("Verify your email for {instance_name}"), |
| 59 | format!("Confirm this email address to use it on {instance_name}."), |
| 60 | "verify this email address", |
| 61 | ), |
| 62 | }; |
| 63 | |
| 64 | let plain = format!( |
| 65 | "{lead}\n\nOpen this link to {action}:\n\n {link}\n\n\ |
| 66 | If you did not expect this email, you can safely ignore it.\n" |
| 67 | ); |
| 68 | let html = format!( |
| 69 | "<p>{}</p>\n<p>Open this link to {}:</p>\n<p><a href=\"{}\">{}</a></p>\n\ |
| 70 | <p>If you did not expect this email, you can safely ignore it.</p>\n", |
| 71 | escape(&lead), |
| 72 | escape(action), |
| 73 | escape(link), |
| 74 | escape(link), |
| 75 | ); |
| 76 | |
| 77 | Rendered { |
| 78 | subject, |
| 79 | plain, |
| 80 | html, |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | |
| 85 | fn escape(s: &str) -> String { |
| 86 | s.replace('&', "&") |
| 87 | .replace('<', "<") |
| 88 | .replace('>', ">") |
| 89 | .replace('"', """) |
| 90 | } |
| 91 | |
| 92 | #[cfg(test)] |
| 93 | mod tests { |
| 94 | use super::*; |
| 95 | |
| 96 | #[test] |
| 97 | fn activate_body_contains_link_and_name() { |
| 98 | let r = render( |
| 99 | "Forge", |
| 100 | "https://git.example.com/invite/abc123", |
| 101 | Purpose::Activate, |
| 102 | ); |
| 103 | assert!(r.subject.contains("Forge")); |
| 104 | assert!(r.plain.contains("https://git.example.com/invite/abc123")); |
| 105 | assert!( |
| 106 | r.html |
| 107 | .contains("href=\"https://git.example.com/invite/abc123\"") |
| 108 | ); |
| 109 | assert!(r.plain.contains("activate your account")); |
| 110 | } |
| 111 | |
| 112 | #[test] |
| 113 | fn reset_uses_reset_wording() { |
| 114 | let r = render("Forge", "https://x/invite/t", Purpose::Reset); |
| 115 | assert!(r.subject.to_lowercase().contains("reset")); |
| 116 | assert!(r.plain.contains("new password")); |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn html_escapes_interpolated_values() { |
| 121 | let r = render("A&B", "https://x/?a=1&b=2", Purpose::Activate); |
| 122 | assert!(r.html.contains("&")); |
| 123 | assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped"); |
| 124 | } |
| 125 | } |