internal/notify/notify.go

f9845abbc93ead5d74684a56f2e677be71c69587
gitbay/internal/notify/notify.go history · blame · raw

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}