//! Mail templates. Each function returns `(subject, body)` as plain text. Every outgoing message //! is built here. The notification mail is localised through `i18n`; the others are English. use crate::i18n::{Key, Lang, mail_footer, t}; /// Longest comment text copied into a notification mail, in characters. pub const COMMENT_LIMIT: usize = 8000; /// What happened, as the first line of a notification mail says it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verb { Opened, Commented, Closed, Reopened, Merged, Reviewed, AssignedYou, Pushed, MentionedYou, } /// Why the recipient hears about it, as the footer says it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Why { Mention, Assign, ReviewRequested, Author, Comment, Subscribed, Watching, } /// The facts of one notification for one recipient. pub struct Notification<'a> { /// The actor's username. `None` for a system change, which reads as "Someone". pub actor: Option<&'a str>, pub verb: Verb, /// `owner/repo`. pub repo: &'a str, pub number: i64, pub title: &'a str, /// The comment, review or issue body that came with the activity, if any. pub comment: Option<&'a str>, /// The absolute URL of the issue or pull request. pub url: &'a str, pub why: Why, /// The absolute URL of the notification settings page. pub settings_url: &'a str, } /// The notification mail for one recipient, in the recipient's language. pub fn notification(lang: Lang, ctx: &Notification<'_>) -> (String, String) { // A newline in the title would break the Subject header. let title = ctx.title.replace(['\r', '\n'], " "); let subject = format!("[{}] {title} (#{})", ctx.repo, ctx.number); let who = ctx.actor.unwrap_or(t(lang, Key::MailSomeone)); let mut body = format!("{who} {}.\n", t(lang, verb_key(ctx.verb))); if let Some(text) = ctx.comment.filter(|text| !text.trim().is_empty()) { body.push('\n'); body.push_str(&truncate(text, COMMENT_LIMIT)); body.push('\n'); } body.push('\n'); body.push_str(ctx.url); body.push_str("\n\n"); body.push_str(&mail_footer( lang, t(lang, why_key(ctx.why)), ctx.settings_url, )); body.push('\n'); (subject, body) } fn verb_key(verb: Verb) -> Key { match verb { Verb::Opened => Key::MailVerbOpened, Verb::Commented => Key::MailVerbCommented, Verb::Closed => Key::MailVerbClosed, Verb::Reopened => Key::MailVerbReopened, Verb::Merged => Key::MailVerbMerged, Verb::Reviewed => Key::MailVerbReviewed, Verb::AssignedYou => Key::MailVerbAssigned, Verb::Pushed => Key::MailVerbPushed, Verb::MentionedYou => Key::MailVerbMentioned, } } fn why_key(why: Why) -> Key { match why { Why::Mention => Key::MailReasonMention, Why::Assign => Key::MailReasonAssign, Why::ReviewRequested => Key::MailReasonReviewRequested, Why::Author => Key::MailReasonAuthor, Why::Comment => Key::MailReasonComment, Why::Subscribed => Key::MailReasonSubscribed, Why::Watching => Key::MailReasonWatching, } } /// `text` cut to `max` characters, with "…" appended when anything was cut. fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { return text.to_owned(); } let mut out: String = text.chars().take(max).collect(); out.push('…'); out } /// The message an admin sends from `/admin/mail` to check the settings. pub fn test_mail() -> (String, String) { ( "Test message from forge".to_owned(), "This is a test message from forge.\n\nIf you can read it, outgoing mail works.\n" .to_owned(), ) } /// The password reset mail. The link works once and for an hour. pub fn password_reset(username: &str, link: &str) -> (String, String) { ( "Reset your forge password".to_owned(), format!( "Hi {username},\n\nSomeone asked to reset the password for your forge account. \ If that was you, open this link within an hour. It works once:\n\n{link}\n\n\ If it was not you, ignore this message. Your password stays the same.\n" ), ) } /// The email verification mail. The link works once and for 24 hours. pub fn email_verify(username: &str, link: &str) -> (String, String) { ( "Confirm your email address for forge".to_owned(), format!( "Hi {username},\n\nPlease confirm that this address belongs to your forge account. \ Open this link within 24 hours. It works once:\n\n{link}\n\n\ If you did not ask for this, ignore this message.\n" ), ) } /// The invite mail. The link creates an account for the invited address, works once, and lasts /// seven days. pub fn invite(link: &str) -> (String, String) { ( "You are invited to forge".to_owned(), format!( "Hello,\n\nSomeone invited you to join forge. Open this link to create your account. \ It works once and expires in seven days:\n\n{link}\n\n\ If you were not expecting this, ignore this message.\n" ), ) } #[cfg(test)] mod tests { use super::*; fn sample<'a>(comment: Option<&'a str>, title: &'a str) -> Notification<'a> { Notification { actor: Some("alice"), verb: Verb::Commented, repo: "bob/widgets", number: 7, title, comment, url: "https://forge.test/bob/widgets/issues/7", why: Why::Subscribed, settings_url: "https://forge.test/settings/notifications", } } #[test] fn subject_names_the_repo_title_and_number() { let (subject, _) = notification(Lang::En, &sample(None, "Crash on start")); assert_eq!(subject, "[bob/widgets] Crash on start (#7)"); } #[test] fn a_newline_in_the_title_cannot_split_the_subject() { let (subject, _) = notification(Lang::En, &sample(None, "two\r\nlines")); assert_eq!(subject, "[bob/widgets] two lines (#7)"); } #[test] fn long_comments_are_cut_at_the_limit_with_an_ellipsis() { let long = "x".repeat(COMMENT_LIMIT + 500); let (_, body) = notification(Lang::En, &sample(Some(long.as_str()), "T")); let kept = format!("{}…", "x".repeat(COMMENT_LIMIT)); assert!(body.contains(&kept)); assert!(!body.contains(&"x".repeat(COMMENT_LIMIT + 1))); } #[test] fn short_comments_are_kept_whole() { assert_eq!(truncate("héllo", 10), "héllo"); assert_eq!(truncate("héllo", 2), "hé…"); } #[test] fn body_has_the_actor_line_comment_url_and_footer_in_order() { let (_, body) = notification(Lang::En, &sample(Some("Looks good"), "T")); let line = body.find("alice commented on this.").expect("actor line"); let text = body.find("Looks good").expect("comment"); let url = body .find("https://forge.test/bob/widgets/issues/7") .expect("url"); let footer = body.find("You are receiving this because").expect("footer"); assert!(line < text && text < url && url < footer, "{body}"); assert!( body.contains("Manage email notifications: https://forge.test/settings/notifications") ); } #[test] fn turkish_mail_uses_the_turkish_phrases() { let (_, body) = notification(Lang::Tr, &sample(None, "T")); assert!(body.starts_with("alice yorum yaptı."), "{body}"); assert!( body.contains("Bu e-postayı şu nedenle alıyorsunuz:"), "{body}" ); } #[test] fn a_system_change_reads_as_someone() { let mut ctx = sample(None, "T"); ctx.actor = None; let (_, body) = notification(Lang::En, &ctx); assert!(body.starts_with("Someone commented on this."), "{body}"); } #[test] fn test_mail_is_plain_text_with_a_subject() { let (subject, body) = test_mail(); assert!(!subject.is_empty()); assert!(!body.contains('<'), "plain text, no markup"); assert!(body.ends_with('\n')); } }