internal/notify/notify.go
67 lines · 1903 bytes
1// Package notify drains the notification queue: activity mail with the same
2// bounded-retry discipline as webhook delivery, so a flaky relay delays
3// feedback instead of losing it.
4package notify
5
6import (
7 "context"
8 "log/slog"
9 "time"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/mail"
13 "gitbay.org/gitbay/internal/store"
14)
15
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
26type Mailer struct {
27 St *store.Store
28 Cfg config.Config
29 RetryBase time.Duration
30 MaxAttempts int
31}
32
33func New(st *store.Store, cfg config.Config, retryBase time.Duration) *Mailer {
34 return &Mailer{St: st, Cfg: cfg, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}
35}
36
37// Run polls for due mail until ctx is done.
38func (m *Mailer) Run(ctx context.Context) {
39 tick := time.NewTicker(2 * time.Second)
40 defer tick.Stop()
41 for {
42 select {
43 case <-ctx.Done():
44 return
45 case <-tick.C:
46 due, err := m.St.DueMail(20)
47 if err != nil {
48 slog.Error("notify: listing due mail", "err", err)
49 continue
50 }
51 for _, q := range due {
52 if err := mail.Send(m.Cfg, q.Recipient, q.Subject, q.Body); err != nil {
53 attempt := q.Attempts + 1
54 if attempt >= m.MaxAttempts {
55 m.St.MarkMailFailed(q.ID, err.Error(), nil)
56 slog.Warn("notification dead-lettered", "recipient", q.Recipient, "err", err)
57 } else {
58 next := time.Now().Add(m.RetryBase << (attempt - 1))
59 m.St.MarkMailFailed(q.ID, err.Error(), &next)
60 }
61 continue
62 }
63 m.St.MarkMailSent(q.ID)
64 }
65 }
66 }
67}