@marktiderman/genesis-notifications 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- package/src/web/index.tsx +428 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { assertNoSecrets, recipientsToJson } from './chunk-7PHA3WVG.js';
|
|
2
|
+
import { createRunner, failureOf } from './chunk-UXNWI7OS.js';
|
|
3
|
+
|
|
4
|
+
// src/server/store.ts
|
|
5
|
+
var NotificationStoreError = class extends Error {
|
|
6
|
+
constructor(operation, code) {
|
|
7
|
+
super(`${operation} failed${code ? ` (${code})` : ""}`);
|
|
8
|
+
this.operation = operation;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "NotificationStoreError";
|
|
11
|
+
}
|
|
12
|
+
operation;
|
|
13
|
+
code;
|
|
14
|
+
};
|
|
15
|
+
function supabaseNotificationStore(client) {
|
|
16
|
+
const call = async (fn, args) => {
|
|
17
|
+
const { data, error } = await client.rpc(fn, args);
|
|
18
|
+
if (error) throw new NotificationStoreError(fn, error.code ?? null);
|
|
19
|
+
return data;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
claim: (channel, limit, leaseSeconds, maxAttempts = 8) => call("genesis_notifications_claim", { p_channel: channel, p_limit: limit, p_lease_seconds: leaseSeconds, p_max_attempts: maxAttempts }).then((rows) => rows ?? []),
|
|
23
|
+
async storeRendered(id, claimId, rendered, hash) {
|
|
24
|
+
const rows = await call("genesis_notifications_store_rendered", { p_id: id, p_claim: claimId, p_rendered: rendered, p_hash: hash });
|
|
25
|
+
const row = rows?.[0];
|
|
26
|
+
return row?.rendered && row.rendered_hash ? { rendered: row.rendered, hash: row.rendered_hash } : null;
|
|
27
|
+
},
|
|
28
|
+
settle: (id, claimId, outcome) => call("genesis_notifications_settle", { p_id: id, p_claim: claimId, p_outcome: outcome }).then(Boolean),
|
|
29
|
+
recordProviderId: (id, claimId, providerId) => call("genesis_notifications_record_provider_id", { p_id: id, p_claim: claimId, p_provider_id: providerId }).then(Boolean),
|
|
30
|
+
async pushTickets(deliveryId, tickets) {
|
|
31
|
+
await call("genesis_notifications_push_tickets", { p_delivery: deliveryId, p_tickets: tickets });
|
|
32
|
+
},
|
|
33
|
+
receiptsClaim: (limit) => call("genesis_notifications_receipts_claim", { p_limit: limit }).then((r) => r ?? []),
|
|
34
|
+
async receiptsSettle(results) {
|
|
35
|
+
await call("genesis_notifications_receipts_settle", { p_results: results });
|
|
36
|
+
},
|
|
37
|
+
emailEvent: (providerId, event, permanent) => call("genesis_notifications_email_event", { p_provider_id: providerId, p_event: event, p_permanent: permanent }).then(Boolean),
|
|
38
|
+
unsubscribe: (userId, emailHash2, category, tenantId) => call("genesis_notifications_unsubscribe", { p_user_id: userId, p_email_hash: emailHash2, p_category: category, p_tenant_id: tenantId }).then(Boolean),
|
|
39
|
+
syncTypes: (rows) => call("genesis_notifications_sync_types", { p_rows: rows })
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/server/notify.ts
|
|
44
|
+
async function notify(client, input, options = {}) {
|
|
45
|
+
const t = options.registry?.type(input.type);
|
|
46
|
+
if (input.secret !== void 0 && t && !t.secretLink) throw new Error(`notify: type ${input.type} carries no secret link`);
|
|
47
|
+
if (input.secret !== void 0 && input.recipients.length !== 1) throw new Error("notify: a secret goes to exactly one recipient");
|
|
48
|
+
if (!input.idempotencyKey || input.idempotencyKey.length > 200) throw new Error("notify: an idempotency key of 1 to 200 characters is required");
|
|
49
|
+
assertNoSecrets(input.data);
|
|
50
|
+
if (input.badge !== void 0 && (!Number.isInteger(input.badge) || input.badge < 0)) throw new Error("notify: badge is a whole number, 0 or more");
|
|
51
|
+
const { data, error } = await client.rpc("genesis_notify", {
|
|
52
|
+
p_type: input.type,
|
|
53
|
+
p_recipients: recipientsToJson(input.recipients),
|
|
54
|
+
p_data: input.data ?? {},
|
|
55
|
+
p_idempotency_key: input.idempotencyKey,
|
|
56
|
+
p_thread_key: input.threadKey ?? null,
|
|
57
|
+
p_collapse_key: input.collapseKey ?? null,
|
|
58
|
+
p_badge: input.badge ?? null,
|
|
59
|
+
p_tenant_id: input.tenantId ?? null,
|
|
60
|
+
p_brand: input.brand ?? null,
|
|
61
|
+
p_secret: input.secret ?? null
|
|
62
|
+
});
|
|
63
|
+
if (error) throw new NotificationStoreError("genesis_notify", error.code ?? null);
|
|
64
|
+
return (data ?? []).map((r) => ({
|
|
65
|
+
notificationId: r.notification_id,
|
|
66
|
+
recipientKey: r.recipient_key,
|
|
67
|
+
created: r.created
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
async function cancelNotification(client, input) {
|
|
71
|
+
if (!input.type) throw new Error("cancelNotification: a type is required");
|
|
72
|
+
if (!input.idempotencyKey || input.idempotencyKey.length > 200) throw new Error("cancelNotification: an idempotency key of 1 to 200 characters is required");
|
|
73
|
+
const { data, error } = await client.rpc("genesis_notifications_cancel", {
|
|
74
|
+
p_type: input.type,
|
|
75
|
+
p_idempotency_key: input.idempotencyKey,
|
|
76
|
+
p_tenant_id: input.tenantId ?? null,
|
|
77
|
+
p_recipients: input.recipients ? recipientsToJson(input.recipients) : null,
|
|
78
|
+
p_archive: input.archive ?? false
|
|
79
|
+
});
|
|
80
|
+
if (error) throw new NotificationStoreError("genesis_notifications_cancel", error.code ?? null);
|
|
81
|
+
const row = Array.isArray(data) ? data[0] : data;
|
|
82
|
+
return { cancelled: Number(row?.cancelled ?? 0), archived: Number(row?.archived ?? 0) };
|
|
83
|
+
}
|
|
84
|
+
async function syncNotificationTypes(client, registry) {
|
|
85
|
+
const { data, error } = await client.rpc("genesis_notifications_sync_types", { p_rows: registry.toTypeRows() });
|
|
86
|
+
if (error) throw new NotificationStoreError("genesis_notifications_sync_types", error.code ?? null);
|
|
87
|
+
return Number(data ?? 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/server/email-render.ts
|
|
91
|
+
function escapeHtml(text) {
|
|
92
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
93
|
+
}
|
|
94
|
+
function headerSafe(text, max = 200) {
|
|
95
|
+
const flat = text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
96
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
97
|
+
}
|
|
98
|
+
function isSafeAddress(value) {
|
|
99
|
+
return typeof value === "string" && value.length <= 254 && /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/.test(value) && !/[\u0000-\u001f\u007f]/.test(value);
|
|
100
|
+
}
|
|
101
|
+
function displayName(name) {
|
|
102
|
+
return headerSafe(name.replace(/["<>()[\]:;@,\\]/g, ""), 100);
|
|
103
|
+
}
|
|
104
|
+
var defaultEmailTemplate = (ctx) => {
|
|
105
|
+
const title = ctx.text.title;
|
|
106
|
+
const body = ctx.text.body ?? "";
|
|
107
|
+
const link = ctx.secretLink ?? ctx.url;
|
|
108
|
+
const accent = typeof ctx.brand.color === "string" && /^#[0-9a-fA-F]{3,8}$/.test(ctx.brand.color) ? ctx.brand.color : "#111827";
|
|
109
|
+
const from = ctx.brand.name ? escapeHtml(ctx.brand.name) : "";
|
|
110
|
+
const p = "font-family:sans-serif;font-size:15px;line-height:1.5;margin:0 0 16px;color:#111827";
|
|
111
|
+
const html = `<!doctype html><html><body style="margin:0;padding:24px;background:#f9fafb"><div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:8px;padding:24px">` + (from ? `<p style="${p};font-weight:600;color:${accent}">${from}</p>` : "") + `<p style="${p};font-size:18px;font-weight:600">${escapeHtml(title)}</p>` + (body ? `<p style="${p};white-space:pre-wrap">${escapeHtml(body)}</p>` : "") + (link ? `<p style="${p}"><a href="${escapeHtml(link)}" style="display:inline-block;background:${accent};color:#ffffff;padding:10px 16px;border-radius:6px;text-decoration:none">Open</a></p>` : "") + (ctx.unsubscribeUrl ? `<p style="${p};font-size:12px;color:#6b7280"><a href="${escapeHtml(ctx.unsubscribeUrl)}" style="color:#6b7280">Unsubscribe</a> from these emails.</p>` : "") + `</div></body></html>`;
|
|
112
|
+
const text = [from, title, body, link ?? "", ctx.unsubscribeUrl ? `Unsubscribe: ${ctx.unsubscribeUrl}` : ""].filter(Boolean).join("\n\n");
|
|
113
|
+
return { subject: title, html, text };
|
|
114
|
+
};
|
|
115
|
+
function canonicalJson(value) {
|
|
116
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
117
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
118
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
119
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/server/errors.ts
|
|
123
|
+
var SendError = class extends Error {
|
|
124
|
+
name = "SendError";
|
|
125
|
+
retryable;
|
|
126
|
+
retryAfterSeconds;
|
|
127
|
+
status;
|
|
128
|
+
constructor(message, options = {}) {
|
|
129
|
+
super(message.slice(0, 500));
|
|
130
|
+
this.retryable = options.retryable ?? false;
|
|
131
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
132
|
+
this.status = options.status;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
function isRetryableStatus(status) {
|
|
136
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
137
|
+
}
|
|
138
|
+
function retryAfterSeconds(header, fallback = 60, now = Date.now()) {
|
|
139
|
+
const clamp = (s) => Math.min(86400, Math.max(1, s));
|
|
140
|
+
const value = header?.trim();
|
|
141
|
+
if (value && /^\d+$/.test(value)) return clamp(Number(value));
|
|
142
|
+
if (value && /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value)) {
|
|
143
|
+
const at = Date.parse(value);
|
|
144
|
+
if (Number.isFinite(at)) return clamp(Math.ceil((at - now) / 1e3));
|
|
145
|
+
}
|
|
146
|
+
return fallback;
|
|
147
|
+
}
|
|
148
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
149
|
+
async function timedFetch(deps, url, init, signal) {
|
|
150
|
+
const f = deps.fetch ?? fetch;
|
|
151
|
+
const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
152
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
153
|
+
try {
|
|
154
|
+
return await f(url, { ...init, signal: combined });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const name = error?.name;
|
|
157
|
+
throw new SendError(name === "TimeoutError" || name === "AbortError" ? "Timed out" : "Network error", { retryable: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function discard(response) {
|
|
161
|
+
response.body?.cancel().catch(() => {
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/server/dns.ts
|
|
166
|
+
var DOMAIN = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
167
|
+
var SENDER_OWNERSHIP_HOST = "_genesis-notifications";
|
|
168
|
+
function senderOwnershipRecord(domain, token) {
|
|
169
|
+
return { type: "TXT", name: `${SENDER_OWNERSHIP_HOST}.${domain}`, value: `genesis-notifications-verify=${token}` };
|
|
170
|
+
}
|
|
171
|
+
function isReservedDomain(domain, reserved) {
|
|
172
|
+
const d = domain.toLowerCase().replace(/\.$/, "");
|
|
173
|
+
return reserved.some((r) => {
|
|
174
|
+
const x = r?.toLowerCase().replace(/\.$/, "");
|
|
175
|
+
return !!x && (d === x || d.endsWith(`.${x}`));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function txt(options, name) {
|
|
179
|
+
const url = new URL(options.resolver ?? "https://cloudflare-dns.com/dns-query");
|
|
180
|
+
url.searchParams.set("name", name);
|
|
181
|
+
url.searchParams.set("type", "TXT");
|
|
182
|
+
const response = await timedFetch(options, url.toString(), { headers: { accept: "application/dns-json" } });
|
|
183
|
+
if (!response.ok) return [];
|
|
184
|
+
const json2 = await response.json();
|
|
185
|
+
return (json2.Answer ?? []).filter((a) => a.type === 16 && typeof a.data === "string").map((a) => a.data.replace(/"\s*"/g, "").replace(/^"|"$/g, ""));
|
|
186
|
+
}
|
|
187
|
+
async function checkSenderDomain(domain, options) {
|
|
188
|
+
if (!DOMAIN.test(domain)) throw new Error("checkSenderDomain: not a domain name");
|
|
189
|
+
if (typeof options?.token !== "string" || !/^[0-9a-f]{32}$/.test(options.token)) throw new Error("checkSenderDomain: token is the sender's domain_token");
|
|
190
|
+
if (!Array.isArray(options.reservedDomains) || options.reservedDomains.length === 0) {
|
|
191
|
+
throw new Error("checkSenderDomain: reservedDomains (the app's shared sending domains) is required");
|
|
192
|
+
}
|
|
193
|
+
const reserved = isReservedDomain(domain, options.reservedDomains);
|
|
194
|
+
if (reserved) return { ownership: false, spf: false, dkim: false, dmarc: false, reserved: true, ok: false };
|
|
195
|
+
const include = options.spfInclude ?? "amazonses.com";
|
|
196
|
+
const proof = senderOwnershipRecord(domain, options.token);
|
|
197
|
+
const [owner, spf, dkim, dmarc] = await Promise.all([
|
|
198
|
+
txt(options, proof.name),
|
|
199
|
+
txt(options, options.spfHost ?? `send.${domain}`),
|
|
200
|
+
txt(options, `${options.dkimSelector ?? "resend"}._domainkey.${domain}`),
|
|
201
|
+
txt(options, `_dmarc.${domain}`)
|
|
202
|
+
]);
|
|
203
|
+
const result = {
|
|
204
|
+
ownership: owner.some((r) => r.trim() === proof.value),
|
|
205
|
+
spf: spf.some((r) => /^v=spf1\b/i.test(r) && r.toLowerCase().includes(`include:${include}`)),
|
|
206
|
+
dkim: dkim.some((r) => /(^|;)\s*p=[A-Za-z0-9+/=]{20,}/.test(r)),
|
|
207
|
+
dmarc: dmarc.some((r) => /^v=DMARC1\b/i.test(r)),
|
|
208
|
+
reserved: false
|
|
209
|
+
};
|
|
210
|
+
return { ...result, ok: result.ownership && result.spf && result.dkim && result.dmarc };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/server/expo.ts
|
|
214
|
+
var EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
215
|
+
var EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
216
|
+
var EXPO_SEND_BATCH = 100;
|
|
217
|
+
var EXPO_RECEIPTS_BATCH = 1e3;
|
|
218
|
+
function expoErrorCode(value) {
|
|
219
|
+
const code = value?.details?.error;
|
|
220
|
+
return typeof code === "string" && /^[A-Za-z_]{1,64}$/.test(code) ? code : "Unknown";
|
|
221
|
+
}
|
|
222
|
+
function refusedOutright(error) {
|
|
223
|
+
return error instanceof SendError && error.status !== void 0 && error.status >= 400 && error.status < 500 && error.status !== 429;
|
|
224
|
+
}
|
|
225
|
+
function expoPushProvider(options = {}) {
|
|
226
|
+
const headers = {
|
|
227
|
+
accept: "application/json",
|
|
228
|
+
"content-type": "application/json",
|
|
229
|
+
...options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}
|
|
230
|
+
};
|
|
231
|
+
async function post(url, body, signal) {
|
|
232
|
+
const response = await timedFetch(options, url, { method: "POST", headers, body: JSON.stringify(body) }, signal);
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const retryAfter = response.status === 429 ? retryAfterSeconds(response.headers.get("retry-after"), 30) : void 0;
|
|
235
|
+
discard(response);
|
|
236
|
+
throw new SendError(`Expo push answered HTTP ${response.status}`, {
|
|
237
|
+
retryable: isRetryableStatus(response.status),
|
|
238
|
+
status: response.status,
|
|
239
|
+
...retryAfter !== void 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
return await response.json();
|
|
244
|
+
} catch {
|
|
245
|
+
throw new SendError("Expo push answered with something that isn't JSON", { retryable: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
name: "expo",
|
|
250
|
+
async send(messages, { signal } = {}) {
|
|
251
|
+
if (messages.length > EXPO_SEND_BATCH) throw new Error(`expoPushProvider: at most ${EXPO_SEND_BATCH} messages`);
|
|
252
|
+
if (messages.length === 0) return [];
|
|
253
|
+
const json2 = await post(options.sendUrl ?? EXPO_SEND_URL, messages, signal);
|
|
254
|
+
const tickets = Array.isArray(json2?.data) ? json2.data : [];
|
|
255
|
+
return messages.map((_, i) => {
|
|
256
|
+
const t = tickets[i];
|
|
257
|
+
if (t?.status === "ok" && typeof t.id === "string" && t.id.length > 0 && t.id.length <= 128) return { status: "ok", id: t.id };
|
|
258
|
+
if (t?.status === "error") return { status: "error", code: expoErrorCode(t) };
|
|
259
|
+
return { status: "error", code: "NoTicket" };
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
async receipts(ids, { signal } = {}) {
|
|
263
|
+
if (ids.length > EXPO_RECEIPTS_BATCH) throw new Error(`expoPushProvider: at most ${EXPO_RECEIPTS_BATCH} ids`);
|
|
264
|
+
if (ids.length === 0) return {};
|
|
265
|
+
const json2 = await post(options.receiptsUrl ?? EXPO_RECEIPTS_URL, { ids }, signal);
|
|
266
|
+
const data = json2?.data && typeof json2.data === "object" && !Array.isArray(json2.data) ? json2.data : {};
|
|
267
|
+
const out = {};
|
|
268
|
+
for (const id of ids) {
|
|
269
|
+
const r = data[id];
|
|
270
|
+
if (r?.status === "ok") out[id] = { status: "ok" };
|
|
271
|
+
else if (r?.status === "error") out[id] = { status: "error", code: expoErrorCode(r) };
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/server/secret-links.ts
|
|
279
|
+
var SecretLinkError = class extends Error {
|
|
280
|
+
constructor(message) {
|
|
281
|
+
super(message);
|
|
282
|
+
this.name = "SecretLinkError";
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
async function resolveSecretLink(links, type, ref) {
|
|
286
|
+
const build = Object.prototype.hasOwnProperty.call(links, type) ? links[type] : void 0;
|
|
287
|
+
if (!build) throw new SecretLinkError(`No secret link builder for type ${type}`);
|
|
288
|
+
const link = await build(ref);
|
|
289
|
+
let url;
|
|
290
|
+
try {
|
|
291
|
+
url = new URL(link);
|
|
292
|
+
} catch {
|
|
293
|
+
throw new SecretLinkError(`The secret link for type ${type} is not a URL`);
|
|
294
|
+
}
|
|
295
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
296
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new SecretLinkError(`The secret link for type ${type} is not https`);
|
|
297
|
+
return url.toString();
|
|
298
|
+
}
|
|
299
|
+
var SECRET_LINK_PLACEHOLDER = "https://genesis-secret-link.invalid/";
|
|
300
|
+
|
|
301
|
+
// src/server/crypto.ts
|
|
302
|
+
var encoder = new TextEncoder();
|
|
303
|
+
function base64url(bytes) {
|
|
304
|
+
let s = "";
|
|
305
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
306
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
307
|
+
}
|
|
308
|
+
function fromBase64url(text) {
|
|
309
|
+
const s = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
310
|
+
const bin = atob(s + "=".repeat((4 - s.length % 4) % 4));
|
|
311
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
312
|
+
}
|
|
313
|
+
function fromBase64(text) {
|
|
314
|
+
const bin = atob(text);
|
|
315
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
316
|
+
}
|
|
317
|
+
async function hmacSha256(key, message) {
|
|
318
|
+
const raw = typeof key === "string" ? encoder.encode(key) : key;
|
|
319
|
+
const k = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
320
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", k, encoder.encode(message)));
|
|
321
|
+
}
|
|
322
|
+
async function sha256Hex(text) {
|
|
323
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(text)));
|
|
324
|
+
return [...digest].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
325
|
+
}
|
|
326
|
+
function timingSafeEqual(a, b) {
|
|
327
|
+
if (a.length !== b.length) return false;
|
|
328
|
+
let diff = 0;
|
|
329
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
330
|
+
return diff === 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/server/unsubscribe.ts
|
|
334
|
+
function emailHash(email) {
|
|
335
|
+
return sha256Hex(email.trim().toLowerCase());
|
|
336
|
+
}
|
|
337
|
+
function checkSecret(secret) {
|
|
338
|
+
if (typeof secret !== "string" || secret.length < 32) throw new Error("The unsubscribe secret must be at least 32 characters");
|
|
339
|
+
}
|
|
340
|
+
async function createUnsubscribeToken(secret, claims) {
|
|
341
|
+
checkSecret(secret);
|
|
342
|
+
if (claims.emailHash !== void 0 && !/^[0-9a-f]{64}$/.test(claims.emailHash)) throw new Error("createUnsubscribeToken: emailHash is a SHA-256 hex digest (emailHash(address))");
|
|
343
|
+
const payload = base64url(new TextEncoder().encode(JSON.stringify({ u: claims.userId, h: claims.emailHash, c: claims.category, t: claims.tenantId, v: 2 })));
|
|
344
|
+
const sig = base64url(await hmacSha256(secret, `unsubscribe.${payload}`));
|
|
345
|
+
return `${payload}.${sig}`;
|
|
346
|
+
}
|
|
347
|
+
async function verifyUnsubscribeToken(secret, token) {
|
|
348
|
+
checkSecret(secret);
|
|
349
|
+
if (typeof token !== "string" || token.length > 2e3) return null;
|
|
350
|
+
const [payload, sig, extra] = token.split(".");
|
|
351
|
+
if (!payload || !sig || extra !== void 0) return null;
|
|
352
|
+
let given;
|
|
353
|
+
try {
|
|
354
|
+
given = fromBase64url(sig);
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const expected = await hmacSha256(secret, `unsubscribe.${payload}`);
|
|
359
|
+
if (!timingSafeEqual(given, expected)) return null;
|
|
360
|
+
try {
|
|
361
|
+
const raw = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
362
|
+
if (raw.v !== 2 || typeof raw.c !== "string") return null;
|
|
363
|
+
const hash = typeof raw.h === "string" && /^[0-9a-f]{64}$/.test(raw.h) ? raw.h : void 0;
|
|
364
|
+
if (typeof raw.u !== "string" && !hash) return null;
|
|
365
|
+
return {
|
|
366
|
+
category: raw.c,
|
|
367
|
+
...typeof raw.u === "string" ? { userId: raw.u } : {},
|
|
368
|
+
...hash ? { emailHash: hash } : {},
|
|
369
|
+
...typeof raw.t === "string" ? { tenantId: raw.t } : {}
|
|
370
|
+
};
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function unsubscribeHeaders(url, token) {
|
|
376
|
+
const link = new URL(url);
|
|
377
|
+
link.searchParams.set("t", token);
|
|
378
|
+
return {
|
|
379
|
+
"List-Unsubscribe": `<${link.toString()}>`,
|
|
380
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/server/runner.ts
|
|
385
|
+
function settleOf(outcome, providerId) {
|
|
386
|
+
const id = providerId ? { provider_id: providerId } : {};
|
|
387
|
+
switch (outcome.status) {
|
|
388
|
+
case "sent":
|
|
389
|
+
return { status: "sent", provider_id: providerId ?? null };
|
|
390
|
+
case "filtered":
|
|
391
|
+
return { status: "filtered" };
|
|
392
|
+
case "skipped":
|
|
393
|
+
return { status: "skipped", reason: /^[a-z_]{1,64}$/.test(outcome.reason) ? outcome.reason : "not_sent" };
|
|
394
|
+
case "failed":
|
|
395
|
+
return { status: "failed", error: outcome.error, ...id };
|
|
396
|
+
case "retry":
|
|
397
|
+
return { status: "retry", error: outcome.error, retry_at: outcome.retryAt.toISOString(), counted: outcome.counted, ...id };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function appPath(url) {
|
|
401
|
+
if (typeof url !== "string" || url.length > 2e3) return null;
|
|
402
|
+
if (!/^\/(?![/\\])/.test(url) || /[\u0000-\u001f\u007f\s\\]/.test(url)) return null;
|
|
403
|
+
return url;
|
|
404
|
+
}
|
|
405
|
+
function emptyReport() {
|
|
406
|
+
return { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };
|
|
407
|
+
}
|
|
408
|
+
function addReport(a, b) {
|
|
409
|
+
const out = { ...a };
|
|
410
|
+
for (const k of Object.keys(out)) out[k] += b[k];
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function createNotificationRunner(options) {
|
|
414
|
+
const { store, registry } = options;
|
|
415
|
+
const log = (m) => {
|
|
416
|
+
try {
|
|
417
|
+
options.log?.(m);
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const batch = options.batchSize ?? 100;
|
|
422
|
+
const lease = options.leaseSeconds ?? 300;
|
|
423
|
+
const now = () => options.now?.() ?? Date.now();
|
|
424
|
+
if (options.runBudgetMs !== void 0 && !(options.runBudgetMs >= lease * 1e3)) {
|
|
425
|
+
throw new Error("createNotificationRunner: runBudgetMs must be at least leaseSeconds (a run would claim jobs it can't finish)");
|
|
426
|
+
}
|
|
427
|
+
const email = options.email;
|
|
428
|
+
if (email) {
|
|
429
|
+
const needsUnsubscribe = Object.keys(registry.types).some((key) => registry.type(key).channels.email !== void 0 && !registry.type(key).transactional && !registry.isCritical(key));
|
|
430
|
+
if (needsUnsubscribe && !email.unsubscribe) {
|
|
431
|
+
throw new Error("createNotificationRunner: a type sends email that isn't transactional, so email.unsubscribe ({ url, secret }) is required");
|
|
432
|
+
}
|
|
433
|
+
for (const [key, t] of Object.entries(registry.types)) {
|
|
434
|
+
if (t.secretLink && !email.secretLinks?.[key]) throw new Error(`createNotificationRunner: type ${key} needs a secret link builder in email.secretLinks`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const providerIds = /* @__PURE__ */ new Map();
|
|
438
|
+
const record = async (job, outcome) => {
|
|
439
|
+
const providerId = providerIds.get(job.id);
|
|
440
|
+
providerIds.delete(job.id);
|
|
441
|
+
return store.settle(job.id, job.claim_id, settleOf(outcome, providerId));
|
|
442
|
+
};
|
|
443
|
+
const common = {
|
|
444
|
+
record,
|
|
445
|
+
now: options.now,
|
|
446
|
+
log: options.log,
|
|
447
|
+
maxAttempts: options.maxAttempts,
|
|
448
|
+
maxDeferrals: options.maxDeferrals,
|
|
449
|
+
recordLabel: (job) => `${job.channel} job ${job.id}`
|
|
450
|
+
};
|
|
451
|
+
const pushChunk = /* @__PURE__ */ new Map();
|
|
452
|
+
const leaseEnds = /* @__PURE__ */ new Map();
|
|
453
|
+
let pushTurn = Promise.resolve();
|
|
454
|
+
const pushRunner = options.push ? createRunner({
|
|
455
|
+
...common,
|
|
456
|
+
label: (job) => `Push ${job.id}`,
|
|
457
|
+
claim: async ({ limit }) => {
|
|
458
|
+
const claimedAt = now();
|
|
459
|
+
const jobs = await store.claim("push", limit, lease, options.maxAttempts ?? 8);
|
|
460
|
+
let chunk = 0;
|
|
461
|
+
let filled = 0;
|
|
462
|
+
for (const job of jobs) {
|
|
463
|
+
const n = Math.min(job.devices?.length ?? 0, EXPO_SEND_BATCH);
|
|
464
|
+
if (filled > 0 && filled + n > EXPO_SEND_BATCH) {
|
|
465
|
+
chunk++;
|
|
466
|
+
filled = 0;
|
|
467
|
+
}
|
|
468
|
+
filled += n;
|
|
469
|
+
pushChunk.set(job.id, chunk);
|
|
470
|
+
leaseEnds.set(job.id, claimedAt + lease * 1e3);
|
|
471
|
+
}
|
|
472
|
+
return jobs;
|
|
473
|
+
},
|
|
474
|
+
// One group per Expo batch: each is sent, recorded and settled on its own.
|
|
475
|
+
group: (job) => `push:${pushChunk.get(job.id) ?? 0}`,
|
|
476
|
+
send: (jobs, { signal }) => {
|
|
477
|
+
const deadline = Math.min(...jobs.map((j) => leaseEnds.get(j.id) ?? Infinity));
|
|
478
|
+
for (const j of jobs) {
|
|
479
|
+
pushChunk.delete(j.id);
|
|
480
|
+
leaseEnds.delete(j.id);
|
|
481
|
+
}
|
|
482
|
+
const mine = pushTurn.then(() => sendPush(jobs, options.push.provider, deadline, signal));
|
|
483
|
+
pushTurn = mine.catch(() => void 0);
|
|
484
|
+
return mine;
|
|
485
|
+
},
|
|
486
|
+
batchSize: batch
|
|
487
|
+
}) : null;
|
|
488
|
+
function flatValues(values) {
|
|
489
|
+
const out = {};
|
|
490
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
491
|
+
if (typeof value === "string" || typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
function pushMessage(job, token) {
|
|
496
|
+
const hidden = job.hide_previews || job.push_privacy === "generic";
|
|
497
|
+
const rendered = registry.render(job.type, job.data);
|
|
498
|
+
const text = hidden ? { title: registry.hiddenPreview.title, body: registry.hiddenPreview.body } : rendered;
|
|
499
|
+
const url = appPath(rendered.url);
|
|
500
|
+
const extras = { ...flatValues(rendered.push?.ids), ...hidden ? {} : flatValues(rendered.push?.display) };
|
|
501
|
+
return {
|
|
502
|
+
to: token,
|
|
503
|
+
title: text.title,
|
|
504
|
+
...text.body ? { body: text.body } : {},
|
|
505
|
+
data: { ...extras, notification_id: job.notification_id, type: job.type, ...url ? { url } : {}, ...job.thread_key ? { thread_key: job.thread_key } : {} },
|
|
506
|
+
...!hidden && rendered.push?.mutableContent ? { mutableContent: true } : {},
|
|
507
|
+
sound: "default",
|
|
508
|
+
...job.badge !== null && job.badge !== void 0 ? { badge: job.badge } : {},
|
|
509
|
+
channelId: job.category,
|
|
510
|
+
...job.ios_category ? { categoryId: job.ios_category } : {},
|
|
511
|
+
...job.thread_key ? { threadId: job.thread_key } : {},
|
|
512
|
+
collapseId: (job.collapse_key ?? job.notification_id).slice(0, 64),
|
|
513
|
+
tag: (job.collapse_key ?? job.notification_id).slice(0, 64),
|
|
514
|
+
priority: "high"
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
async function sendPush(jobs, provider, deadline, signal) {
|
|
518
|
+
const lastStart = deadline - REQUEST_TIMEOUT_MS - 5e3;
|
|
519
|
+
const leaseRunningOut = () => new SendError("lease_running_out", { retryable: true, retryAfterSeconds: 1 });
|
|
520
|
+
const slots = [];
|
|
521
|
+
const results = jobs.map((job) => registry.has(job.type) ? null : { ok: false, retryable: false, error: "unknown_type" });
|
|
522
|
+
jobs.forEach((job, j) => {
|
|
523
|
+
if (results[j]) return;
|
|
524
|
+
for (const d of job.devices ?? []) slots.push({ job: j, device: d.id, message: pushMessage(job, d.token) });
|
|
525
|
+
});
|
|
526
|
+
const tickets = new Array(slots.length);
|
|
527
|
+
for (let start = 0; start < slots.length; start += EXPO_SEND_BATCH) {
|
|
528
|
+
const chunk = slots.slice(start, start + EXPO_SEND_BATCH);
|
|
529
|
+
if (now() > lastStart) {
|
|
530
|
+
for (let i = 0; i < chunk.length; i++) tickets[start + i] = leaseRunningOut();
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const got = await provider.send(chunk.map((s) => s.message), { signal });
|
|
535
|
+
got.forEach((t, i) => tickets[start + i] = t);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (refusedOutright(error) && chunk.length > 1) {
|
|
538
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
539
|
+
if (now() > lastStart) {
|
|
540
|
+
tickets[start + i] = leaseRunningOut();
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
tickets[start + i] = (await provider.send([chunk[i].message], { signal }))[0] ?? { status: "error", code: "NoTicket" };
|
|
545
|
+
} catch (one) {
|
|
546
|
+
tickets[start + i] = one instanceof SendError ? one : new SendError("Push failed", { retryable: true });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
const e = error instanceof SendError ? error : new SendError("Push failed", { retryable: true });
|
|
551
|
+
for (let i = 0; i < chunk.length; i++) tickets[start + i] = e;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
await Promise.all(
|
|
556
|
+
jobs.map(async (job, j) => {
|
|
557
|
+
if (results[j]) return;
|
|
558
|
+
const mine = slots.map((s, i) => ({ s, t: tickets[i] })).filter((x) => x.s.job === j);
|
|
559
|
+
const rows = mine.filter((x) => !(x.t instanceof SendError)).map(({ s, t }) => {
|
|
560
|
+
const ticket = t;
|
|
561
|
+
return ticket.status === "ok" ? { device_id: s.device, ticket_id: ticket.id, status: "sent", error: null } : { device_id: s.device, ticket_id: null, status: "failed", error: ticket.code };
|
|
562
|
+
});
|
|
563
|
+
if (rows.length) {
|
|
564
|
+
try {
|
|
565
|
+
await store.pushTickets(job.id, rows);
|
|
566
|
+
} catch {
|
|
567
|
+
log(`Recording push ${job.id}'s tickets failed`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (rows.some((r) => r.status === "sent")) {
|
|
571
|
+
results[j] = { ok: true };
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const httpError = mine.find((x) => x.t instanceof SendError)?.t;
|
|
575
|
+
if (httpError) {
|
|
576
|
+
results[j] = failureOf(httpError);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const codes = rows.map((r) => r.error);
|
|
580
|
+
if (codes.length && codes.every((c) => c === "DeviceNotRegistered")) results[j] = { ok: true, notSent: "device_not_registered" };
|
|
581
|
+
else if (codes.includes("MessageRateExceeded")) results[j] = { ok: false, retryable: true, retryAfterSeconds: 60, error: "Expo: MessageRateExceeded" };
|
|
582
|
+
else if (!codes.length) results[j] = { ok: true, notSent: "no_devices" };
|
|
583
|
+
else results[j] = { ok: false, retryable: false, error: `Expo: ${codes.find((c) => c !== "DeviceNotRegistered") ?? "Unknown"}` };
|
|
584
|
+
})
|
|
585
|
+
);
|
|
586
|
+
return results;
|
|
587
|
+
}
|
|
588
|
+
const emailRunner = email ? createRunner({
|
|
589
|
+
...common,
|
|
590
|
+
label: (job) => `Email ${job.id}`,
|
|
591
|
+
claim: ({ limit }) => store.claim("email", limit, lease, options.maxAttempts ?? 8),
|
|
592
|
+
send: async ([job], { signal }) => [await sendEmail(job, email, signal)],
|
|
593
|
+
batchSize: batch
|
|
594
|
+
}) : null;
|
|
595
|
+
function fromAddress(job, e) {
|
|
596
|
+
const local = e.localPart ?? "notifications";
|
|
597
|
+
const shared = job.transactional ? e.domains.transactional : e.domains.other ?? e.domains.transactional;
|
|
598
|
+
const own = job.sender?.domain_verified && job.sender.domain && !isReservedDomain(job.sender.domain, [e.domains.transactional, e.domains.other]) ? job.sender.domain : null;
|
|
599
|
+
const domain = own ?? shared;
|
|
600
|
+
const brandName = typeof job.brand?.["name"] === "string" ? job.brand["name"] : null;
|
|
601
|
+
const name = displayName(job.sender?.from_name ?? brandName ?? e.appName) || displayName(e.appName);
|
|
602
|
+
return `${name} <${local}@${domain}>`;
|
|
603
|
+
}
|
|
604
|
+
async function render(job, e) {
|
|
605
|
+
const to = job.email;
|
|
606
|
+
if (!isSafeAddress(to)) throw new SendError("no_valid_address");
|
|
607
|
+
const t = registry.type(job.type);
|
|
608
|
+
let unsubscribeUrl = null;
|
|
609
|
+
let headers;
|
|
610
|
+
if (!job.transactional && !job.critical && e.unsubscribe) {
|
|
611
|
+
const token = await createUnsubscribeToken(e.unsubscribe.secret, {
|
|
612
|
+
...job.user_id ? { userId: job.user_id } : { emailHash: await emailHash(to) },
|
|
613
|
+
category: job.category,
|
|
614
|
+
...job.tenant_id ? { tenantId: job.tenant_id } : {}
|
|
615
|
+
});
|
|
616
|
+
headers = unsubscribeHeaders(e.unsubscribe.url, token);
|
|
617
|
+
unsubscribeUrl = headers["List-Unsubscribe"].slice(1, -1);
|
|
618
|
+
}
|
|
619
|
+
const text = registry.render(job.type, job.data);
|
|
620
|
+
const path = appPath(text.url);
|
|
621
|
+
const url = path && options.appUrl ? new URL(path, new URL(options.appUrl).origin).toString() : null;
|
|
622
|
+
const template = e.templates?.[job.type] ?? e.defaultTemplate ?? defaultEmailTemplate;
|
|
623
|
+
const content = await template({
|
|
624
|
+
type: job.type,
|
|
625
|
+
category: job.category,
|
|
626
|
+
data: job.data,
|
|
627
|
+
text,
|
|
628
|
+
recipient: { email: to, name: job.recipient_name },
|
|
629
|
+
brand: job.brand ?? {},
|
|
630
|
+
tenantId: job.tenant_id,
|
|
631
|
+
transactional: job.transactional,
|
|
632
|
+
secretLink: t.secretLink ? SECRET_LINK_PLACEHOLDER : null,
|
|
633
|
+
unsubscribeUrl,
|
|
634
|
+
url
|
|
635
|
+
});
|
|
636
|
+
const replyTo = job.sender?.reply_to;
|
|
637
|
+
return {
|
|
638
|
+
from: fromAddress(job, e),
|
|
639
|
+
to: [to],
|
|
640
|
+
...isSafeAddress(replyTo) ? { reply_to: replyTo } : {},
|
|
641
|
+
subject: headerSafe(content.subject) || headerSafe(text.title) || "Notification",
|
|
642
|
+
html: content.html,
|
|
643
|
+
text: content.text,
|
|
644
|
+
...headers ? { headers } : {},
|
|
645
|
+
tags: [
|
|
646
|
+
{ name: "category", value: job.category.replace(/[^A-Za-z0-9_-]/g, "_") },
|
|
647
|
+
{ name: "stream", value: job.transactional ? "transactional" : "other" }
|
|
648
|
+
]
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
async function sendEmail(job, e, signal) {
|
|
652
|
+
try {
|
|
653
|
+
if (!registry.has(job.type)) return { ok: false, retryable: false, error: "unknown_type" };
|
|
654
|
+
let rendered = job.rendered;
|
|
655
|
+
if (rendered) {
|
|
656
|
+
if (!job.rendered_hash || await sha256Hex(canonicalJson(rendered)) !== job.rendered_hash) {
|
|
657
|
+
return { ok: false, retryable: false, error: "rendered_email_changed" };
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
const fresh = await render(job, e);
|
|
661
|
+
const hash = await sha256Hex(canonicalJson(fresh));
|
|
662
|
+
const kept = await store.storeRendered(job.id, job.claim_id, fresh, hash);
|
|
663
|
+
if (!kept) return { ok: false, retryable: true, error: "claim_lost" };
|
|
664
|
+
rendered = kept.rendered;
|
|
665
|
+
}
|
|
666
|
+
let html = rendered.html;
|
|
667
|
+
let text = rendered.text;
|
|
668
|
+
if (registry.type(job.type).secretLink) {
|
|
669
|
+
const link = await resolveSecretLink(e.secretLinks ?? {}, job.type, {
|
|
670
|
+
data: job.data,
|
|
671
|
+
secret: job.secret,
|
|
672
|
+
notificationId: job.notification_id,
|
|
673
|
+
tenantId: job.tenant_id
|
|
674
|
+
});
|
|
675
|
+
html = html.split(SECRET_LINK_PLACEHOLDER).join(escapeHtml(link));
|
|
676
|
+
text = text.split(SECRET_LINK_PLACEHOLDER).join(link);
|
|
677
|
+
}
|
|
678
|
+
const provider = e.providerFor?.(job) ?? e.provider;
|
|
679
|
+
const result = await provider.send(
|
|
680
|
+
{
|
|
681
|
+
from: rendered.from,
|
|
682
|
+
to: rendered.to,
|
|
683
|
+
...rendered.reply_to ? { replyTo: rendered.reply_to } : {},
|
|
684
|
+
subject: rendered.subject,
|
|
685
|
+
html,
|
|
686
|
+
text,
|
|
687
|
+
...rendered.headers ? { headers: rendered.headers } : {},
|
|
688
|
+
...rendered.tags ? { tags: rendered.tags } : {},
|
|
689
|
+
idempotencyKey: `genesis-notification-${job.id}`
|
|
690
|
+
},
|
|
691
|
+
{ signal }
|
|
692
|
+
);
|
|
693
|
+
if (result.id) {
|
|
694
|
+
providerIds.set(job.id, result.id);
|
|
695
|
+
try {
|
|
696
|
+
await store.recordProviderId(job.id, job.claim_id, result.id);
|
|
697
|
+
} catch {
|
|
698
|
+
log(`Keeping email ${job.id}'s provider id failed; it is kept when it settles`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return result.alreadySent ? { ok: true, alreadyThere: true } : { ok: true };
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error instanceof SendError && error.message === "no_valid_address") return { ok: true, notSent: "no_address" };
|
|
704
|
+
if (error instanceof Error && error.name === "SecretLinkError") return { ok: false, retryable: false, error: "secret_link_unavailable" };
|
|
705
|
+
return failureOf(error);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const none = async () => {
|
|
709
|
+
throw new Error("This channel isn't configured");
|
|
710
|
+
};
|
|
711
|
+
async function drain(r, signal) {
|
|
712
|
+
const budget = options.runBudgetMs;
|
|
713
|
+
if (budget === void 0) return r.run({ signal });
|
|
714
|
+
const started = now();
|
|
715
|
+
let total = emptyReport();
|
|
716
|
+
while (budget - (now() - started) >= lease * 1e3 && !signal?.aborted) {
|
|
717
|
+
const report = await r.run({ signal });
|
|
718
|
+
total = addReport(total, report);
|
|
719
|
+
if (report.claimed < batch) break;
|
|
720
|
+
}
|
|
721
|
+
return total;
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
async run({ signal } = {}) {
|
|
725
|
+
const [push, mail] = await Promise.all([pushRunner ? drain(pushRunner, signal) : null, emailRunner ? drain(emailRunner, signal) : null]);
|
|
726
|
+
return { push, email: mail };
|
|
727
|
+
},
|
|
728
|
+
runPush: ({ signal } = {}) => pushRunner ? drain(pushRunner, signal) : none(),
|
|
729
|
+
runEmail: ({ signal } = {}) => emailRunner ? drain(emailRunner, signal) : none(),
|
|
730
|
+
async checkReceipts({ signal, budgetMs = 6e4 } = {}) {
|
|
731
|
+
const provider = options.push?.provider;
|
|
732
|
+
if (!provider) return { checked: 0, settled: 0 };
|
|
733
|
+
const started = Date.now();
|
|
734
|
+
let checked = 0;
|
|
735
|
+
let settled = 0;
|
|
736
|
+
for (; ; ) {
|
|
737
|
+
const due = await store.receiptsClaim(EXPO_RECEIPTS_BATCH);
|
|
738
|
+
if (!due.length) break;
|
|
739
|
+
checked += due.length;
|
|
740
|
+
let receipts;
|
|
741
|
+
try {
|
|
742
|
+
receipts = await provider.receipts(due.map((d) => d.ticket_id), { signal });
|
|
743
|
+
} catch (error) {
|
|
744
|
+
log(`Push receipts failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
const results = due.flatMap((d) => {
|
|
748
|
+
const r = receipts[d.ticket_id];
|
|
749
|
+
if (!r) return [];
|
|
750
|
+
return [{ id: d.id, status: r.status === "ok" ? "delivered" : "failed", error: r.status === "error" ? r.code : null }];
|
|
751
|
+
});
|
|
752
|
+
if (results.some((r) => r.error === "InvalidCredentials")) log("Push receipts: InvalidCredentials (the APNs or FCM credentials in EAS are wrong)");
|
|
753
|
+
if (results.length) await store.receiptsSettle(results);
|
|
754
|
+
settled += results.length;
|
|
755
|
+
if (due.length < EXPO_RECEIPTS_BATCH || Date.now() - started > budgetMs) break;
|
|
756
|
+
}
|
|
757
|
+
return { checked, settled };
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/server/webhook.ts
|
|
763
|
+
var WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
764
|
+
async function verifyResendWebhook(secret, headers, body, now = Date.now()) {
|
|
765
|
+
const id = headers.get("svix-id") ?? headers.get("webhook-id");
|
|
766
|
+
const timestamp = headers.get("svix-timestamp") ?? headers.get("webhook-timestamp");
|
|
767
|
+
const signatures = headers.get("svix-signature") ?? headers.get("webhook-signature");
|
|
768
|
+
if (!secret || !id || !timestamp || !signatures || !/^\d{1,12}$/.test(timestamp)) return false;
|
|
769
|
+
if (Math.abs(now / 1e3 - Number(timestamp)) > WEBHOOK_TOLERANCE_SECONDS) return false;
|
|
770
|
+
let key;
|
|
771
|
+
try {
|
|
772
|
+
key = fromBase64(secret.startsWith("whsec_") ? secret.slice(6) : secret);
|
|
773
|
+
} catch {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
const expected = await hmacSha256(key, `${id}.${timestamp}.${body}`);
|
|
777
|
+
for (const part of signatures.split(" ")) {
|
|
778
|
+
const [version, sig] = part.split(",");
|
|
779
|
+
if (version !== "v1" || !sig) continue;
|
|
780
|
+
let given;
|
|
781
|
+
try {
|
|
782
|
+
given = fromBase64(sig);
|
|
783
|
+
} catch {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (timingSafeEqual(given, expected)) return true;
|
|
787
|
+
}
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
function emailEventOf(payload) {
|
|
791
|
+
const p = payload;
|
|
792
|
+
const id = p?.data?.email_id;
|
|
793
|
+
if (typeof id !== "string" || !id || id.length > 200) return null;
|
|
794
|
+
switch (p?.type) {
|
|
795
|
+
case "email.delivered":
|
|
796
|
+
return { providerId: id, event: "delivered", permanent: false };
|
|
797
|
+
case "email.bounced":
|
|
798
|
+
return { providerId: id, event: "bounced", permanent: p.data?.bounce?.type === "Permanent" };
|
|
799
|
+
case "email.complained":
|
|
800
|
+
return { providerId: id, event: "complained", permanent: true };
|
|
801
|
+
default:
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/server/handler.ts
|
|
807
|
+
var COPY = {
|
|
808
|
+
title: "Unsubscribe",
|
|
809
|
+
confirm: "Stop getting these emails?",
|
|
810
|
+
button: "Unsubscribe",
|
|
811
|
+
done: "You're unsubscribed. You won't get these emails any more.",
|
|
812
|
+
invalid: "This link isn't valid any more."
|
|
813
|
+
};
|
|
814
|
+
function page(title, body, status = 200) {
|
|
815
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${escapeHtml(title)}</title></head><body style="font-family:sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5">${body}</body></html>`;
|
|
816
|
+
return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
817
|
+
}
|
|
818
|
+
function json(body, status = 200) {
|
|
819
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
820
|
+
}
|
|
821
|
+
function createNotificationsHandler(options) {
|
|
822
|
+
if (!options.cronSecret || options.cronSecret.length < 32) throw new Error("createNotificationsHandler: cronSecret must be at least 32 characters");
|
|
823
|
+
const copy = { ...COPY, ...options.copy };
|
|
824
|
+
const enc = new TextEncoder();
|
|
825
|
+
const authorized = (request) => {
|
|
826
|
+
const header = request.headers.get("authorization") ?? "";
|
|
827
|
+
const given = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
828
|
+
return timingSafeEqual(enc.encode(given), enc.encode(options.cronSecret));
|
|
829
|
+
};
|
|
830
|
+
const log = (m) => {
|
|
831
|
+
try {
|
|
832
|
+
options.log?.(m);
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
return async (request) => {
|
|
837
|
+
const url = new URL(request.url);
|
|
838
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
839
|
+
try {
|
|
840
|
+
if (path.endsWith("/run") || path.endsWith("/receipts")) {
|
|
841
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
842
|
+
if (!authorized(request)) return json({ error: "unauthorized" }, 401);
|
|
843
|
+
if (path.endsWith("/run")) return json({ ok: true, ...await options.runner.run({ signal: request.signal }) });
|
|
844
|
+
return json({ ok: true, ...await options.runner.checkReceipts({ signal: request.signal }) });
|
|
845
|
+
}
|
|
846
|
+
if (path.endsWith("/webhooks/resend")) {
|
|
847
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
848
|
+
if (!options.resendWebhookSecret) return json({ error: "not_configured" }, 404);
|
|
849
|
+
const body = await request.text();
|
|
850
|
+
if (body.length > 256e3 || !await verifyResendWebhook(options.resendWebhookSecret, request.headers, body)) return json({ error: "bad_signature" }, 401);
|
|
851
|
+
let payload;
|
|
852
|
+
try {
|
|
853
|
+
payload = JSON.parse(body);
|
|
854
|
+
} catch {
|
|
855
|
+
return json({ error: "bad_json" }, 400);
|
|
856
|
+
}
|
|
857
|
+
const event = emailEventOf(payload);
|
|
858
|
+
if (!event) return json({ ok: true, ignored: true });
|
|
859
|
+
const ours = await options.store.emailEvent(event.providerId, event.event, event.permanent);
|
|
860
|
+
return json({ ok: true, ours });
|
|
861
|
+
}
|
|
862
|
+
if (path.endsWith("/unsubscribe")) {
|
|
863
|
+
if (!options.unsubscribeSecret) return page(copy.title, `<p>${escapeHtml(copy.invalid)}</p>`, 404);
|
|
864
|
+
const token = url.searchParams.get("t") ?? "";
|
|
865
|
+
const claims = await verifyUnsubscribeToken(options.unsubscribeSecret, token);
|
|
866
|
+
if (!claims) return page(copy.title, `<p>${escapeHtml(copy.invalid)}</p>`, 400);
|
|
867
|
+
if (request.method === "GET") {
|
|
868
|
+
const action = `?t=${encodeURIComponent(token)}`;
|
|
869
|
+
return page(
|
|
870
|
+
copy.title,
|
|
871
|
+
`<h1 style="font-size:1.25rem">${escapeHtml(copy.confirm)}</h1><form method="post" action="${escapeHtml(action)}"><input type="hidden" name="List-Unsubscribe" value="One-Click"><button type="submit" style="font-size:1rem;padding:.5rem 1rem">${escapeHtml(copy.button)}</button></form>`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
875
|
+
await options.store.unsubscribe(claims.userId ?? null, claims.userId ? null : claims.emailHash ?? null, claims.category, claims.tenantId ?? null);
|
|
876
|
+
return page(copy.title, `<p>${escapeHtml(copy.done)}</p>`);
|
|
877
|
+
}
|
|
878
|
+
return json({ error: "not_found" }, 404);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
log(`notifications handler: ${error instanceof Error ? error.name : "error"} on ${path.split("/").slice(-1)[0]}`);
|
|
881
|
+
return json({ error: "server_error" }, 500);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/server/rate.ts
|
|
887
|
+
function defaultSleep(ms, signal) {
|
|
888
|
+
return new Promise((resolve, reject) => {
|
|
889
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error("Aborted"));
|
|
890
|
+
const timer = setTimeout(() => {
|
|
891
|
+
signal?.removeEventListener("abort", onAbort);
|
|
892
|
+
resolve();
|
|
893
|
+
}, ms);
|
|
894
|
+
const onAbort = () => {
|
|
895
|
+
clearTimeout(timer);
|
|
896
|
+
reject(signal?.reason ?? new Error("Aborted"));
|
|
897
|
+
};
|
|
898
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function createRateLimiter(options) {
|
|
902
|
+
if (!(options.perSecond > 0) || !Number.isFinite(options.perSecond)) throw new Error("createRateLimiter: perSecond must be a positive number");
|
|
903
|
+
const gap = 1e3 / options.perSecond;
|
|
904
|
+
const now = options.now ?? Date.now;
|
|
905
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
906
|
+
let next = 0;
|
|
907
|
+
return {
|
|
908
|
+
async acquire(signal) {
|
|
909
|
+
const at = Math.max(now(), next);
|
|
910
|
+
next = at + gap;
|
|
911
|
+
const wait = at - now();
|
|
912
|
+
if (wait > 0) await sleep(wait, signal);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/server/resend.ts
|
|
918
|
+
var RESEND_API = "https://api.resend.com/emails";
|
|
919
|
+
var RESEND_DEFAULT_PER_SECOND = 5;
|
|
920
|
+
function resendProvider(options) {
|
|
921
|
+
const pace = createRateLimiter({
|
|
922
|
+
perSecond: options.perSecond ?? RESEND_DEFAULT_PER_SECOND,
|
|
923
|
+
...options.now ? { now: options.now } : {},
|
|
924
|
+
...options.sleep ? { sleep: options.sleep } : {}
|
|
925
|
+
});
|
|
926
|
+
return {
|
|
927
|
+
name: "resend",
|
|
928
|
+
async send(message, { signal } = {}) {
|
|
929
|
+
if (!options.apiKey) throw new SendError("The Resend API key is not set");
|
|
930
|
+
try {
|
|
931
|
+
await pace.acquire(signal);
|
|
932
|
+
} catch {
|
|
933
|
+
throw new SendError("Timed out", { retryable: true });
|
|
934
|
+
}
|
|
935
|
+
const response = await timedFetch(
|
|
936
|
+
options,
|
|
937
|
+
options.endpoint ?? RESEND_API,
|
|
938
|
+
{
|
|
939
|
+
method: "POST",
|
|
940
|
+
headers: {
|
|
941
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
942
|
+
"content-type": "application/json",
|
|
943
|
+
"idempotency-key": message.idempotencyKey
|
|
944
|
+
},
|
|
945
|
+
body: JSON.stringify({
|
|
946
|
+
from: message.from,
|
|
947
|
+
to: message.to,
|
|
948
|
+
subject: message.subject,
|
|
949
|
+
html: message.html,
|
|
950
|
+
text: message.text,
|
|
951
|
+
...message.replyTo ? { reply_to: message.replyTo } : {},
|
|
952
|
+
...message.headers && Object.keys(message.headers).length ? { headers: message.headers } : {},
|
|
953
|
+
...message.tags?.length ? { tags: message.tags } : {}
|
|
954
|
+
})
|
|
955
|
+
},
|
|
956
|
+
signal
|
|
957
|
+
);
|
|
958
|
+
if (response.ok) {
|
|
959
|
+
let id = null;
|
|
960
|
+
try {
|
|
961
|
+
const body = await response.json();
|
|
962
|
+
if (typeof body.id === "string" && body.id.length <= 200) id = body.id;
|
|
963
|
+
} catch {
|
|
964
|
+
}
|
|
965
|
+
return { id };
|
|
966
|
+
}
|
|
967
|
+
let name;
|
|
968
|
+
try {
|
|
969
|
+
const body = await response.json();
|
|
970
|
+
if (typeof body.name === "string" && /^[a-z_]{1,64}$/.test(body.name)) name = body.name;
|
|
971
|
+
} catch {
|
|
972
|
+
discard(response);
|
|
973
|
+
}
|
|
974
|
+
if (response.status === 409 && name === "invalid_idempotent_request") {
|
|
975
|
+
throw new SendError("idempotency_body_changed: Resend answered 409 (invalid_idempotent_request); a secret link builder must give the same link every time", {
|
|
976
|
+
retryable: false,
|
|
977
|
+
status: 409
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
if (response.status === 409 && name === "concurrent_idempotent_requests") {
|
|
981
|
+
throw new SendError("Resend answered 409 (concurrent_idempotent_requests)", { retryable: true, retryAfterSeconds: 5, status: 409 });
|
|
982
|
+
}
|
|
983
|
+
const retryable = isRetryableStatus(response.status);
|
|
984
|
+
throw new SendError(`Resend answered HTTP ${response.status}${name ? ` (${name})` : ""}`, {
|
|
985
|
+
retryable,
|
|
986
|
+
status: response.status,
|
|
987
|
+
...response.status === 429 || response.status === 503 ? { retryAfterSeconds: retryAfterSeconds(response.headers.get("retry-after"), 30) } : {}
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export { EXPO_RECEIPTS_BATCH, EXPO_RECEIPTS_URL, EXPO_SEND_BATCH, EXPO_SEND_URL, NotificationStoreError, REQUEST_TIMEOUT_MS, RESEND_API, RESEND_DEFAULT_PER_SECOND, SECRET_LINK_PLACEHOLDER, SENDER_OWNERSHIP_HOST, SecretLinkError, SendError, WEBHOOK_TOLERANCE_SECONDS, cancelNotification, canonicalJson, checkSenderDomain, createNotificationRunner, createNotificationsHandler, createRateLimiter, createUnsubscribeToken, defaultEmailTemplate, displayName, emailEventOf, emailHash, escapeHtml, expoErrorCode, expoPushProvider, headerSafe, isReservedDomain, isRetryableStatus, isSafeAddress, notify, refusedOutright, resendProvider, resolveSecretLink, retryAfterSeconds, senderOwnershipRecord, supabaseNotificationStore, syncNotificationTypes, unsubscribeHeaders, verifyResendWebhook, verifyUnsubscribeToken };
|
|
994
|
+
//# sourceMappingURL=server.js.map
|
|
995
|
+
//# sourceMappingURL=server.js.map
|