Go · 2788 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package notif owns S29's notification fan-out: consume from
4 // `domain_events`, compute the recipient set per the routing
5 // matrix, materialize inbox rows + email-side dispatch.
6 //
7 // The recipient computation is the only interesting code here. The
8 // routing matrix is data (a Go map keyed on event-kind +
9 // recipient-relation), the auto-subscription rules are a tiny set
10 // of "if absent, insert", and the storm dampener is a per-recipient,
11 // per-thread, time-window count.
12 //
13 // Visibility re-check at fan-out is the security boundary: a repo
14 // can flip from public to private between event-emit and fan-out;
15 // a stale-read public event must not produce a notification for a
16 // recipient who can no longer see the underlying resource.
17 package notif
18
19 import (
20 "errors"
21 "log/slog"
22
23 "github.com/jackc/pgx/v5/pgxpool"
24
25 "github.com/tenseleyFlow/shithub/internal/auth/email"
26 )
27
28 // Deps wires the package against the rest of the runtime.
29 //
30 // EmailSender is optional: when nil, notifications still land in
31 // the inbox but no email goes out. Useful in tests + during local
32 // dev when the operator hasn't configured SMTP. EmailFrom is the
33 // default From address; per-template overrides are post-MVP.
34 //
35 // SiteName is the human-readable site name used in subject lines /
36 // list-unsubscribe footers ("shithub" by default; operators can
37 // re-brand for self-hosted instances).
38 //
39 // BaseURL is the public-facing scheme+host (e.g.
40 // "https://shithub.example") used to build canonical links in
41 // email bodies + the unsubscribe URL.
42 //
43 // UnsubscribeKey is the HMAC-SHA256 key for the one-click
44 // list-unsubscribe URL. Rotating the key invalidates outstanding
45 // unsubscribe links — operators are expected to keep it stable.
46 type Deps struct {
47 Pool *pgxpool.Pool
48 Logger *slog.Logger
49 EmailSender email.Sender
50 EmailFrom string
51 SiteName string
52 BaseURL string
53 UnsubscribeKey []byte
54 }
55
56 // Errors surfaced by the orchestrator.
57 var (
58 ErrNotFound = errors.New("notif: notification not found")
59 ErrUnauthorized = errors.New("notif: not your notification")
60 ErrUnsubscribeBad = errors.New("notif: invalid unsubscribe token")
61 )
62
63 // FanoutBatch is the per-tick cap on how many domain_events the
64 // fan-out worker drains in one call. Bounded so a single tick can't
65 // monopolize the worker pool when a backlog accumulates (e.g. after
66 // a deploy that takes the worker offline for a moment).
67 const FanoutBatch = 200
68
69 // StormDampener defaults — one email per recipient per thread per
70 // 10 minutes (the spec's day-1 lean). Per-recipient absolute cap
71 // is 100 emails per hour.
72 const (
73 StormPerThreadCap = 1
74 StormPerThreadMins = 10
75 StormAbsoluteCap = 100
76 StormAbsoluteMins = 60
77 )
78