@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.
- package/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- 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
|
package/dist/keys.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAgCtD,MAAM,QAAQ,GAA0B;IACtC,kBAAkB;IAClB,gBAAgB;IAChB,gBAAgB;IAChB,cAAc;IACd,aAAa;CACd,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC,MAAM,EAAE,CAAC;AAElE,kFAAkF;AAClF,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;IACzB,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,KAAK,CAAC,MAAc;IAClC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC9E,CAAC;AAkBD,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,QAA6B;IACzE,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,QAAQ,CAAC,8CAA8C,CAAC,CAAC;IAEhF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,MAAM,MAAM,GAAG,CAAC,CAAS,EAAE,OAAmB,EAAU,EAAE;QACxD,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;YAChC,uEAAuE;YACvE,uEAAuE;YACvE,MAAM,IAAI,QAAQ,CAChB,IAAI,OAAO,0DAA0D,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAC5F,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,OAAO,EAAE,CAAC;QACtC,IAAI,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACxB,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,oEAAoE;YACpE,gEAAgE;YAChE,oDAAoD;YACpD,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;YAC3G,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;IAEF,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC;IACjC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAEtD,OAAO;QACL,GAAG,EAAE,UAAU;QACf,MAAM,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;QAC5C,MAAM,CAAC,GAAG,EAAE,OAAO;YACjB,wEAAwE;YACxE,qEAAqE;YACrE,iBAAiB;YACjB,MAAM,EAAE,GAAG,CAAC,CAAS,EAAE,CAAS,EAAW,EAAE,CAC3C,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3E,IAAI,EAAE,CAAC,GAAG,EAAE,UAAU,CAAC;gBAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACxD,IAAI,QAAQ,IAAI,WAAW,IAAI,EAAE,CAAC,GAAG,EAAE,WAAW,CAAC;gBAAE,OAAO,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YACtF,OAAO,IAAI,CAAC;QACd,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -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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"}
|