@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.
- package/dist/app.js +23 -1
- package/dist/app.js.map +1 -1
- package/dist/config.generated.js +2 -1
- package/dist/config.generated.js.map +1 -1
- package/dist/db/migrate.js +44 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/migrations/0008_outbox_org_rows.sql +29 -0
- package/dist/db/migrations/0009_member_invites.sql +84 -0
- package/dist/jobs/set.js +15 -4
- package/dist/jobs/set.js.map +1 -1
- package/dist/mail/digest.js +291 -0
- package/dist/mail/digest.js.map +1 -0
- package/dist/mail/links.js +41 -0
- package/dist/mail/links.js.map +1 -0
- package/dist/mail/notify.js +126 -25
- package/dist/mail/notify.js.map +1 -1
- package/dist/net/publicFetch.js +202 -0
- package/dist/net/publicFetch.js.map +1 -0
- package/dist/routes/commentEdit.js +20 -5
- package/dist/routes/commentEdit.js.map +1 -1
- package/dist/routes/imageProxy.js +77 -0
- package/dist/routes/imageProxy.js.map +1 -0
- package/dist/routes/invites.js +417 -0
- package/dist/routes/invites.js.map +1 -0
- package/dist/routes/members.js +36 -68
- package/dist/routes/members.js.map +1 -1
- package/dist/routes/owner.js +5 -0
- package/dist/routes/owner.js.map +1 -1
- package/dist/routes/threads.js +182 -42
- package/dist/routes/threads.js.map +1 -1
- package/package.json +3 -3
- package/static/dashboard/assets/CompositeItem-CYCgjQYA.js +1 -0
- package/static/dashboard/assets/{ConfirmDialog-eCzw7vjK.js → ConfirmDialog-ChIOOj7I.js} +1 -1
- package/static/dashboard/assets/{CopyButton-lcjux1TA.js → CopyButton-V7OJiMLP.js} +1 -1
- package/static/dashboard/assets/{FieldItemContext-i5zdyQYl.js → FieldItemContext-DVtdzu3C.js} +1 -1
- package/static/dashboard/assets/{InstallTag-Eqs3RM9e.js → InstallTag-OD-dW_hC.js} +1 -1
- package/static/dashboard/assets/{SaveState-Cf_bnJmg.js → SaveState-JVgANqCr.js} +1 -1
- package/static/dashboard/assets/{_app-CuKLSKZ2.js → _app-B0TJyHg-.js} +1 -1
- package/static/dashboard/assets/_app-Bwss1pXe.js +56 -0
- package/static/dashboard/assets/activity-CbcgWM3L.js +1 -0
- package/static/dashboard/assets/{arrow-left-CQUHEFUP.js → arrow-left-BrmSwEeI.js} +1 -1
- package/static/dashboard/assets/{avatar-CWT37Ivf.js → avatar-CwD1_B-n.js} +1 -1
- package/static/dashboard/assets/{badge-thtKAJ0h.js → badge-DM_IoUbC.js} +1 -1
- package/static/dashboard/assets/{card-CsXE7vKD.js → card-DpmJQuox.js} +1 -1
- package/static/dashboard/assets/{check-Bc_1fmPx.js → check-DNKwULUa.js} +1 -1
- package/static/dashboard/assets/{chevron-right-neQQkaOt.js → chevron-right-ob3VZye2.js} +1 -1
- package/static/dashboard/assets/{copy-O3USBatC.js → copy-CyBHfvzN.js} +1 -1
- package/static/dashboard/assets/{dialog-MC-TOvQV.js → dialog-Bys-LJLw.js} +2 -2
- package/static/dashboard/assets/{dropdown-menu-C2ZhmONu.js → dropdown-menu-Cqas_wS3.js} +2 -2
- package/static/dashboard/assets/{image-off-Bu6l8gP5.js → image-off-D_65_17F.js} +1 -1
- package/static/dashboard/assets/index-Cxnlb88o.js +169 -0
- package/static/dashboard/assets/index-Ddi7bl87.css +1 -0
- package/static/dashboard/assets/invite-Cc7l9fdY.js +1 -0
- package/static/dashboard/assets/label-CwqSv8L3.js +1 -0
- package/static/dashboard/assets/{layout-grid-rYWIGaIJ.js → layout-grid-PTAyhqU8.js} +1 -1
- package/static/dashboard/assets/me-kMczOn0k.js +1 -0
- package/static/dashboard/assets/{parts-kPNZVO9H.js → parts-CErghVAB.js} +1 -1
- package/static/dashboard/assets/{plus-CcJ1Zwa7.js → plus-BE-gOo3v.js} +1 -1
- package/static/dashboard/assets/s._site-BBRI74gv.js +1 -0
- package/static/dashboard/assets/s._site.comments-CbfjF9-s.js +54 -0
- package/static/dashboard/assets/{s._site.reviewers-Djq2t6Dx.js → s._site.reviewers-BMiqVuRm.js} +1 -1
- package/static/dashboard/assets/s._site.settings-CEvRwMQt.js +1 -0
- package/static/dashboard/assets/{setup-D748SEIm.js → setup-CkNn_rwp.js} +1 -1
- package/static/dashboard/assets/{sign-in-CKSiZWCo.js → sign-in-Bz6iRxcc.js} +1 -1
- package/static/dashboard/assets/{sign-up-BX9j_Ya2.js → sign-up-QcstGuaV.js} +1 -1
- package/static/dashboard/assets/sites-DJjGT-f2.js +1 -0
- package/static/dashboard/assets/studio-mKw0u2SL.js +1 -0
- package/static/dashboard/assets/studio-qqUvuQo5.js +1 -0
- package/static/dashboard/assets/studio.index-zjJeqf8B.js +1 -0
- package/static/dashboard/assets/studio.integrations-C9-R6CQZ.js +1 -0
- package/static/dashboard/assets/studio.members-BYd5gcvp.js +6 -0
- package/static/dashboard/assets/{sun-CWPmS6_O.js → sun-DTQiNbTt.js} +1 -1
- package/static/dashboard/assets/switch-Cqqz6oQN.js +1 -0
- package/static/dashboard/assets/{table-CiZO2uuW.js → table-D_36h1Oq.js} +1 -1
- package/static/dashboard/assets/{useAriaLabelledBy-oZFqC809.js → useAriaLabelledBy-D-dab2TD.js} +1 -1
- package/static/dashboard/assets/{useBaseQuery-CHgLQD-M.js → useBaseQuery-DmDP3Pxx.js} +1 -1
- package/static/dashboard/assets/useQuery-CKn14AcE.js +1 -0
- package/static/dashboard/assets/useRender-B_JrSXOO.js +1 -0
- package/static/dashboard/assets/{useValueChanged-Dvr7PvAV.js → useValueChanged--Isnj6ZH.js} +1 -1
- package/static/dashboard/assets/{users-Ci-ogubz.js → users-CJVqlkra.js} +1 -1
- package/static/dashboard/assets/{welcome-BDWw5Qlv.js → welcome-BMr4vd5V.js} +2 -2
- package/static/dashboard/assets/{you-DAUNHAFI.js → you-Bqw_r3i7.js} +1 -1
- package/static/dashboard/assets/{zod-CtjFmGX5.js → zod-BMw0fS-7.js} +1 -1
- package/static/dashboard/index.html +2 -2
- package/static/widget/snag.capture.js +1 -1
- package/static/widget/snag.dock.js +1 -1
- package/static/widget/snag.runtime.js +2 -2
- package/static/widget/snag.screenshot.js +1 -1
- package/static/dashboard/assets/CompositeItem-Db_8iHSG.js +0 -1
- package/static/dashboard/assets/_app-qNVG5lsX.js +0 -56
- package/static/dashboard/assets/activity-CQLBFIV4.js +0 -1
- package/static/dashboard/assets/index-BR4Mnw-o.css +0 -1
- package/static/dashboard/assets/index-C6gyyLd5.js +0 -169
- package/static/dashboard/assets/label-BTdAT39L.js +0 -1
- package/static/dashboard/assets/me-CI9giaT9.js +0 -1
- package/static/dashboard/assets/s._site-D0HRtia-.js +0 -1
- package/static/dashboard/assets/s._site.comments-D3ACsr3a.js +0 -39
- package/static/dashboard/assets/s._site.settings-D0b6vJci.js +0 -1
- package/static/dashboard/assets/sites-QwJy87Y8.js +0 -1
- package/static/dashboard/assets/studio-B_ma5JGA.js +0 -1
- package/static/dashboard/assets/studio-HH_J6PPv.js +0 -1
- package/static/dashboard/assets/studio.index-DrtJF7DT.js +0 -1
- package/static/dashboard/assets/studio.integrations-Cv34ukzT.js +0 -1
- package/static/dashboard/assets/studio.members-DTbKcrEI.js +0 -1
- package/static/dashboard/assets/switch-CzijGUed.js +0 -1
- package/static/dashboard/assets/useQuery-C6FyjMS-.js +0 -1
- 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"}
|
package/dist/mail/notify.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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({ ...
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
504
|
+
`Open the thread: ${link}`,
|
|
404
505
|
"",
|
|
405
506
|
"— Snagset",
|
|
406
507
|
]
|