Skip to content

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.sh
739 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1//! 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
Merge branch 'worktree-agent-aaf03bdceac799c89'10/// Where links in mail point: SITE_URL, or g1t.sh when it is not set (a
11/// self-hosted installation sets it from its PUBLIC_URL).
12pub fn site(env: &Env) -> String {
13 let value = env.var("SITE_URL").map(|value| value.to_string()).unwrap_or_default();
14 let value = value.trim().trim_end_matches('/');
15 if value.is_empty() { SITE.to_owned() } else { value.to_owned() }
16}
17
18/// Who mail is from: MAIL_FROM, or g1t.sh's address when it is not set.
19fn from(env: &Env) -> String {
20 let value = env.var("MAIL_FROM").map(|value| value.to_string()).unwrap_or_default();
21 if value.trim().is_empty() { FROM.to_owned() } else { value.trim().to_owned() }
22}
23
24/// A site's address without its scheme, as mail names it in a sentence.
25fn bare(site: &str) -> &str {
26 site.split_once("://").map_or(site, |(_, rest)| rest)
27}
28
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas29#[derive(Serialize)]
30struct Message<'a> {
31 to: &'a str,
32 from: &'a str,
33 subject: &'a str,
34 text: String,
35 html: String,
36}
37
38/// What one email says, before it is laid out as text and HTML.
39#[derive(Debug, Default)]
40pub struct Letter {
41 pub paragraphs: Vec<String>,
42 /// Quoted passages, each with who or what it is from: a note from the
43 /// person who sent an invite, or what someone asking for access said.
44 pub quotes: Vec<(String, String)>,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)45 /// A code to type, shown large before the button, with the line that
46 /// leads from it to the button.
47 pub code: Option<Code>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas48 /// The button: what it says, and where it goes.
49 pub action: Option<(String, String)>,
50 pub footer: String,
51}
52
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)53/// A code in a letter, and what joins it to the button after it.
54#[derive(Debug, Default, PartialEq, Eq)]
55pub struct Code {
56 pub code: String,
57 /// Said between the code and the button, such as "or follow the link".
58 pub then: String,
59}
60
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas61/// The plain text and HTML of a letter. Everything in it is escaped:
62/// names, notes and requests people wrote can reach every line.
Merge branch 'worktree-agent-aaf03bdceac799c89'63pub fn render(letter: &Letter, site: &str) -> (String, String) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas64 let mut text = String::new();
65 let mut html = format!(
66 "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\
Merge branch 'worktree-agent-aaf03bdceac799c89'67 <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>"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas68 );
69 for paragraph in &letter.paragraphs {
70 text.push_str(paragraph);
71 text.push_str("\n\n");
72 html.push_str(&format!("<p style=\"font-size:15px;line-height:1.6\">{}</p>", escape(paragraph)));
73 }
74 for (from, quote) in &letter.quotes {
75 text.push_str(&format!("{from}:\n"));
76 for line in quote.lines() {
77 text.push_str(&format!("> {line}\n"));
78 }
79 text.push('\n');
80 html.push_str(&format!(
81 "<p style=\"margin:20px 0 6px;font-size:13px;color:#6e6a5e\">{}</p>\
82 <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>",
83 escape(from),
84 escape(quote)
85 ));
86 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)87 if let Some(code) = &letter.code {
88 text.push_str(&format!(" {}\n\n{}\n\n", code.code, code.then));
89 html.push_str(&format!(
90 "<p style=\"margin:24px 0;padding:16px 0;text-align:center;background:#f3f1ea;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:34px;font-weight:600;letter-spacing:10px;color:#16150f\">{}</p>\
91 <p style=\"font-size:15px;line-height:1.6\">{}</p>",
92 escape(&code.code),
93 escape(&code.then)
94 ));
95 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas96 if let Some((label, link)) = &letter.action {
97 text.push_str(&format!("{label}: {link}\n\n"));
98 html.push_str(&format!(
99 "<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>",
100 escape(link),
101 escape(label)
102 ));
103 }
104 text.push_str(&letter.footer);
105 text.push('\n');
106 html.push_str(&format!(
107 "<p style=\"font-size:13px;line-height:1.6;color:#6e6a5e\">{}</p></div>",
108 escape(&letter.footer)
109 ));
110 (text, html)
111}
112
113/// Sends a letter.
114pub async fn send(env: &Env, to: &str, subject: &str, letter: &Letter) -> Result<()> {
Merge branch 'worktree-agent-aaf03bdceac799c89'115 let (text, html) = render(letter, &site(env));
116 let from = from(env);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas117 let message = Message {
118 to,
Merge branch 'worktree-agent-aaf03bdceac799c89'119 from: &from,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas120 subject,
121 text,
122 html,
123 };
124 let binding = js::binding(env, "EMAIL")?;
125 js::call(&binding, "send", &[js::to_js(&message)?]).await?;
126 Ok(())
127}
128
129/// A short message with one link to follow.
130pub async fn send_link(
131 env: &Env,
132 to: &str,
133 subject: &str,
134 intro: &str,
135 action: &str,
136 link: &str,
137 footer: &str,
138) -> Result<()> {
139 let letter = Letter {
140 paragraphs: vec![intro.to_owned()],
141 quotes: Vec::new(),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)142 code: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas143 action: Some((action.to_owned(), link.to_owned())),
144 footer: footer.to_owned(),
145 };
146 send(env, to, subject, &letter).await
147}
148
149/// Text made safe to put in HTML, in an element or a quoted attribute.
150fn escape(text: &str) -> String {
151 let mut escaped = String::with_capacity(text.len());
152 for c in text.chars() {
153 match c {
154 '&' => escaped.push_str("&amp;"),
155 '<' => escaped.push_str("&lt;"),
156 '>' => escaped.push_str("&gt;"),
157 '"' => escaped.push_str("&quot;"),
158 '\'' => escaped.push_str("&#39;"),
159 c => escaped.push(c),
160 }
161 }
162 escaped
163}
164
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)165/// What a confirmation email is for.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum Confirming {
168 /// The address a new account signed up with (or changed to before
169 /// confirming any).
170 NewAccount,
171 /// An address added to an account in use.
172 AddedAddress,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas173}
174
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)175/// A confirmation email: the code to type, large, and the link that does
176/// the same, either one enough. The code leads the subject, so a phone's
177/// notification shows it.
178pub fn confirmation_letter(confirming: Confirming, username: &str, code: &str, link: &str) -> (String, Letter) {
179 let minutes = g1t_contracts::accounts::CONFIRM_TTL_SECONDS / 60;
180 let (intro, ignore) = match confirming {
181 Confirming::NewAccount => (
182 format!("Welcome to g1t, {username}. To finish creating your account, enter this code on the confirmation page:"),
183 "If you did not create a g1t account, you can ignore this message.",
184 ),
185 Confirming::AddedAddress => (
186 format!("To add this address to the g1t account {username}, enter this code on the confirmation page:"),
187 "If you did not add this address to a g1t account, you can ignore this message.",
188 ),
189 };
190 let letter = Letter {
191 paragraphs: vec![intro],
192 quotes: Vec::new(),
193 code: Some(Code {
194 code: code.to_owned(),
195 then: "Or skip the code and confirm with this link instead. Either one works; you need only one.".to_owned(),
196 }),
197 action: Some(("Confirm email".to_owned(), link.to_owned())),
198 footer: format!(
199 "The code and the link work for {minutes} minutes, and only once. Asking for a new email ends them both. {ignore}"
200 ),
201 };
202 (format!("{code} is your g1t confirmation code"), letter)
203}
204
205/// Sends a confirmation email with `code` and the link for `token`.
206pub async fn send_confirmation(env: &Env, to: &str, username: &str, confirming: Confirming, token: &str, code: &str) -> Result<()> {
207 let link = format!("{}/verify?token={token}", site(env));
208 let (subject, letter) = confirmation_letter(confirming, username, code, &link);
209 send(env, to, &subject, &letter).await
210}
211
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas212pub async fn send_password_reset(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
213 send_link(
214 env,
215 to,
216 "Reset your g1t password",
217 &format!("Someone asked to reset the password for the g1t account {username}."),
218 "Choose a new password",
Merge branch 'worktree-agent-aaf03bdceac799c89'219 &format!("{}/reset?token={token}", site(env)),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas220 "This link works for 1 hour. If this was not you, ignore this message and your password stays the same.",
221 )
222 .await
223}
224
225/// What a security notice says: one sentence about what changed.
226pub fn security_wording(username: &str, change: &str) -> (String, String) {
227 (
228 format!("Security notice for your g1t account {username}"),
229 format!("{change}. This is about your g1t account {username}."),
230 )
231}
232
233/// Tells an account's addresses that something about its security changed.
234pub async fn send_security_notice(env: &Env, to: &str, username: &str, change: &str) -> Result<()> {
235 let (subject, intro) = security_wording(username, change);
236 send_link(
237 env,
238 to,
239 &subject,
240 &intro,
241 "Review your email settings",
Merge branch 'worktree-agent-aaf03bdceac799c89'242 &format!("{}/settings/emails", site(env)),
243 &format!(
244 "If this was you, there is nothing to do. If it was not, reset your password at {}/forgot straight away and remove any address you do not recognise.",
245 bare(&site(env))
246 ),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas247 )
248 .await
249}
250
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)251/// What the email telling an account it was deleted says: its subject and
252/// first paragraph. `by_staff` when g1t's staff deleted it.
253pub fn deleted_wording(username: &str, by_staff: bool, days: u64) -> (String, String) {
254 let who = if by_staff { "g1t's staff deleted" } else { "You deleted" };
255 (
256 format!("Your g1t account {username} was deleted"),
257 format!(
258 "{who} your g1t account {username}. It has signed out everywhere, its access tokens and keys no longer work, and it has left every workspace. g1t keeps it for {days} days; after that it is removed for good."
259 ),
260 )
261}
262
263/// Tells an account's primary and backup addresses it was deleted, with how
264/// to ask for it back.
265pub async fn send_account_deleted(env: &Env, to: &str, username: &str, by_staff: bool, days: u64) -> Result<()> {
266 let (subject, intro) = deleted_wording(username, by_staff, days);
267 send_link(
268 env,
269 to,
270 &subject,
271 &intro,
272 "Contact support",
273 &format!("{}/support", site(env)),
274 &format!(
275 "If you did not mean to delete it, or did not delete it, write to support within {days} days and g1t can restore it."
276 ),
277 )
278 .await
279}
280
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas281/// An invite email: to make an account, or for an existing one to join a
282/// workspace.
283pub struct InviteEmail<'a> {
284 pub to: &'a str,
285 /// Who sent it (a name, or a username); None when g1t staff did.
286 pub from: Option<&'a str>,
287 /// The workspace it joins, by name.
288 pub workspace: Option<&'a str>,
289 pub joins_existing_account: bool,
290 pub code: &'a str,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm291 /// The proof that whoever follows the link reads this inbox
292 /// (invites.rs, `email_proof`): the account made from it starts with
293 /// the address confirmed. None for an invite to an existing account,
294 /// or without IDENTITY_KEY.
295 pub proof: Option<&'a str>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas296 pub days: u64,
297 /// A line from whoever sent it, such as staff approving a request.
298 pub note: Option<&'a str>,
299}
300
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm301/// An invite's page, as its email links to it: with the email's proof
302/// when it has one, so following it confirms the address (invites.rs).
303/// The code alone is what the inviter can see and share.
304pub fn invite_link(site: &str, code: &str, proof: Option<&str>) -> String {
305 match proof {
306 Some(proof) => format!("{site}/invite/{code}?proof={proof}"),
307 None => format!("{site}/invite/{code}"),
308 }
309}
310
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas311/// The subject and letter of an invite email.
Merge branch 'worktree-agent-aaf03bdceac799c89'312pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas313 let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account);
314 let action = match invite.workspace {
315 Some(workspace) if invite.joins_existing_account => format!("Join {workspace}"),
316 _ => "Accept invite".to_owned(),
317 };
318 let quotes = match invite.note.map(str::trim).filter(|note| !note.is_empty()) {
319 Some(note) => vec![(
320 match invite.from {
321 Some(from) => format!("A note from {from}"),
322 None => "A note from the g1t team".to_owned(),
323 },
324 note.to_owned(),
325 )],
326 None => Vec::new(),
327 };
328 let letter = Letter {
329 paragraphs: vec![intro],
330 quotes,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)331 code: None,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm332 action: Some((action, invite_link(site, invite.code, invite.proof))),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas333 footer: format!(
334 "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.",
335 invite.days
336 ),
337 };
338 (subject, letter)
339}
340
341pub async fn send_invite(env: &Env, invite: &InviteEmail<'_>) -> Result<()> {
Merge branch 'worktree-agent-aaf03bdceac799c89'342 let (subject, letter) = invite_letter(invite, &site(env));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas343 send(env, invite.to, &subject, &letter).await
344}
345
346/// Where staff decide on access requests.
347pub const SUDO_WAITLIST: &str = "https://sudo.g1t.sh/invites?tab=waitlist";
348
349/// The one confirmation someone gets after asking for access.
Merge branch 'worktree-agent-aaf03bdceac799c89'350pub fn waitlist_confirmation(site: &str) -> (String, Letter) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas351 (
352 "You're on the list for g1t".to_owned(),
353 Letter {
354 paragraphs: vec![
355 "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(),
356 "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(),
357 ],
358 quotes: Vec::new(),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)359 code: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas360 action: None,
Merge branch 'worktree-agent-aaf03bdceac799c89'361 footer: format!("You're getting this because this address asked for access at {}/register. If that wasn't you, ignore this message; nothing more is sent unless you're invited.", bare(site)),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas362 },
363 )
364}
365
366pub async fn send_waitlist_confirmation(env: &Env, to: &str) -> Result<()> {
Merge branch 'worktree-agent-aaf03bdceac799c89'367 let (subject, letter) = waitlist_confirmation(&site(env));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas368 send(env, to, &subject, &letter).await
369}
370
371/// One access request, as a staff summary lists it.
372pub struct Requested {
373 pub email: String,
374 pub about: Option<String>,
375}
376
377/// The most requests one summary lists; the rest are counted.
378pub const SUMMARY_LISTS: usize = 20;
379
380/// The summary staff get of new access requests: every one since the last
381/// summary, and how many are waiting in all.
382pub fn waitlist_summary(new: &[Requested], waiting: u32) -> (String, Letter) {
383 let subject = match new {
384 [one] => format!("g1t access request from {}", one.email),
385 _ => format!("{} new g1t access requests", new.len()),
386 };
387 let asked = match new.len() {
388 1 => "Someone asked for access to g1t.".to_owned(),
389 n => format!("{n} people asked for access to g1t since the last summary."),
390 };
391 let in_all = if waiting as usize > new.len() {
392 format!(" {waiting} requests are waiting in all.")
393 } else {
394 String::new()
395 };
396 let mut quotes: Vec<(String, String)> = new
397 .iter()
398 .take(SUMMARY_LISTS)
399 .map(|request| {
400 (
401 request.email.clone(),
402 request
403 .about
404 .as_deref()
405 .map(str::trim)
406 .filter(|about| !about.is_empty())
407 .unwrap_or("(They did not say what they will build.)")
408 .to_owned(),
409 )
410 })
411 .collect();
412 if new.len() > SUMMARY_LISTS {
413 quotes.push(("And more".to_owned(), format!("{} more requests are on the waitlist.", new.len() - SUMMARY_LISTS)));
414 }
415 let letter = Letter {
416 paragraphs: vec![format!("{asked}{in_all}")],
417 quotes,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)418 code: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas419 action: Some(("Review the waitlist".to_owned(), SUDO_WAITLIST.to_owned())),
420 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(),
421 };
422 (subject, letter)
423}
424
425pub async fn send_waitlist_summary(env: &Env, to: &str, new: &[Requested], waiting: u32) -> Result<()> {
426 let (subject, letter) = waitlist_summary(new, waiting);
427 send(env, to, &subject, &letter).await
428}
429
430/// An invitation to collaborate on one repository. `code` is set when the
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm431/// address has no account yet: the link then makes one and accepts, with
432/// `proof` (see [`invite_link`]); without it, the link opens the
433/// invitation to accept or decline.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas434pub async fn send_repo_invite(
435 env: &Env,
436 to: &str,
437 from: &str,
438 repo: &str,
439 role: &str,
440 code: Option<&str>,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm441 proof: Option<&str>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas442 days: u64,
443) -> Result<()> {
444 let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some());
445 let link = match code {
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm446 Some(code) => invite_link(&site(env), code, proof),
Merge branch 'worktree-agent-aaf03bdceac799c89'447 None => format!("{}/{repo}/invitations", site(env)),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas448 };
449 send_link(
450 env,
451 to,
452 &subject,
453 &intro,
454 "View invitation",
455 &link,
456 &format!("This invitation works for {days} days. If you were not expecting it, you can ignore this message."),
457 )
458 .await
459}
460
Inbox: threads, reasons, subscriptions and watching461/// Why a person is emailed about an inbox item, as the end of a sentence.
462fn notified_because(reason: g1t_contracts::inbox::Reason) -> &'static str {
463 use g1t_contracts::inbox::Reason;
464 match reason {
465 Reason::Agent => "an agent is waiting on you",
466 Reason::ReviewRequested => "you were asked to review",
467 Reason::Assign => "you were assigned",
468 Reason::Mention => "you were mentioned",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar469 Reason::TeamMention => "a team you are in was mentioned",
Inbox: threads, reasons, subscriptions and watching470 Reason::CiActivity => "it is about your work",
471 Reason::SecurityAlert => "you look after this repository's security",
472 Reason::StateChange => "you are subscribed to it",
473 Reason::Author => "you opened it",
474 Reason::Comment => "you commented on it",
475 Reason::Manual => "you subscribed to it",
476 Reason::Subscribed => "you watch this repository",
477 }
478}
479
480/// An item from the inbox, by email: what happened, what it happened to,
481/// and a link to it.
482pub fn notification_letter(a: &g1t_contracts::inbox::NotifyByEmailArgs, site: &str) -> Letter {
483 let path = if a.path.starts_with('/') { a.path.clone() } else { format!("/{}", a.path) };
484 Letter {
485 paragraphs: [a.subject.trim(), a.intro.trim()]
486 .into_iter()
487 .filter(|line| !line.is_empty())
488 .map(str::to_owned)
489 .collect(),
490 quotes: a.quote.iter().cloned().collect(),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)491 code: None,
Inbox: threads, reasons, subscriptions and watching492 action: Some(("Open on g1t".to_owned(), format!("{site}{path}"))),
493 footer: format!(
494 "You are getting this because {}. Choose what you are emailed for at {site}/settings/notifications.",
495 notified_because(a.reason)
496 ),
497 }
498}
499
500pub async fn send_notification(env: &Env, to: &str, a: &g1t_contracts::inbox::NotifyByEmailArgs) -> Result<()> {
501 let letter = notification_letter(a, &site(env));
502 send(env, to, &a.subject, &letter).await
503}
504
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas505/// The subject and first line of a repository invitation.
506pub fn repo_invite_wording(from: &str, repo: &str, role: &str, new_account: bool) -> (String, String) {
507 let subject = format!("{from} invited you to {repo} on g1t");
508 let intro = if new_account {
509 format!(
510 "{from} invited you to collaborate on {repo} on g1t, with the {role} role. Accepting makes your g1t account and gives you access to {repo}."
511 )
512 } else {
513 format!("{from} invited you to collaborate on {repo} on g1t, with the {role} role.")
514 };
515 (subject, intro)
516}
517
518/// The subject and first line of an invite email.
519pub fn invite_wording(from: Option<&str>, workspace: Option<&str>, joins_existing_account: bool) -> (String, String) {
520 let who = from.unwrap_or("The g1t team");
521 match (workspace, joins_existing_account) {
522 (Some(workspace), true) => (
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)523 format!("{who} invited you to join {workspace} on g1t"),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas524 format!("{who} invited you to join the {workspace} workspace on g1t."),
525 ),
526 (Some(workspace), false) => (
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)527 format!("{who} invited you to join {workspace} on g1t"),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas528 format!(
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)529 "{who} invited you to join the {workspace} workspace on g1t, where people and agents ship software together. You do not have a g1t account yet, so this invitation also lets you make one; then you accept or decline joining {workspace}."
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas530 ),
531 ),
532 (None, _) => (
533 match from {
534 Some(from) => format!("{from} invited you to g1t"),
535 None => "Your invite to g1t".to_owned(),
536 },
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)537 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. It does not add you to anyone's workspace: your account starts with a workspace of its own."),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas538 ),
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn html_is_escaped() {
548 assert_eq!(
549 escape("<a href=\"x\">Tom & Jerry's</a>"),
550 "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;"
551 );
552 assert_eq!(escape("https://g1t.sh/verify?token=ab12"), "https://g1t.sh/verify?token=ab12");
553 }
554
555 #[test]
Inbox: threads, reasons, subscriptions and watching556 fn an_inbox_item_says_what_happened_and_why_it_was_sent() {
557 let a = g1t_contracts::inbox::NotifyByEmailArgs {
558 username: "ana".into(),
559 repo_id: "rep_1".into(),
560 subject: "bo asked you to review acme/rocket#7".into(),
561 intro: "Add the inbox".into(),
562 quote: None,
563 path: "/acme/rocket/pull/7".into(),
564 reason: g1t_contracts::inbox::Reason::ReviewRequested,
565 };
566 let letter = notification_letter(&a, SITE);
567 assert_eq!(letter.paragraphs, vec!["bo asked you to review acme/rocket#7", "Add the inbox"]);
568 assert_eq!(letter.action.unwrap().1, "https://g1t.sh/acme/rocket/pull/7");
569 assert!(letter.footer.starts_with("You are getting this because you were asked to review."));
570 assert!(letter.footer.ends_with("https://g1t.sh/settings/notifications."));
571 }
572
573 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas574 fn invites_say_who_sent_them_and_what_they_are_for() {
575 let (subject, intro) = invite_wording(Some("ada"), None, false);
576 assert_eq!(subject, "ada invited you to g1t");
577 assert!(intro.starts_with("ada invited you to g1t"));
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)578 assert!(intro.contains("does not add you to anyone's workspace"));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas579 let (subject, _) = invite_wording(None, None, false);
580 assert_eq!(subject, "Your invite to g1t");
581 let (subject, intro) = invite_wording(Some("ada"), Some("acme"), true);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)582 assert_eq!(subject, "ada invited you to join acme on g1t");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas583 assert_eq!(intro, "ada invited you to join the acme workspace on g1t.");
584 let (_, intro) = invite_wording(Some("ada"), Some("acme"), false);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)585 assert!(intro.contains("also lets you make one; then you accept or decline joining acme"));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas586 }
587
588 fn invite<'a>(note: Option<&'a str>, from: Option<&'a str>) -> InviteEmail<'a> {
589 InviteEmail {
590 to: "ada@example.com",
591 from,
592 workspace: Some("Flagon, Inc."),
593 joins_existing_account: false,
594 code: "g1t-abcd",
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm595 proof: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas596 days: 30,
597 note,
598 }
599 }
600
601 #[test]
602 fn an_invite_links_to_its_page_and_carries_a_note() {
Merge branch 'worktree-agent-aaf03bdceac799c89'603 let (subject, letter) = invite_letter(&invite(Some("Welcome aboard <3"), None), SITE);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)604 assert_eq!(subject, "The g1t team invited you to join Flagon, Inc. on g1t");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas605 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd");
606 assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]);
Merge branch 'worktree-agent-aaf03bdceac799c89'607 let (text, html) = render(&letter, SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas608 assert!(text.contains("> Welcome aboard <3"));
609 assert!(html.contains("Welcome aboard &lt;3"));
610 assert!(!html.contains("<3"));
611 // No note, no quote; a blank note is no note.
Merge branch 'worktree-agent-aaf03bdceac799c89'612 assert!(invite_letter(&invite(None, Some("Chase Pierce")), SITE).1.quotes.is_empty());
613 assert!(invite_letter(&invite(Some(" "), Some("Chase Pierce")), SITE).1.quotes.is_empty());
614 assert_eq!(invite_letter(&invite(Some("hi"), Some("Chase Pierce")), SITE).1.quotes[0].0, "A note from Chase Pierce");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas615 }
616
617 #[test]
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm618 fn an_invite_link_carries_the_emails_proof_when_it_has_one() {
619 let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) };
620 let (_, letter) = invite_letter(&proven, SITE);
621 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a");
622 // An invite sent before proofs, or for an existing account: the code alone.
623 assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd");
624 }
625
626 #[test]
Merge branch 'worktree-agent-aaf03bdceac799c89'627 fn links_point_at_the_site_they_are_given() {
628 let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787");
629 assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd");
630 let (text, html) = render(&letter, "http://localhost:8787");
631 assert!(html.contains("http://localhost:8787/brand/g1t-logo.png"));
632 assert!(!text.contains("g1t.sh/") && !html.contains("https://g1t.sh"));
633 let (_, letter) = waitlist_confirmation("http://localhost:8787");
634 assert!(letter.footer.contains("localhost:8787/register"));
635 assert_eq!(bare("https://git.example.com"), "git.example.com");
636 }
637
638 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas639 fn the_waitlist_confirmation_promises_no_date() {
Merge branch 'worktree-agent-aaf03bdceac799c89'640 let (subject, letter) = waitlist_confirmation(SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas641 assert_eq!(subject, "You're on the list for g1t");
Merge branch 'worktree-agent-aaf03bdceac799c89'642 let (text, _) = render(&letter, SITE);
643 assert!(text.contains("g1t.sh/register"));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas644 assert!(text.contains("we'll email you an invite"));
645 assert!(text.contains("can't say exactly when"));
646 assert!(letter.action.is_none());
647 }
648
649 #[test]
650 fn staff_summaries_list_each_request_and_link_to_sudo() {
651 let one = [Requested { email: "ada@example.com".into(), about: Some("A compiler <for> fun".into()) }];
652 let (subject, letter) = waitlist_summary(&one, 1);
653 assert_eq!(subject, "g1t access request from ada@example.com");
654 assert_eq!(letter.paragraphs, vec!["Someone asked for access to g1t."]);
655 assert_eq!(letter.action.as_ref().unwrap().1, SUDO_WAITLIST);
Merge branch 'worktree-agent-aaf03bdceac799c89'656 let (_, html) = render(&letter, SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas657 assert!(html.contains("A compiler &lt;for&gt; fun"));
658
659 let many: Vec<Requested> = (0..25).map(|n| Requested { email: format!("p{n}@example.com"), about: None }).collect();
660 let (subject, letter) = waitlist_summary(&many, 40);
661 assert_eq!(subject, "25 new g1t access requests");
662 assert_eq!(
663 letter.paragraphs[0],
664 "25 people asked for access to g1t since the last summary. 40 requests are waiting in all."
665 );
666 assert_eq!(letter.quotes.len(), SUMMARY_LISTS + 1);
667 assert_eq!(letter.quotes[0].1, "(They did not say what they will build.)");
668 assert!(letter.quotes.last().unwrap().1.starts_with("5 more"));
669 }
670
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)671 #[test]
672 fn a_confirmation_email_shows_the_code_large_and_the_link_as_the_other_way() {
673 let (subject, letter) = confirmation_letter(Confirming::NewAccount, "ada", "482913", "https://g1t.sh/verify?token=ab12");
674 assert_eq!(subject, "482913 is your g1t confirmation code");
675 assert!(letter.paragraphs[0].starts_with("Welcome to g1t, ada."));
676 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/verify?token=ab12");
677 assert!(letter.footer.contains("60 minutes, and only once"));
678 let (text, html) = render(&letter, SITE);
679 // The code comes before the link, and says either one works.
680 let code_at = text.find(" 482913").unwrap();
681 let link_at = text.find("Confirm email: https://g1t.sh/verify?token=ab12").unwrap();
682 assert!(code_at < link_at);
683 assert!(text.contains("Either one works"));
684 assert!(html.contains("font-size:34px"));
685 assert!(html.contains(">482913</p>"));
686 assert!(html.find("482913").unwrap() < html.find("verify?token=ab12").unwrap());
687 let (_, added) = confirmation_letter(Confirming::AddedAddress, "ada", "000001", "x");
688 assert!(added.paragraphs[0].contains("add this address to the g1t account ada"));
689 assert!(added.footer.contains("did not add this address"));
690 }
691
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas692 /// Writes each email as HTML for a look in a browser:
693 /// `G1T_WRITE_EMAILS=<dir> cargo test -p g1t-identity write_emails`.
694 #[test]
695 fn write_emails() {
696 let Ok(dir) = std::env::var("G1T_WRITE_EMAILS") else { return };
697 let page = |name: &str, subject: &str, letter: &Letter| {
Merge branch 'worktree-agent-aaf03bdceac799c89'698 let (_, html) = render(letter, SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas699 std::fs::write(
700 format!("{dir}/{name}.html"),
701 format!("<!doctype html><meta charset=utf-8><title>{}</title><body style=\"margin:0;background:#fff\">{html}", escape(subject)),
702 )
703 .unwrap();
704 };
Merge branch 'worktree-agent-aaf03bdceac799c89'705 let (subject, letter) = waitlist_confirmation(SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas706 page("waitlist-confirmation", &subject, &letter);
707 let requests = [
708 Requested { email: "ada@example.com".into(), about: Some("A compiler for a teaching language, with agents writing the test suite.".into()) },
709 Requested { email: "linus@example.com".into(), about: None },
710 ];
711 let (subject, letter) = waitlist_summary(&requests, 7);
712 page("waitlist-summary", &subject, &letter);
713 let (subject, letter) = invite_letter(&InviteEmail {
714 to: "margaret@example.com",
715 from: Some("Chase Pierce"),
716 workspace: Some("Flagon, Inc."),
717 joins_existing_account: false,
718 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm719 proof: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas720 days: 30,
721 note: None,
Merge branch 'worktree-agent-aaf03bdceac799c89'722 }, SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas723 page("workspace-invite", &subject, &letter);
724 let (subject, letter) = invite_letter(&InviteEmail {
725 to: "ada@example.com",
726 from: None,
727 workspace: None,
728 joins_existing_account: false,
729 code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk",
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm730 proof: None,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas731 days: 30,
732 note: Some("Thanks for waiting. We would love to see the compiler."),
Merge branch 'worktree-agent-aaf03bdceac799c89'733 }, SITE);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas734 page("waitlist-approved", &subject, &letter);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)735 let (subject, letter) =
736 confirmation_letter(Confirming::NewAccount, "ada", "482913", "https://g1t.sh/verify?token=4f9c2a7e0b13d5c8");
737 page("confirm-email", &subject, &letter);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas738 }
739}

This file's history is long; its oldest lines are credited to the oldest commit read.