@snagset/server 0.1.10 → 0.1.12

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 (107) hide show
  1. package/dist/app.js +23 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/config.generated.js +2 -1
  4. package/dist/config.generated.js.map +1 -1
  5. package/dist/db/migrate.js +44 -0
  6. package/dist/db/migrate.js.map +1 -1
  7. package/dist/db/migrations/0008_outbox_org_rows.sql +29 -0
  8. package/dist/db/migrations/0009_member_invites.sql +84 -0
  9. package/dist/jobs/set.js +15 -4
  10. package/dist/jobs/set.js.map +1 -1
  11. package/dist/mail/digest.js +291 -0
  12. package/dist/mail/digest.js.map +1 -0
  13. package/dist/mail/links.js +41 -0
  14. package/dist/mail/links.js.map +1 -0
  15. package/dist/mail/notify.js +126 -25
  16. package/dist/mail/notify.js.map +1 -1
  17. package/dist/net/publicFetch.js +202 -0
  18. package/dist/net/publicFetch.js.map +1 -0
  19. package/dist/routes/commentEdit.js +20 -5
  20. package/dist/routes/commentEdit.js.map +1 -1
  21. package/dist/routes/imageProxy.js +77 -0
  22. package/dist/routes/imageProxy.js.map +1 -0
  23. package/dist/routes/invites.js +417 -0
  24. package/dist/routes/invites.js.map +1 -0
  25. package/dist/routes/members.js +36 -68
  26. package/dist/routes/members.js.map +1 -1
  27. package/dist/routes/owner.js +5 -0
  28. package/dist/routes/owner.js.map +1 -1
  29. package/dist/routes/threads.js +182 -42
  30. package/dist/routes/threads.js.map +1 -1
  31. package/package.json +3 -3
  32. package/static/dashboard/assets/CompositeItem-CYCgjQYA.js +1 -0
  33. package/static/dashboard/assets/{ConfirmDialog-eCzw7vjK.js → ConfirmDialog-ChIOOj7I.js} +1 -1
  34. package/static/dashboard/assets/{CopyButton-lcjux1TA.js → CopyButton-V7OJiMLP.js} +1 -1
  35. package/static/dashboard/assets/{FieldItemContext-i5zdyQYl.js → FieldItemContext-DVtdzu3C.js} +1 -1
  36. package/static/dashboard/assets/{InstallTag-Eqs3RM9e.js → InstallTag-OD-dW_hC.js} +1 -1
  37. package/static/dashboard/assets/{SaveState-Cf_bnJmg.js → SaveState-JVgANqCr.js} +1 -1
  38. package/static/dashboard/assets/{_app-CuKLSKZ2.js → _app-B0TJyHg-.js} +1 -1
  39. package/static/dashboard/assets/_app-Bwss1pXe.js +56 -0
  40. package/static/dashboard/assets/activity-CbcgWM3L.js +1 -0
  41. package/static/dashboard/assets/{arrow-left-CQUHEFUP.js → arrow-left-BrmSwEeI.js} +1 -1
  42. package/static/dashboard/assets/{avatar-CWT37Ivf.js → avatar-CwD1_B-n.js} +1 -1
  43. package/static/dashboard/assets/{badge-thtKAJ0h.js → badge-DM_IoUbC.js} +1 -1
  44. package/static/dashboard/assets/{card-CsXE7vKD.js → card-DpmJQuox.js} +1 -1
  45. package/static/dashboard/assets/{check-Bc_1fmPx.js → check-DNKwULUa.js} +1 -1
  46. package/static/dashboard/assets/{chevron-right-neQQkaOt.js → chevron-right-ob3VZye2.js} +1 -1
  47. package/static/dashboard/assets/{copy-O3USBatC.js → copy-CyBHfvzN.js} +1 -1
  48. package/static/dashboard/assets/{dialog-MC-TOvQV.js → dialog-Bys-LJLw.js} +2 -2
  49. package/static/dashboard/assets/{dropdown-menu-C2ZhmONu.js → dropdown-menu-Cqas_wS3.js} +2 -2
  50. package/static/dashboard/assets/{image-off-Bu6l8gP5.js → image-off-D_65_17F.js} +1 -1
  51. package/static/dashboard/assets/index-Cxnlb88o.js +169 -0
  52. package/static/dashboard/assets/index-Ddi7bl87.css +1 -0
  53. package/static/dashboard/assets/invite-Cc7l9fdY.js +1 -0
  54. package/static/dashboard/assets/label-CwqSv8L3.js +1 -0
  55. package/static/dashboard/assets/{layout-grid-rYWIGaIJ.js → layout-grid-PTAyhqU8.js} +1 -1
  56. package/static/dashboard/assets/me-kMczOn0k.js +1 -0
  57. package/static/dashboard/assets/{parts-kPNZVO9H.js → parts-CErghVAB.js} +1 -1
  58. package/static/dashboard/assets/{plus-CcJ1Zwa7.js → plus-BE-gOo3v.js} +1 -1
  59. package/static/dashboard/assets/s._site-BBRI74gv.js +1 -0
  60. package/static/dashboard/assets/s._site.comments-CbfjF9-s.js +54 -0
  61. package/static/dashboard/assets/{s._site.reviewers-Djq2t6Dx.js → s._site.reviewers-BMiqVuRm.js} +1 -1
  62. package/static/dashboard/assets/s._site.settings-CEvRwMQt.js +1 -0
  63. package/static/dashboard/assets/{setup-D748SEIm.js → setup-CkNn_rwp.js} +1 -1
  64. package/static/dashboard/assets/{sign-in-CKSiZWCo.js → sign-in-Bz6iRxcc.js} +1 -1
  65. package/static/dashboard/assets/{sign-up-BX9j_Ya2.js → sign-up-QcstGuaV.js} +1 -1
  66. package/static/dashboard/assets/sites-DJjGT-f2.js +1 -0
  67. package/static/dashboard/assets/studio-mKw0u2SL.js +1 -0
  68. package/static/dashboard/assets/studio-qqUvuQo5.js +1 -0
  69. package/static/dashboard/assets/studio.index-zjJeqf8B.js +1 -0
  70. package/static/dashboard/assets/studio.integrations-C9-R6CQZ.js +1 -0
  71. package/static/dashboard/assets/studio.members-BYd5gcvp.js +6 -0
  72. package/static/dashboard/assets/{sun-CWPmS6_O.js → sun-DTQiNbTt.js} +1 -1
  73. package/static/dashboard/assets/switch-Cqqz6oQN.js +1 -0
  74. package/static/dashboard/assets/{table-CiZO2uuW.js → table-D_36h1Oq.js} +1 -1
  75. package/static/dashboard/assets/{useAriaLabelledBy-oZFqC809.js → useAriaLabelledBy-D-dab2TD.js} +1 -1
  76. package/static/dashboard/assets/{useBaseQuery-CHgLQD-M.js → useBaseQuery-DmDP3Pxx.js} +1 -1
  77. package/static/dashboard/assets/useQuery-CKn14AcE.js +1 -0
  78. package/static/dashboard/assets/useRender-B_JrSXOO.js +1 -0
  79. package/static/dashboard/assets/{useValueChanged-Dvr7PvAV.js → useValueChanged--Isnj6ZH.js} +1 -1
  80. package/static/dashboard/assets/{users-Ci-ogubz.js → users-CJVqlkra.js} +1 -1
  81. package/static/dashboard/assets/{welcome-BDWw5Qlv.js → welcome-BMr4vd5V.js} +2 -2
  82. package/static/dashboard/assets/{you-DAUNHAFI.js → you-Bqw_r3i7.js} +1 -1
  83. package/static/dashboard/assets/{zod-CtjFmGX5.js → zod-BMw0fS-7.js} +1 -1
  84. package/static/dashboard/index.html +2 -2
  85. package/static/widget/snag.capture.js +1 -1
  86. package/static/widget/snag.dock.js +1 -1
  87. package/static/widget/snag.runtime.js +2 -2
  88. package/static/widget/snag.screenshot.js +1 -1
  89. package/static/dashboard/assets/CompositeItem-Db_8iHSG.js +0 -1
  90. package/static/dashboard/assets/_app-qNVG5lsX.js +0 -56
  91. package/static/dashboard/assets/activity-CQLBFIV4.js +0 -1
  92. package/static/dashboard/assets/index-BR4Mnw-o.css +0 -1
  93. package/static/dashboard/assets/index-C6gyyLd5.js +0 -169
  94. package/static/dashboard/assets/label-BTdAT39L.js +0 -1
  95. package/static/dashboard/assets/me-CI9giaT9.js +0 -1
  96. package/static/dashboard/assets/s._site-D0HRtia-.js +0 -1
  97. package/static/dashboard/assets/s._site.comments-D3ACsr3a.js +0 -39
  98. package/static/dashboard/assets/s._site.settings-D0b6vJci.js +0 -1
  99. package/static/dashboard/assets/sites-QwJy87Y8.js +0 -1
  100. package/static/dashboard/assets/studio-B_ma5JGA.js +0 -1
  101. package/static/dashboard/assets/studio-HH_J6PPv.js +0 -1
  102. package/static/dashboard/assets/studio.index-DrtJF7DT.js +0 -1
  103. package/static/dashboard/assets/studio.integrations-Cv34ukzT.js +0 -1
  104. package/static/dashboard/assets/studio.members-DTbKcrEI.js +0 -1
  105. package/static/dashboard/assets/switch-CzijGUed.js +0 -1
  106. package/static/dashboard/assets/useQuery-C6FyjMS-.js +0 -1
  107. package/static/dashboard/assets/useRender-D5jZ1wns.js +0 -1
@@ -0,0 +1,291 @@
1
+ import { 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 { dashboardUrl, threadUrl } from "./links.js";
6
+ /**
7
+ * The daily digest — D120, on Eurico's "we don't need to send the email every
8
+ * time there is a comment, maybe once in a while".
9
+ *
10
+ * `SNAGSET_MAIL_NOTIFY=digest` (the default) changes ONE thing: how the
11
+ * project OWNER hears about `comment.created`. Reviewer mail — a reply on a
12
+ * thread they took part in, a snag marked resolved — stays per event, because
13
+ * a reviewer hears about one conversation they are waiting on, not about a
14
+ * whole studio's day.
15
+ *
16
+ * ── NO NEW SCHEDULER, NO NEW TABLE ──
17
+ *
18
+ * The owner's row is queued exactly as before, in the comment's transaction,
19
+ * with the same dedupe key (`notify.ts`). In digest mode the delivery job
20
+ * simply does not claim it. This job FOLDS those rows: once a day it takes
21
+ * every pending owner row queued before midnight UTC, writes ONE outbox row
22
+ * of topic `comment.digest` that lists them, and marks them `done` — in one
23
+ * transaction, the job engine's. The digest row is then an ordinary outbox
24
+ * row, delivered by the ordinary delivery job, with its claim-before-send,
25
+ * its backoff and its per-address rate limit. Nothing about sending is
26
+ * written twice.
27
+ *
28
+ * ── HOW "AT MOST ONE A DAY" IS ACTUALLY ACHIEVED ──
29
+ *
30
+ * 1. **The day is in the dedupe key.** `comment.digest:<orgId>:<YYYY-MM-DD>`
31
+ * under the outbox's `UNIQUE (provider, dedupe_key)`. A second build on
32
+ * the same UTC day — the bin's next hourly tick, an operator forcing the
33
+ * cron route, two instances at once — cannot create a second row, and an
34
+ * org that already has today's row is not even considered.
35
+ * 2. **The window is fixed, not "since the last run".** Only rows queued
36
+ * BEFORE today's midnight (UTC) are folded. A comment at 09:15 is not
37
+ * picked up by a 10:00 tick that finds today's digest unbuilt — it waits
38
+ * for tomorrow's, with everything else from today. Without this, a quiet
39
+ * night would make the first comment of the morning its own digest and
40
+ * push the rest of the day to the next one: the time the mail arrives
41
+ * would follow the traffic rather than the clock.
42
+ * 3. **Fold and record are one transaction.** The digest row's INSERT and
43
+ * the `done` on the rows it lists commit together or not at all, so a
44
+ * crash cannot fold rows into a digest that does not exist, nor build a
45
+ * digest whose rows stay pending to be folded again tomorrow.
46
+ *
47
+ * Last-sent is therefore not a separate value anywhere: it IS the newest
48
+ * `comment.digest` row, and its status says whether it went — the outbox
49
+ * already holds what "when did we last send, and did it work" needs. The one
50
+ * schema change is 0008: a digest is the STUDIO's mail, not a site's, so its
51
+ * row carries no `project_id`, and deleting a site cannot cascade away a
52
+ * digest that also lists the others.
53
+ *
54
+ * Whatever runs the sweep runs this: the platform's daily cron (03:00 UTC in
55
+ * the scaffold, so the digest arrives then), the bin's hourly ticker (so the
56
+ * first tick after midnight), or `/api/jobs/run` by hand. NOT the drain a
57
+ * write starts (D119): a comment is never the reason to send a digest, and the
58
+ * drain running this would only find the day's window already closed.
59
+ */
60
+ export const DIGEST_TOPIC = "comment.digest";
61
+ /** The topic the folded rows carry — `notify.ts`'s, restated to avoid a cycle. */
62
+ const COMMENT_TOPIC = "comment.created";
63
+ /**
64
+ * How many comments the mail LISTS. Every pending row is folded and counted;
65
+ * past this the mail says how many more there are and links to the
66
+ * dashboard. The outbox's `CHECK (octet_length(event::text) <= 65536)` is the
67
+ * hard reason — a digest row over it would fail to insert and take the fold
68
+ * with it — and a mail of fifty excerpts is also past where anybody reads.
69
+ * At the clamps below an item is under 1 KB, so fifty fit with room.
70
+ */
71
+ export const DIGEST_LISTED = 50;
72
+ const EXCERPT_MAX = 140;
73
+ const PAGE_MAX = 300;
74
+ const NAME_MAX = 60;
75
+ /** Midnight UTC at the start of `now`'s day — the close of the digest window. */
76
+ export function digestCutoff(now) {
77
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
78
+ }
79
+ export function digestKey(orgId, cutoff) {
80
+ return `${DIGEST_TOPIC}:${orgId}:${cutoff.toISOString().slice(0, 10)}`;
81
+ }
82
+ /**
83
+ * The job. Registered only in `digest` mode and only in the sweep
84
+ * (`jobs/set.ts`).
85
+ *
86
+ * `everyMs` is fifteen minutes and not a day, on purpose: the DAY is enforced
87
+ * by the dedupe key, not by the engine's schedule. A daily `everyMs` would
88
+ * make a cron that fires at 03:00:02 today and 03:00:01 tomorrow "not due",
89
+ * and skip a whole day for a second's jitter.
90
+ */
91
+ export function buildDigests(mailer) {
92
+ return {
93
+ name: "digest-email",
94
+ everyMs: 15 * 60_000,
95
+ async run({ sql, now }) {
96
+ // No transport, no digest: the rows stay pending and are folded the
97
+ // first day there is a mailer, rather than into a digest nothing sends.
98
+ if (!isConfigured(mailer))
99
+ return { did: 0 };
100
+ const cutoff = digestCutoff(now);
101
+ const day = cutoff.toISOString().slice(0, 10);
102
+ // ONE ORG PER PASS, the delivery job's bargain: `more: true` and the
103
+ // engine drains. The NOT EXISTS is what makes an org that already has
104
+ // today's digest drop out, so a row that arrives late for a closed
105
+ // window cannot keep this job spinning on a conflict.
106
+ const [next] = await sql `
107
+ SELECT o.org_id
108
+ FROM outbox o
109
+ WHERE o.provider = 'email'
110
+ AND o.topic = ${COMMENT_TOPIC}
111
+ AND o.status = 'pending'
112
+ AND o.event->>'toReviewer' IS NULL
113
+ AND o.created_at < ${cutoff}
114
+ AND NOT EXISTS (
115
+ SELECT 1 FROM outbox d
116
+ WHERE d.provider = 'email'
117
+ AND d.dedupe_key = ${`${DIGEST_TOPIC}:`}::text || o.org_id::text || ${`:${day}`}::text
118
+ )
119
+ ORDER BY o.created_at
120
+ LIMIT 1`;
121
+ if (!next)
122
+ return { did: 0 };
123
+ const orgId = next.org_id;
124
+ /**
125
+ * The rows, LOCKED. `FOR UPDATE OF o` so that a delivery job switched
126
+ * to `each` mid-fold (an operator changing the variable and restarting
127
+ * one instance of several) cannot claim a row this digest is about to
128
+ * list; `SKIP LOCKED` so that the opposite case skips rather than waits.
129
+ */
130
+ const rows = await sql `
131
+ SELECT o.id, o.project_id, o.event,
132
+ p.name AS site_name, p.uid AS site_uid,
133
+ t.key AS thread_key, r.number AS round,
134
+ (p.deleted_at IS NOT NULL OR t.deleted_at IS NOT NULL OR c.deleted_at IS NOT NULL) AS gone
135
+ FROM outbox o
136
+ JOIN projects p ON p.id = o.project_id
137
+ LEFT JOIN threads t ON t.id = o.thread_id
138
+ LEFT JOIN rounds r ON r.id = t.round_id
139
+ LEFT JOIN comments c ON c.id = (
140
+ CASE WHEN o.event->>'commentId' ~ '^[0-9a-fA-F-]{36}$'
141
+ THEN (o.event->>'commentId')::uuid END
142
+ )
143
+ WHERE o.org_id = ${orgId}
144
+ AND o.provider = 'email'
145
+ AND o.topic = ${COMMENT_TOPIC}
146
+ AND o.status = 'pending'
147
+ AND o.event->>'toReviewer' IS NULL
148
+ AND o.created_at < ${cutoff}
149
+ ORDER BY o.created_at
150
+ FOR UPDATE OF o SKIP LOCKED`;
151
+ // Every row locked by somebody else: they are working on it. Stop
152
+ // rather than ask again, or this pass would repeat until maxPasses.
153
+ if (rows.length === 0)
154
+ return { did: 0 };
155
+ // A comment deleted before its digest is not news. Dropped, with the
156
+ // status 07 §2.5 has for "nothing to send", rather than listed.
157
+ const gone = rows.filter((r) => r.gone).map((r) => r.id);
158
+ const live = rows.filter((r) => !r.gone);
159
+ if (gone.length) {
160
+ await sql `
161
+ UPDATE outbox SET status = 'dropped', last_error = 'deleted before the digest', completed_at = ${now}
162
+ WHERE id = ANY(${gone}::uuid[])`;
163
+ }
164
+ if (live.length === 0)
165
+ return { did: 0, more: true };
166
+ const event = fold(live, cutoff);
167
+ const [made] = await sql `
168
+ INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event, next_attempt_at)
169
+ VALUES (
170
+ ${randomUUID()}, ${orgId},
171
+ -- No site: a digest is the STUDIO's mail, and 0008 lets this one
172
+ -- topic leave project_id empty. Pinning it to one of the sites it
173
+ -- lists would let deleting that site cascade the digest away, and
174
+ -- with it every other site's comments.
175
+ NULL,
176
+ NULL, 'email', ${DIGEST_TOPIC}, ${digestKey(orgId, cutoff)}, ${sql.json(event)},
177
+ -- The job's clock, not the database's: the delivery job claims with
178
+ -- next_attempt_at <= now in the same sweep, on the same clock.
179
+ ${now}
180
+ )
181
+ ON CONFLICT (provider, dedupe_key) DO NOTHING
182
+ RETURNING id`;
183
+ // Today's digest exists after all (NOT EXISTS above is a read; this is
184
+ // the constraint). Leave the rows pending for tomorrow's: folding them
185
+ // into nothing would be mail that silently never happens.
186
+ if (!made)
187
+ return { did: 0 };
188
+ /**
189
+ * `done`, because their news now lives in the digest row, which carries
190
+ * its own status. A digest that fails for good is `failed` and stays on
191
+ * the list of mail that never sent; marking these rows anything that
192
+ * the delivery job would pick up again is how one comment reaches the
193
+ * owner twice.
194
+ */
195
+ await sql `
196
+ UPDATE outbox SET status = 'done', completed_at = ${now}, last_error = NULL
197
+ WHERE id = ANY(${live.map((r) => r.id)}::uuid[])`;
198
+ return { did: 1, more: true, note: `digest of ${live.length} comment(s) queued for one owner` };
199
+ },
200
+ };
201
+ }
202
+ /**
203
+ * Pending rows → the digest's event, grouped by site, oldest first.
204
+ *
205
+ * The excerpt is REDACTED HERE, before it is clamped — the order matters.
206
+ * Clamping first can cut an address or a token in half, and half a token is
207
+ * one the redactor no longer recognises, so the mail would carry the half.
208
+ */
209
+ function fold(rows, cutoff) {
210
+ const bySite = new Map();
211
+ let listed = 0;
212
+ for (const r of rows) {
213
+ let site = bySite.get(r.project_id);
214
+ if (!site) {
215
+ site = { name: clip(r.site_name, NAME_MAX), uid: r.site_uid, count: 0, items: [] };
216
+ bySite.set(r.project_id, site);
217
+ }
218
+ site.count++;
219
+ if (listed >= DIGEST_LISTED)
220
+ continue;
221
+ listed++;
222
+ site.items.push({
223
+ threadKey: clip(r.thread_key ?? r.event.threadKey ?? "a snag", 24),
224
+ round: r.round,
225
+ author: clip(r.event.authorName ?? "Someone", NAME_MAX),
226
+ excerpt: sanitiseWithRedactor(r.event.body ?? "", "plain", redactText, { singleLine: true, max: EXCERPT_MAX }),
227
+ page: clip(r.event.pageUrl ?? "", PAGE_MAX),
228
+ });
229
+ }
230
+ return { digest: true, through: cutoff.toISOString(), total: rows.length, sites: [...bySite.values()] };
231
+ }
232
+ const clip = (s, max) => (s.length > max ? s.slice(0, max) : s);
233
+ /**
234
+ * The digest as it should be sent NOW: without the sites deleted since it was
235
+ * folded. Null when none is left, which the delivery job drops.
236
+ *
237
+ * Read at send time because the gap is real — a digest folded at 00:05 can be
238
+ * waiting on a rate limit or a retry when the owner deletes a site — and a
239
+ * mail that lists a deleted site's comments links to a page that no longer
240
+ * opens. A site delete is SOFT (`routes/sites.ts`), so it is `deleted_at` that
241
+ * removes it here; a site removed outright is simply not found. Its folded
242
+ * comment rows stay `done`: they were news about a site the owner has since
243
+ * decided they do not want, and re-queueing them would be mail about nothing.
244
+ */
245
+ export async function pruneDigest(sql, orgId, event) {
246
+ const uids = (event.sites ?? []).map((s) => s.uid);
247
+ if (uids.length === 0)
248
+ return null;
249
+ const alive = new Set((await sql `
250
+ SELECT uid FROM projects
251
+ WHERE org_id = ${orgId} AND uid = ANY(${uids}::text[]) AND deleted_at IS NULL`).map((r) => r.uid));
252
+ const sites = event.sites.filter((s) => alive.has(s.uid));
253
+ if (sites.length === 0)
254
+ return null;
255
+ if (sites.length === event.sites.length)
256
+ return event;
257
+ return { ...event, sites, total: sites.reduce((n, s) => n + s.count, 0) };
258
+ }
259
+ /**
260
+ * The digest mail. Text only, like every other mail here, and for the same
261
+ * reason: who, where, what and a link need no `<div>`.
262
+ *
263
+ * Every stored string is sanitised again on the way out, as the per-comment
264
+ * mail does. The excerpt was already redacted at fold time; sanitising it a
265
+ * second time is cheap and means this function never has to trust its input.
266
+ */
267
+ export function renderDigest(event, to, baseUrl) {
268
+ const plain = (s, max) => sanitise(s, "plain", { singleLine: true, max });
269
+ const sites = event.sites;
270
+ const n = event.total;
271
+ const comments = n === 1 ? "1 new comment" : `${n} new comments`;
272
+ const subject = sites.length === 1
273
+ ? `${comments} on ${plain(sites[0].name, NAME_MAX)}`
274
+ : `${comments} across ${sites.length} sites`;
275
+ const lines = [`${comments} since the last digest.`, ""];
276
+ let listed = 0;
277
+ for (const site of sites) {
278
+ lines.push(`${plain(site.name, NAME_MAX)} (${site.count})`);
279
+ for (const item of site.items) {
280
+ listed++;
281
+ const key = plain(item.threadKey, 24);
282
+ lines.push(` ${key} · ${plain(item.author, NAME_MAX)}: ${plain(item.excerpt, EXCERPT_MAX + 1)}`, ...(item.page ? [` On the page: ${plain(item.page, PAGE_MAX)}`] : []), ` ${threadUrl(baseUrl, site.uid, item.threadKey, item.round)}`);
283
+ }
284
+ lines.push("");
285
+ }
286
+ if (n > listed)
287
+ lines.push(`…and ${n - listed} more not listed here.`, "");
288
+ lines.push(`Open the dashboard: ${dashboardUrl(baseUrl)}`, "", "— Snagset", "You get this at most once a day. For one email per comment instead, set SNAGSET_MAIL_NOTIFY=each.");
289
+ return { to, subject, text: lines.join("\n") };
290
+ }
291
+ //# sourceMappingURL=digest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"digest.js","sourceRoot":"","sources":["../../src/mail/digest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where a notification's link goes: the thread, open, in the dashboard (D120).
3
+ *
4
+ * Until D120 every mail linked `<address>/dashboard`, and the dashboard is
5
+ * served at the ROOT of the address (`app.ts`'s fallback, 08 §3), so that link
6
+ * landed on a route the SPA does not have. The comments page has always taken
7
+ * the thread as a search parameter — every view there is a link, D87 — and the
8
+ * round with it, because the list shows the latest round unless told
9
+ * otherwise, and a thread from an earlier round would open to nothing.
10
+ *
11
+ * One function for both mails, the per-comment one and the digest, so the two
12
+ * cannot drift into linking different places for the same thread.
13
+ */
14
+ export function threadUrl(baseUrl, site, key, round) {
15
+ const q = new URLSearchParams({ thread: key });
16
+ if (round !== null)
17
+ q.set("round", String(round));
18
+ return `${baseUrl}/s/${encodeURIComponent(site)}/comments?${q.toString()}`;
19
+ }
20
+ /** The dashboard itself — what a mail links to when it is about no one thread. */
21
+ export function dashboardUrl(baseUrl) {
22
+ return `${baseUrl}/`;
23
+ }
24
+ /**
25
+ * The link for a row that names a thread, read at SEND time: the round and
26
+ * the site's uid are not in the event, and a row queued before D120 has
27
+ * neither. A thread that has gone since falls back to the dashboard rather
28
+ * than to a link that opens nothing.
29
+ */
30
+ export async function threadLink(sql, threadId, baseUrl) {
31
+ if (!threadId)
32
+ return dashboardUrl(baseUrl);
33
+ const [t] = await sql `
34
+ SELECT p.uid, t.key, r.number AS round
35
+ FROM threads t
36
+ JOIN projects p ON p.id = t.project_id
37
+ LEFT JOIN rounds r ON r.id = t.round_id
38
+ WHERE t.id = ${threadId}::uuid AND t.deleted_at IS NULL`;
39
+ return t ? threadUrl(baseUrl, t.uid, t.key, t.round) : dashboardUrl(baseUrl);
40
+ }
41
+ //# sourceMappingURL=links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/mail/links.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,SAAS,CAAC,OAAe,EAAE,IAAY,EAAE,GAAW,EAAE,KAAoB;IACxF,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IAC/C,IAAI,KAAK,KAAK,IAAI;QAAE,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAClD,OAAO,GAAG,OAAO,MAAM,kBAAkB,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC7E,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,GAAG,OAAO,GAAG,CAAC;AACvB,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,GAA2C,EAC3C,QAAuB,EACvB,OAAe;IAEf,IAAI,CAAC,QAAQ;QAAE,OAAO,YAAY,CAAC,OAAO,CAAC,CAAC;IAC5C,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,CAAsD;;;;;oBAKzD,QAAQ,iCAAiC,CAAC;IAC5D,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;AAC/E,CAAC"}
@@ -4,6 +4,8 @@ import { redactText } from "../vendor/widget/predicates.js";
4
4
  import { noteQueued } from "../jobs/soon.js";
5
5
  import { isConfigured } from "./mailer.js";
6
6
  import { unsubscribeHeaders, unsubscribeToken } from "./unsubscribe.js";
7
+ import { DIGEST_TOPIC, pruneDigest, renderDigest } from "./digest.js";
8
+ import { threadLink } from "./links.js";
7
9
  /**
8
10
  * Notification email — L1.4, and deliberately ONE event.
9
11
  *
@@ -16,6 +18,13 @@ import { unsubscribeHeaders, unsubscribeToken } from "./unsubscribe.js";
16
18
  * does not exist yet. The rest lands with full M2-32, behind subscription and
17
19
  * unsubscribe.
18
20
  *
21
+ * Since D120 the owner's half arrives as a DAILY DIGEST by default
22
+ * (`SNAGSET_MAIL_NOTIFY=digest`, 08 §4.8): the rows below are queued exactly
23
+ * as before, the delivery job leaves them alone, and `digest.ts` folds a day's
24
+ * worth into one outbox row of its own. `each` restores one mail per comment.
25
+ * In both modes nobody is mailed about a comment they wrote — see
26
+ * `queueCommentNotice`.
27
+ *
19
28
  * ── HOW "A RETRY NEVER DOUBLE-SENDS" IS ACTUALLY ACHIEVED ──
20
29
  *
21
30
  * Two mechanisms, and both are needed:
@@ -58,18 +67,37 @@ export async function queueCommentNotice(tx, notice, id) {
58
67
  pageUrl: notice.pageUrl,
59
68
  commentId: notice.commentId,
60
69
  };
61
- // THE OWNER's row. No recipient in the event: the delivery job reads that
62
- // absence as "the project owner", which is what every row queued before
63
- // this fan-out existed also means. Backward compatibility by shape rather
64
- // than by version flag.
65
- await tx `
66
- INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
67
- VALUES (
68
- ${id}, ${notice.orgId}, ${notice.projectId}, ${notice.threadId},
69
- 'email', ${TOPIC}, ${`${TOPIC}:${notice.commentId}`}, ${tx.json(payload)}
70
- )
71
- ON CONFLICT (provider, dedupe_key) DO NOTHING
72
- `;
70
+ const owner = await ownerOf(tx, notice.orgId);
71
+ const author = await reviewerIdentity(tx, notice.authorId);
72
+ /**
73
+ * THE OWNER's row — unless the owner wrote this comment (D120).
74
+ *
75
+ * "If there is a comment we made, we don't need to receive the email." The
76
+ * owner writes in two ways and both are theirs: from the dashboard, where
77
+ * the author is their studio reviewer row (`user_id` set), and through the
78
+ * widget on their own site, where the author is whatever reviewer identity
79
+ * they typed — "eurico-test" — with their own address in it. The first is
80
+ * matched by user id, the second by address, case-insensitively, because an
81
+ * address typed into a widget has whatever capitals the hand gave it.
82
+ *
83
+ * Decided HERE, at enqueue, rather than at send: the row that is never
84
+ * written is the one that cannot be sent in `each` mode and cannot be folded
85
+ * into a digest in `digest` mode, so one check covers both.
86
+ *
87
+ * No recipient in the event: the delivery job reads that absence as "the
88
+ * project owner", which is what every row queued before the fan-out existed
89
+ * also means. Backward compatibility by shape rather than by version flag.
90
+ */
91
+ if (!isSamePerson(author, owner)) {
92
+ await tx `
93
+ INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
94
+ VALUES (
95
+ ${id}, ${notice.orgId}, ${notice.projectId}, ${notice.threadId},
96
+ 'email', ${TOPIC}, ${`${TOPIC}:${notice.commentId}`}, ${tx.json(payload)}
97
+ )
98
+ ON CONFLICT (provider, dedupe_key) DO NOTHING
99
+ `;
100
+ }
73
101
  /**
74
102
  * …and ONE ROW PER PARTICIPANT — B2's "created or replied".
75
103
  *
@@ -87,6 +115,12 @@ export async function queueCommentNotice(tx, notice, id) {
87
115
  * Excluded: the author of this very comment (nobody needs mail about their
88
116
  * own typing), anyone with no address, anyone who unsubscribed, and anyone
89
117
  * blocked or erased.
118
+ *
119
+ * …and THE OWNER, under any identity (D120). The owner already has the row
120
+ * above; if they have also replied on this thread from the dashboard, their
121
+ * studio reviewer row is a participant too, and without this they got the
122
+ * same comment twice — and in `digest` mode the participant copy would have
123
+ * arrived at once, around the digest that exists to stop exactly that.
90
124
  */
91
125
  await fanOut(tx, {
92
126
  orgId: notice.orgId,
@@ -95,6 +129,8 @@ export async function queueCommentNotice(tx, notice, id) {
95
129
  topic: TOPIC,
96
130
  keyPrefix: `${TOPIC}:${notice.commentId}`,
97
131
  exclude: notice.authorId,
132
+ excludeEmail: author?.email ?? null,
133
+ excludeOwner: owner,
98
134
  payload,
99
135
  });
100
136
  // D119: the request drains this once its response is ready.
@@ -109,6 +145,9 @@ export async function queueCommentNotice(tx, notice, id) {
109
145
  * somebody who did not ask.
110
146
  */
111
147
  async function fanOut(tx, o) {
148
+ const email = o.excludeEmail ?? null;
149
+ const ownerUser = o.excludeOwner?.userId ?? null;
150
+ const ownerEmail = o.excludeOwner?.email ?? null;
112
151
  const participants = await tx `
113
152
  SELECT DISTINCT r.id
114
153
  FROM comments c
@@ -120,6 +159,9 @@ async function fanOut(tx, o) {
120
159
  AND r.notifications_off_at IS NULL
121
160
  AND r.blocked_at IS NULL
122
161
  AND r.erased_at IS NULL
162
+ AND (${email}::text IS NULL OR lower(r.email) <> lower(${email}::text))
163
+ AND (${ownerUser}::uuid IS NULL OR r.user_id IS DISTINCT FROM ${ownerUser}::uuid)
164
+ AND (${ownerEmail}::text IS NULL OR lower(r.email) <> lower(${ownerEmail}::text))
123
165
  `;
124
166
  for (const p of participants) {
125
167
  await tx `
@@ -133,6 +175,40 @@ async function fanOut(tx, o) {
133
175
  `;
134
176
  }
135
177
  }
178
+ /**
179
+ * THE owner of an org — the first `owner` membership, as it has always been
180
+ * chosen for owner mail. One query in one place, because "who is the owner"
181
+ * answered differently at enqueue (am I mailing myself?) and at send (whom do
182
+ * I mail?) is how the self-check passes and the mail still arrives.
183
+ */
184
+ export async function ownerOf(sql, orgId) {
185
+ const [owner] = await sql `
186
+ SELECT u.id AS user_id, u.email
187
+ FROM memberships m
188
+ JOIN users u ON u.id = m.user_id
189
+ WHERE m.org_id = ${orgId} AND m.role = 'owner' AND u.deleted_at IS NULL
190
+ ORDER BY m.created_at
191
+ LIMIT 1`;
192
+ return owner ? { userId: owner.user_id, email: owner.email } : null;
193
+ }
194
+ /** A reviewer row's link to a person: the dashboard user behind it, and its address. */
195
+ async function reviewerIdentity(sql, reviewerId) {
196
+ const [r] = await sql `
197
+ SELECT user_id, email FROM reviewers WHERE id = ${reviewerId}::uuid`;
198
+ return r ? { userId: r.user_id, email: r.email } : null;
199
+ }
200
+ /**
201
+ * Is this reviewer identity the owner? By user id first — the studio row the
202
+ * dashboard writes with — and then by address, case-insensitively, which is
203
+ * what catches the owner reviewing their own site through the widget.
204
+ */
205
+ export function isSamePerson(who, owner) {
206
+ if (!who || !owner)
207
+ return false;
208
+ if (who.userId && who.userId === owner.userId)
209
+ return true;
210
+ return !!who.email && who.email.trim().toLowerCase() === owner.email.trim().toLowerCase();
211
+ }
136
212
  const RESOLVED = "thread.resolved";
137
213
  /**
138
214
  * "Somebody marked your snag resolved" — B2's second topic.
@@ -157,6 +233,8 @@ export async function queueResolvedNotice(tx, notice) {
157
233
  topic: RESOLVED,
158
234
  keyPrefix: `${RESOLVED}:${notice.threadId}:${notice.version}`,
159
235
  exclude: notice.actorId,
236
+ // The person who clicked, under any identity — the same rule as a comment.
237
+ excludeEmail: (await reviewerIdentity(tx, notice.actorId))?.email ?? null,
160
238
  payload: {
161
239
  threadKey: notice.threadKey,
162
240
  actorName: notice.actorName,
@@ -173,8 +251,14 @@ const BACKOFF_MIN = [1, 5, 15, 30, 60];
173
251
  /**
174
252
  * The delivery job. Registered only when a mailer is configured — with none,
175
253
  * there is nothing to drain and the absence is reported at boot instead.
254
+ *
255
+ * `notify` defaults to `each` HERE — the job's behaviour before D120, for a
256
+ * caller that does not say — while the PRODUCT's default is `digest`, which
257
+ * `jobs/set.ts` reads from the registry. The registry is where a default an
258
+ * operator can see belongs; a function's default is only what it does when
259
+ * nobody asked.
176
260
  */
177
- export function deliverEmail(mailer, address, keys) {
261
+ export function deliverEmail(mailer, address, keys, notify = "each") {
178
262
  return {
179
263
  name: "deliver-email",
180
264
  everyMs: 30_000,
@@ -187,6 +271,11 @@ export function deliverEmail(mailer, address, keys) {
187
271
  // that one provider timeout takes the whole batch's rows to `inflight`
188
272
  // with nothing sent. One row per pass, `more: true` to drain — the
189
273
  // engine's batching, reused rather than reinvented.
274
+ //
275
+ // In `digest` mode the owner's comment rows are NOT claimable: they wait
276
+ // for `digest.ts` to fold them. Excluded in the claim itself rather than
277
+ // claimed and put back, because a row moved to `inflight` and back is a
278
+ // row the "never twice" machinery has to reason about for nothing.
190
279
  const [row] = await sql `
191
280
  UPDATE outbox SET status = 'inflight', attempts = attempts + 1
192
281
  WHERE id = (
@@ -194,11 +283,12 @@ export function deliverEmail(mailer, address, keys) {
194
283
  WHERE provider = 'email'
195
284
  AND status = 'pending'
196
285
  AND next_attempt_at <= ${now}
286
+ ${notify === "digest" ? sql `AND NOT (topic = ${TOPIC} AND event->>'toReviewer' IS NULL)` : sql ``}
197
287
  ORDER BY next_attempt_at
198
288
  FOR UPDATE SKIP LOCKED
199
289
  LIMIT 1
200
290
  )
201
- RETURNING id, org_id, project_id, thread_id, attempts, event
291
+ RETURNING id, org_id, project_id, thread_id, topic, attempts, event
202
292
  `;
203
293
  if (!row)
204
294
  return { did: 0 };
@@ -217,6 +307,17 @@ export function deliverEmail(mailer, address, keys) {
217
307
  await sql `UPDATE outbox SET status = 'dropped', last_error = 'no deliverable recipient', completed_at = ${now} WHERE id = ${row.id}`;
218
308
  return { did: 0, more: true, note: "one notification had no recipient — dropped" };
219
309
  }
310
+ // A digest without the sites deleted since it was folded; with none
311
+ // left there is nothing to say, and it is dropped before it can count
312
+ // against the address's allowance.
313
+ let digest = null;
314
+ if (row.topic === DIGEST_TOPIC) {
315
+ digest = await pruneDigest(sql, row.org_id, row.event);
316
+ if (!digest) {
317
+ await sql `UPDATE outbox SET status = 'dropped', last_error = 'every site in the digest was deleted', completed_at = ${now} WHERE id = ${row.id}`;
318
+ return { did: 0, more: true, note: "one digest listed only deleted sites — dropped" };
319
+ }
320
+ }
220
321
  /**
221
322
  * PER-ADDRESS RATE LIMIT — B2's last piece.
222
323
  *
@@ -241,8 +342,14 @@ export function deliverEmail(mailer, address, keys) {
241
342
  WHERE id = ${row.id}`;
242
343
  return { did: 0, more: true, note: "one notification deferred — address over its hourly rate" };
243
344
  }
345
+ // The digest is its own message (`digest.ts`); every other row links
346
+ // to its thread, read now — outside the `try`, because a failing read
347
+ // is the database's problem, not a send to back off from.
348
+ const mail = digest
349
+ ? renderDigest(digest, to.email, baseUrl)
350
+ : render(row, to.email, await threadLink(sql, row.thread_id, baseUrl));
244
351
  try {
245
- await mailer.send({ ...render(row, to.email, baseUrl), ...(to.headers ? { headers: to.headers } : {}) });
352
+ await mailer.send({ ...mail, ...(to.headers ? { headers: to.headers } : {}) });
246
353
  await sql `
247
354
  UPDATE outbox SET status = 'done', completed_at = ${now}, last_error = NULL
248
355
  WHERE id = ${row.id}`;
@@ -341,13 +448,7 @@ async function recipient(sql, row, keys, baseUrl) {
341
448
  ? { email: r.email, headers: unsubscribeHeaders(baseUrl, unsubscribeToken(keys, reviewerId)) }
342
449
  : { email: r.email };
343
450
  }
344
- const [owner] = await sql `
345
- SELECT u.email
346
- FROM memberships m
347
- JOIN users u ON u.id = m.user_id
348
- WHERE m.org_id = ${row.org_id} AND m.role = 'owner' AND u.deleted_at IS NULL
349
- ORDER BY m.created_at
350
- LIMIT 1`;
451
+ const owner = await ownerOf(sql, row.org_id);
351
452
  return owner ? { email: owner.email } : null;
352
453
  }
353
454
  /**
@@ -363,7 +464,7 @@ async function recipient(sql, row, keys, baseUrl) {
363
464
  * the ones above, and the message is four lines: who, where, what, and a link.
364
465
  * There is nothing here that a `<div>` would make clearer.
365
466
  */
366
- function render(row, to, baseUrl) {
467
+ function render(row, to, link) {
367
468
  const key = sanitise(row.event.threadKey ?? "a snag", "plain", { singleLine: true, max: 24 });
368
469
  const author = sanitise(row.event.authorName ?? "Someone", "plain", { singleLine: true, max: 60 });
369
470
  const body = sanitiseWithRedactor(row.event.body ?? "", "plain", redactText, { max: 2000 });
@@ -383,7 +484,7 @@ function render(row, to, baseUrl) {
383
484
  `${actor} marked ${key} resolved.`,
384
485
  "",
385
486
  page ? `On the page: ${page}` : "",
386
- `See the round: ${baseUrl}/dashboard`,
487
+ `See the thread: ${link}`,
387
488
  "",
388
489
  "— Snagset",
389
490
  ]
@@ -400,7 +501,7 @@ function render(row, to, baseUrl) {
400
501
  body,
401
502
  "",
402
503
  page ? `On the page: ${page}` : "",
403
- `Open the review: ${baseUrl}/dashboard`,
504
+ `Open the thread: ${link}`,
404
505
  "",
405
506
  "— Snagset",
406
507
  ]