@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `http` adapter — Resend (D57).
|
|
3
|
+
*
|
|
4
|
+
* ONE of the two modules in this codebase allowed to touch a mail transport;
|
|
5
|
+
* `mail.test.ts` asserts there is no third. Everything else depends on
|
|
6
|
+
* `Mailer`.
|
|
7
|
+
*
|
|
8
|
+
* `fetch` and nothing else: Resend's API is one POST with a JSON body and a
|
|
9
|
+
* bearer token, and its own SDK is a wrapper around exactly that. A dependency
|
|
10
|
+
* whose job is to build one request object is a dependency that has to be
|
|
11
|
+
* audited, updated and shipped inside a package whose pitch is `npx snagset`.
|
|
12
|
+
* The same argument settled S3 in this repo (aws4fetch over the AWS SDK) and it
|
|
13
|
+
* settles this.
|
|
14
|
+
*/
|
|
15
|
+
const ENDPOINT = "https://api.resend.com/emails";
|
|
16
|
+
/**
|
|
17
|
+
* How long to wait before giving up on the API.
|
|
18
|
+
*
|
|
19
|
+
* Bounded because the caller is a JOB with a budget (`runDueJobs`), and an
|
|
20
|
+
* un-timed fetch inside a budgeted tick means the budget is a suggestion. Ten
|
|
21
|
+
* seconds is far beyond Resend's normal response and far below the 20s tick
|
|
22
|
+
* budget, so a hung request loses one job's turn rather than the whole tick.
|
|
23
|
+
*/
|
|
24
|
+
const TIMEOUT_MS = 10_000;
|
|
25
|
+
export function createResendMailer(apiKey, from) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "http",
|
|
28
|
+
from,
|
|
29
|
+
async send(mail) {
|
|
30
|
+
const res = await fetch(ENDPOINT, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
authorization: `Bearer ${apiKey}`,
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
from,
|
|
38
|
+
to: [mail.to],
|
|
39
|
+
subject: mail.subject,
|
|
40
|
+
text: mail.text,
|
|
41
|
+
...(mail.html === undefined ? {} : { html: mail.html }),
|
|
42
|
+
// Resend takes custom headers as an object on the send payload.
|
|
43
|
+
...(mail.headers === undefined ? {} : { headers: mail.headers }),
|
|
44
|
+
}),
|
|
45
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw new Error(await describe(res));
|
|
49
|
+
// Resend answers `{ id }`. A body that does not parse is not a failure to
|
|
50
|
+
// send — the mail is already accepted — so the id is dropped rather than
|
|
51
|
+
// the send being reported as failed and retried into a duplicate.
|
|
52
|
+
const id = await res
|
|
53
|
+
.json()
|
|
54
|
+
.then((b) => b.id ?? null)
|
|
55
|
+
.catch(() => null);
|
|
56
|
+
return { id };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Turn a Resend error into a sentence that names the fix.
|
|
62
|
+
*
|
|
63
|
+
* The same reasoning as the S3 error mapper next door: the raw body is JSON
|
|
64
|
+
* around one useful word, and the operator reading this at 9pm needs the next
|
|
65
|
+
* step rather than the status code. The three below are the ones a first
|
|
66
|
+
* configuration actually hits — an unverified sending domain in particular,
|
|
67
|
+
* which is the single most common Resend setup failure and whose message
|
|
68
|
+
* ("domain is not verified") does not say where to go.
|
|
69
|
+
*/
|
|
70
|
+
async function describe(res) {
|
|
71
|
+
const body = await res.text().catch(() => "");
|
|
72
|
+
const message = (() => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(body).message ?? body.slice(0, 200);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return body.slice(0, 200);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
if (res.status === 401 || res.status === 403) {
|
|
81
|
+
return (`resend rejected the API key (${res.status}). Check the key in SNAGSET_MAIL_URL — ` +
|
|
82
|
+
`it starts "re_" and is created at resend.com/api-keys. A key with only ` +
|
|
83
|
+
`"sending access" to a different domain fails here too.`);
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 422 && /domain/i.test(message)) {
|
|
86
|
+
return (`resend refused the From address "${message}". The sending DOMAIN must be ` +
|
|
87
|
+
`verified at resend.com/domains before it will accept mail from it — ` +
|
|
88
|
+
`check SNAGSET_MAIL_FROM uses a domain you have added there.`);
|
|
89
|
+
}
|
|
90
|
+
if (res.status === 429) {
|
|
91
|
+
return `resend rate-limited us (429). The outbox will retry with backoff; no mail was lost.`;
|
|
92
|
+
}
|
|
93
|
+
return `resend returned ${res.status}: ${message}`;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=resend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resend.js","sourceRoot":"","sources":["../../src/mail/resend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { connect as tlsConnect } from "node:tls";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* The `smtp` adapter — the second and last module allowed to touch a mail
|
|
6
|
+
* transport (LOCK L7). `mail.test.ts` asserts there is no third.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS IS HAND-ROLLED ──
|
|
9
|
+
*
|
|
10
|
+
* The same argument as `resend.ts` and as this repo's S3 client: the subset of
|
|
11
|
+
* ESMTP a transactional sender needs is EHLO, optional STARTTLS, optional AUTH,
|
|
12
|
+
* MAIL/RCPT/DATA, QUIT. That is a small, rigidly specified protocol. Against it
|
|
13
|
+
* sits a dependency tree that ships inside a package whose entire pitch is
|
|
14
|
+
* `npx snagset`, and which has to be audited and updated for the life of the
|
|
15
|
+
* product.
|
|
16
|
+
*
|
|
17
|
+
* The risk is real and worth naming: a hand-rolled SMTP client that gets AUTH
|
|
18
|
+
* or line endings wrong sends NOTHING, and mail is exactly where a subtle
|
|
19
|
+
* protocol bug hides. That is why `smtp.test.ts` drives this against a real TCP
|
|
20
|
+
* server speaking real SMTP rather than against a mocked socket — the bugs this
|
|
21
|
+
* class of code has are wire bugs, and a mock cannot have them.
|
|
22
|
+
*
|
|
23
|
+
* ── TWO DECISIONS THAT REMOVE WHOLE CATEGORIES OF BUG ──
|
|
24
|
+
*
|
|
25
|
+
* **Everything is base64.** Bodies are base64 with charset=utf-8 and subjects
|
|
26
|
+
* are RFC 2047 base64 encoded-words. This is not about size — base64 is 33%
|
|
27
|
+
* bigger than quoted-printable for ASCII text. It is that the base64 alphabet
|
|
28
|
+
* contains no `.` and no CR/LF, which makes DOT-STUFFING (RFC 5321 §4.5.2)
|
|
29
|
+
* and the 998-character line limit unreachable by construction. A line of a
|
|
30
|
+
* reviewer's comment that happens to begin with a full stop is a real message
|
|
31
|
+
* that a naive sender truncates, and this is the version of the fix that cannot
|
|
32
|
+
* be forgotten in one branch.
|
|
33
|
+
*
|
|
34
|
+
* **CRLF, always, everywhere.** `\n` alone is accepted by some servers and
|
|
35
|
+
* silently mangles the message on others. One constant, used everywhere.
|
|
36
|
+
*/
|
|
37
|
+
const CRLF = "\r\n";
|
|
38
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
39
|
+
/** Per command. A server that accepts the connection and then stops answering
|
|
40
|
+
* is the failure mode a socket-level timeout does not catch. */
|
|
41
|
+
const REPLY_TIMEOUT_MS = 15_000;
|
|
42
|
+
export function createSmtpMailer(opts) {
|
|
43
|
+
const url = new URL(opts.url);
|
|
44
|
+
const implicitTls = url.protocol === "smtps:";
|
|
45
|
+
const port = url.port ? Number(url.port) : implicitTls ? 465 : 587;
|
|
46
|
+
const host = url.hostname;
|
|
47
|
+
const user = url.username ? decodeURIComponent(url.username) : null;
|
|
48
|
+
const pass = url.password ? decodeURIComponent(url.password) : null;
|
|
49
|
+
return {
|
|
50
|
+
kind: "smtp",
|
|
51
|
+
from: opts.from,
|
|
52
|
+
async send(mail) {
|
|
53
|
+
const messageId = `<${randomUUID()}@${senderDomain(opts.from)}>`;
|
|
54
|
+
const session = await open(host, port, implicitTls);
|
|
55
|
+
try {
|
|
56
|
+
await session.expect(220);
|
|
57
|
+
let caps = await ehlo(session, host);
|
|
58
|
+
if (!implicitTls && caps.has("STARTTLS")) {
|
|
59
|
+
await session.command("STARTTLS", 220);
|
|
60
|
+
await session.upgrade(host);
|
|
61
|
+
// RFC 3207 §4.2: everything the server said before STARTTLS is
|
|
62
|
+
// discarded and EHLO is re-issued. Servers routinely advertise AUTH
|
|
63
|
+
// only after the upgrade, so skipping this makes authentication
|
|
64
|
+
// silently unavailable on a correctly configured server.
|
|
65
|
+
caps = await ehlo(session, host);
|
|
66
|
+
}
|
|
67
|
+
if (user && pass)
|
|
68
|
+
await authenticate(session, caps, user, pass);
|
|
69
|
+
await session.command(`MAIL FROM:<${opts.from}>`, 250);
|
|
70
|
+
await session.command(`RCPT TO:<${mail.to}>`, 250);
|
|
71
|
+
await session.command("DATA", 354);
|
|
72
|
+
await session.raw(build(mail, opts.from, messageId) + CRLF + "." + CRLF, 250);
|
|
73
|
+
// QUIT is best-effort: the message is accepted at the 250 above, and a
|
|
74
|
+
// server that drops the connection instead of answering QUIT has still
|
|
75
|
+
// taken it. Treating that as a send failure would retry a delivered
|
|
76
|
+
// message — the one outcome the outbox exists to prevent.
|
|
77
|
+
await session.command("QUIT", 221).catch(() => { });
|
|
78
|
+
return { id: messageId };
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
session.close();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** EHLO, and the capability set it returns, upper-cased for comparison. */
|
|
87
|
+
async function ehlo(session, host) {
|
|
88
|
+
const reply = await session.command(`EHLO ${clientName(host)}`, 250);
|
|
89
|
+
const caps = new Set();
|
|
90
|
+
for (const line of reply.split(CRLF).slice(1)) {
|
|
91
|
+
const verb = line.slice(4).trim().toUpperCase();
|
|
92
|
+
if (verb)
|
|
93
|
+
caps.add(verb.split(" ")[0]);
|
|
94
|
+
// AUTH's mechanisms are on the same line as the verb.
|
|
95
|
+
if (verb.startsWith("AUTH "))
|
|
96
|
+
for (const m of verb.slice(5).split(/\s+/))
|
|
97
|
+
caps.add(`AUTH=${m}`);
|
|
98
|
+
}
|
|
99
|
+
return caps;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* AUTH, preferring PLAIN.
|
|
103
|
+
*
|
|
104
|
+
* PLAIN is one round trip and LOGIN is three; both send the password base64'd,
|
|
105
|
+
* which is encoding and not encryption — so this refuses to run at all on a
|
|
106
|
+
* connection that is not TLS. An adapter that would rather authenticate in
|
|
107
|
+
* cleartext than fail is an adapter that leaks the operator's mail password to
|
|
108
|
+
* anyone on the path, once, silently, and then works fine.
|
|
109
|
+
*/
|
|
110
|
+
async function authenticate(session, caps, user, pass) {
|
|
111
|
+
if (!session.secure) {
|
|
112
|
+
throw new Error("refusing to send SMTP credentials over an unencrypted connection. " +
|
|
113
|
+
"The server did not offer STARTTLS — use smtps:// (implicit TLS, usually " +
|
|
114
|
+
"port 465), or a server that supports STARTTLS on 587.");
|
|
115
|
+
}
|
|
116
|
+
if (caps.has("AUTH=PLAIN") || (!caps.has("AUTH=LOGIN") && caps.has("AUTH"))) {
|
|
117
|
+
const token = Buffer.from(`\0${user}\0${pass}`, "utf8").toString("base64");
|
|
118
|
+
await session.command(`AUTH PLAIN ${token}`, 235);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (caps.has("AUTH=LOGIN")) {
|
|
122
|
+
await session.command("AUTH LOGIN", 334);
|
|
123
|
+
await session.command(Buffer.from(user, "utf8").toString("base64"), 334);
|
|
124
|
+
await session.command(Buffer.from(pass, "utf8").toString("base64"), 235);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
throw new Error("the SMTP server advertised no authentication mechanism this client supports " +
|
|
128
|
+
"(PLAIN or LOGIN). Credentials were supplied in SNAGSET_MAIL_URL, so it was " +
|
|
129
|
+
"expected to need them.");
|
|
130
|
+
}
|
|
131
|
+
/** The message itself: headers, then either one part or a multipart pair. */
|
|
132
|
+
function build(mail, from, messageId) {
|
|
133
|
+
const headers = [
|
|
134
|
+
`From: ${from}`,
|
|
135
|
+
`To: ${mail.to}`,
|
|
136
|
+
`Subject: ${encodeHeader(mail.subject)}`,
|
|
137
|
+
`Message-ID: ${messageId}`,
|
|
138
|
+
`Date: ${new Date().toUTCString()}`,
|
|
139
|
+
"MIME-Version: 1.0",
|
|
140
|
+
];
|
|
141
|
+
// Extra headers before the Content-Type block, because everything after that
|
|
142
|
+
// point describes the BODY and a header appended there would land inside the
|
|
143
|
+
// part rather than on the message. CRLF and newline are stripped from the
|
|
144
|
+
// value: a header injection here would let anything that reaches this
|
|
145
|
+
// function add arbitrary headers, and one of them is `Bcc`.
|
|
146
|
+
for (const [name, value] of Object.entries(mail.headers ?? {})) {
|
|
147
|
+
headers.push(`${name}: ${encodeHeader(String(value).replace(/[\r\n]+/g, " "))}`);
|
|
148
|
+
}
|
|
149
|
+
if (mail.html === undefined) {
|
|
150
|
+
headers.push('Content-Type: text/plain; charset="utf-8"', "Content-Transfer-Encoding: base64");
|
|
151
|
+
return headers.join(CRLF) + CRLF + CRLF + base64Lines(mail.text);
|
|
152
|
+
}
|
|
153
|
+
// The boundary is random rather than fixed: a fixed boundary that appears in
|
|
154
|
+
// a reviewer's comment ends the part early and truncates the mail. Random
|
|
155
|
+
// does not make that impossible, but 32 hex characters makes it a
|
|
156
|
+
// non-event, where a memorable constant makes it a matter of time.
|
|
157
|
+
const boundary = `=_${randomUUID().replace(/-/g, "")}`;
|
|
158
|
+
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
159
|
+
// Plain part FIRST: RFC 2046 §5.1.4 orders alternatives worst-to-best, and a
|
|
160
|
+
// client shows the LAST one it understands. Reversed, every graphical client
|
|
161
|
+
// shows the plain text.
|
|
162
|
+
const parts = [
|
|
163
|
+
`--${boundary}`,
|
|
164
|
+
'Content-Type: text/plain; charset="utf-8"',
|
|
165
|
+
"Content-Transfer-Encoding: base64",
|
|
166
|
+
"",
|
|
167
|
+
base64Lines(mail.text),
|
|
168
|
+
`--${boundary}`,
|
|
169
|
+
'Content-Type: text/html; charset="utf-8"',
|
|
170
|
+
"Content-Transfer-Encoding: base64",
|
|
171
|
+
"",
|
|
172
|
+
base64Lines(mail.html),
|
|
173
|
+
`--${boundary}--`,
|
|
174
|
+
];
|
|
175
|
+
return headers.join(CRLF) + CRLF + CRLF + parts.join(CRLF);
|
|
176
|
+
}
|
|
177
|
+
/** RFC 2047 encoded-word, but only when the subject needs one: an ASCII
|
|
178
|
+
* subject stays readable in a raw message, which matters when the thing you
|
|
179
|
+
* are debugging is why the mail did not arrive. */
|
|
180
|
+
function encodeHeader(s) {
|
|
181
|
+
// eslint-disable-next-line no-control-regex
|
|
182
|
+
if (/^[\x20-\x7E]*$/.test(s))
|
|
183
|
+
return s;
|
|
184
|
+
return `=?utf-8?B?${Buffer.from(s, "utf8").toString("base64")}?=`;
|
|
185
|
+
}
|
|
186
|
+
/** Base64, wrapped at 76 characters per RFC 2045 §6.8. Servers are entitled to
|
|
187
|
+
* reject longer lines and some do. */
|
|
188
|
+
function base64Lines(s) {
|
|
189
|
+
const b64 = Buffer.from(s, "utf8").toString("base64");
|
|
190
|
+
const out = [];
|
|
191
|
+
for (let i = 0; i < b64.length; i += 76)
|
|
192
|
+
out.push(b64.slice(i, i + 76));
|
|
193
|
+
return out.join(CRLF);
|
|
194
|
+
}
|
|
195
|
+
/** The domain half of the From address, for the Message-ID. */
|
|
196
|
+
function senderDomain(from) {
|
|
197
|
+
const at = from.lastIndexOf("@");
|
|
198
|
+
const domain = at === -1 ? "" : from.slice(at + 1).replace(/[>\s]/g, "");
|
|
199
|
+
return domain || "localhost";
|
|
200
|
+
}
|
|
201
|
+
/** What we call ourselves in EHLO. Not the server's name — ours — and a bare
|
|
202
|
+
* hostname is what a server expects from a client without a public FQDN. */
|
|
203
|
+
function clientName(host) {
|
|
204
|
+
return /^[\d.]+$/.test(host) || host.includes(":") ? "[127.0.0.1]" : "snagset";
|
|
205
|
+
}
|
|
206
|
+
async function open(host, port, tls) {
|
|
207
|
+
let socket = await new Promise((resolve, reject) => {
|
|
208
|
+
const s = tls
|
|
209
|
+
? tlsConnect({ host, port, servername: host }, () => resolve(s))
|
|
210
|
+
: createConnection({ host, port }, () => resolve(s));
|
|
211
|
+
s.setTimeout(CONNECT_TIMEOUT_MS, () => {
|
|
212
|
+
s.destroy();
|
|
213
|
+
reject(new Error(`timed out connecting to ${host}:${port} after ${CONNECT_TIMEOUT_MS}ms`));
|
|
214
|
+
});
|
|
215
|
+
s.once("error", reject);
|
|
216
|
+
});
|
|
217
|
+
let secure = tls;
|
|
218
|
+
// One buffer for the whole session. SMTP replies arrive in whatever chunks
|
|
219
|
+
// the network chose, and a reply is not complete until a line matches
|
|
220
|
+
// `NNN<space>` — the continuation form is `NNN-`. Reading per-chunk instead
|
|
221
|
+
// is the classic bug: it works locally and fails against a server that
|
|
222
|
+
// sends its multi-line EHLO in two packets.
|
|
223
|
+
let buffer = "";
|
|
224
|
+
let waiter = null;
|
|
225
|
+
let closed = null;
|
|
226
|
+
const settle = () => {
|
|
227
|
+
if (!waiter)
|
|
228
|
+
return;
|
|
229
|
+
const match = /^\d{3} [^\r\n]*\r\n/m.exec(buffer);
|
|
230
|
+
if (!match)
|
|
231
|
+
return;
|
|
232
|
+
const end = buffer.indexOf(match[0]) + match[0].length;
|
|
233
|
+
const reply = buffer.slice(0, end);
|
|
234
|
+
buffer = buffer.slice(end);
|
|
235
|
+
const w = waiter;
|
|
236
|
+
waiter = null;
|
|
237
|
+
w.resolve(reply);
|
|
238
|
+
};
|
|
239
|
+
const attach = (s) => {
|
|
240
|
+
s.setEncoding("utf8");
|
|
241
|
+
s.on("data", (chunk) => {
|
|
242
|
+
buffer += chunk;
|
|
243
|
+
settle();
|
|
244
|
+
});
|
|
245
|
+
s.on("error", (e) => {
|
|
246
|
+
closed = e;
|
|
247
|
+
waiter?.reject(e);
|
|
248
|
+
waiter = null;
|
|
249
|
+
});
|
|
250
|
+
s.on("close", () => {
|
|
251
|
+
closed ??= new Error("the SMTP server closed the connection");
|
|
252
|
+
waiter?.reject(closed);
|
|
253
|
+
waiter = null;
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
attach(socket);
|
|
257
|
+
const read = () => new Promise((resolve, reject) => {
|
|
258
|
+
if (closed)
|
|
259
|
+
return reject(closed);
|
|
260
|
+
waiter = { resolve, reject };
|
|
261
|
+
settle();
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
if (waiter) {
|
|
264
|
+
waiter = null;
|
|
265
|
+
reject(new Error(`the SMTP server did not reply within ${REPLY_TIMEOUT_MS}ms`));
|
|
266
|
+
}
|
|
267
|
+
}, REPLY_TIMEOUT_MS);
|
|
268
|
+
timer.unref();
|
|
269
|
+
});
|
|
270
|
+
const check = (reply, want, sent) => {
|
|
271
|
+
const code = Number(reply.slice(0, 3));
|
|
272
|
+
if (code !== want) {
|
|
273
|
+
// The command is echoed because "550 5.7.1 rejected" without it is
|
|
274
|
+
// unattributable — the same code means a bad sender on MAIL FROM and a
|
|
275
|
+
// bad recipient on RCPT TO.
|
|
276
|
+
throw new Error(`SMTP ${sent} → ${reply.trim()}`);
|
|
277
|
+
}
|
|
278
|
+
return reply;
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
get secure() {
|
|
282
|
+
return secure;
|
|
283
|
+
},
|
|
284
|
+
async expect(code) {
|
|
285
|
+
return check(await read(), code, "<greeting>");
|
|
286
|
+
},
|
|
287
|
+
async command(line, code) {
|
|
288
|
+
socket.write(line + CRLF);
|
|
289
|
+
// The password is never echoed into an error message.
|
|
290
|
+
const shown = /^AUTH /.test(line) ? line.split(" ").slice(0, 2).join(" ") : line;
|
|
291
|
+
return check(await read(), code, shown);
|
|
292
|
+
},
|
|
293
|
+
async raw(payload, code) {
|
|
294
|
+
socket.write(payload);
|
|
295
|
+
return check(await read(), code, "DATA");
|
|
296
|
+
},
|
|
297
|
+
async upgrade(hostname) {
|
|
298
|
+
const plain = socket;
|
|
299
|
+
plain.removeAllListeners("data");
|
|
300
|
+
plain.removeAllListeners("error");
|
|
301
|
+
plain.removeAllListeners("close");
|
|
302
|
+
socket = await new Promise((resolve, reject) => {
|
|
303
|
+
const s = tlsConnect({ socket: plain, servername: hostname }, () => resolve(s));
|
|
304
|
+
s.once("error", reject);
|
|
305
|
+
});
|
|
306
|
+
secure = true;
|
|
307
|
+
buffer = "";
|
|
308
|
+
attach(socket);
|
|
309
|
+
},
|
|
310
|
+
close() {
|
|
311
|
+
socket.destroy();
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=smtp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smtp.js","sourceRoot":"","sources":["../../src/mail/smtp.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
/**
|
|
4
|
+
* One-click unsubscribe — B2, and the thing that has to exist before a single
|
|
5
|
+
* notification is sent to a reviewer.
|
|
6
|
+
*
|
|
7
|
+
* ── WHY THIS IS NOT OPTIONAL ──
|
|
8
|
+
*
|
|
9
|
+
* Until now the only notification went to the project OWNER, whose address was
|
|
10
|
+
* verified at claim time. Reviewer addresses are self-declared, and a system
|
|
11
|
+
* that mails a self-declared address on a stranger's trigger with no way out
|
|
12
|
+
* is an open relay with better manners. RFC 8058 and the bulk-sender rules at
|
|
13
|
+
* Gmail and Yahoo both say the same thing in different words: carry a
|
|
14
|
+
* one-click opt-out or expect the spam folder.
|
|
15
|
+
*
|
|
16
|
+
* ── WHY IT CANNOT USE A SESSION ──
|
|
17
|
+
*
|
|
18
|
+
* The person clicking may have lost their review link, closed the tab, or
|
|
19
|
+
* never held a session at all — and none of that is a reason to keep mailing
|
|
20
|
+
* them. So the link is not authenticated by *who is asking*; it is
|
|
21
|
+
* authenticated by being unforgeable. An HMAC over the reviewer's id, under
|
|
22
|
+
* its own key purpose (08 §4.10), is exactly that, and the separate purpose is
|
|
23
|
+
* what stops an unsubscribe link from ever being accepted as a session or a
|
|
24
|
+
* share signature.
|
|
25
|
+
*
|
|
26
|
+
* ── WHY GET DOES NOT UNSUBSCRIBE ──
|
|
27
|
+
*
|
|
28
|
+
* Mail clients and security scanners fetch every link in a message. A GET that
|
|
29
|
+
* mutates would unsubscribe people who never clicked — silently, and in a way
|
|
30
|
+
* they could not detect. So GET renders a confirmation and POST performs it,
|
|
31
|
+
* which is also precisely what RFC 8058's `List-Unsubscribe-Post` specifies:
|
|
32
|
+
* the client posts `List-Unsubscribe=One-Click`, and only a POST changes
|
|
33
|
+
* anything.
|
|
34
|
+
*/
|
|
35
|
+
/** `<reviewerId>.<mac>` — the whole token, opaque to the reader. */
|
|
36
|
+
export function unsubscribeToken(keys, reviewerId) {
|
|
37
|
+
const mac = createHmac("sha256", keys.derive("unsubscribe")).update(reviewerId).digest("base64url");
|
|
38
|
+
return `${reviewerId}.${mac}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The reviewer id this token vouches for, or null.
|
|
42
|
+
*
|
|
43
|
+
* No `kid` and therefore no rotation overlap, deliberately: an unsubscribe
|
|
44
|
+
* link lives in somebody's mailbox for years, and the failure mode of a
|
|
45
|
+
* rotated key here is "the opt-out link in a two-year-old email stops
|
|
46
|
+
* working", which is the one outcome this whole file exists to prevent. The
|
|
47
|
+
* token authorises exactly one irreversible-in-the-safe-direction act, so a
|
|
48
|
+
* long-lived signature is the right trade.
|
|
49
|
+
*/
|
|
50
|
+
export function readUnsubscribeToken(keys, token) {
|
|
51
|
+
const cut = token.lastIndexOf(".");
|
|
52
|
+
if (cut <= 0)
|
|
53
|
+
return null;
|
|
54
|
+
const id = token.slice(0, cut);
|
|
55
|
+
const given = Buffer.from(token.slice(cut + 1), "base64url");
|
|
56
|
+
const want = createHmac("sha256", keys.derive("unsubscribe")).update(id).digest();
|
|
57
|
+
// Length check first: timingSafeEqual THROWS on a length mismatch rather
|
|
58
|
+
// than returning false, so a short token would be a 500 instead of a 404.
|
|
59
|
+
if (given.length !== want.length || !timingSafeEqual(given, want))
|
|
60
|
+
return null;
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
/** The header pair a notification carries. RFC 8058. */
|
|
64
|
+
export function unsubscribeHeaders(baseUrl, token) {
|
|
65
|
+
return {
|
|
66
|
+
"List-Unsubscribe": `<${baseUrl.replace(/\/+$/, "")}/unsubscribe?t=${encodeURIComponent(token)}>`,
|
|
67
|
+
// Without this line the URL is treated as an ordinary link and the
|
|
68
|
+
// provider renders no unsubscribe button — the header pair is the feature,
|
|
69
|
+
// not either half of it.
|
|
70
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const page = (title, body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
74
|
+
`<title>${title}</title>` +
|
|
75
|
+
`<style>body{font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;color:#16181d;background:#f7f7f5;` +
|
|
76
|
+
`margin:0;display:grid;place-items:center;min-height:100vh;padding:24px}` +
|
|
77
|
+
`main{background:#fff;border:1px solid #e3e3df;border-radius:6px;padding:28px 32px;max-width:34rem}` +
|
|
78
|
+
`h1{font-size:20px;margin:0 0 12px}p{margin:0 0 16px;color:#5b6270}` +
|
|
79
|
+
`button{font:inherit;padding:9px 16px;border-radius:6px;border:1px solid #2b53d8;background:#2b53d8;color:#fff;cursor:pointer}` +
|
|
80
|
+
`@media(prefers-color-scheme:dark){body{background:#101216;color:#e9eaee}main{background:#171a20;border-color:#262a33}p{color:#a2a9b8}}` +
|
|
81
|
+
`</style><main>${body}</main>`;
|
|
82
|
+
export function unsubscribeRoutes(deps) {
|
|
83
|
+
const app = new Hono();
|
|
84
|
+
const { sql, keys } = deps;
|
|
85
|
+
/**
|
|
86
|
+
* Look the reviewer up WITHOUT revealing whether the id exists.
|
|
87
|
+
*
|
|
88
|
+
* A valid signature already proves the id came from us, so there is nothing
|
|
89
|
+
* to enumerate — but a "no such person" page would still tell a forwarded
|
|
90
|
+
* recipient something about somebody else's account, and the useful answer
|
|
91
|
+
* in every case is the same: you will not be mailed.
|
|
92
|
+
*/
|
|
93
|
+
const nameFor = async (id) => {
|
|
94
|
+
const [row] = await sql `
|
|
95
|
+
SELECT name FROM reviewers WHERE id = ${id}::uuid AND erased_at IS NULL`;
|
|
96
|
+
return row?.name ?? null;
|
|
97
|
+
};
|
|
98
|
+
app.get("/unsubscribe", async (c) => {
|
|
99
|
+
const token = c.req.query("t") ?? "";
|
|
100
|
+
const id = readUnsubscribeToken(keys, token);
|
|
101
|
+
if (!id) {
|
|
102
|
+
return c.html(page("Link not recognised", `<h1>That link is not one of ours</h1><p>It may have been altered in transit. If you are still receiving mail you did not ask for, reply to it and a person will sort it out.</p>`), 404);
|
|
103
|
+
}
|
|
104
|
+
const name = await nameFor(id).catch(() => null);
|
|
105
|
+
return c.html(page("Stop these emails?", `<h1>Stop emailing ${name ? escapeHtml(name) : "you"} about this review?</h1>` +
|
|
106
|
+
`<p>You will stop receiving notifications about comments you wrote or replied to. ` +
|
|
107
|
+
`Your comments stay exactly as they are, and you can still open your review link at any time.</p>` +
|
|
108
|
+
`<form method="post" action="/unsubscribe"><input type="hidden" name="t" value="${escapeHtml(token)}">` +
|
|
109
|
+
`<button type="submit">Stop the emails</button></form>`));
|
|
110
|
+
});
|
|
111
|
+
app.post("/unsubscribe", async (c) => {
|
|
112
|
+
// The token arrives as a form field from our own confirmation page, or as
|
|
113
|
+
// a query parameter from a provider's one-click POST, which sends
|
|
114
|
+
// `List-Unsubscribe=One-Click` as the BODY and keeps the URL intact.
|
|
115
|
+
const form = await c.req.parseBody().catch(() => ({}));
|
|
116
|
+
const token = String(form["t"] ?? c.req.query("t") ?? "");
|
|
117
|
+
const id = readUnsubscribeToken(keys, token);
|
|
118
|
+
if (!id)
|
|
119
|
+
return c.text("not found", 404);
|
|
120
|
+
// Idempotent: clicking twice, or a provider retrying, is the same outcome
|
|
121
|
+
// as clicking once. `COALESCE` keeps the FIRST opt-out time, which is the
|
|
122
|
+
// one a deliverability complaint asks about.
|
|
123
|
+
await sql `
|
|
124
|
+
UPDATE reviewers
|
|
125
|
+
SET notifications_off_at = COALESCE(notifications_off_at, now())
|
|
126
|
+
WHERE id = ${id}::uuid`;
|
|
127
|
+
return c.html(page("Unsubscribed", `<h1>Done — no more emails</h1><p>You will not be notified about this review again. Nothing else changed.</p>`));
|
|
128
|
+
});
|
|
129
|
+
return app;
|
|
130
|
+
}
|
|
131
|
+
function escapeHtml(s) {
|
|
132
|
+
return s.replace(/[&<>"']/g, (ch) => ch === "&" ? "&" : ch === "<" ? "<" : ch === ">" ? ">" : ch === '"' ? """ : "'");
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=unsubscribe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/mail/unsubscribe.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class ProjectUnavailable extends Error {
|
|
2
|
+
constructor(cause) {
|
|
3
|
+
super(`the project cache could not refresh: ${cause}`);
|
|
4
|
+
this.name = "ProjectUnavailable";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function createProjectCache(sql, opts = {}) {
|
|
8
|
+
const ttl = opts.ttlMs ?? 10_000;
|
|
9
|
+
const now = opts.now ?? (() => Date.now());
|
|
10
|
+
const entries = new Map();
|
|
11
|
+
// One in-flight query per uid: a cold cache under load would otherwise send
|
|
12
|
+
// one identical query per concurrent request, which is the stampede the
|
|
13
|
+
// cache exists to prevent.
|
|
14
|
+
const inflight = new Map();
|
|
15
|
+
async function load(uid) {
|
|
16
|
+
const rows = await sql `
|
|
17
|
+
SELECT id, org_id, uid, status, write_mode, write_mode_downgrade_at, read_mode,
|
|
18
|
+
session_epoch, config_version, config_etag, key_prefix, ip_salt
|
|
19
|
+
FROM projects WHERE uid = ${uid} AND deleted_at IS NULL
|
|
20
|
+
`;
|
|
21
|
+
return rows[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
async get(uid) {
|
|
25
|
+
const hit = entries.get(uid);
|
|
26
|
+
if (hit && now() - hit.at < ttl)
|
|
27
|
+
return hit.row;
|
|
28
|
+
let pending = inflight.get(uid);
|
|
29
|
+
if (!pending) {
|
|
30
|
+
pending = load(uid)
|
|
31
|
+
.then((row) => {
|
|
32
|
+
entries.set(uid, { at: now(), row });
|
|
33
|
+
return row;
|
|
34
|
+
})
|
|
35
|
+
.catch((e) => {
|
|
36
|
+
// Do NOT fall back to `hit.row`. See the header.
|
|
37
|
+
entries.delete(uid);
|
|
38
|
+
throw new ProjectUnavailable(e instanceof Error ? e.message : String(e));
|
|
39
|
+
})
|
|
40
|
+
.finally(() => inflight.delete(uid));
|
|
41
|
+
inflight.set(uid, pending);
|
|
42
|
+
}
|
|
43
|
+
return pending;
|
|
44
|
+
},
|
|
45
|
+
invalidate(uid) {
|
|
46
|
+
entries.delete(uid);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=projectCache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projectCache.js","sourceRoot":"","sources":["../src/projectCache.ts"],"names":[],"mappings":"AAyCA,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,KAAa;QACvB,KAAK,CAAC,wCAAwC,KAAK,EAAE,CAAC,CAAC;QACvD,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAgBD,MAAM,UAAU,kBAAkB,CAChC,GAAiB,EACjB,OAA+C,EAAE;IAEjD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,IAAI,MAAM,CAAC;IACjC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC3C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAiB,CAAC;IACzC,4EAA4E;IAC5E,wEAAwE;IACxE,2BAA2B;IAC3B,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAsC,CAAC;IAE/D,KAAK,UAAU,IAAI,CAAC,GAAW;QAC7B,MAAM,IAAI,GAAG,MAAM,GAAG,CAAc;;;kCAGN,GAAG;KAChC,CAAC;QACF,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACzB,CAAC;IAED,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG;YACX,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,GAAG,IAAI,GAAG,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG;gBAAE,OAAO,GAAG,CAAC,GAAG,CAAC;YAEhD,IAAI,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAChC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC;qBAChB,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;oBACZ,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;oBACrC,OAAO,GAAG,CAAC;gBACb,CAAC,CAAC;qBACD,KAAK,CAAC,CAAC,CAAU,EAAE,EAAE;oBACpB,iDAAiD;oBACjD,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;oBACpB,MAAM,IAAI,kBAAkB,CAAC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC3E,CAAC,CAAC;qBACD,OAAO,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;gBACvC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC7B,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC;QACD,UAAU,CAAC,GAAG;YACZ,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC"}
|