internal/notify/notify.go
82 lines · 2557 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 "regexp"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/mail"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// DefaultMaxAttempts and DefaultRetryBase are the retry parameters gitbayd
18// wires up in production (cmd/gitbayd/main.go), named so anything that
19// needs to reason about the mailer's worst-case delivery time — such as
20// checking it against a login link's TTL — computes it from the numbers
21// actually in force rather than a copy of them.
22const (
23 DefaultMaxAttempts = 5
24 DefaultRetryBase = 30 * time.Second
25)
26
27type Mailer struct {
28 St *store.Store
29 Cfg config.Config
30 RetryBase time.Duration
31 MaxAttempts int
32}
33
34func New(st *store.Store, cfg config.Config, retryBase time.Duration) *Mailer {
35 return &Mailer{St: st, Cfg: cfg, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}
36}
37
38// Run polls for due mail until ctx is done.
39func (m *Mailer) Run(ctx context.Context) {
40 tick := time.NewTicker(2 * time.Second)
41 defer tick.Stop()
42 for {
43 select {
44 case <-ctx.Done():
45 return
46 case <-tick.C:
47 due, err := m.St.DueMail(20)
48 if err != nil {
49 slog.Error("notify: listing due mail", "err", err)
50 continue
51 }
52 for _, q := range due {
53 if err := mail.Send(m.Cfg, q.Recipient, q.Subject, q.Body); err != nil {
54 attempt := q.Attempts + 1
55 if attempt >= m.MaxAttempts {
56 m.St.MarkMailFailed(q.ID, err.Error(), nil)
57 slog.Warn("notification dead-lettered",
58 "mail", q.ID, "attempts", attempt, "err", redactAddresses(err.Error()))
59 } else {
60 next := time.Now().Add(m.RetryBase << (attempt - 1))
61 m.St.MarkMailFailed(q.ID, err.Error(), &next)
62 }
63 continue
64 }
65 m.St.MarkMailSent(q.ID)
66 }
67 }
68 }
69}
70
71// A relay's rejection usually quotes the address it rejected — "550 5.1.1
72// <x@y>: Recipient address rejected" — so dropping the recipient field
73// alone would not keep an address out of the log.
74var addressPat = regexp.MustCompile(`[^\s<>@,;:"]+@[^\s<>@,;:"]+`)
75
76// redactAddresses removes mail addresses from text bound for the log. The
77// unredacted error is still recorded on the queue row, where an instance
78// admin reads it on /admin: the database holds the address, the log does
79// not (#173).
80func redactAddresses(s string) string {
81 return addressPat.ReplaceAllString(s, "<address>")
82}