forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.1 KiB
1//! Mail templates. Each function returns `(subject, body)` as plain text. Every outgoing message
2//! is built here. The notification mail is localised through `i18n`; the others are English.
3
4use crate::i18n::{Key, Lang, mail_footer, t};
5
6/// Longest comment text copied into a notification mail, in characters.
7pub const COMMENT_LIMIT: usize = 8000;
8
9/// What happened, as the first line of a notification mail says it.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Verb {
12 Opened,
13 Commented,
14 Closed,
15 Reopened,
16 Merged,
17 Reviewed,
18 AssignedYou,
19 Pushed,
20 MentionedYou,
21}
22
23/// Why the recipient hears about it, as the footer says it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Why {
26 Mention,
27 Assign,
28 ReviewRequested,
29 Author,
30 Comment,
31 Subscribed,
32 Watching,
33}
34
35/// The facts of one notification for one recipient.
36pub struct Notification<'a> {
37 /// The actor's username. `None` for a system change, which reads as "Someone".
38 pub actor: Option<&'a str>,
39 pub verb: Verb,
40 /// `owner/repo`.
41 pub repo: &'a str,
42 pub number: i64,
43 pub title: &'a str,
44 /// The comment, review or issue body that came with the activity, if any.
45 pub comment: Option<&'a str>,
46 /// The absolute URL of the issue or pull request.
47 pub url: &'a str,
48 pub why: Why,
49 /// The absolute URL of the notification settings page.
50 pub settings_url: &'a str,
51}
52
53/// The notification mail for one recipient, in the recipient's language.
54pub fn notification(lang: Lang, ctx: &Notification<'_>) -> (String, String) {
55 // A newline in the title would break the Subject header.
56 let title = ctx.title.replace(['\r', '\n'], " ");
57 let subject = format!("[{}] {title} (#{})", ctx.repo, ctx.number);
58 let who = ctx.actor.unwrap_or(t(lang, Key::MailSomeone));
59 let mut body = format!("{who} {}.\n", t(lang, verb_key(ctx.verb)));
60 if let Some(text) = ctx.comment.filter(|text| !text.trim().is_empty()) {
61 body.push('\n');
62 body.push_str(&truncate(text, COMMENT_LIMIT));
63 body.push('\n');
64 }
65 body.push('\n');
66 body.push_str(ctx.url);
67 body.push_str("\n\n");
68 body.push_str(&mail_footer(
69 lang,
70 t(lang, why_key(ctx.why)),
71 ctx.settings_url,
72 ));
73 body.push('\n');
74 (subject, body)
75}
76
77fn verb_key(verb: Verb) -> Key {
78 match verb {
79 Verb::Opened => Key::MailVerbOpened,
80 Verb::Commented => Key::MailVerbCommented,
81 Verb::Closed => Key::MailVerbClosed,
82 Verb::Reopened => Key::MailVerbReopened,
83 Verb::Merged => Key::MailVerbMerged,
84 Verb::Reviewed => Key::MailVerbReviewed,
85 Verb::AssignedYou => Key::MailVerbAssigned,
86 Verb::Pushed => Key::MailVerbPushed,
87 Verb::MentionedYou => Key::MailVerbMentioned,
88 }
89}
90
91fn why_key(why: Why) -> Key {
92 match why {
93 Why::Mention => Key::MailReasonMention,
94 Why::Assign => Key::MailReasonAssign,
95 Why::ReviewRequested => Key::MailReasonReviewRequested,
96 Why::Author => Key::MailReasonAuthor,
97 Why::Comment => Key::MailReasonComment,
98 Why::Subscribed => Key::MailReasonSubscribed,
99 Why::Watching => Key::MailReasonWatching,
100 }
101}
102
103/// `text` cut to `max` characters, with "…" appended when anything was cut.
104fn truncate(text: &str, max: usize) -> String {
105 if text.chars().count() <= max {
106 return text.to_owned();
107 }
108 let mut out: String = text.chars().take(max).collect();
109 out.push('…');
110 out
111}
112
113/// The message an admin sends from `/admin/mail` to check the settings.
114pub fn test_mail() -> (String, String) {
115 (
116 "Test message from forge".to_owned(),
117 "This is a test message from forge.\n\nIf you can read it, outgoing mail works.\n"
118 .to_owned(),
119 )
120}
121
122/// The password reset mail. The link works once and for an hour.
123pub fn password_reset(username: &str, link: &str) -> (String, String) {
124 (
125 "Reset your forge password".to_owned(),
126 format!(
127 "Hi {username},\n\nSomeone asked to reset the password for your forge account. \
128 If that was you, open this link within an hour. It works once:\n\n{link}\n\n\
129 If it was not you, ignore this message. Your password stays the same.\n"
130 ),
131 )
132}
133
134/// The email verification mail. The link works once and for 24 hours.
135pub fn email_verify(username: &str, link: &str) -> (String, String) {
136 (
137 "Confirm your email address for forge".to_owned(),
138 format!(
139 "Hi {username},\n\nPlease confirm that this address belongs to your forge account. \
140 Open this link within 24 hours. It works once:\n\n{link}\n\n\
141 If you did not ask for this, ignore this message.\n"
142 ),
143 )
144}
145
146/// The invite mail. The link creates an account for the invited address, works once, and lasts
147/// seven days.
148pub fn invite(link: &str) -> (String, String) {
149 (
150 "You are invited to forge".to_owned(),
151 format!(
152 "Hello,\n\nSomeone invited you to join forge. Open this link to create your account. \
153 It works once and expires in seven days:\n\n{link}\n\n\
154 If you were not expecting this, ignore this message.\n"
155 ),
156 )
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn sample<'a>(comment: Option<&'a str>, title: &'a str) -> Notification<'a> {
164 Notification {
165 actor: Some("alice"),
166 verb: Verb::Commented,
167 repo: "bob/widgets",
168 number: 7,
169 title,
170 comment,
171 url: "https://forge.test/bob/widgets/issues/7",
172 why: Why::Subscribed,
173 settings_url: "https://forge.test/settings/notifications",
174 }
175 }
176
177 #[test]
178 fn subject_names_the_repo_title_and_number() {
179 let (subject, _) = notification(Lang::En, &sample(None, "Crash on start"));
180 assert_eq!(subject, "[bob/widgets] Crash on start (#7)");
181 }
182
183 #[test]
184 fn a_newline_in_the_title_cannot_split_the_subject() {
185 let (subject, _) = notification(Lang::En, &sample(None, "two\r\nlines"));
186 assert_eq!(subject, "[bob/widgets] two lines (#7)");
187 }
188
189 #[test]
190 fn long_comments_are_cut_at_the_limit_with_an_ellipsis() {
191 let long = "x".repeat(COMMENT_LIMIT + 500);
192 let (_, body) = notification(Lang::En, &sample(Some(long.as_str()), "T"));
193 let kept = format!("{}…", "x".repeat(COMMENT_LIMIT));
194 assert!(body.contains(&kept));
195 assert!(!body.contains(&"x".repeat(COMMENT_LIMIT + 1)));
196 }
197
198 #[test]
199 fn short_comments_are_kept_whole() {
200 assert_eq!(truncate("héllo", 10), "héllo");
201 assert_eq!(truncate("héllo", 2), "hé…");
202 }
203
204 #[test]
205 fn body_has_the_actor_line_comment_url_and_footer_in_order() {
206 let (_, body) = notification(Lang::En, &sample(Some("Looks good"), "T"));
207 let line = body.find("alice commented on this.").expect("actor line");
208 let text = body.find("Looks good").expect("comment");
209 let url = body
210 .find("https://forge.test/bob/widgets/issues/7")
211 .expect("url");
212 let footer = body.find("You are receiving this because").expect("footer");
213 assert!(line < text && text < url && url < footer, "{body}");
214 assert!(
215 body.contains("Manage email notifications: https://forge.test/settings/notifications")
216 );
217 }
218
219 #[test]
220 fn turkish_mail_uses_the_turkish_phrases() {
221 let (_, body) = notification(Lang::Tr, &sample(None, "T"));
222 assert!(body.starts_with("alice yorum yaptı."), "{body}");
223 assert!(
224 body.contains("Bu e-postayı şu nedenle alıyorsunuz:"),
225 "{body}"
226 );
227 }
228
229 #[test]
230 fn a_system_change_reads_as_someone() {
231 let mut ctx = sample(None, "T");
232 ctx.actor = None;
233 let (_, body) = notification(Lang::En, &ctx);
234 assert!(body.starts_with("Someone commented on this."), "{body}");
235 }
236
237 #[test]
238 fn test_mail_is_plain_text_with_a_subject() {
239 let (subject, body) = test_mail();
240 assert!(!subject.is_empty());
241 assert!(!body.contains('<'), "plain text, no markup");
242 assert!(body.ends_with('\n'));
243 }
244}