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 running | 1 | //! Transactional email through Cloudflare Email Sending. |
| 2 | ||
| 3 | use g1t_kit::js; | |
| 4 | use serde::Serialize; | |
| 5 | use worker::{Env, Result}; | |
| 6 | ||
| 7 | const FROM: &str = "g1t <noreply@g1t.sh>"; | |
| 8 | const 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). | |
| 12 | pub 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. | |
| 19 | fn 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. | |
| 25 | fn bare(site: &str) -> &str { | |
| 26 | site.split_once("://").map_or(site, |(_, rest)| rest) | |
| 27 | } | |
| 28 | ||
| 29 | #[derive(Serialize)] | |
| 30 | struct 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)] | |
| 40 | pub 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 running | 48 | /// 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)] | |
| 55 | pub 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 running | 61 | /// The plain text and HTML of a letter. Everything in it is escaped: |
| 62 | /// names, notes and requests people wrote can reach every line. | |
| 63 | pub 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 running | 96 | 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. | |
| 114 | pub 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. | |
| 130 | pub 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 running | 143 | 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. | |
| 150 | fn 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("&"), | |
| 155 | '<' => escaped.push_str("<"), | |
| 156 | '>' => escaped.push_str(">"), | |
| 157 | '"' => escaped.push_str("""), | |
| 158 | '\'' => escaped.push_str("'"), | |
| 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)] | |
| 167 | pub 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 running | 173 | } |
| 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. | |
| 178 | pub 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`. | |
| 206 | pub 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 running | 212 | pub 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. | |
| 226 | pub 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. | |
| 234 | pub 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. | |
| 253 | pub 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. | |
| 265 | pub 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 running | 281 | /// An invite email: to make an account, or for an existing one to join a |
| 282 | /// workspace. | |
| 283 | pub 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 confirm | 291 | /// 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 running | 296 | 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 running | 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 confirm | 301 | /// 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. | |
| 304 | pub 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 running | 311 | /// The subject and letter of an invite email. |
| 312 | pub 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 running | 319 | }; |
| 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 confirm | 334 | 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 running | 335 | 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 | ||
| 343 | pub 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 confirm | 349 | /// 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 running | 352 | pub 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 confirm | 359 | 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 running | 360 | 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 confirm | 364 | 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 running | 365 | 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 watching | 379 | /// Why a person is emailed about an inbox item, as the end of a sentence. |
| 380 | fn 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 bar | 387 | Reason::TeamMention => "a team you are in was mentioned", |
| Inbox: threads, reasons, subscriptions and watching | 388 | 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. | |
| 400 | pub 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 watching | 410 | 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 | ||
| 418 | pub 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 running | 423 | /// The subject and first line of a repository invitation. |
| 424 | pub 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. | |
| 437 | pub 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 running | 442 | 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 running | 446 | 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 running | 448 | ), |
| 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 running | 456 | ), |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | #[cfg(test)] | |
| 461 | mod 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 | "<a href="x">Tom & Jerry's</a>" | |
| 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 watching | 474 | 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 running | 492 | 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 running | 503 | 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 running | 507 | 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 running | 510 | } |
| 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 confirm | 519 | 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 running | 520 | 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 running | 529 | 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 <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 confirm | 542 | 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 running | 551 | 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 running | 581 | /// 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 confirm | 600 | 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 running | 601 | 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 confirm | 611 | 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 running | 612 | 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 running | 614 | }, 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 running | 619 | } |
| 620 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.