Skip to content
620 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.

Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1//! 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/// 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
29#[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>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running48 /// 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
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running61/// The plain text and HTML of a letter. Everything in it is escaped:
62/// names, notes and requests people wrote can reach every line.
63pub fn render(letter: &Letter, site: &str) -> (String, String) {
64 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\">\
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>"
68 );
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 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running96 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<()> {
115 let (text, html) = render(letter, &site(env));
116 let from = from(env);
117 let message = Message {
118 to,
119 from: &from,
120 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,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running143 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,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running173}
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
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running212pub 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",
219 &format!("{}/reset?token={token}", site(env)),
220 "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",
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 ),
247 )
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
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running281/// 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>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running296 pub days: u64,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.297 /// A line from whoever sent it.
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running298 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
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running311/// The subject and letter of an invite email.
312pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) {
313 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}"),
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.316 Some(_) => "Accept invite".to_owned(),
317 // An invite to g1t from a friend: the link opens sign-up.
318 None => "Sign up for g1t".to_owned(),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running319 };
320 let quotes = match invite.note.map(str::trim).filter(|note| !note.is_empty()) {
321 Some(note) => vec![(
322 match invite.from {
323 Some(from) => format!("A note from {from}"),
324 None => "A note from the g1t team".to_owned(),
325 },
326 note.to_owned(),
327 )],
328 None => Vec::new(),
329 };
330 let letter = Letter {
331 paragraphs: vec![intro],
332 quotes,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)333 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 confirm334 action: Some((action, invite_link(site, invite.code, invite.proof))),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running335 footer: format!(
336 "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.",
337 invite.days
338 ),
339 };
340 (subject, letter)
341}
342
343pub async fn send_invite(env: &Env, invite: &InviteEmail<'_>) -> Result<()> {
344 let (subject, letter) = invite_letter(invite, &site(env));
345 send(env, invite.to, &subject, &letter).await
346}
347
348/// 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 confirm349/// address has no account yet: the link then makes one and accepts, with
350/// `proof` (see [`invite_link`]); without it, the link opens the
351/// invitation to accept or decline.
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running352pub async fn send_repo_invite(
353 env: &Env,
354 to: &str,
355 from: &str,
356 repo: &str,
357 role: &str,
358 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 confirm359 proof: Option<&str>,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running360 days: u64,
361) -> Result<()> {
362 let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some());
363 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 confirm364 Some(code) => invite_link(&site(env), code, proof),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running365 None => format!("{}/{repo}/invitations", site(env)),
366 };
367 send_link(
368 env,
369 to,
370 &subject,
371 &intro,
372 "View invitation",
373 &link,
374 &format!("This invitation works for {days} days. If you were not expecting it, you can ignore this message."),
375 )
376 .await
377}
378
Inbox: threads, reasons, subscriptions and watching379/// Why a person is emailed about an inbox item, as the end of a sentence.
380fn notified_because(reason: g1t_contracts::inbox::Reason) -> &'static str {
381 use g1t_contracts::inbox::Reason;
382 match reason {
383 Reason::Agent => "an agent is waiting on you",
384 Reason::ReviewRequested => "you were asked to review",
385 Reason::Assign => "you were assigned",
386 Reason::Mention => "you were mentioned",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar387 Reason::TeamMention => "a team you are in was mentioned",
Inbox: threads, reasons, subscriptions and watching388 Reason::CiActivity => "it is about your work",
389 Reason::SecurityAlert => "you look after this repository's security",
390 Reason::StateChange => "you are subscribed to it",
391 Reason::Author => "you opened it",
392 Reason::Comment => "you commented on it",
393 Reason::Manual => "you subscribed to it",
394 Reason::Subscribed => "you watch this repository",
395 }
396}
397
398/// An item from the inbox, by email: what happened, what it happened to,
399/// and a link to it.
400pub fn notification_letter(a: &g1t_contracts::inbox::NotifyByEmailArgs, site: &str) -> Letter {
401 let path = if a.path.starts_with('/') { a.path.clone() } else { format!("/{}", a.path) };
402 Letter {
403 paragraphs: [a.subject.trim(), a.intro.trim()]
404 .into_iter()
405 .filter(|line| !line.is_empty())
406 .map(str::to_owned)
407 .collect(),
408 quotes: a.quote.iter().cloned().collect(),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)409 code: None,
Inbox: threads, reasons, subscriptions and watching410 action: Some(("Open on g1t".to_owned(), format!("{site}{path}"))),
411 footer: format!(
412 "You are getting this because {}. Choose what you are emailed for at {site}/settings/notifications.",
413 notified_because(a.reason)
414 ),
415 }
416}
417
418pub async fn send_notification(env: &Env, to: &str, a: &g1t_contracts::inbox::NotifyByEmailArgs) -> Result<()> {
419 let letter = notification_letter(a, &site(env));
420 send(env, to, &a.subject, &letter).await
421}
422
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running423/// The subject and first line of a repository invitation.
424pub fn repo_invite_wording(from: &str, repo: &str, role: &str, new_account: bool) -> (String, String) {
425 let subject = format!("{from} invited you to {repo} on g1t");
426 let intro = if new_account {
427 format!(
428 "{from} invited you to collaborate on {repo} on g1t, with the {role} role. Accepting makes your g1t account and gives you access to {repo}."
429 )
430 } else {
431 format!("{from} invited you to collaborate on {repo} on g1t, with the {role} role.")
432 };
433 (subject, intro)
434}
435
436/// The subject and first line of an invite email.
437pub fn invite_wording(from: Option<&str>, workspace: Option<&str>, joins_existing_account: bool) -> (String, String) {
438 let who = from.unwrap_or("The g1t team");
439 match (workspace, joins_existing_account) {
440 (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)441 format!("{who} invited you to join {workspace} on g1t"),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running442 format!("{who} invited you to join the {workspace} workspace on g1t."),
443 ),
444 (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)445 format!("{who} invited you to join {workspace} on g1t"),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running446 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)447 "{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}."
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running448 ),
449 ),
450 (None, _) => (
451 match from {
452 Some(from) => format!("{from} invited you to g1t"),
453 None => "Your invite to g1t".to_owned(),
454 },
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.455 format!("{who} invited you to g1t, where people and agents ship software together. The link opens sign-up with this address filled in and confirmed, so you can start in a minute. It does not add you to anyone's workspace: your account starts with a workspace of its own."),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running456 ),
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn html_is_escaped() {
466 assert_eq!(
467 escape("<a href=\"x\">Tom & Jerry's</a>"),
468 "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;"
469 );
470 assert_eq!(escape("https://g1t.sh/verify?token=ab12"), "https://g1t.sh/verify?token=ab12");
471 }
472
473 #[test]
Inbox: threads, reasons, subscriptions and watching474 fn an_inbox_item_says_what_happened_and_why_it_was_sent() {
475 let a = g1t_contracts::inbox::NotifyByEmailArgs {
476 username: "ana".into(),
477 repo_id: "rep_1".into(),
478 subject: "bo asked you to review acme/rocket#7".into(),
479 intro: "Add the inbox".into(),
480 quote: None,
481 path: "/acme/rocket/pull/7".into(),
482 reason: g1t_contracts::inbox::Reason::ReviewRequested,
483 };
484 let letter = notification_letter(&a, SITE);
485 assert_eq!(letter.paragraphs, vec!["bo asked you to review acme/rocket#7", "Add the inbox"]);
486 assert_eq!(letter.action.unwrap().1, "https://g1t.sh/acme/rocket/pull/7");
487 assert!(letter.footer.starts_with("You are getting this because you were asked to review."));
488 assert!(letter.footer.ends_with("https://g1t.sh/settings/notifications."));
489 }
490
491 #[test]
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running492 fn invites_say_who_sent_them_and_what_they_are_for() {
493 let (subject, intro) = invite_wording(Some("ada"), None, false);
494 assert_eq!(subject, "ada invited you to g1t");
495 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)496 assert!(intro.contains("does not add you to anyone's workspace"));
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.497 // Never a gate: the link opens sign-up with the address confirmed.
498 assert!(intro.contains("filled in and confirmed"));
499 assert!(!intro.to_lowercase().contains("invite-only"));
500 let friend = InviteEmail { workspace: None, ..invite(None, Some("ada")) };
501 let (_, letter) = invite_letter(&friend, SITE);
502 assert_eq!(letter.action.unwrap().0, "Sign up for g1t");
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running503 let (subject, _) = invite_wording(None, None, false);
504 assert_eq!(subject, "Your invite to g1t");
505 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)506 assert_eq!(subject, "ada invited you to join acme on g1t");
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running507 assert_eq!(intro, "ada invited you to join the acme workspace on g1t.");
508 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)509 assert!(intro.contains("also lets you make one; then you accept or decline joining acme"));
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running510 }
511
512 fn invite<'a>(note: Option<&'a str>, from: Option<&'a str>) -> InviteEmail<'a> {
513 InviteEmail {
514 to: "ada@example.com",
515 from,
516 workspace: Some("Flagon, Inc."),
517 joins_existing_account: false,
518 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 confirm519 proof: None,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running520 days: 30,
521 note,
522 }
523 }
524
525 #[test]
526 fn an_invite_links_to_its_page_and_carries_a_note() {
527 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)528 assert_eq!(subject, "The g1t team invited you to join Flagon, Inc. on g1t");
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running529 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd");
530 assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]);
531 let (text, html) = render(&letter, SITE);
532 assert!(text.contains("> Welcome aboard <3"));
533 assert!(html.contains("Welcome aboard &lt;3"));
534 assert!(!html.contains("<3"));
535 // No note, no quote; a blank note is no note.
536 assert!(invite_letter(&invite(None, Some("Chase Pierce")), SITE).1.quotes.is_empty());
537 assert!(invite_letter(&invite(Some(" "), Some("Chase Pierce")), SITE).1.quotes.is_empty());
538 assert_eq!(invite_letter(&invite(Some("hi"), Some("Chase Pierce")), SITE).1.quotes[0].0, "A note from Chase Pierce");
539 }
540
541 #[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 confirm542 fn an_invite_link_carries_the_emails_proof_when_it_has_one() {
543 let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) };
544 let (_, letter) = invite_letter(&proven, SITE);
545 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a");
546 // An invite sent before proofs, or for an existing account: the code alone.
547 assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd");
548 }
549
550 #[test]
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running551 fn links_point_at_the_site_they_are_given() {
552 let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787");
553 assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd");
554 let (text, html) = render(&letter, "http://localhost:8787");
555 assert!(html.contains("http://localhost:8787/brand/g1t-logo.png"));
556 assert!(!text.contains("g1t.sh/") && !html.contains("https://g1t.sh"));
557 assert_eq!(bare("https://git.example.com"), "git.example.com");
558 }
559
560 #[test]
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)561 fn a_confirmation_email_shows_the_code_large_and_the_link_as_the_other_way() {
562 let (subject, letter) = confirmation_letter(Confirming::NewAccount, "ada", "482913", "https://g1t.sh/verify?token=ab12");
563 assert_eq!(subject, "482913 is your g1t confirmation code");
564 assert!(letter.paragraphs[0].starts_with("Welcome to g1t, ada."));
565 assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/verify?token=ab12");
566 assert!(letter.footer.contains("60 minutes, and only once"));
567 let (text, html) = render(&letter, SITE);
568 // The code comes before the link, and says either one works.
569 let code_at = text.find(" 482913").unwrap();
570 let link_at = text.find("Confirm email: https://g1t.sh/verify?token=ab12").unwrap();
571 assert!(code_at < link_at);
572 assert!(text.contains("Either one works"));
573 assert!(html.contains("font-size:34px"));
574 assert!(html.contains(">482913</p>"));
575 assert!(html.find("482913").unwrap() < html.find("verify?token=ab12").unwrap());
576 let (_, added) = confirmation_letter(Confirming::AddedAddress, "ada", "000001", "x");
577 assert!(added.paragraphs[0].contains("add this address to the g1t account ada"));
578 assert!(added.footer.contains("did not add this address"));
579 }
580
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running581 /// Writes each email as HTML for a look in a browser:
582 /// `G1T_WRITE_EMAILS=<dir> cargo test -p g1t-identity write_emails`.
583 #[test]
584 fn write_emails() {
585 let Ok(dir) = std::env::var("G1T_WRITE_EMAILS") else { return };
586 let page = |name: &str, subject: &str, letter: &Letter| {
587 let (_, html) = render(letter, SITE);
588 std::fs::write(
589 format!("{dir}/{name}.html"),
590 format!("<!doctype html><meta charset=utf-8><title>{}</title><body style=\"margin:0;background:#fff\">{html}", escape(subject)),
591 )
592 .unwrap();
593 };
594 let (subject, letter) = invite_letter(&InviteEmail {
595 to: "margaret@example.com",
596 from: Some("Chase Pierce"),
597 workspace: Some("Flagon, Inc."),
598 joins_existing_account: false,
599 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 confirm600 proof: None,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running601 days: 30,
602 note: None,
603 }, SITE);
604 page("workspace-invite", &subject, &letter);
605 let (subject, letter) = invite_letter(&InviteEmail {
606 to: "ada@example.com",
607 from: None,
608 workspace: None,
609 joins_existing_account: false,
610 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 confirm611 proof: None,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running612 days: 30,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.613 note: Some("We would love to see the compiler."),
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running614 }, SITE);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.615 page("staff-invite", &subject, &letter);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)616 let (subject, letter) =
617 confirmation_letter(Confirming::NewAccount, "ada", "482913", "https://g1t.sh/verify?token=4f9c2a7e0b13d5c8");
618 page("confirm-email", &subject, &letter);
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running619 }
620}

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