Go · 14090 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 // NoticeMessage builds a 2FA / security state-change notice for kind. The
282 // kind names match audit-log Action values where applicable.
283 func NoticeMessage(b Branding, to, username, kind string) (Message, error) {
284 body, ok := noticeBodies[kind]
285 if !ok {
286 return Message{}, fmt.Errorf("email: unknown notice kind %q", kind)
287 }
288 data := struct{ SiteName, Username string }{b.SiteName, username}
289 subj, err := renderText(textTemplate.Must(textTemplate.New("subj").Parse(body.Subject)), data)
290 if err != nil {
291 return Message{}, err
292 }
293 txt, err := renderText(textTemplate.Must(textTemplate.New("txt").Parse(body.Text)), data)
294 if err != nil {
295 return Message{}, err
296 }
297 html, err := renderHTML(template.Must(template.New("html").Parse(body.HTML)), data)
298 if err != nil {
299 return Message{}, err
300 }
301 return Message{
302 From: b.From,
303 To: to,
304 Subject: strings.TrimSpace(subj),
305 Text: txt,
306 HTML: html,
307 }, nil
308 }
309
310 // VerifyMessage builds the email-verification message.
311 func VerifyMessage(b Branding, to, username, token string) (Message, error) {
312 data := struct{ SiteName, BaseURL, Username, Token string }{b.SiteName, b.BaseURL, username, token}
313 text, err := renderText(verifyTextTpl, data)
314 if err != nil {
315 return Message{}, err
316 }
317 html, err := renderHTML(verifyHTMLTpl, data)
318 if err != nil {
319 return Message{}, err
320 }
321 return Message{
322 From: b.From,
323 To: to,
324 Subject: fmt.Sprintf("Verify your %s email", b.SiteName),
325 HTML: html,
326 Text: text,
327 }, nil
328 }
329
330 // ResetMessage builds the password-reset message.
331 func ResetMessage(b Branding, to, token string) (Message, error) {
332 data := struct{ SiteName, BaseURL, Token string }{b.SiteName, b.BaseURL, token}
333 text, err := renderText(resetTextTpl, data)
334 if err != nil {
335 return Message{}, err
336 }
337 html, err := renderHTML(resetHTMLTpl, data)
338 if err != nil {
339 return Message{}, err
340 }
341 return Message{
342 From: b.From,
343 To: to,
344 Subject: fmt.Sprintf("Reset your %s password", b.SiteName),
345 HTML: html,
346 Text: text,
347 }, nil
348 }
349
350 func renderText(t *textTemplate.Template, data any) (string, error) {
351 var buf bytes.Buffer
352 if err := t.Execute(&buf, data); err != nil {
353 return "", err
354 }
355 return buf.String(), nil
356 }
357
358 func renderHTML(t *template.Template, data any) (string, error) {
359 var buf bytes.Buffer
360 if err := t.Execute(&buf, data); err != nil {
361 return "", err
362 }
363 return buf.String(), nil
364 }
365