@snagset/server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
package/dist/keys.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createHash, hkdfSync, timingSafeEqual } from "node:crypto";
2
+ import { SCHEMA_VERSION } from "./vendor/types/names.js";
3
+ const PURPOSES = [
4
+ "reviewer-session",
5
+ "credential-enc",
6
+ "attachment-url",
7
+ "webhook-sign",
8
+ "unsubscribe",
9
+ ];
10
+ /**
11
+ * The salt: the 32 RAW bytes of `sha256(SCHEMA_VERSION)`, not its hex text.
12
+ *
13
+ * Hex would be a 64-byte salt wearing a 32-byte name, and the difference is
14
+ * invisible until two builds disagree. Tying it to the schema id means the
15
+ * same secret under a future schema derives different keys, which is the point
16
+ * 08 §4.10 makes: keys do not leak across schema generations.
17
+ */
18
+ const SALT = createHash("sha256").update(SCHEMA_VERSION).digest();
19
+ /** 32 bytes for every purpose. HS256 wants ≥ 32; AES-256-GCM wants exactly 32. */
20
+ const KEY_BYTES = 32;
21
+ export class KeyError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "KeyError";
25
+ }
26
+ }
27
+ /**
28
+ * `kid` — which key signed this, so verification does not have to guess.
29
+ *
30
+ * 05 §4.4 mandates it in the JWT header and defines it nowhere; 08 §4.10 now
31
+ * does. First 8 bytes of `sha256(secret)`, base64url, 11 characters. It is not
32
+ * a secret — it is a fingerprint of one, and 8 bytes is far more than enough to
33
+ * tell two keys apart while revealing nothing about either.
34
+ */
35
+ export function keyId(secret) {
36
+ return createHash("sha256").update(secret).digest("base64url").slice(0, 11);
37
+ }
38
+ export function createKeyring(secret, previous) {
39
+ if (!secret)
40
+ throw new KeyError("no signing key — SNAGSET_SECRET_KEY is empty");
41
+ const cache = new Map();
42
+ const derive = (s, purpose) => {
43
+ if (!PURPOSES.includes(purpose)) {
44
+ // Unreachable through the type, reachable through JSON, a plugin, or a
45
+ // future edit. Loud, because the silent version signs with a real key.
46
+ throw new KeyError(`"${purpose}" is not a KeyPurpose. The union is closed (08 §4.10): ${PURPOSES.join(", ")}.`);
47
+ }
48
+ const memo = `${keyId(s)}:${purpose}`;
49
+ let k = cache.get(memo);
50
+ if (!k) {
51
+ // `info` is the UTF-8 bytes of the purpose literal — the only thing
52
+ // separating the four keys. A case or separator difference is a
53
+ // different key, and nothing anywhere would say so.
54
+ k = Buffer.from(hkdfSync("sha256", Buffer.from(s, "utf8"), SALT, Buffer.from(purpose, "utf8"), KEY_BYTES));
55
+ cache.set(memo, k);
56
+ }
57
+ return k;
58
+ };
59
+ const currentKid = keyId(secret);
60
+ const previousKid = previous ? keyId(previous) : null;
61
+ return {
62
+ kid: currentKid,
63
+ derive: (purpose) => derive(secret, purpose),
64
+ forKid(kid, purpose) {
65
+ // Constant-time on the kid too. It is not secret, but comparing it with
66
+ // `===` makes one more thing in the auth path timing-variable for no
67
+ // saving at all.
68
+ const eq = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
69
+ if (eq(kid, currentKid))
70
+ return derive(secret, purpose);
71
+ if (previous && previousKid && eq(kid, previousKid))
72
+ return derive(previous, purpose);
73
+ return null;
74
+ },
75
+ };
76
+ }
77
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ import { createResendMailer } from "./resend.js";
2
+ import { createSmtpMailer } from "./smtp.js";
3
+ export { isConfigured } from "./mailer.js";
4
+ /**
5
+ * Which transports this build can speak. Exported so the boot error can name
6
+ * them rather than describing them, and so a test can assert the list rather
7
+ * than a sentence about the list.
8
+ */
9
+ export const MAIL_SCHEMES = ["smtp", "smtps", "resend"];
10
+ /**
11
+ * Choose a transport from ONE variable, by its scheme — the only place that
12
+ * decision is made.
13
+ *
14
+ * ── WHY THE SCHEME AND NOT A VARIABLE PER VENDOR ──
15
+ *
16
+ * The first shape was `SNAGSET_RESEND_API_KEY` beside `SNAGSET_SMTP_URL`, and
17
+ * it was wrong for a reason that only shows up on the third provider: a
18
+ * vendor's name in the KEY means every new provider costs a new variable, a
19
+ * new precedence rule, and a new boot-time answer to "which one wins if two
20
+ * are set". The scheme puts that information in the value, where it belongs —
21
+ * `resend://re_…`, `smtp://user:pass@host:587` — so adding Postmark is a new
22
+ * case in one function and no new configuration at all. An operator moving
23
+ * from their own mail server to a provider edits one line.
24
+ *
25
+ * 08 §4.8's other rules, implemented rather than restated:
26
+ *
27
+ * · Unset ⇒ notifications are **off and say so**. Not queued. A feature that
28
+ * is off and visible is an operator's decision; one that queues rows
29
+ * nobody will deliver is a bug that surfaces three weeks later as "did
30
+ * that email ever send?".
31
+ * · A transport without a From address is a BOOT failure, not a runtime one:
32
+ * every send would fail identically, and the operator should learn that
33
+ * now rather than from the first missing notification.
34
+ * · An unknown scheme is also a boot failure. Falling back to "no mail"
35
+ * would leave someone who configured mail believing it works, which is the
36
+ * worst of the three outcomes.
37
+ */
38
+ export function selectMailer(cfg) {
39
+ const raw = cfg.get("SNAGSET_MAIL_URL");
40
+ const from = cfg.get("SNAGSET_MAIL_FROM");
41
+ if (!raw) {
42
+ return {
43
+ kind: "none",
44
+ reason: "no mail transport is configured, so notifications are off. Set SNAGSET_MAIL_URL " +
45
+ "to resend://<api-key> (recommended — the provider owns SPF/DKIM) or to " +
46
+ "smtp://user:pass@host:587, together with SNAGSET_MAIL_FROM.",
47
+ };
48
+ }
49
+ // Parsed by hand rather than with `new URL`: a Resend key is the AUTHORITY
50
+ // component of `resend://re_abc123`, and WHATWG URL lower-cases hostnames.
51
+ // An API key is case-sensitive, so `new URL().hostname` would silently
52
+ // corrupt it and produce a 401 that looks like a bad key rather than a bad
53
+ // parser.
54
+ const at = raw.indexOf("://");
55
+ const scheme = at === -1 ? "" : raw.slice(0, at).toLowerCase();
56
+ const rest = at === -1 ? "" : raw.slice(at + 3);
57
+ if (!MAIL_SCHEMES.includes(scheme)) {
58
+ throw new Error(`SNAGSET_MAIL_URL has an unsupported scheme ${JSON.stringify(scheme || raw.slice(0, 12))}. ` +
59
+ `This build speaks: ${MAIL_SCHEMES.map((s) => `${s}://`).join(", ")}.`);
60
+ }
61
+ if (!from) {
62
+ throw new Error("SNAGSET_MAIL_URL is set but SNAGSET_MAIL_FROM is not. Every message needs a From " +
63
+ "address, so this would fail on the first send rather than here.");
64
+ }
65
+ if (scheme === "resend") {
66
+ if (!rest)
67
+ throw new Error("SNAGSET_MAIL_URL is resend:// with no API key after it.");
68
+ // A trailing slash is what a browser or a copy-paste adds; it is not part
69
+ // of the key, and a key with one on the end fails authentication in a way
70
+ // that reads as "wrong key".
71
+ return createResendMailer(rest.replace(/\/+$/, ""), from);
72
+ }
73
+ return createSmtpMailer({ url: raw, from });
74
+ }
75
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/mail/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAG7C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAU,CAAC;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IAE1C,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,MAAM,EACJ,kFAAkF;gBAClF,yEAAyE;gBACzE,6DAA6D;SAChE,CAAC;IACJ,CAAC;IAED,2EAA2E;IAC3E,2EAA2E;IAC3E,uEAAuE;IACvE,2EAA2E;IAC3E,UAAU;IACV,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAC/D,MAAM,IAAI,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IAEhD,IAAI,CAAE,YAAkC,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CACb,8CAA8C,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI;YAC1F,sBAAsB,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CACzE,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACb,mFAAmF;YACjF,iEAAiE,CACpE,CAAC;IACJ,CAAC;IAED,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;QACxB,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;QACtF,0EAA0E;QAC1E,0EAA0E;QAC1E,6BAA6B;QAC7B,OAAO,kBAAkB,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,gBAAgB,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;AAC9C,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `Mailer` seam — LOCK L7, and built BEFORE the first notification call
3
+ * site exists, which is the entire point of naming it in the lock.
4
+ *
5
+ * *"otherwise the transport gets imported in eight places, and the import lint
6
+ * is what catches the ninth."* Everything that sends mail depends on this
7
+ * interface; exactly one module per adapter touches a transport. `mail.test.ts`
8
+ * asserts that by grep, because a rule nothing checks is a rule that lasts
9
+ * until the next feature.
10
+ *
11
+ * ── WHY TWO ADAPTERS, WHEN ONE WOULD RUN ──
12
+ *
13
+ * They answer different questions. `http` (Resend) is the DELIVERABILITY
14
+ * answer: a provider owns SPF, DKIM and DMARC, and a self-hosted box mailing
15
+ * third-party addresses without them lands in spam — which no amount of
16
+ * retrying fixes, because the mail was accepted and then filed away. `smtp` is
17
+ * the SOVEREIGNTY answer: an operator who already runs a mail server should not
18
+ * have to add a vendor to a product whose pitch is that it self-hosts.
19
+ *
20
+ * Neither is required. `DATABASE_URL` remains the only mandatory variable
21
+ * (LOCK L5), and with no transport configured notifications are OFF AND SAY SO
22
+ * rather than queueing rows nobody will deliver.
23
+ */
24
+ export function isConfigured(m) {
25
+ return m.kind !== "none";
26
+ }
27
+ //# sourceMappingURL=mailer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mailer.js","sourceRoot":"","sources":["../../src/mail/mailer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAyDH,MAAM,UAAU,YAAY,CAAC,CAAe;IAC1C,OAAO,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC;AAC3B,CAAC"}
@@ -0,0 +1,405 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { sanitise, sanitiseWithRedactor } from "../vendor/adapters/index.js";
3
+ import { redactText } from "../vendor/widget/predicates.js";
4
+ import { isConfigured } from "./mailer.js";
5
+ import { unsubscribeHeaders, unsubscribeToken } from "./unsubscribe.js";
6
+ /**
7
+ * Notification email — L1.4, and deliberately ONE event.
8
+ *
9
+ * `comment.created` → the project owner, and nothing else. Not because the
10
+ * others are hard, but because of who they mail: `thread.created` and
11
+ * `thread.resolved` go to *reviewers*, whose addresses are self-declared and
12
+ * unverified, and a system that mails unverified third-party addresses on a
13
+ * stranger's trigger is an open relay with extra steps. The owner's address is
14
+ * one we verified at claim time, so with only owner mail that abuse surface
15
+ * does not exist yet. The rest lands with full M2-32, behind subscription and
16
+ * unsubscribe.
17
+ *
18
+ * ── HOW "A RETRY NEVER DOUBLE-SENDS" IS ACTUALLY ACHIEVED ──
19
+ *
20
+ * Two mechanisms, and both are needed:
21
+ *
22
+ * 1. **Enqueue is idempotent.** The row is inserted in the SAME transaction
23
+ * as the comment, with `dedupe_key = 'comment.created:<commentId>'` and
24
+ * `UNIQUE (provider, dedupe_key)` behind it. A retried request that
25
+ * re-creates the same comment cannot create a second row.
26
+ * 2. **Claiming is atomic.** The delivery job moves a row `pending →
27
+ * inflight` with `UPDATE … WHERE status = 'pending' … RETURNING` and
28
+ * `SKIP LOCKED`, so two instances draining at once take disjoint sets.
29
+ *
30
+ * What neither can prevent is a crash between "the provider accepted it" and
31
+ * "we wrote `done`". That window is real and cannot be closed without a
32
+ * distributed transaction across an HTTP API; the honest posture is to make it
33
+ * SMALL and to fail toward not-sending rather than toward sending twice — so
34
+ * the row is marked `inflight` before the send and only a completed send
35
+ * writes `done`. A row stuck `inflight` is visible and is not retried
36
+ * automatically, because an automatic retry is exactly the duplicate.
37
+ */
38
+ const TOPIC = "comment.created";
39
+ /**
40
+ * Queue one notification, INSIDE the caller's transaction.
41
+ *
42
+ * Taking `tx` rather than a pool is the whole design: 07 §2.5 requires outbox
43
+ * rows to be inserted in the same transaction as the domain mutation, and the
44
+ * reason is not tidiness. A comment that commits without its notification row
45
+ * is a notification that never happens; a notification row that commits
46
+ * without its comment is mail about something that does not exist. Only one
47
+ * transaction can rule out both.
48
+ *
49
+ * `ON CONFLICT DO NOTHING` rather than an error: a duplicate here means the
50
+ * caller retried, which is the case the dedupe key exists to absorb quietly.
51
+ */
52
+ export async function queueCommentNotice(tx, notice, id) {
53
+ const payload = {
54
+ threadKey: notice.threadKey,
55
+ authorName: notice.authorName,
56
+ body: notice.body,
57
+ pageUrl: notice.pageUrl,
58
+ commentId: notice.commentId,
59
+ };
60
+ // THE OWNER's row. No recipient in the event: the delivery job reads that
61
+ // absence as "the project owner", which is what every row queued before
62
+ // this fan-out existed also means. Backward compatibility by shape rather
63
+ // than by version flag.
64
+ await tx `
65
+ INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
66
+ VALUES (
67
+ ${id}, ${notice.orgId}, ${notice.projectId}, ${notice.threadId},
68
+ 'email', ${TOPIC}, ${`${TOPIC}:${notice.commentId}`}, ${tx.json(payload)}
69
+ )
70
+ ON CONFLICT (provider, dedupe_key) DO NOTHING
71
+ `;
72
+ /**
73
+ * …and ONE ROW PER PARTICIPANT — B2's "created or replied".
74
+ *
75
+ * Fanned out at enqueue rather than at send, because every guarantee this
76
+ * system has is per-row: `UNIQUE (provider, dedupe_key)` makes a retry a
77
+ * no-op for THAT recipient, and a provider rejecting one address cannot
78
+ * take the others down with it. One row addressed to N people would have
79
+ * neither property.
80
+ *
81
+ * The subscription rule is derived, not stored — a reviewer is subscribed to
82
+ * a thread because they wrote on it. There is deliberately no endpoint that
83
+ * subscribes an arbitrary address, because that endpoint is the one an
84
+ * abuser wants.
85
+ *
86
+ * Excluded: the author of this very comment (nobody needs mail about their
87
+ * own typing), anyone with no address, anyone who unsubscribed, and anyone
88
+ * blocked or erased.
89
+ */
90
+ await fanOut(tx, {
91
+ orgId: notice.orgId,
92
+ projectId: notice.projectId,
93
+ threadId: notice.threadId,
94
+ topic: TOPIC,
95
+ keyPrefix: `${TOPIC}:${notice.commentId}`,
96
+ exclude: notice.authorId,
97
+ payload,
98
+ });
99
+ }
100
+ /**
101
+ * Everyone who took part in a thread, minus one person, each in their own row.
102
+ *
103
+ * Shared by both topics because the SUBSCRIPTION RULE is the thing that must
104
+ * not drift: "created or replied" answered in two places is how the two
105
+ * answers diverge, and the divergence is always in the direction of mailing
106
+ * somebody who did not ask.
107
+ */
108
+ async function fanOut(tx, o) {
109
+ const participants = await tx `
110
+ SELECT DISTINCT r.id
111
+ FROM comments c
112
+ JOIN reviewers r ON r.id = c.author_id
113
+ WHERE c.thread_id = ${o.threadId}
114
+ AND c.deleted_at IS NULL
115
+ AND r.id <> ${o.exclude}::uuid
116
+ AND r.email IS NOT NULL
117
+ AND r.notifications_off_at IS NULL
118
+ AND r.blocked_at IS NULL
119
+ AND r.erased_at IS NULL
120
+ `;
121
+ for (const p of participants) {
122
+ await tx `
123
+ INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
124
+ VALUES (
125
+ ${randomUUID()}, ${o.orgId}, ${o.projectId}, ${o.threadId},
126
+ 'email', ${o.topic}, ${`${o.keyPrefix}:${p.id}`},
127
+ ${tx.json({ ...o.payload, toReviewer: p.id })}
128
+ )
129
+ ON CONFLICT (provider, dedupe_key) DO NOTHING
130
+ `;
131
+ }
132
+ }
133
+ const RESOLVED = "thread.resolved";
134
+ /**
135
+ * "Somebody marked your snag resolved" — B2's second topic.
136
+ *
137
+ * This one goes to PARTICIPANTS and not to the owner, which is the opposite of
138
+ * `comment.created` and deliberate: resolving is almost always the studio's own
139
+ * act, and mailing the owner about a button they just pressed is the noise that
140
+ * teaches people to filter the sender.
141
+ *
142
+ * The dedupe key carries `threads.version`, which is 05 §3's documented
143
+ * composition (`'<topic>:<entityId>:<threads.version>'`) and matters here more
144
+ * than anywhere else: a thread can be resolved, reopened and resolved again,
145
+ * and each of those is a real event that people should hear about. Keying on
146
+ * the thread id alone would send the first one and silently swallow every
147
+ * reopening after it.
148
+ */
149
+ export async function queueResolvedNotice(tx, notice) {
150
+ await fanOut(tx, {
151
+ orgId: notice.orgId,
152
+ projectId: notice.projectId,
153
+ threadId: notice.threadId,
154
+ topic: RESOLVED,
155
+ keyPrefix: `${RESOLVED}:${notice.threadId}:${notice.version}`,
156
+ exclude: notice.actorId,
157
+ payload: {
158
+ threadKey: notice.threadKey,
159
+ actorName: notice.actorName,
160
+ pageUrl: notice.pageUrl,
161
+ resolved: true,
162
+ },
163
+ });
164
+ }
165
+ /** Retry ladder, in minutes. Five attempts over roughly an hour, then the row
166
+ * is `failed` and stays for the dashboard's "these never sent" list — 05
167
+ * §3.11 step 14 keeps failed rows until an operator dismisses them. */
168
+ const BACKOFF_MIN = [1, 5, 15, 30, 60];
169
+ /**
170
+ * The delivery job. Registered only when a mailer is configured — with none,
171
+ * there is nothing to drain and the absence is reported at boot instead.
172
+ */
173
+ export function deliverEmail(mailer, baseUrl, keys) {
174
+ return {
175
+ name: "deliver-email",
176
+ everyMs: 30_000,
177
+ async run({ sql, now, log }) {
178
+ if (!isConfigured(mailer))
179
+ return { did: 0 };
180
+ // Claim ONE row at a time. A batch would be faster and would also mean
181
+ // that one provider timeout takes the whole batch's rows to `inflight`
182
+ // with nothing sent. One row per pass, `more: true` to drain — the
183
+ // engine's batching, reused rather than reinvented.
184
+ const [row] = await sql `
185
+ UPDATE outbox SET status = 'inflight', attempts = attempts + 1
186
+ WHERE id = (
187
+ SELECT id FROM outbox
188
+ WHERE provider = 'email'
189
+ AND status = 'pending'
190
+ AND next_attempt_at <= ${now}
191
+ ORDER BY next_attempt_at
192
+ FOR UPDATE SKIP LOCKED
193
+ LIMIT 1
194
+ )
195
+ RETURNING id, org_id, project_id, thread_id, attempts, event
196
+ `;
197
+ if (!row)
198
+ return { did: 0 };
199
+ /**
200
+ * WHO this row is for. Absent `toReviewer` means the project owner —
201
+ * which is also what every row queued before the fan-out existed means,
202
+ * so the absence is the compatibility story rather than a version flag.
203
+ */
204
+ const to = await recipient(sql, row, keys, baseUrl);
205
+ if (!to) {
206
+ // Nothing to send this to is not a transient failure and is not
207
+ // retried: it is `dropped`, the status 07 §2.5 has for exactly this.
208
+ // Reached when the owner is gone, or when a reviewer unsubscribed,
209
+ // was blocked or erased between enqueue and send — which is a real
210
+ // window, and re-checking here is what closes it.
211
+ await sql `UPDATE outbox SET status = 'dropped', last_error = 'no deliverable recipient', completed_at = ${now} WHERE id = ${row.id}`;
212
+ return { did: 0, more: true, note: "one notification had no recipient — dropped" };
213
+ }
214
+ /**
215
+ * PER-ADDRESS RATE LIMIT — B2's last piece.
216
+ *
217
+ * A busy round can produce a burst: five people replying on one thread
218
+ * inside a minute is four notifications each. That is not abuse, it is
219
+ * a lively review, and the remedy is not to drop mail but to slow it —
220
+ * so an address over its hourly allowance has the row DEFERRED to the
221
+ * end of its window rather than dropped. Nothing is lost; it arrives
222
+ * late, which is the correct trade for a notification.
223
+ *
224
+ * The bucket key is a HASH of the address, never the address itself.
225
+ * `rate_counters` is a durable table that outlives the mail it governs,
226
+ * and 08 §4.7's rule about not keeping raw identifiers applies to a
227
+ * counter as much as to a log.
228
+ */
229
+ const over = await overRateLimit(sql, row.org_id, to.email, now);
230
+ if (over) {
231
+ await sql `
232
+ UPDATE outbox
233
+ SET status = 'pending', next_attempt_at = ${over},
234
+ last_error = 'rate limited for this address'
235
+ WHERE id = ${row.id}`;
236
+ return { did: 0, more: true, note: "one notification deferred — address over its hourly rate" };
237
+ }
238
+ try {
239
+ await mailer.send({ ...render(row, to.email, baseUrl), ...(to.headers ? { headers: to.headers } : {}) });
240
+ await sql `
241
+ UPDATE outbox SET status = 'done', completed_at = ${now}, last_error = NULL
242
+ WHERE id = ${row.id}`;
243
+ return { did: 1, more: true };
244
+ }
245
+ catch (e) {
246
+ const message = e instanceof Error ? e.message : String(e);
247
+ const wait = BACKOFF_MIN[Math.min(row.attempts - 1, BACKOFF_MIN.length - 1)];
248
+ const exhausted = row.attempts >= BACKOFF_MIN.length;
249
+ await sql `
250
+ UPDATE outbox
251
+ SET status = ${exhausted ? "failed" : "pending"},
252
+ last_error = ${message},
253
+ next_attempt_at = ${new Date(now.getTime() + wait * 60_000)}
254
+ WHERE id = ${row.id}`;
255
+ log(`jobs: deliver-email: attempt ${row.attempts} failed: ${message}`);
256
+ // `more` even on failure: the NEXT row may be fine, and one bad
257
+ // recipient must not stall the queue behind it.
258
+ return { did: 0, more: true };
259
+ }
260
+ },
261
+ };
262
+ }
263
+ /**
264
+ * How many notifications one address may receive per hour.
265
+ *
266
+ * A constant rather than a registered variable, on purpose: 08 §4's config is
267
+ * generated from the spec, and a number nobody has yet wanted to change is not
268
+ * worth a variable an operator has to read past. It becomes one the first time
269
+ * somebody asks — which is a better trigger than a guess.
270
+ *
271
+ * Twelve is roughly "a busy thread every five minutes" and well under what any
272
+ * provider treats as a pattern.
273
+ */
274
+ const PER_ADDRESS_HOURLY = 12;
275
+ const RATE_WINDOW_MS = 60 * 60 * 1000;
276
+ /**
277
+ * Count this send against the address's hourly allowance.
278
+ *
279
+ * Returns null when it is within the allowance, or the time the window resets
280
+ * when it is not. The counter is incremented BEFORE the send, so a provider
281
+ * timeout still consumes the allowance — deliberately, because the alternative
282
+ * is a failing address retrying without limit.
283
+ */
284
+ async function overRateLimit(sql, orgId, email, now) {
285
+ const key = `mail:${createHash("sha256").update(email.toLowerCase()).digest("base64url")}`;
286
+ const windowStart = new Date(now.getTime() - RATE_WINDOW_MS);
287
+ const [row] = await sql `
288
+ INSERT INTO rate_counters (org_id, bucket_key, window_start, count, expires_at)
289
+ VALUES (${orgId}, ${key}, ${now}, 1, ${new Date(now.getTime() + RATE_WINDOW_MS)})
290
+ ON CONFLICT (org_id, bucket_key) DO UPDATE
291
+ SET count = CASE WHEN rate_counters.window_start < ${windowStart} THEN 1
292
+ ELSE rate_counters.count + 1 END,
293
+ window_start = CASE WHEN rate_counters.window_start < ${windowStart} THEN ${now}
294
+ ELSE rate_counters.window_start END,
295
+ expires_at = CASE WHEN rate_counters.window_start < ${windowStart}
296
+ THEN ${new Date(now.getTime() + RATE_WINDOW_MS)}
297
+ ELSE rate_counters.expires_at END
298
+ RETURNING count::text, window_start
299
+ `;
300
+ if (!row)
301
+ return null;
302
+ if (Number(row.count) <= PER_ADDRESS_HOURLY)
303
+ return null;
304
+ return new Date(new Date(row.window_start).getTime() + RATE_WINDOW_MS);
305
+ }
306
+ /**
307
+ * The address this row goes to, and the headers that go with it.
308
+ *
309
+ * Returns null when there is nobody to send to — including the reviewer who
310
+ * unsubscribed AFTER the row was queued. That re-check is not belt-and-braces:
311
+ * the outbox retries over an hour, and "I clicked unsubscribe and then got
312
+ * another one" is precisely the complaint the feature exists to prevent.
313
+ *
314
+ * Only REVIEWER mail carries `List-Unsubscribe`. The owner is the operator of
315
+ * the instance: they did not receive a message from a stranger, they configured
316
+ * the mailer that sends it, and the control they need is a setting rather than
317
+ * a link. Giving them a one-click opt-out signed over a reviewer id they do not
318
+ * have would be a link that cannot work.
319
+ */
320
+ async function recipient(sql, row, keys, baseUrl) {
321
+ const reviewerId = row.event.toReviewer;
322
+ if (reviewerId) {
323
+ const [r] = await sql `
324
+ SELECT email FROM reviewers
325
+ WHERE id = ${reviewerId}::uuid
326
+ AND notifications_off_at IS NULL
327
+ AND blocked_at IS NULL
328
+ AND erased_at IS NULL`;
329
+ if (!r?.email)
330
+ return null;
331
+ // No keyring means no signable link, and an unsubscribe header we cannot
332
+ // honour is worse than none: send the mail without it rather than mint a
333
+ // URL that 404s.
334
+ return keys
335
+ ? { email: r.email, headers: unsubscribeHeaders(baseUrl, unsubscribeToken(keys, reviewerId)) }
336
+ : { email: r.email };
337
+ }
338
+ const [owner] = await sql `
339
+ SELECT u.email
340
+ FROM memberships m
341
+ JOIN users u ON u.id = m.user_id
342
+ WHERE m.org_id = ${row.org_id} AND m.role = 'owner' AND u.deleted_at IS NULL
343
+ ORDER BY m.created_at
344
+ LIMIT 1`;
345
+ return owner ? { email: owner.email } : null;
346
+ }
347
+ /**
348
+ * The message.
349
+ *
350
+ * Every reviewer-supplied string goes through the sanitiser (L1.3) before it
351
+ * reaches a subject line or a body — this is a channel we do not control, and
352
+ * the name and body are exactly the Class R/P fields 07 §2.6 is about. The
353
+ * body additionally gets the redactor, because a comment can contain anything
354
+ * the reviewer typed, including an address or a token they pasted in.
355
+ *
356
+ * Text only, no HTML. An HTML part would need its own escaping rules on top of
357
+ * the ones above, and the message is four lines: who, where, what, and a link.
358
+ * There is nothing here that a `<div>` would make clearer.
359
+ */
360
+ function render(row, to, baseUrl) {
361
+ const key = sanitise(row.event.threadKey ?? "a snag", "plain", { singleLine: true, max: 24 });
362
+ const author = sanitise(row.event.authorName ?? "Someone", "plain", { singleLine: true, max: 60 });
363
+ const body = sanitiseWithRedactor(row.event.body ?? "", "plain", redactText, { max: 2000 });
364
+ const page = row.event.pageUrl ?? "";
365
+ /**
366
+ * `thread.resolved` — a different message, not the comment one with a
367
+ * different noun. It carries NO body, because there is no new text: the
368
+ * event is a state change, and padding it with the original comment would
369
+ * re-send words the recipient already has.
370
+ */
371
+ if (row.event.resolved) {
372
+ const actor = sanitise(row.event.actorName ?? "Someone", "plain", { singleLine: true, max: 60 });
373
+ return {
374
+ to,
375
+ subject: `${key} was marked resolved`,
376
+ text: [
377
+ `${actor} marked ${key} resolved.`,
378
+ "",
379
+ page ? `On the page: ${page}` : "",
380
+ `See the round: ${baseUrl}/dashboard`,
381
+ "",
382
+ "— Snagset",
383
+ ]
384
+ .filter((line, i, all) => !(line === "" && all[i - 1] === ""))
385
+ .join("\n"),
386
+ };
387
+ }
388
+ return {
389
+ to,
390
+ subject: `${key}: new comment from ${author}`,
391
+ text: [
392
+ `${author} commented on ${key}:`,
393
+ "",
394
+ body,
395
+ "",
396
+ page ? `On the page: ${page}` : "",
397
+ `Open the review: ${baseUrl}/dashboard`,
398
+ "",
399
+ "— Snagset",
400
+ ]
401
+ .filter((line, i, all) => !(line === "" && all[i - 1] === ""))
402
+ .join("\n"),
403
+ };
404
+ }
405
+ //# sourceMappingURL=notify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.js","sourceRoot":"","sources":["../../src/mail/notify.ts"],"names":[],"mappings":"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"}