Commit 3dd60ce9a5

3dd60ce9a5b2f84cb7a0bfed8fdb8f637740acaf

parent: 3eb692a4a5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 19:36 UTC

control: queue login link mail instead of a fire-and-forget goroutine

RequestLoginLink handed mail.Send to a goroutine so a hit and a miss
returned on the same path. Nothing drained those goroutines, so a
restart mid-delivery dropped the mail silently; http.Server.Shutdown
waits for requests, not for goroutines a handler spawned.

Replace it with store.EnqueueMail: the INSERT is the same order of
cost as the miss path's SELECT, so the timing property holds, and the
mail survives a crash rather than only a graceful shutdown.

notify.Mailer's retry schedule (30s, 60s, 120s, 240s, then
dead-lettered) tops out at 450s, well inside the 15-minute link TTL,
so a retried delivery cannot outlive the link it carries.

That bound is only as good as nobody moving either side of it, and
nothing enforced the coupling: notify's retry parameters were a bare
5 and a local 30-second literal in cmd/gitbayd/main.go, unreachable by
name from anywhere that would need to check them against a login
link's TTL. Named them (notify.DefaultMaxAttempts,
notify.DefaultRetryBase) and added
TestLoginLinkOutlivesMailerRetries in internal/control, which computes
the mailer's worst case from those constants and fails if it ever
reaches loginLinkTTL, so retuning mail retries for an unrelated reason
cannot silently let a link arrive after it has expired.

e2e's manual poll loops waited long enough for a synchronous send;
widen them to fit the mailer's 2-second poll tick.

Closes #159

Layout: unified · split

cmd/gitbayd/main.go +5 −3
@@ -160,9 +160,11 @@ func serveCmd() *cobra.Command {
160160 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
161161 defer stop()
162162
163 // Outbound webhook deliveries. The retry base is overridable
164 // for tests via GITBAY_WEBHOOK_RETRY_BASE.
165 retryBase := 30 * time.Second
163 // Outbound webhook deliveries, and the mail queue when SMTP is
164 // configured, share one retry base. It is overridable for
165 // tests via GITBAY_WEBHOOK_RETRY_BASE; notify.DefaultRetryBase
166 // names the production default so nothing else has to guess it.
167 retryBase := notify.DefaultRetryBase
166168 if v := os.Getenv("GITBAY_WEBHOOK_RETRY_BASE"); v != "" {
167169 if d, err := time.ParseDuration(v); err == nil {
168170 retryBase = d
e2e/emaillogin_test.go +6 −4
@@ -57,7 +57,9 @@ func TestEmailLogin(t *testing.T) {
5757 if status != 200 || !strings.Contains(body, "on its way") {
5858 t.Fatalf("POST /login by username: %d", status)
5959 }
60 deadline := time.Now().Add(2 * time.Second)
60 // Mail goes out through the notification queue, not on the request
61 // path, so give the mailer's poll tick time to pick it up.
62 deadline := time.Now().Add(5 * time.Second)
6163 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 {
6264 time.Sleep(25 * time.Millisecond)
6365 }
@@ -146,10 +148,10 @@ func TestEmailLoginThrottled(t *testing.T) {
146148 t.Error("the throttled response differs from the first")
147149 }
148150
149 // Mail goes out from a goroutine, not on the request path, so give the
150 // last permitted one time to land before counting.
151 // Mail goes out through the notification queue, not on the request
152 // path, so give the last permitted one time to land before counting.
151153 var n int
152 deadline := time.Now().Add(2 * time.Second)
154 deadline := time.Now().Add(5 * time.Second)
153155 for time.Now().Before(deadline) {
154156 n = len(smtp.mailTo("dana@example.test"))
155157 if n >= 5 {
internal/control/loginlink.go +9 −13
@@ -2,12 +2,10 @@ package control
22
33import (
44 "fmt"
5 "log/slog"
65 "strings"
76 "time"
87
98 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/mail"
119 "gitbay.org/gitbay/internal/store"
1210)
1311
@@ -100,15 +98,13 @@ func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) err
10098 host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token)
10199 subject := "log in to " + host
102100
103 // Sent in the background: mail.Send is a synchronous SMTP round trip to
104 // the relay, tens to hundreds of milliseconds against the sub-millisecond
105 // a miss takes to answer. Returning before it completes keeps every case
106 // — hit, miss, unverified, throttled — on the same DB-bound path, so
107 // response time cannot answer what the response body is built not to.
108 go func() {
109 if err := mail.Send(cfg, address, subject, body); err != nil {
110 slog.Error("login link mail", "user", user.ID, "err", err)
111 }
112 }()
113 return nil
101 // Queued rather than sent inline: the INSERT is sub-millisecond, the
102 // same order of cost as the miss path's SELECT, so every case — hit,
103 // miss, unverified, throttled — still resolves on the same DB-bound
104 // path. notify.Mailer drains the queue with retries (30s, 60s, 120s,
105 // 240s, then dead-lettered) that top out at 450s, comfortably inside
106 // the 15-minute link TTL, so a retried delivery cannot outlive the
107 // link it carries. Unlike the goroutine this replaces, a crash mid
108 // delivery does not lose the mail.
109 return st.EnqueueMail(address, subject, body)
114110}
internal/control/loginlink_test.go added +31
@@ -0,0 +1,31 @@
1package control
2
3import (
4 "testing"
5 "time"
6
7 // Aliased: this package already has a top-level function named notify
8 // (notifications.go), which the default import name would collide with.
9 mailqueue "gitbay.org/gitbay/internal/notify"
10)
11
12// Nothing ties notify's retry parameters to loginLinkTTL: someone tuning
13// mail retries for a slow relay has no reason to think about login links,
14// and the failure if they drift apart is silent — a link delivered after
15// it has already expired, refused with no indication why. This computes
16// the mailer's worst-case delivery time from notify's own named constants
17// (attempts 1..MaxAttempts-1 each wait RetryBase<<(attempt-1); the
18// MaxAttempts'th failure is dead-lettered immediately, per notify.Mailer.Run)
19// rather than a copy of the numbers, so either side moving breaks this test.
20func TestLoginLinkOutlivesMailerRetries(t *testing.T) {
21 var worst time.Duration
22 for attempt := 1; attempt < mailqueue.DefaultMaxAttempts; attempt++ {
23 worst += mailqueue.DefaultRetryBase << (attempt - 1)
24 }
25 if worst >= loginLinkTTL {
26 t.Fatalf("mailqueue.DefaultRetryBase=%s, mailqueue.DefaultMaxAttempts=%d: worst-case "+
27 "delivery is %s, not less than loginLinkTTL=%s — a retried login link mail can "+
28 "arrive after the link it carries has expired",
29 mailqueue.DefaultRetryBase, mailqueue.DefaultMaxAttempts, worst, loginLinkTTL)
30 }
31}
internal/notify/notify.go +11 −1
@@ -13,6 +13,16 @@ import (
1313 "gitbay.org/gitbay/internal/store"
1414)
1515
16// DefaultMaxAttempts and DefaultRetryBase are the retry parameters gitbayd
17// wires up in production (cmd/gitbayd/main.go), named so anything that
18// needs to reason about the mailer's worst-case delivery time — such as
19// checking it against a login link's TTL — computes it from the numbers
20// actually in force rather than a copy of them.
21const (
22 DefaultMaxAttempts = 5
23 DefaultRetryBase = 30 * time.Second
24)
25
1626type Mailer struct {
1727 St *store.Store
1828 Cfg config.Config
@@ -21,7 +31,7 @@ type Mailer struct {
2131}
2232
2333func New(st *store.Store, cfg config.Config, retryBase time.Duration) *Mailer {
24 return &Mailer{St: st, Cfg: cfg, RetryBase: retryBase, MaxAttempts: 5}
34 return &Mailer{St: st, Cfg: cfg, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}
2535}
2636
2737// Run polls for due mail until ctx is done.