flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/email.rs

253 lines9,202 bytesCodeBlame

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 positioning1//! 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#[derive(Serialize)]
11struct 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 workspace20pub async fn send_link(
Email verification, password reset, and Git for AI scale positioning21 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 look29 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 positioning32 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 look36 text,
Email verification, password reset, and Git for AI scale positioning37 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 look39 <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 positioning40 <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 look51/// Text made safe to put in HTML, in an element or a quoted attribute.
52fn 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("&amp;"),
57 '<' => escaped.push_str("&lt;"),
58 '>' => escaped.push_str("&gt;"),
59 '"' => escaped.push_str("&quot;"),
60 '\'' => escaped.push_str("&#39;"),
61 c => escaped.push(c),
62 }
63 }
64 escaped
65}
66
Email verification, password reset, and Git for AI scale positioning67pub 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
80pub 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 look92
93/// Confirms an address added to an existing account.
94pub 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.
108pub 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.
116pub 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.
133pub 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.
164pub 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.
191pub 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.
204pub 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)]
228mod 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 "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;"
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}