fabrica

hanna/fabrica

3939 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Message templates. A plain-text primary and a minimal HTML alternative are
6//! rendered from the same data, with the instance name and URL interpolated.
7
8/// Why an invite was sent.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Purpose {
11 /// A new-account activation link.
12 Activate,
13 /// A password-reset link.
14 Reset,
15 /// An email-address verification link.
16 VerifyEmail,
17}
18
19impl Purpose {
20 /// The `invites.purpose` token.
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/// A rendered message: subject plus the two body alternatives.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Rendered {
34 /// The `Subject:` header.
35 pub subject: String,
36 /// The plain-text body (primary).
37 pub plain: String,
38 /// The HTML body (alternative).
39 pub html: String,
40}
41
42/// Render the invite message for `purpose`, interpolating the instance name/URL
43/// and the activation `link`.
44#[must_use]
45pub 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/// Minimal HTML escaping for the interpolated values.
85fn escape(s: &str) -> String {
86 s.replace('&', "&amp;")
87 .replace('<', "&lt;")
88 .replace('>', "&gt;")
89 .replace('"', "&quot;")
90}
91
92#[cfg(test)]
93mod 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("&amp;"));
123 assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped");
124 }
125}