// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Message templates. A plain-text primary and a minimal HTML alternative are //! rendered from the same data, with the instance name and URL interpolated. /// Why an invite was sent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Purpose { /// A new-account activation link. Activate, /// A password-reset link. Reset, /// An email-address verification link. VerifyEmail, } impl Purpose { /// The `invites.purpose` token. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Activate => "activate", Self::Reset => "reset", Self::VerifyEmail => "verify-email", } } } /// A rendered message: subject plus the two body alternatives. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rendered { /// The `Subject:` header. pub subject: String, /// The plain-text body (primary). pub plain: String, /// The HTML body (alternative). pub html: String, } /// Render the invite message for `purpose`, interpolating the instance name/URL /// and the activation `link`. #[must_use] pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered { let (subject, lead, action) = match purpose { Purpose::Activate => ( format!("Activate your {instance_name} account"), format!("An account has been created for you on {instance_name}."), "set your password and activate your account", ), Purpose::Reset => ( format!("Reset your {instance_name} password"), format!("A password reset was requested for your {instance_name} account."), "choose a new password", ), Purpose::VerifyEmail => ( format!("Verify your email for {instance_name}"), format!("Confirm this email address to use it on {instance_name}."), "verify this email address", ), }; let plain = format!( "{lead}\n\nOpen this link to {action}:\n\n {link}\n\n\ If you did not expect this email, you can safely ignore it.\n" ); let html = format!( "
{}
\nOpen this link to {}:
\n\n\If you did not expect this email, you can safely ignore it.
\n", escape(&lead), escape(action), escape(link), escape(link), ); Rendered { subject, plain, html, } } /// Minimal HTML escaping for the interpolated values. fn escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; #[test] fn activate_body_contains_link_and_name() { let r = render( "Forge", "https://git.example.com/invite/abc123", Purpose::Activate, ); assert!(r.subject.contains("Forge")); assert!(r.plain.contains("https://git.example.com/invite/abc123")); assert!( r.html .contains("href=\"https://git.example.com/invite/abc123\"") ); assert!(r.plain.contains("activate your account")); } #[test] fn reset_uses_reset_wording() { let r = render("Forge", "https://x/invite/t", Purpose::Reset); assert!(r.subject.to_lowercase().contains("reset")); assert!(r.plain.contains("new password")); } #[test] fn html_escapes_interpolated_values() { let r = render("A&B", "https://x/?a=1&b=2", Purpose::Activate); assert!(r.html.contains("&")); assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped"); } }