pr_01m47d24b0e6n91zwymwxg0vpx/services/identity/src/email.rs
| 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. |
| 20 | pub async fn send_link( |
| 21 | env: &Env, |
| 22 | to: &str, |
| 23 | subject: &str, |
| 24 | intro: &str, |
| 25 | action: &str, |
| 26 | link: &str, |
| 27 | footer: &str, |
| 28 | ) -> Result<()> { |
| 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)); |
| 32 | let message = Message { |
| 33 | to, |
| 34 | from: FROM, |
| 35 | subject, |
| 36 | text, |
| 37 | html: format!( |
| 38 | "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\ |
| 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>\ |
| 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 | |
| 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 | |
| 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 | } |
| 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 | } |