~gitpocket/gitpocket

1

feat(mail): send through the cloudflare email api

commit
d1edfe696d112b3ab3b3ad3c0852fd0f09ed32e6 browse tree
parent
902122eec69dd2721d7c131cf1b88722070b3582
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-25 14:23 UTC

5 files changed, +236 −14

account.go modified

@@ -29,13 +29,7 @@ func (s *Server) setEmail(w http.ResponseWriter, r *http.Request) {
2929 http.Redirect(w, r, "/settings?error=Mail+is+not+configured+on+this+server", http.StatusSeeOther)
3030 return
3131 }
32- token, err := s.store.CreateMailToken(u.ID, "verify", email)
33- if err != nil {
34- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
35- return
36- }
37- link := fmt.Sprintf("https://%s/verify/%s", s.host, token)
38- if err := s.mailer.Send(email, "Verify your email for "+s.host, fmt.Sprintf("Hello ~%s,\n\nOpen this link to confirm your email address:\n\n%s\n\nThe link is valid for 24 hours. If you did not ask for this, ignore it.\n", u.Name, link)); err != nil {
32+ if err := s.sendVerification(*u, email); err != nil {
3933 http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper("Could not send mail: "+err.Error()), http.StatusSeeOther)
4034 return
4135 }
@@ -77,11 +71,12 @@ func (s *Server) resetRequest(w http.ResponseWriter, r *http.Request) {
7771 if err != nil {
7872 u, err = s.store.UserByEmail(who)
7973 }
80- if err == nil && s.mailer != nil {
74+ if err == nil && s.mailer != nil && !s.limiter.blocked("reset:"+u.Name) {
8175 if email := s.store.Account(u.ID).Email; email != "" && s.store.Account(u.ID).Verified {
76+ s.limiter.fail("reset:" + u.Name)
8277 token, _ := s.store.CreateMailToken(u.ID, "reset", email)
8378 link := fmt.Sprintf("https://%s/reset/%s", s.host, token)
84- s.mailer.Send(email, "Password reset for "+s.host, fmt.Sprintf("Hello ~%s,\n\nSomeone asked to reset the password for your account. Open this link to choose a new one:\n\n%s\n\nThe link is valid for 24 hours. If that was not you, ignore this message and nothing changes.\n", u.Name, link))
79+ s.notify(email, "Password reset for "+s.host, fmt.Sprintf("Hello ~%s,\n\nSomeone asked to reset the password for your account. Open this link to choose a new one:\n\n%s\n\nThe link is valid for 24 hours. If that was not you, ignore this message and nothing changes.\n", u.Name, link))
8580 }
8681 }
8782 http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther)
@@ -207,3 +202,12 @@ func (s *Server) disableTOTP(w http.ResponseWriter, r *http.Request) {
207202 s.store.SetTOTP(u.ID, "", "")
208203 http.Redirect(w, r, "/settings/security", http.StatusSeeOther)
209204 }
205+
206+func (s *Server) sendVerification(u User, email string) error {
207+ token, err := s.store.CreateMailToken(u.ID, "verify", email)
208+ if err != nil {
209+ return err
210+ }
211+ link := fmt.Sprintf("https://%s/verify/%s", s.host, token)
212+ return s.mailer.Send(email, "Verify your email for "+s.host, fmt.Sprintf("Hello ~%s,\n\nOpen this link to confirm your email address:\n\n%s\n\nOnce it is confirmed you can reset your password from https://%s/reset if you ever lose it. The link is valid for 24 hours. If you did not ask for this, ignore it.\n", u.Name, link, s.host))
213+}

mail.go modified

@@ -10,7 +10,9 @@ import (
1010 "log"
1111 "net"
1212 "net/http"
13+ "net/mail"
1314 "net/smtp"
15+ "net/url"
1416 "os"
1517 "path/filepath"
1618 "strings"
@@ -27,15 +29,21 @@ type Mailer struct {
2729 Insecure bool `json:"insecure"`
2830 API string `json:"api"`
2931 Key string `json:"key"`
32+ Account string `json:"account"`
33+ Token string `json:"token"`
3034 }
3135
36+var cloudflareAPI = "https://api.cloudflare.com/client/v4"
37+
38+var mailSlots = make(chan struct{}, 4)
39+
3240 func loadMailer(root string) *Mailer {
3341 b, err := os.ReadFile(filepath.Join(root, "smtp.json"))
3442 if err != nil {
3543 return nil
3644 }
3745 var m Mailer
38- if json.Unmarshal(b, &m) != nil || (m.Host == "" && m.API == "") || m.From == "" {
46+ if json.Unmarshal(b, &m) != nil || (m.Host == "" && m.API == "" && m.Account == "") || m.From == "" {
3947 return nil
4048 }
4149 if m.Port == 0 {
@@ -48,6 +56,9 @@ func (m *Mailer) Send(to, subject, body string) error {
4856 if m == nil {
4957 return errors.New("mail is not configured")
5058 }
59+ if m.Account != "" {
60+ return m.cloudflare(to, subject, body)
61+ }
5162 if m.API != "" {
5263 return m.post(to, subject, body)
5364 }
@@ -104,6 +115,8 @@ func (s *Server) notify(to, subject, body string) {
104115 return
105116 }
106117 go func() {
118+ mailSlots <- struct{}{}
119+ defer func() { <-mailSlots }()
107120 if err := s.mailer.Send(to, subject, body); err != nil {
108121 log.Printf("mail to %s: %v", to, err)
109122 }
@@ -147,3 +160,52 @@ func headerText(s string) string {
147160 return r
148161 }, s)
149162 }
163+
164+func (m *Mailer) cloudflare(to, subject, body string) error {
165+ from := m.From
166+ if addr, err := mail.ParseAddress(from); err == nil {
167+ from = addr.Address
168+ }
169+ payload, _ := json.Marshal(map[string]any{"from": from, "to": to, "subject": headerText(subject), "text": body})
170+ req, err := http.NewRequest("POST", cloudflareAPI+"/accounts/"+url.PathEscape(m.Account)+"/email/sending/send", bytes.NewReader(payload))
171+ if err != nil {
172+ return err
173+ }
174+ req.Header.Set("Authorization", "Bearer "+m.Token)
175+ req.Header.Set("Content-Type", "application/json")
176+ resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
177+ if err != nil {
178+ return err
179+ }
180+ defer resp.Body.Close()
181+ var out struct {
182+ Success bool `json:"success"`
183+ Errors []struct {
184+ Code int `json:"code"`
185+ Message string `json:"message"`
186+ } `json:"errors"`
187+ Result *struct {
188+ Delivered []string `json:"delivered"`
189+ Queued []string `json:"queued"`
190+ Bounced []string `json:"permanent_bounces"`
191+ } `json:"result"`
192+ }
193+ if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&out); err != nil {
194+ return fmt.Errorf("cloudflare mail: %s", resp.Status)
195+ }
196+ if !out.Success || out.Result == nil {
197+ var reasons []string
198+ for _, e := range out.Errors {
199+ reasons = append(reasons, fmt.Sprintf("%d %s", e.Code, e.Message))
200+ }
201+ return fmt.Errorf("cloudflare mail: %s: %s", resp.Status, strings.Join(reasons, ", "))
202+ }
203+ for _, list := range [][]string{out.Result.Delivered, out.Result.Queued} {
204+ for _, addr := range list {
205+ if strings.EqualFold(addr, to) {
206+ return nil
207+ }
208+ }
209+ }
210+ return fmt.Errorf("cloudflare mail: %s bounced", to)
211+}

mail_test.go added

@@ -0,0 +1,141 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "net/url"
8+ "regexp"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+)
14+
15+type fakeCloudflare struct {
16+ sync.Mutex
17+ sent []map[string]string
18+ bounce bool
19+ headers http.Header
20+}
21+
22+func (f *fakeCloudflare) ServeHTTP(w http.ResponseWriter, r *http.Request) {
23+ var msg map[string]string
24+ json.NewDecoder(r.Body).Decode(&msg)
25+ f.Lock()
26+ f.sent = append(f.sent, msg)
27+ f.headers = r.Header.Clone()
28+ bounce := f.bounce
29+ f.Unlock()
30+ if r.URL.Path != "/accounts/acct/email/sending/send" {
31+ w.WriteHeader(404)
32+ w.Write([]byte(`{"success":false,"errors":[{"code":10000,"message":"not found"}],"result":null}`))
33+ return
34+ }
35+ if bounce {
36+ w.Write([]byte(`{"success":true,"errors":[],"result":{"delivered":[],"queued":[],"permanent_bounces":["` + msg["to"] + `"]}}`))
37+ return
38+ }
39+ w.Write([]byte(`{"success":true,"errors":[],"result":{"delivered":["` + msg["to"] + `"],"queued":[],"permanent_bounces":[]}}`))
40+}
41+
42+func (f *fakeCloudflare) wait(t *testing.T, n int) map[string]string {
43+ t.Helper()
44+ for i := 0; i < 200; i++ {
45+ f.Lock()
46+ if len(f.sent) >= n {
47+ m := f.sent[n-1]
48+ f.Unlock()
49+ return m
50+ }
51+ f.Unlock()
52+ time.Sleep(10 * time.Millisecond)
53+ }
54+ t.Fatalf("message %d never arrived", n)
55+ return nil
56+}
57+
58+func withCloudflare(t *testing.T) (*fakeCloudflare, *Mailer) {
59+ fake := &fakeCloudflare{}
60+ srv := httptest.NewServer(fake)
61+ t.Cleanup(srv.Close)
62+ old := cloudflareAPI
63+ cloudflareAPI = srv.URL
64+ t.Cleanup(func() { cloudflareAPI = old })
65+ return fake, &Mailer{Account: "acct", Token: "secret", From: "gitpocket <noreply@example.org>"}
66+}
67+
68+func TestCloudflareSender(t *testing.T) {
69+ fake, m := withCloudflare(t)
70+ if err := m.Send("a@example.org", "hi\r\nBcc: x@evil.example", "body"); err != nil {
71+ t.Fatal(err)
72+ }
73+ got := fake.wait(t, 1)
74+ if got["from"] != "noreply@example.org" || got["to"] != "a@example.org" || strings.ContainsAny(got["subject"], "\r\n") {
75+ t.Errorf("sent %v", got)
76+ }
77+ if fake.headers.Get("Authorization") != "Bearer secret" {
78+ t.Error("token not sent")
79+ }
80+ fake.bounce = true
81+ if err := m.Send("b@example.org", "s", "b"); err == nil || !strings.Contains(err.Error(), "bounced") {
82+ t.Errorf("bounce not reported: %v", err)
83+ }
84+ m.Account = "other"
85+ if err := m.Send("c@example.org", "s", "b"); err == nil {
86+ t.Error("api error not reported")
87+ }
88+}
89+
90+func TestSignupVerifyAndReset(t *testing.T) {
91+ fake, m := withCloudflare(t)
92+ s, h := testServer(t)
93+ s.mailer = m
94+ link := regexp.MustCompile(`https://example\.org(/[a-z]+/[0-9a-f]+)`)
95+
96+ w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"Alice@Example.org"}}, "")
97+ if w.Code != http.StatusSeeOther || session(w) == "" {
98+ t.Fatalf("signup with email failed: %d", w.Code)
99+ }
100+ verify := fake.wait(t, 1)
101+ if verify["to"] != "alice@example.org" {
102+ t.Fatalf("verification went to %q", verify["to"])
103+ }
104+ path := link.FindStringSubmatch(verify["text"])
105+ if path == nil || !strings.HasPrefix(path[1], "/verify/") {
106+ t.Fatalf("no verification link in %q", verify["text"])
107+ }
108+ if w := get(h, path[1], ""); w.Code != 200 {
109+ t.Fatalf("verification link gave %d", w.Code)
110+ }
111+
112+ post(h, "/reset", url.Values{"who": {"alice"}}, "")
113+ reset := fake.wait(t, 2)
114+ path = link.FindStringSubmatch(reset["text"])
115+ if path == nil || !strings.HasPrefix(path[1], "/reset/") {
116+ t.Fatalf("no reset link in %q", reset["text"])
117+ }
118+ if w := post(h, path[1], url.Values{"password": {"battery staple"}}, ""); w.Code != http.StatusSeeOther {
119+ t.Fatalf("reset gave %d", w.Code)
120+ }
121+ if w := post(h, path[1], url.Values{"password": {"another one!"}}, ""); w.Code != 400 {
122+ t.Errorf("reset link worked twice: %d", w.Code)
123+ }
124+ if _, ok := s.store.CheckPassword("alice", "battery staple"); !ok {
125+ t.Error("new password does not log in")
126+ }
127+
128+ if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "email": {"bob@x.y\r\nBcc: z@evil.example"}}, ""); w.Code != 400 {
129+ t.Errorf("injected address accepted: %d", w.Code)
130+ }
131+ post(h, "/register", url.Values{"name": {"carol"}, "password": {"correct horse"}, "email": {"carol@example.org"}}, "")
132+ fake.wait(t, 3)
133+ post(h, "/reset", url.Values{"who": {"carol"}}, "")
134+ time.Sleep(100 * time.Millisecond)
135+ fake.Lock()
136+ n := len(fake.sent)
137+ fake.Unlock()
138+ if n != 3 {
139+ t.Errorf("reset mail sent to an unverified address, %d messages", n)
140+ }
141+}

templates/register.html modified

@@ -1,10 +1,11 @@
11 {{define "content"}}
22 <div class="auth">
33 <h3 class="rule">Register for {{.Host}} <small>or <a href="/login">log in</a></small></h3>
4-{{if eq .Data.Mode "closed"}}<p class="info">Registration is closed on {{.Host}}. Ask an administrator for an account.</p>{{else}}<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. No email is needed and you are logged in straight away.</p>
4+{{if eq .Data.Mode "closed"}}<p class="info">Registration is closed on {{.Host}}. Ask an administrator for an account.</p>{{else}}<p>Registration gives you a page at <code>~username</code>, repositories under it, SSH and HTTPS push, and pull requests on anything public. An email address is optional and you are logged in straight away either way.</p>
55 <form method="post" action="/register" class="stack centred">
6-<label>Username <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}"><small>Lowercase letters, digits, dot, dash or underscore.</small></label>
6+<label>Username <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}" value="{{.Data.Name}}"><small>Lowercase letters, digits, dot, dash or underscore.</small></label>
77 <label>Password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
8+{{if .Data.Mail}}<label>Email <span class="muted">(optional)</span> <input name="email" type="email" autocomplete="email" maxlength="200" value="{{.Data.Email}}"><small>We send one link to confirm it, after which it can reset your password. Never shown publicly.</small></label>{{end}}
89 {{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
910 <div class="actions"><button class="btn primary">Register ▸</button></div>
1011 </form>{{end}}

web.go modified

@@ -312,14 +312,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
312312 }
313313
314314 func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) {
315- s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite")}})
315+ s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite"), "Mail": s.mailer != nil}})
316316 }
317317
318318 func (s *Server) register(w http.ResponseWriter, r *http.Request) {
319319 invite := strings.TrimSpace(r.FormValue("invite"))
320320 retry := func(code int, msg string) {
321321 w.WriteHeader(code)
322- s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite}})
322+ s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite, "Mail": s.mailer != nil, "Email": r.FormValue("email"), "Name": r.FormValue("name")}})
323323 }
324324 mode := s.store.Registration()
325325 if mode == "closed" {
@@ -336,6 +336,11 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
336336 retry(400, err.Error())
337337 return
338338 }
339+ email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
340+ if email != "" && !validEmail(email) {
341+ retry(400, "That does not look like an email address")
342+ return
343+ }
339344 if mode == "invite" && !s.store.UseInvite(invite) {
340345 s.limiter.fail(ip)
341346 retry(400, "That invite is invalid or already used")
@@ -348,6 +353,15 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
348353 }
349354 s.limiter.fail("register:" + ip)
350355 s.startSession(w, u)
356+ if email != "" && s.store.SetEmail(u.ID, email) == nil && s.mailer != nil {
357+ go func() {
358+ mailSlots <- struct{}{}
359+ defer func() { <-mailSlots }()
360+ if err := s.sendVerification(u, email); err != nil {
361+ log.Printf("verification mail for ~%s: %v", u.Name, err)
362+ }
363+ }()
364+ }
351365 http.Redirect(w, r, "/settings", http.StatusSeeOther)
352366 }
353367