feat(mail): send through the cloudflare email api
5 files changed, +236 −14
- account.go modified +13 −9
- mail.go modified +63 −1
- mail_test.go added +141 −0
- templates/register.html modified +3 −2
- web.go modified +16 −2
account.go modified
| @@ -29,13 +29,7 @@ func (s *Server) setEmail(w http.ResponseWriter, r *http.Request) { | ||
| 29 | 29 | http.Redirect(w, r, "/settings?error=Mail+is+not+configured+on+this+server", http.StatusSeeOther) |
| 30 | 30 | return |
| 31 | 31 | } |
| 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 { | |
| 39 | 33 | http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper("Could not send mail: "+err.Error()), http.StatusSeeOther) |
| 40 | 34 | return |
| 41 | 35 | } |
| @@ -77,11 +71,12 @@ func (s *Server) resetRequest(w http.ResponseWriter, r *http.Request) { | ||
| 77 | 71 | if err != nil { |
| 78 | 72 | u, err = s.store.UserByEmail(who) |
| 79 | 73 | } |
| 80 | - if err == nil && s.mailer != nil { | |
| 74 | + if err == nil && s.mailer != nil && !s.limiter.blocked("reset:"+u.Name) { | |
| 81 | 75 | if email := s.store.Account(u.ID).Email; email != "" && s.store.Account(u.ID).Verified { |
| 76 | + s.limiter.fail("reset:" + u.Name) | |
| 82 | 77 | token, _ := s.store.CreateMailToken(u.ID, "reset", email) |
| 83 | 78 | 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)) | |
| 85 | 80 | } |
| 86 | 81 | } |
| 87 | 82 | http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther) |
| @@ -207,3 +202,12 @@ func (s *Server) disableTOTP(w http.ResponseWriter, r *http.Request) { | ||
| 207 | 202 | s.store.SetTOTP(u.ID, "", "") |
| 208 | 203 | http.Redirect(w, r, "/settings/security", http.StatusSeeOther) |
| 209 | 204 | } |
| 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 ( | ||
| 10 | 10 | "log" |
| 11 | 11 | "net" |
| 12 | 12 | "net/http" |
| 13 | + "net/mail" | |
| 13 | 14 | "net/smtp" |
| 15 | + "net/url" | |
| 14 | 16 | "os" |
| 15 | 17 | "path/filepath" |
| 16 | 18 | "strings" |
| @@ -27,15 +29,21 @@ type Mailer struct { | ||
| 27 | 29 | Insecure bool `json:"insecure"` |
| 28 | 30 | API string `json:"api"` |
| 29 | 31 | Key string `json:"key"` |
| 32 | + Account string `json:"account"` | |
| 33 | + Token string `json:"token"` | |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | +var cloudflareAPI = "https://api.cloudflare.com/client/v4" | |
| 37 | + | |
| 38 | +var mailSlots = make(chan struct{}, 4) | |
| 39 | + | |
| 32 | 40 | func loadMailer(root string) *Mailer { |
| 33 | 41 | b, err := os.ReadFile(filepath.Join(root, "smtp.json")) |
| 34 | 42 | if err != nil { |
| 35 | 43 | return nil |
| 36 | 44 | } |
| 37 | 45 | 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 == "" { | |
| 39 | 47 | return nil |
| 40 | 48 | } |
| 41 | 49 | if m.Port == 0 { |
| @@ -48,6 +56,9 @@ func (m *Mailer) Send(to, subject, body string) error { | ||
| 48 | 56 | if m == nil { |
| 49 | 57 | return errors.New("mail is not configured") |
| 50 | 58 | } |
| 59 | + if m.Account != "" { | |
| 60 | + return m.cloudflare(to, subject, body) | |
| 61 | + } | |
| 51 | 62 | if m.API != "" { |
| 52 | 63 | return m.post(to, subject, body) |
| 53 | 64 | } |
| @@ -104,6 +115,8 @@ func (s *Server) notify(to, subject, body string) { | ||
| 104 | 115 | return |
| 105 | 116 | } |
| 106 | 117 | go func() { |
| 118 | + mailSlots <- struct{}{} | |
| 119 | + defer func() { <-mailSlots }() | |
| 107 | 120 | if err := s.mailer.Send(to, subject, body); err != nil { |
| 108 | 121 | log.Printf("mail to %s: %v", to, err) |
| 109 | 122 | } |
| @@ -147,3 +160,52 @@ func headerText(s string) string { | ||
| 147 | 160 | return r |
| 148 | 161 | }, s) |
| 149 | 162 | } |
| 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 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="auth"> |
| 3 | 3 | <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> | |
| 5 | 5 | <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> | |
| 7 | 7 | <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}} | |
| 8 | 9 | {{if eq .Data.Mode "invite"}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}} |
| 9 | 10 | <div class="actions"><button class="btn primary">Register ▸</button></div> |
| 10 | 11 | </form>{{end}} |
web.go modified
| @@ -312,14 +312,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | ||
| 312 | 312 | } |
| 313 | 313 | |
| 314 | 314 | 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}}) | |
| 316 | 316 | } |
| 317 | 317 | |
| 318 | 318 | func (s *Server) register(w http.ResponseWriter, r *http.Request) { |
| 319 | 319 | invite := strings.TrimSpace(r.FormValue("invite")) |
| 320 | 320 | retry := func(code int, msg string) { |
| 321 | 321 | 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")}}) | |
| 323 | 323 | } |
| 324 | 324 | mode := s.store.Registration() |
| 325 | 325 | if mode == "closed" { |
| @@ -336,6 +336,11 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 336 | 336 | retry(400, err.Error()) |
| 337 | 337 | return |
| 338 | 338 | } |
| 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 | + } | |
| 339 | 344 | if mode == "invite" && !s.store.UseInvite(invite) { |
| 340 | 345 | s.limiter.fail(ip) |
| 341 | 346 | retry(400, "That invite is invalid or already used") |
| @@ -348,6 +353,15 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 348 | 353 | } |
| 349 | 354 | s.limiter.fail("register:" + ip) |
| 350 | 355 | 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 | + } | |
| 351 | 365 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 352 | 366 | } |
| 353 | 367 | |