g1t/services/identity/src/email.rs
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.
| Email verification, password reset, and Git for AI scale positioning | 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 | #[derive(Serialize)] | |
| 11 | struct Message<'a> { | |
| 12 | to: &'a str, | |
| 13 | from: &'a str, | |
| 14 | subject: &'a str, | |
| 15 | text: String, | |
| 16 | html: String, | |
| 17 | } | |
| 18 | ||
| 19 | /// A short message with one link to follow. | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 20 | pub async fn send_link( |
| Email verification, password reset, and Git for AI scale positioning | 21 | env: &Env, |
| 22 | to: &str, | |
| 23 | subject: &str, | |
| 24 | intro: &str, | |
| 25 | action: &str, | |
| 26 | link: &str, | |
| 27 | footer: &str, | |
| 28 | ) -> Result<()> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 29 | let text = format!("{intro}\n\n{action}: {link}\n\n{footer}\n"); |
| 30 | // Names people chose can reach these lines. | |
| 31 | let (intro, action, link, footer) = (escape(intro), escape(action), escape(link), escape(footer)); | |
| Email verification, password reset, and Git for AI scale positioning | 32 | let message = Message { |
| 33 | to, | |
| 34 | from: FROM, | |
| 35 | subject, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 36 | text, |
| Email verification, password reset, and Git for AI scale positioning | 37 | html: format!( |
| 38 | "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\ | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 39 | <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>\ |
| Email verification, password reset, and Git for AI scale positioning | 40 | <p style=\"font-size:15px;line-height:1.6\">{intro}</p>\ |
| 41 | <p style=\"margin:24px 0\"><a href=\"{link}\" style=\"background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px\">{action}</a></p>\ | |
| 42 | <p style=\"font-size:13px;line-height:1.6;color:#6e6a5e\">{footer}</p>\ | |
| 43 | </div>" | |
| 44 | ), | |
| 45 | }; | |
| 46 | let binding = js::binding(env, "EMAIL")?; | |
| 47 | js::call(&binding, "send", &[js::to_js(&message)?]).await?; | |
| 48 | Ok(()) | |
| 49 | } | |
| 50 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 51 | /// Text made safe to put in HTML, in an element or a quoted attribute. |
| 52 | fn escape(text: &str) -> String { | |
| 53 | let mut escaped = String::with_capacity(text.len()); | |
| 54 | for c in text.chars() { | |
| 55 | match c { | |
| 56 | '&' => escaped.push_str("&"), | |
| 57 | '<' => escaped.push_str("<"), | |
| 58 | '>' => escaped.push_str(">"), | |
| 59 | '"' => escaped.push_str("""), | |
| 60 | '\'' => escaped.push_str("'"), | |
| 61 | c => escaped.push(c), | |
| 62 | } | |
| 63 | } | |
| 64 | escaped | |
| 65 | } | |
| 66 | ||
| Email verification, password reset, and Git for AI scale positioning | 67 | pub async fn send_verification(env: &Env, to: &str, username: &str, token: &str) -> Result<()> { |
| 68 | send_link( | |
| 69 | env, | |
| 70 | to, | |
| 71 | "Confirm your email for g1t", | |
| 72 | &format!("Welcome to g1t, {username}. Confirm this address to finish creating your account."), | |
| 73 | "Confirm email", | |
| 74 | &format!("{SITE}/verify?token={token}"), | |
| 75 | "This link works for 24 hours. If you did not create a g1t account, you can ignore this message.", | |
| 76 | ) | |
| 77 | .await | |
| 78 | } | |
| 79 | ||
| 80 | pub async fn send_password_reset(env: &Env, to: &str, username: &str, token: &str) -> Result<()> { | |
| 81 | send_link( | |
| 82 | env, | |
| 83 | to, | |
| 84 | "Reset your g1t password", | |
| 85 | &format!("Someone asked to reset the password for the g1t account {username}."), | |
| 86 | "Choose a new password", | |
| 87 | &format!("{SITE}/reset?token={token}"), | |
| 88 | "This link works for 1 hour. If this was not you, ignore this message and your password stays the same.", | |
| 89 | ) | |
| 90 | .await | |
| 91 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 92 | |
| 93 | /// Confirms an address added to an existing account. | |
| 94 | pub async fn send_added_address(env: &Env, to: &str, username: &str, token: &str) -> Result<()> { | |
| 95 | send_link( | |
| 96 | env, | |
| 97 | to, | |
| 98 | "Confirm your email for g1t", | |
| 99 | &format!("Confirm this address to add it to the g1t account {username}."), | |
| 100 | "Confirm email", | |
| 101 | &format!("{SITE}/verify?token={token}"), | |
| 102 | "This link works for 24 hours. If you did not add this address to a g1t account, you can ignore this message.", | |
| 103 | ) | |
| 104 | .await | |
| 105 | } | |
| 106 | ||
| 107 | /// What a security notice says: one sentence about what changed. | |
| 108 | pub fn security_wording(username: &str, change: &str) -> (String, String) { | |
| 109 | ( | |
| 110 | format!("Security notice for your g1t account {username}"), | |
| 111 | format!("{change}. This is about your g1t account {username}."), | |
| 112 | ) | |
| 113 | } | |
| 114 | ||
| 115 | /// Tells an account's addresses that something about its security changed. | |
| 116 | pub async fn send_security_notice(env: &Env, to: &str, username: &str, change: &str) -> Result<()> { | |
| 117 | let (subject, intro) = security_wording(username, change); | |
| 118 | send_link( | |
| 119 | env, | |
| 120 | to, | |
| 121 | &subject, | |
| 122 | &intro, | |
| 123 | "Review your email settings", | |
| 124 | &format!("{SITE}/settings#emails"), | |
| 125 | "If this was you, there is nothing to do. If it was not, reset your password at g1t.sh/forgot straight away and remove any address you do not recognise.", | |
| 126 | ) | |
| 127 | .await | |
| 128 | } | |
| 129 | ||
| 130 | /// An invite: to make an account, or for an existing one to join a | |
| 131 | /// workspace. `from` is who sent it (a username, or a name and username); | |
| 132 | /// None when g1t staff did. | |
| 133 | pub async fn send_invite( | |
| 134 | env: &Env, | |
| 135 | to: &str, | |
| 136 | from: Option<&str>, | |
| 137 | workspace: Option<&str>, | |
| 138 | joins_existing_account: bool, | |
| 139 | code: &str, | |
| 140 | days: u64, | |
| 141 | ) -> Result<()> { | |
| 142 | let (subject, intro) = invite_wording(from, workspace, joins_existing_account); | |
| 143 | let action = match workspace { | |
| 144 | Some(workspace) if joins_existing_account => format!("Join {workspace}"), | |
| 145 | _ => "Accept invite".to_owned(), | |
| 146 | }; | |
| 147 | send_link( | |
| 148 | env, | |
| 149 | to, | |
| 150 | &subject, | |
| 151 | &intro, | |
| 152 | &action, | |
| 153 | &format!("{SITE}/invite/{code}"), | |
| 154 | &format!( | |
| 155 | "This invite works for {days} days, only for this address. If you were not expecting it, you can ignore this message." | |
| 156 | ), | |
| 157 | ) | |
| 158 | .await | |
| 159 | } | |
| 160 | ||
| 161 | /// An invitation to collaborate on one repository. `code` is set when the | |
| 162 | /// address has no account yet: the link then makes one and accepts; without | |
| 163 | /// it, the link opens the invitation to accept or decline. | |
| 164 | pub async fn send_repo_invite( | |
| 165 | env: &Env, | |
| 166 | to: &str, | |
| 167 | from: &str, | |
| 168 | repo: &str, | |
| 169 | role: &str, | |
| 170 | code: Option<&str>, | |
| 171 | days: u64, | |
| 172 | ) -> Result<()> { | |
| 173 | let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some()); | |
| 174 | let link = match code { | |
| 175 | Some(code) => format!("{SITE}/invite/{code}"), | |
| 176 | None => format!("{SITE}/{repo}/invitations"), | |
| 177 | }; | |
| 178 | send_link( | |
| 179 | env, | |
| 180 | to, | |
| 181 | &subject, | |
| 182 | &intro, | |
| 183 | "View invitation", | |
| 184 | &link, | |
| 185 | &format!("This invitation works for {days} days. If you were not expecting it, you can ignore this message."), | |
| 186 | ) | |
| 187 | .await | |
| 188 | } | |
| 189 | ||
| 190 | /// The subject and first line of a repository invitation. | |
| 191 | pub fn repo_invite_wording(from: &str, repo: &str, role: &str, new_account: bool) -> (String, String) { | |
| 192 | let subject = format!("{from} invited you to {repo} on g1t"); | |
| 193 | let intro = if new_account { | |
| 194 | format!( | |
| 195 | "{from} invited you to collaborate on {repo} on g1t, with the {role} role. Accepting makes your g1t account and gives you access to {repo}." | |
| 196 | ) | |
| 197 | } else { | |
| 198 | format!("{from} invited you to collaborate on {repo} on g1t, with the {role} role.") | |
| 199 | }; | |
| 200 | (subject, intro) | |
| 201 | } | |
| 202 | ||
| 203 | /// The subject and first line of an invite email. | |
| 204 | pub fn invite_wording(from: Option<&str>, workspace: Option<&str>, joins_existing_account: bool) -> (String, String) { | |
| 205 | let who = from.unwrap_or("The g1t team"); | |
| 206 | match (workspace, joins_existing_account) { | |
| 207 | (Some(workspace), true) => ( | |
| 208 | format!("{who} invited you to {workspace} on g1t"), | |
| 209 | format!("{who} invited you to join the {workspace} workspace on g1t."), | |
| 210 | ), | |
| 211 | (Some(workspace), false) => ( | |
| 212 | format!("{who} invited you to {workspace} on g1t"), | |
| 213 | format!( | |
| 214 | "{who} invited you to join the {workspace} workspace on g1t, where people and agents ship software together. Accepting makes your account and joins you to {workspace}." | |
| 215 | ), | |
| 216 | ), | |
| 217 | (None, _) => ( | |
| 218 | match from { | |
| 219 | Some(from) => format!("{from} invited you to g1t"), | |
| 220 | None => "Your invite to g1t".to_owned(), | |
| 221 | }, | |
| 222 | 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."), | |
| 223 | ), | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | #[cfg(test)] | |
| 228 | mod tests { | |
| 229 | use super::{escape, invite_wording}; | |
| 230 | ||
| 231 | #[test] | |
| 232 | fn html_is_escaped() { | |
| 233 | assert_eq!( | |
| 234 | escape("<a href=\"x\">Tom & Jerry's</a>"), | |
| 235 | "<a href="x">Tom & Jerry's</a>" | |
| 236 | ); | |
| 237 | assert_eq!(escape("https://g1t.sh/verify?token=ab12"), "https://g1t.sh/verify?token=ab12"); | |
| 238 | } | |
| 239 | ||
| 240 | #[test] | |
| 241 | fn invites_say_who_sent_them_and_what_they_are_for() { | |
| 242 | let (subject, intro) = invite_wording(Some("ada"), None, false); | |
| 243 | assert_eq!(subject, "ada invited you to g1t"); | |
| 244 | assert!(intro.starts_with("ada invited you to g1t")); | |
| 245 | let (subject, _) = invite_wording(None, None, false); | |
| 246 | assert_eq!(subject, "Your invite to g1t"); | |
| 247 | let (subject, intro) = invite_wording(Some("ada"), Some("acme"), true); | |
| 248 | assert_eq!(subject, "ada invited you to acme on g1t"); | |
| 249 | assert_eq!(intro, "ada invited you to join the acme workspace on g1t."); | |
| 250 | let (_, intro) = invite_wording(Some("ada"), Some("acme"), false); | |
| 251 | assert!(intro.contains("makes your account and joins you to acme")); | |
| 252 | } | |
| 253 | } |