flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/email.rs

514 lines20,259 bytesCodeBlame
1//! Transactional email through Cloudflare Email Sending.
2
3use g1t_kit::js;
4use serde::Serialize;
5use worker::{Env, Result};
6
7const FROM: &str = "g1t <noreply@g1t.sh>";
8const SITE: &str = "https://g1t.sh";
9
10#[derive(Serialize)]
11struct Message<'a> {
12 to: &'a str,
13 from: &'a str,
14 subject: &'a str,
15 text: String,
16 html: String,
17}
18
19/// What one email says, before it is laid out as text and HTML.
20#[derive(Debug, Default)]
21pub struct Letter {
22 pub paragraphs: Vec<String>,
23 /// Quoted passages, each with who or what it is from: a note from the
24 /// person who sent an invite, or what someone asking for access said.
25 pub quotes: Vec<(String, String)>,
26 /// The button: what it says, and where it goes.
27 pub action: Option<(String, String)>,
28 pub footer: String,
29}
30
31/// The plain text and HTML of a letter. Everything in it is escaped:
32/// names, notes and requests people wrote can reach every line.
33pub fn render(letter: &Letter) -> (String, String) {
34 let mut text = String::new();
35 let mut html = format!(
36 "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\
37 <p style=\"margin:0 0 20px\"><img src=\"{SITE}/brand/g1t-logo.png\" width=\"60\" height=\"28\" alt=\"g1t\" style=\"display:block;border:0\"></p>"
38 );
39 for paragraph in &letter.paragraphs {
40 text.push_str(paragraph);
41 text.push_str("\n\n");
42 html.push_str(&format!("<p style=\"font-size:15px;line-height:1.6\">{}</p>", escape(paragraph)));
43 }
44 for (from, quote) in &letter.quotes {
45 text.push_str(&format!("{from}:\n"));
46 for line in quote.lines() {
47 text.push_str(&format!("> {line}\n"));
48 }
49 text.push('\n');
50 html.push_str(&format!(
51 "<p style=\"margin:20px 0 6px;font-size:13px;color:#6e6a5e\">{}</p>\
52 <blockquote style=\"margin:0;padding:2px 0 2px 14px;border-left:3px solid #b9a6f2;font-size:15px;line-height:1.6;white-space:pre-line\">{}</blockquote>",
53 escape(from),
54 escape(quote)
55 ));
56 }
57 if let Some((label, link)) = &letter.action {
58 text.push_str(&format!("{label}: {link}\n\n"));
59 html.push_str(&format!(
60 "<p style=\"margin:24px 0\"><a href=\"{}\" style=\"background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px\">{}</a></p>",
61 escape(link),
62 escape(label)
63 ));
64 }
65 text.push_str(&letter.footer);
66 text.push('\n');
67 html.push_str(&format!(
68 "<p style=\"font-size:13px;line-height:1.6;color:#6e6a5e\">{}</p></div>",
69 escape(&letter.footer)
70 ));
71 (text, html)
72}
73
74/// Sends a letter.
75pub async fn send(env: &Env, to: &str, subject: &str, letter: &Letter) -> Result<()> {
76 let (text, html) = render(letter);
77 let message = Message {
78 to,
79 from: FROM,
80 subject,
81 text,
82 html,
83 };
84 let binding = js::binding(env, "EMAIL")?;
85 js::call(&binding, "send", &[js::to_js(&message)?]).await?;
86 Ok(())
87}
88
89/// A short message with one link to follow.
90pub async fn send_link(
91 env: &Env,
92 to: &str,
93 subject: &str,
94 intro: &str,
95 action: &str,
96 link: &str,
97 footer: &str,
98) -> Result<()> {
99 let letter = Letter {
100 paragraphs: vec![intro.to_owned()],
101 quotes: Vec::new(),
102 action: Some((action.to_owned(), link.to_owned())),
103 footer: footer.to_owned(),
104 };
105 send(env, to, subject, &letter).await
106}
107
108/// Text made safe to put in HTML, in an element or a quoted attribute.
109fn escape(text: &str) -> String {
110 let mut escaped = String::with_capacity(text.len());
111 for c in text.chars() {
112 match c {
113 '&' => escaped.push_str("&amp;"),
114 '<' => escaped.push_str("&lt;"),
115 '>' => escaped.push_str("&gt;"),
116 '"' => escaped.push_str("&quot;"),
117 '\'' => escaped.push_str("&#39;"),
118 c => escaped.push(c),
119 }
120 }
121 escaped
122}
123
124pub async fn send_verification(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
125 send_link(
126 env,
127 to,
128 "Confirm your email for g1t",
129 &format!("Welcome to g1t, {username}. Confirm this address to finish creating your account."),
130 "Confirm email",
131 &format!("{SITE}/verify?token={token}"),
132 "This link works for 24 hours. If you did not create a g1t account, you can ignore this message.",
133 )
134 .await
135}
136
137pub async fn send_password_reset(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
138 send_link(
139 env,
140 to,
141 "Reset your g1t password",
142 &format!("Someone asked to reset the password for the g1t account {username}."),
143 "Choose a new password",
144 &format!("{SITE}/reset?token={token}"),
145 "This link works for 1 hour. If this was not you, ignore this message and your password stays the same.",
146 )
147 .await
148}
149
150/// Confirms an address added to an existing account.
151pub async fn send_added_address(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
152 send_link(
153 env,
154 to,
155 "Confirm your email for g1t",
156 &format!("Confirm this address to add it to the g1t account {username}."),
157 "Confirm email",
158 &format!("{SITE}/verify?token={token}"),
159 "This link works for 24 hours. If you did not add this address to a g1t account, you can ignore this message.",
160 )
161 .await
162}
163
164/// What a security notice says: one sentence about what changed.
165pub fn security_wording(username: &str, change: &str) -> (String, String) {
166 (
167 format!("Security notice for your g1t account {username}"),
168 format!("{change}. This is about your g1t account {username}."),
169 )
170}
171
172/// Tells an account's addresses that something about its security changed.
173pub async fn send_security_notice(env: &Env, to: &str, username: &str, change: &str) -> Result<()> {
174 let (subject, intro) = security_wording(username, change);
175 send_link(
176 env,
177 to,
178 &subject,
179 &intro,
180 "Review your email settings",
181 &format!("{SITE}/settings/emails"),
182 "If this was you, there is nothing to do. If it was not, reset your password at g1t.sh/forgot straight away and remove any address you do not recognise.",
183 )
184 .await
185}
186
187/// An invite email: to make an account, or for an existing one to join a
188/// workspace.
189pub struct InviteEmail<'a> {
190 pub to: &'a str,
191 /// Who sent it (a name, or a username); None when g1t staff did.
192 pub from: Option<&'a str>,
193 /// The workspace it joins, by name.
194 pub workspace: Option<&'a str>,
195 pub joins_existing_account: bool,
196 pub code: &'a str,
197 pub days: u64,
198 /// A line from whoever sent it, such as staff approving a request.
199 pub note: Option<&'a str>,
200}
201
202/// The subject and letter of an invite email.
203pub fn invite_letter(invite: &InviteEmail) -> (String, Letter) {
204 let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account);
205 let action = match invite.workspace {
206 Some(workspace) if invite.joins_existing_account => format!("Join {workspace}"),
207 _ => "Accept invite".to_owned(),
208 };
209 let quotes = match invite.note.map(str::trim).filter(|note| !note.is_empty()) {
210 Some(note) => vec![(
211 match invite.from {
212 Some(from) => format!("A note from {from}"),
213 None => "A note from the g1t team".to_owned(),
214 },
215 note.to_owned(),
216 )],
217 None => Vec::new(),
218 };
219 let letter = Letter {
220 paragraphs: vec![intro],
221 quotes,
222 action: Some((action, format!("{SITE}/invite/{}", invite.code))),
223 footer: format!(
224 "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.",
225 invite.days
226 ),
227 };
228 (subject, letter)
229}
230
231pub async fn send_invite(env: &Env, invite: &InviteEmail<'_>) -> Result<()> {
232 let (subject, letter) = invite_letter(invite);
233 send(env, invite.to, &subject, &letter).await
234}
235
236/// Where staff decide on access requests.
237pub const SUDO_WAITLIST: &str = "https://sudo.g1t.sh/invites?tab=waitlist";
238
239/// The one confirmation someone gets after asking for access.
240pub fn waitlist_confirmation() -> (String, Letter) {
241 (
242 "You're on the list for g1t".to_owned(),
243 Letter {
244 paragraphs: vec![
245 "Thanks for asking to try g1t. You're on the list, and we'll email you an invite at this address when there's a place for you.".to_owned(),
246 "g1t is invite-only while we open it up a few people at a time, so we can't say exactly when that will be. Someone already on g1t can also invite you sooner.".to_owned(),
247 ],
248 quotes: Vec::new(),
249 action: None,
250 footer: "You're getting this because this address asked for access at g1t.sh/register. If that wasn't you, ignore this message; nothing more is sent unless you're invited.".to_owned(),
251 },
252 )
253}
254
255pub async fn send_waitlist_confirmation(env: &Env, to: &str) -> Result<()> {
256 let (subject, letter) = waitlist_confirmation();
257 send(env, to, &subject, &letter).await
258}
259
260/// One access request, as a staff summary lists it.
261pub struct Requested {
262 pub email: String,
263 pub about: Option<String>,
264}
265
266/// The most requests one summary lists; the rest are counted.
267pub const SUMMARY_LISTS: usize = 20;
268
269/// The summary staff get of new access requests: every one since the last
270/// summary, and how many are waiting in all.
271pub fn waitlist_summary(new: &[Requested], waiting: u32) -> (String, Letter) {
272 let subject = match new {
273 [one] => format!("g1t access request from {}", one.email),
274 _ => format!("{} new g1t access requests", new.len()),
275 };
276 let asked = match new.len() {
277 1 => "Someone asked for access to g1t.".to_owned(),
278 n => format!("{n} people asked for access to g1t since the last summary."),
279 };
280 let in_all = if waiting as usize > new.len() {
281 format!(" {waiting} requests are waiting in all.")
282 } else {
283 String::new()
284 };
285 let mut quotes: Vec<(String, String)> = new
286 .iter()
287 .take(SUMMARY_LISTS)
288 .map(|request| {
289 (
290 request.email.clone(),
291 request
292 .about
293 .as_deref()
294 .map(str::trim)
295 .filter(|about| !about.is_empty())
296 .unwrap_or("(They did not say what they will build.)")
297 .to_owned(),
298 )
299 })
300 .collect();
301 if new.len() > SUMMARY_LISTS {
302 quotes.push(("And more".to_owned(), format!("{} more requests are on the waitlist.", new.len() - SUMMARY_LISTS)));
303 }
304 let letter = Letter {
305 paragraphs: vec![format!("{asked}{in_all}")],
306 quotes,
307 action: Some(("Review the waitlist".to_owned(), SUDO_WAITLIST.to_owned())),
308 footer: "Sent to WAITLIST_NOTIFY_EMAIL at most once every 15 minutes. A request that arrives in between is in the next summary, and every request is in sudo straight away.".to_owned(),
309 };
310 (subject, letter)
311}
312
313pub async fn send_waitlist_summary(env: &Env, to: &str, new: &[Requested], waiting: u32) -> Result<()> {
314 let (subject, letter) = waitlist_summary(new, waiting);
315 send(env, to, &subject, &letter).await
316}
317
318/// An invitation to collaborate on one repository. `code` is set when the
319/// address has no account yet: the link then makes one and accepts; without
320/// it, the link opens the invitation to accept or decline.
321pub async fn send_repo_invite(
322 env: &Env,
323 to: &str,
324 from: &str,
325 repo: &str,
326 role: &str,
327 code: Option<&str>,
328 days: u64,
329) -> Result<()> {
330 let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some());
331 let link = match code {
332 Some(code) => format!("{SITE}/invite/{code}"),
333 None => format!("{SITE}/{repo}/invitations"),
334 };
335 send_link(
336 env,
337 to,
338 &subject,
339 &intro,
340 "View invitation",
341 &link,
342 &format!("This invitation works for {days} days. If you were not expecting it, you can ignore this message."),
343 )
344 .await
345}
346
347/// The subject and first line of a repository invitation.
348pub fn repo_invite_wording(from: &str, repo: &str, role: &str, new_account: bool) -> (String, String) {
349 let subject = format!("{from} invited you to {repo} on g1t");
350 let intro = if new_account {
351 format!(
352 "{from} invited you to collaborate on {repo} on g1t, with the {role} role. Accepting makes your g1t account and gives you access to {repo}."
353 )
354 } else {
355 format!("{from} invited you to collaborate on {repo} on g1t, with the {role} role.")
356 };
357 (subject, intro)
358}
359
360/// The subject and first line of an invite email.
361pub fn invite_wording(from: Option<&str>, workspace: Option<&str>, joins_existing_account: bool) -> (String, String) {
362 let who = from.unwrap_or("The g1t team");
363 match (workspace, joins_existing_account) {
364 (Some(workspace), true) => (
365 format!("{who} invited you to {workspace} on g1t"),
366 format!("{who} invited you to join the {workspace} workspace on g1t."),
367 ),
368 (Some(workspace), false) => (
369 format!("{who} invited you to {workspace} on g1t"),
370 format!(
371 "{who} invited you to join the {workspace} workspace on g1t, where people and agents ship software together. Accepting makes your account and joins you to {workspace}."
372 ),
373 ),
374 (None, _) => (
375 match from {
376 Some(from) => format!("{from} invited you to g1t"),
377 None => "Your invite to g1t".to_owned(),
378 },
379 format!("{who} invited you to g1t, where people and agents ship software together. g1t is invite-only for now; this invite lets you make your account."),
380 ),
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn html_is_escaped() {
390 assert_eq!(
391 escape("<a href=\"x\">Tom & Jerry's</a>"),
392 "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;"
393 );
394 assert_eq!(escape("https://g1t.sh/verify?token=ab12"), "https://g1t.sh/verify?token=ab12");
395 }
396
397 #[test]
398 fn invites_say_who_sent_them_and_what_they_are_for() {
399 let (subject, intro) = invite_wording(Some("ada"), None, false);
400 assert_eq!(subject, "ada invited you to g1t");
401 assert!(intro.starts_with("ada invited you to g1t"));
402 let (subject, _) = invite_wording(None, None, false);
403 assert_eq!(subject, "Your invite to g1t");
404 let (subject, intro) = invite_wording(Some("ada"), Some("acme"), true);
405 assert_eq!(subject, "ada invited you to acme on g1t");
406 assert_eq!(intro, "ada invited you to join the acme workspace on g1t.");
407 let (_, intro) = invite_wording(Some("ada"), Some("acme"), false);
408 assert!(intro.contains("makes your account and joins you to acme"));
409 }
410
411 fn invite<'a>(note: Option<&'a str>, from: Option<&'a str>) -> InviteEmail<'a> {
412 InviteEmail {
413 to: "ada@example.com",
414 from,
415 workspace: Some("Flagon, Inc."),
416 joins_existing_account: false,
417 code: "g1t-abcd",
418 days: 30,
419 note,
420 }
421 }
422
423 #[test]
424 fn an_invite_links_to_its_page_and_carries_a_note() {
425 let (subject, letter) = invite_letter(&invite(Some("Welcome aboard <3"), None));
426 assert_eq!(subject, "The g1t team invited you to Flagon, Inc. on g1t");
427 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd");
428 assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]);
429 let (text, html) = render(&letter);
430 assert!(text.contains("> Welcome aboard <3"));
431 assert!(html.contains("Welcome aboard &lt;3"));
432 assert!(!html.contains("<3"));
433 // No note, no quote; a blank note is no note.
434 assert!(invite_letter(&invite(None, Some("Chase Pierce"))).1.quotes.is_empty());
435 assert!(invite_letter(&invite(Some(" "), Some("Chase Pierce"))).1.quotes.is_empty());
436 assert_eq!(invite_letter(&invite(Some("hi"), Some("Chase Pierce"))).1.quotes[0].0, "A note from Chase Pierce");
437 }
438
439 #[test]
440 fn the_waitlist_confirmation_promises_no_date() {
441 let (subject, letter) = waitlist_confirmation();
442 assert_eq!(subject, "You're on the list for g1t");
443 let (text, _) = render(&letter);
444 assert!(text.contains("we'll email you an invite"));
445 assert!(text.contains("can't say exactly when"));
446 assert!(letter.action.is_none());
447 }
448
449 #[test]
450 fn staff_summaries_list_each_request_and_link_to_sudo() {
451 let one = [Requested { email: "ada@example.com".into(), about: Some("A compiler <for> fun".into()) }];
452 let (subject, letter) = waitlist_summary(&one, 1);
453 assert_eq!(subject, "g1t access request from ada@example.com");
454 assert_eq!(letter.paragraphs, vec!["Someone asked for access to g1t."]);
455 assert_eq!(letter.action.as_ref().unwrap().1, SUDO_WAITLIST);
456 let (_, html) = render(&letter);
457 assert!(html.contains("A compiler &lt;for&gt; fun"));
458
459 let many: Vec<Requested> = (0..25).map(|n| Requested { email: format!("p{n}@example.com"), about: None }).collect();
460 let (subject, letter) = waitlist_summary(&many, 40);
461 assert_eq!(subject, "25 new g1t access requests");
462 assert_eq!(
463 letter.paragraphs[0],
464 "25 people asked for access to g1t since the last summary. 40 requests are waiting in all."
465 );
466 assert_eq!(letter.quotes.len(), SUMMARY_LISTS + 1);
467 assert_eq!(letter.quotes[0].1, "(They did not say what they will build.)");
468 assert!(letter.quotes.last().unwrap().1.starts_with("5 more"));
469 }
470
471 /// Writes each email as HTML for a look in a browser:
472 /// `G1T_WRITE_EMAILS=<dir> cargo test -p g1t-identity write_emails`.
473 #[test]
474 fn write_emails() {
475 let Ok(dir) = std::env::var("G1T_WRITE_EMAILS") else { return };
476 let page = |name: &str, subject: &str, letter: &Letter| {
477 let (_, html) = render(letter);
478 let html = html.replace(SITE, "https://g1t.sh");
479 std::fs::write(
480 format!("{dir}/{name}.html"),
481 format!("<!doctype html><meta charset=utf-8><title>{}</title><body style=\"margin:0;background:#fff\">{html}", escape(subject)),
482 )
483 .unwrap();
484 };
485 let (subject, letter) = waitlist_confirmation();
486 page("waitlist-confirmation", &subject, &letter);
487 let requests = [
488 Requested { email: "ada@example.com".into(), about: Some("A compiler for a teaching language, with agents writing the test suite.".into()) },
489 Requested { email: "linus@example.com".into(), about: None },
490 ];
491 let (subject, letter) = waitlist_summary(&requests, 7);
492 page("waitlist-summary", &subject, &letter);
493 let (subject, letter) = invite_letter(&InviteEmail {
494 to: "margaret@example.com",
495 from: Some("Chase Pierce"),
496 workspace: Some("Flagon, Inc."),
497 joins_existing_account: false,
498 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
499 days: 30,
500 note: None,
501 });
502 page("workspace-invite", &subject, &letter);
503 let (subject, letter) = invite_letter(&InviteEmail {
504 to: "ada@example.com",
505 from: None,
506 workspace: None,
507 joins_existing_account: false,
508 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
509 days: 30,
510 note: Some("Thanks for waiting. We would love to see the compiler."),
511 });
512 page("waitlist-approved", &subject, &letter);
513 }
514}