| 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 |