Go · 16093 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package email
4
5 import (
6 "bytes"
7 "fmt"
8 "html/template"
9 "strings"
10 textTemplate "text/template"
11 )
12
13 // Branding is the per-instance customization for outgoing emails.
14 type Branding struct {
15 SiteName string // e.g. "shithub"
16 BaseURL string // e.g. "https://shithub.example" — no trailing slash
17 From string // e.g. "shithub <noreply@shithub.example>"
18 }
19
20 // Templates are inlined here rather than embedded as files. They're short,
21 // rarely change, and keeping them in code avoids template-discovery bugs
22 // in the embed.FS layout. When marketing wants editable email templates
23 // (S25-ish), promote these to templates/email/*.{html,txt}.
24 var (
25 verifyTextTpl = textTemplate.Must(textTemplate.New("verify.txt").Parse(strings.TrimSpace(`
26 Welcome to {{.SiteName}}, {{.Username}}!
27
28 To finish setting up your account, verify your email address by visiting:
29
30 {{.BaseURL}}/verify-email/{{.Token}}
31
32 This link expires in 24 hours.
33
34 If you didn't create a {{.SiteName}} account, you can ignore this email.
35 `)))
36
37 verifyHTMLTpl = template.Must(template.New("verify.html").Parse(strings.TrimSpace(`
38 <p>Welcome to {{.SiteName}}, <strong>{{.Username}}</strong>!</p>
39 <p>To finish setting up your account, verify your email address:</p>
40 <p><a href="{{.BaseURL}}/verify-email/{{.Token}}">Verify email</a></p>
41 <p>This link expires in 24 hours. If you didn't create a {{.SiteName}} account, you can ignore this email.</p>
42 `)))
43
44 resetTextTpl = textTemplate.Must(textTemplate.New("reset.txt").Parse(strings.TrimSpace(`
45 Hello,
46
47 We received a request to reset the password for the {{.SiteName}} account
48 associated with this email address.
49
50 To choose a new password, visit:
51
52 {{.BaseURL}}/password/reset/{{.Token}}
53
54 This link expires in 1 hour.
55
56 If you didn't request a password reset, you can ignore this email — your
57 password will not change.
58 `)))
59
60 resetHTMLTpl = template.Must(template.New("reset.html").Parse(strings.TrimSpace(`
61 <p>Hello,</p>
62 <p>We received a request to reset the password for the {{.SiteName}} account associated with this email address.</p>
63 <p><a href="{{.BaseURL}}/password/reset/{{.Token}}">Choose a new password</a></p>
64 <p>This link expires in 1 hour. If you didn't request a password reset, you can ignore this email — your password will not change.</p>
65 `)))
66 )
67
68 // noticeBodies maps a notice kind to its (subject, plaintext, html) bodies.
69 // Each body is run through text/template — only the canonical {{.SiteName}}
70 // and {{.Username}} variables are exposed.
71 var noticeBodies = map[string]struct {
72 Subject, Text, HTML string
73 }{
74 "2fa_enabled": {
75 Subject: "Two-factor authentication enabled on your {{.SiteName}} account",
76 Text: `Hi {{.Username}},
77
78 Two-factor authentication has just been enabled on your {{.SiteName}} account.
79 If this wasn't you, sign in immediately and disable 2FA, then change your password.
80 Recovery codes are stored on the security settings page — keep them somewhere safe.`,
81 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
82 <p>Two-factor authentication has just been enabled on your {{.SiteName}} account.</p>
83 <p>If this wasn't you, sign in immediately and disable 2FA, then change your password.</p>
84 <p>Recovery codes are stored on the security settings page — keep them somewhere safe.</p>`,
85 },
86 "2fa_disabled": {
87 Subject: "Two-factor authentication disabled on your {{.SiteName}} account",
88 Text: `Hi {{.Username}},
89
90 Two-factor authentication has been disabled on your {{.SiteName}} account.
91 If this wasn't you, sign in immediately and re-enable 2FA, then change your password.`,
92 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
93 <p>Two-factor authentication has been disabled on your {{.SiteName}} account.</p>
94 <p>If this wasn't you, sign in immediately and re-enable 2FA, then change your password.</p>`,
95 },
96 "recovery_regenerated": {
97 Subject: "New recovery codes generated for your {{.SiteName}} account",
98 Text: `Hi {{.Username}},
99
100 Your {{.SiteName}} recovery codes were regenerated. Any previous codes
101 no longer work. Store the new codes somewhere safe.
102
103 If this wasn't you, sign in immediately and review your security settings.`,
104 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
105 <p>Your {{.SiteName}} recovery codes were regenerated. Any previous codes no longer work. Store the new codes somewhere safe.</p>
106 <p>If this wasn't you, sign in immediately and review your security settings.</p>`,
107 },
108 "admin_cleared_2fa": {
109 Subject: "Two-factor authentication cleared by support — {{.SiteName}}",
110 Text: `Hi {{.Username}},
111
112 A {{.SiteName}} administrator cleared two-factor authentication from your
113 account, typically as part of a support request you initiated.
114
115 Sign in and re-enable 2FA at /settings/security/2fa/enable as soon as you can.
116
117 If you did NOT request this, sign in immediately and reset your password,
118 then contact support.`,
119 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
120 <p>A {{.SiteName}} administrator cleared two-factor authentication from your account, typically as part of a support request you initiated.</p>
121 <p>Sign in and re-enable 2FA as soon as you can.</p>
122 <p>If you did NOT request this, sign in immediately and reset your password, then contact support.</p>`,
123 },
124 "password_changed": {
125 Subject: "Your {{.SiteName}} password was changed",
126 Text: `Hi {{.Username}},
127
128 Your {{.SiteName}} password was just changed from the account settings.
129 All other sessions have been signed out as a precaution.
130
131 If this wasn't you, reset your password immediately at /password/reset
132 and review the security log under /settings.`,
133 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
134 <p>Your {{.SiteName}} password was just changed from the account settings. All other sessions have been signed out as a precaution.</p>
135 <p>If this wasn't you, reset your password immediately and review your security log.</p>`,
136 },
137 "log_out_everywhere": {
138 Subject: "All other sessions signed out — {{.SiteName}}",
139 Text: `Hi {{.Username}},
140
141 You signed out of every other session on your {{.SiteName}} account.
142 Your current browser stays signed in.
143
144 If this wasn't you, change your password immediately and review your
145 security settings.`,
146 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
147 <p>You signed out of every other session on your {{.SiteName}} account. Your current browser stays signed in.</p>
148 <p>If this wasn't you, change your password immediately and review your security settings.</p>`,
149 },
150 "username_changed": {
151 Subject: "Your {{.SiteName}} username was changed",
152 Text: `Hi {{.Username}},
153
154 Your {{.SiteName}} username was just changed. The old name now redirects
155 to the new one for 30 days, then is released.
156
157 If this wasn't you, sign in and review your account immediately.`,
158 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
159 <p>Your {{.SiteName}} username was just changed. The old name now redirects to the new one for 30 days, then is released.</p>
160 <p>If this wasn't you, sign in and review your account immediately.</p>`,
161 },
162 "primary_email_changed": {
163 Subject: "Primary email changed on your {{.SiteName}} account",
164 Text: `Hi {{.Username}},
165
166 The primary email on your {{.SiteName}} account was just changed.
167 This is the address account-related notifications go to from now on.
168
169 If this wasn't you, sign in and review your security settings.`,
170 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
171 <p>The primary email on your {{.SiteName}} account was just changed. This is the address account-related notifications go to from now on.</p>
172 <p>If this wasn't you, sign in and review your security settings.</p>`,
173 },
174 "account_deletion_initiated": {
175 Subject: "Your {{.SiteName}} account was scheduled for deletion",
176 Text: `Hi {{.Username}},
177
178 Your {{.SiteName}} account has been deleted. You have 14 days to undo this
179 by signing in again with your existing username and password — that
180 restores the account in place.
181
182 After 14 days the account stays permanently deleted.
183
184 If this wasn't you, sign in immediately to restore the account and then
185 change your password.`,
186 HTML: `<p>Hi <strong>{{.Username}}</strong>,</p>
187 <p>Your {{.SiteName}} account has been deleted. You have <strong>14 days</strong> to undo this by signing in again with your existing username and password — that restores the account in place.</p>
188 <p>After 14 days the account stays permanently deleted.</p>
189 <p>If this wasn't you, sign in immediately to restore the account and then change your password.</p>`,
190 },
191 }
192
193 // TokenCreatedMessage notifies the user that a new PAT was minted on
194 // their account. Helps detect compromise — a token they didn't make is a
195 // big red flag.
196 func TokenCreatedMessage(b Branding, to, username, name, prefix, ip string) (Message, error) {
197 const text = `Hi {{.Username}},
198
199 A new personal access token was created on your {{.SiteName}} account.
200
201 Name: {{.Name}}
202 Prefix: {{.Prefix}}
203 {{if .IP}} IP: {{.IP}}
204 {{end}}
205 If this wasn't you, sign in immediately, revoke the token at
206 {{.BaseURL}}/settings/tokens, and reset your password.`
207 const html = `<p>Hi <strong>{{.Username}}</strong>,</p>
208 <p>A new personal access token was created on your {{.SiteName}} account.</p>
209 <ul>
210 <li><strong>Name:</strong> {{.Name}}</li>
211 <li><strong>Prefix:</strong> <code>{{.Prefix}}…</code></li>
212 {{if .IP}}<li><strong>IP:</strong> {{.IP}}</li>{{end}}
213 </ul>
214 <p>If this wasn't you, sign in immediately, revoke the token at
215 <a href="{{.BaseURL}}/settings/tokens">your tokens settings</a>, and reset your password.</p>`
216 data := struct{ SiteName, BaseURL, Username, Name, Prefix, IP string }{
217 b.SiteName, b.BaseURL, username, name, prefix, ip,
218 }
219 txt, err := renderText(textTemplate.Must(textTemplate.New("token_added.txt").Parse(text)), data)
220 if err != nil {
221 return Message{}, err
222 }
223 htmlBody, err := renderHTML(template.Must(template.New("token_added.html").Parse(html)), data)
224 if err != nil {
225 return Message{}, err
226 }
227 return Message{
228 From: b.From,
229 To: to,
230 Subject: fmt.Sprintf("New personal access token on your %s account", b.SiteName),
231 Text: txt,
232 HTML: htmlBody,
233 }, nil
234 }
235
236 // SSHKeyAddedMessage builds the new-key notification email. Sent on
237 // successful key add — title/fingerprint/IP help the user spot a
238 // compromise quickly.
239 func SSHKeyAddedMessage(b Branding, to, username, title, fingerprint, ip string) (Message, error) {
240 const text = `Hi {{.Username}},
241
242 A new SSH key was added to your {{.SiteName}} account.
243
244 Title: {{.Title}}
245 Fingerprint: {{.Fingerprint}}
246 {{if .IP}} IP: {{.IP}}
247 {{end}}
248 If this wasn't you, sign in immediately, delete the key from
249 {{.BaseURL}}/settings/keys, and reset your password.`
250
251 const html = `<p>Hi <strong>{{.Username}}</strong>,</p>
252 <p>A new SSH key was added to your {{.SiteName}} account.</p>
253 <ul>
254 <li><strong>Title:</strong> {{.Title}}</li>
255 <li><strong>Fingerprint:</strong> <code>{{.Fingerprint}}</code></li>
256 {{if .IP}}<li><strong>IP:</strong> {{.IP}}</li>{{end}}
257 </ul>
258 <p>If this wasn't you, sign in immediately, delete the key from
259 <a href="{{.BaseURL}}/settings/keys">your SSH keys settings</a>, and reset your password.</p>`
260
261 data := struct{ SiteName, BaseURL, Username, Title, Fingerprint, IP string }{
262 b.SiteName, b.BaseURL, username, title, fingerprint, ip,
263 }
264 txt, err := renderText(textTemplate.Must(textTemplate.New("ssh_added.txt").Parse(text)), data)
265 if err != nil {
266 return Message{}, err
267 }
268 htmlBody, err := renderHTML(template.Must(template.New("ssh_added.html").Parse(html)), data)
269 if err != nil {
270 return Message{}, err
271 }
272 return Message{
273 From: b.From,
274 To: to,
275 Subject: fmt.Sprintf("New SSH key added to your %s account", b.SiteName),
276 Text: txt,
277 HTML: htmlBody,
278 }, nil
279 }
280
281 // GPGKeyAddedMessage builds the new-GPG-key notification email. Sent
282 // on successful key add — name/fingerprint/IP help the user spot a
283 // compromise. Mirrors SSHKeyAddedMessage; the only visible difference
284 // in the body is the wording ("GPG key" vs "SSH key") and the
285 // settings link target.
286 //
287 // The display "name" comes from the user-supplied title field; when
288 // the user omits it (gh allows blank), we fall back to a generic
289 // "(no title)" label so the email body stays readable.
290 func GPGKeyAddedMessage(b Branding, to, username, name, fingerprint, ip string) (Message, error) {
291 if strings.TrimSpace(name) == "" {
292 name = "(no title)"
293 }
294 const text = `Hi {{.Username}},
295
296 A new GPG key was added to your {{.SiteName}} account.
297
298 Name: {{.Name}}
299 Fingerprint: {{.Fingerprint}}
300 {{if .IP}} IP: {{.IP}}
301 {{end}}
302 If this wasn't you, sign in immediately, delete the key from
303 {{.BaseURL}}/settings/keys, and reset your password.`
304
305 const html = `<p>Hi <strong>{{.Username}}</strong>,</p>
306 <p>A new GPG key was added to your {{.SiteName}} account.</p>
307 <ul>
308 <li><strong>Name:</strong> {{.Name}}</li>
309 <li><strong>Fingerprint:</strong> <code>{{.Fingerprint}}</code></li>
310 {{if .IP}}<li><strong>IP:</strong> {{.IP}}</li>{{end}}
311 </ul>
312 <p>If this wasn't you, sign in immediately, delete the key from
313 <a href="{{.BaseURL}}/settings/keys">your SSH and GPG keys settings</a>, and reset your password.</p>`
314
315 data := struct{ SiteName, BaseURL, Username, Name, Fingerprint, IP string }{
316 b.SiteName, b.BaseURL, username, name, fingerprint, ip,
317 }
318 txt, err := renderText(textTemplate.Must(textTemplate.New("gpg_added.txt").Parse(text)), data)
319 if err != nil {
320 return Message{}, err
321 }
322 htmlBody, err := renderHTML(template.Must(template.New("gpg_added.html").Parse(html)), data)
323 if err != nil {
324 return Message{}, err
325 }
326 return Message{
327 From: b.From,
328 To: to,
329 Subject: fmt.Sprintf("New GPG key added to your %s account", b.SiteName),
330 Text: txt,
331 HTML: htmlBody,
332 }, nil
333 }
334
335 // NoticeMessage builds a 2FA / security state-change notice for kind. The
336 // kind names match audit-log Action values where applicable.
337 func NoticeMessage(b Branding, to, username, kind string) (Message, error) {
338 body, ok := noticeBodies[kind]
339 if !ok {
340 return Message{}, fmt.Errorf("email: unknown notice kind %q", kind)
341 }
342 data := struct{ SiteName, Username string }{b.SiteName, username}
343 subj, err := renderText(textTemplate.Must(textTemplate.New("subj").Parse(body.Subject)), data)
344 if err != nil {
345 return Message{}, err
346 }
347 txt, err := renderText(textTemplate.Must(textTemplate.New("txt").Parse(body.Text)), data)
348 if err != nil {
349 return Message{}, err
350 }
351 html, err := renderHTML(template.Must(template.New("html").Parse(body.HTML)), data)
352 if err != nil {
353 return Message{}, err
354 }
355 return Message{
356 From: b.From,
357 To: to,
358 Subject: strings.TrimSpace(subj),
359 Text: txt,
360 HTML: html,
361 }, nil
362 }
363
364 // VerifyMessage builds the email-verification message.
365 func VerifyMessage(b Branding, to, username, token string) (Message, error) {
366 data := struct{ SiteName, BaseURL, Username, Token string }{b.SiteName, b.BaseURL, username, token}
367 text, err := renderText(verifyTextTpl, data)
368 if err != nil {
369 return Message{}, err
370 }
371 html, err := renderHTML(verifyHTMLTpl, data)
372 if err != nil {
373 return Message{}, err
374 }
375 return Message{
376 From: b.From,
377 To: to,
378 Subject: fmt.Sprintf("Verify your %s email", b.SiteName),
379 HTML: html,
380 Text: text,
381 }, nil
382 }
383
384 // ResetMessage builds the password-reset message.
385 func ResetMessage(b Branding, to, token string) (Message, error) {
386 data := struct{ SiteName, BaseURL, Token string }{b.SiteName, b.BaseURL, token}
387 text, err := renderText(resetTextTpl, data)
388 if err != nil {
389 return Message{}, err
390 }
391 html, err := renderHTML(resetHTMLTpl, data)
392 if err != nil {
393 return Message{}, err
394 }
395 return Message{
396 From: b.From,
397 To: to,
398 Subject: fmt.Sprintf("Reset your %s password", b.SiteName),
399 HTML: html,
400 Text: text,
401 }, nil
402 }
403
404 func renderText(t *textTemplate.Template, data any) (string, error) {
405 var buf bytes.Buffer
406 if err := t.Execute(&buf, data); err != nil {
407 return "", err
408 }
409 return buf.String(), nil
410 }
411
412 func renderHTML(t *template.Template, data any) (string, error) {
413 var buf bytes.Buffer
414 if err := t.Execute(&buf, data); err != nil {
415 return "", err
416 }
417 return buf.String(), nil
418 }
419