@marktiderman/genesis-notifications 0.0.0-stage → 0.1.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/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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resend's REST API as an `EmailProvider`. One key per app (`RESEND_API_KEY`);
|
|
3
|
+
* each tenant gets its own sender identity (`senderFor`), not its own key.
|
|
4
|
+
*
|
|
5
|
+
* Retries are byte-identical (the runner stores the first render), and every
|
|
6
|
+
* send carries an idempotency key, so Resend sends once:
|
|
7
|
+
* - 409 `invalid_idempotent_request`: the key was used with another body. The
|
|
8
|
+
* stored render never changes, so the body differed in what is built at send
|
|
9
|
+
* time: a secret link builder that isn't deterministic (it must give the same
|
|
10
|
+
* link for the same notification every time). The job fails loudly
|
|
11
|
+
* (`idempotency_body_changed`); it is never counted as sent.
|
|
12
|
+
* - 409 `concurrent_idempotent_requests`: another attempt is in flight: retry soon.
|
|
13
|
+
*
|
|
14
|
+
* Requests are paced (`perSecond`, default 5) under Resend's default limit of
|
|
15
|
+
* 10 requests a second per team, which every key of the team shares.
|
|
16
|
+
*/
|
|
17
|
+
import { SendError, discard, isRetryableStatus, retryAfterSeconds, timedFetch, type HttpDeps } from "./errors";
|
|
18
|
+
import { createRateLimiter } from "./rate";
|
|
19
|
+
|
|
20
|
+
/** One email, ready to send. Addresses and headers are already checked. */
|
|
21
|
+
export interface EmailMessage {
|
|
22
|
+
from: string;
|
|
23
|
+
to: string[];
|
|
24
|
+
replyTo?: string;
|
|
25
|
+
subject: string;
|
|
26
|
+
html: string;
|
|
27
|
+
text: string;
|
|
28
|
+
/** Extra headers (`List-Unsubscribe`, `List-Unsubscribe-Post`). */
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/** Provider tags: name and value are letters, digits, "_" or "-". */
|
|
31
|
+
tags?: { name: string; value: string }[];
|
|
32
|
+
/** Stable across retries of one send job. */
|
|
33
|
+
idempotencyKey: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EmailSendResult {
|
|
37
|
+
/** The provider's id for the email (what its webhooks name). */
|
|
38
|
+
id: string | null;
|
|
39
|
+
/** The provider says it was sent before (the same idempotency key). */
|
|
40
|
+
alreadySent?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sends one email. Throws a `SendError` on failure. */
|
|
44
|
+
export interface EmailProvider {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
send(message: EmailMessage, options?: { signal?: AbortSignal }): Promise<EmailSendResult>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const RESEND_API = "https://api.resend.com/emails";
|
|
50
|
+
|
|
51
|
+
/** Requests a second the provider starts by default: half Resend's default team limit (10). */
|
|
52
|
+
export const RESEND_DEFAULT_PER_SECOND = 5;
|
|
53
|
+
|
|
54
|
+
export interface ResendOptions extends HttpDeps {
|
|
55
|
+
apiKey: string;
|
|
56
|
+
endpoint?: string;
|
|
57
|
+
/** Requests started per second, at most. Default `RESEND_DEFAULT_PER_SECOND` (5). Raise it only if Resend raised your team's limit. */
|
|
58
|
+
perSecond?: number;
|
|
59
|
+
/** For tests: the clock and the wait the pacer uses. */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resend as an `EmailProvider`.
|
|
66
|
+
*
|
|
67
|
+
* @stability Beta
|
|
68
|
+
*/
|
|
69
|
+
export function resendProvider(options: ResendOptions): EmailProvider {
|
|
70
|
+
const pace = createRateLimiter({
|
|
71
|
+
perSecond: options.perSecond ?? RESEND_DEFAULT_PER_SECOND,
|
|
72
|
+
...(options.now ? { now: options.now } : {}),
|
|
73
|
+
...(options.sleep ? { sleep: options.sleep } : {}),
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
name: "resend",
|
|
77
|
+
async send(message, { signal } = {}) {
|
|
78
|
+
if (!options.apiKey) throw new SendError("The Resend API key is not set");
|
|
79
|
+
try {
|
|
80
|
+
await pace.acquire(signal);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new SendError("Timed out", { retryable: true });
|
|
83
|
+
}
|
|
84
|
+
const response = await timedFetch(
|
|
85
|
+
options,
|
|
86
|
+
options.endpoint ?? RESEND_API,
|
|
87
|
+
{
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
91
|
+
"content-type": "application/json",
|
|
92
|
+
"idempotency-key": message.idempotencyKey,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
from: message.from,
|
|
96
|
+
to: message.to,
|
|
97
|
+
subject: message.subject,
|
|
98
|
+
html: message.html,
|
|
99
|
+
text: message.text,
|
|
100
|
+
...(message.replyTo ? { reply_to: message.replyTo } : {}),
|
|
101
|
+
...(message.headers && Object.keys(message.headers).length ? { headers: message.headers } : {}),
|
|
102
|
+
...(message.tags?.length ? { tags: message.tags } : {}),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
signal,
|
|
106
|
+
);
|
|
107
|
+
if (response.ok) {
|
|
108
|
+
let id: string | null = null;
|
|
109
|
+
try {
|
|
110
|
+
const body = (await response.json()) as { id?: unknown };
|
|
111
|
+
if (typeof body.id === "string" && body.id.length <= 200) id = body.id;
|
|
112
|
+
} catch {
|
|
113
|
+
// Sent; the id is only for the webhooks.
|
|
114
|
+
}
|
|
115
|
+
return { id };
|
|
116
|
+
}
|
|
117
|
+
// Resend's error `name` is safe to keep; its message may echo input.
|
|
118
|
+
let name: string | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const body = (await response.json()) as { name?: unknown };
|
|
121
|
+
if (typeof body.name === "string" && /^[a-z_]{1,64}$/.test(body.name)) name = body.name;
|
|
122
|
+
} catch {
|
|
123
|
+
discard(response);
|
|
124
|
+
}
|
|
125
|
+
if (response.status === 409 && name === "invalid_idempotent_request") {
|
|
126
|
+
// Never "sent": the body changed under the same key, so something built at send time isn't deterministic.
|
|
127
|
+
throw new SendError("idempotency_body_changed: Resend answered 409 (invalid_idempotent_request); a secret link builder must give the same link every time", {
|
|
128
|
+
retryable: false,
|
|
129
|
+
status: 409,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (response.status === 409 && name === "concurrent_idempotent_requests") {
|
|
133
|
+
throw new SendError("Resend answered 409 (concurrent_idempotent_requests)", { retryable: true, retryAfterSeconds: 5, status: 409 });
|
|
134
|
+
}
|
|
135
|
+
const retryable = isRetryableStatus(response.status);
|
|
136
|
+
throw new SendError(`Resend answered HTTP ${response.status}${name ? ` (${name})` : ""}`, {
|
|
137
|
+
retryable,
|
|
138
|
+
status: response.status,
|
|
139
|
+
...(response.status === 429 || response.status === 503 ? { retryAfterSeconds: retryAfterSeconds(response.headers.get("retry-after"), 30) } : {}),
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createNotificationRunner`: sends what `notify()` queued, on the shared
|
|
3
|
+
* runner (`@marktiderman/genesis-notifications/runner`). Run `run()` every
|
|
4
|
+
* minute and `checkReceipts()` every 15 minutes (pg_cron with pg_net calling
|
|
5
|
+
* the handler, or any scheduler).
|
|
6
|
+
*
|
|
7
|
+
* Push: the claimed jobs' devices in Expo batches of 100 (a job's devices
|
|
8
|
+
* never split across two), each batch sent, its tickets recorded and its jobs
|
|
9
|
+
* settled on its own, and no batch started once its lease is about to run
|
|
10
|
+
* out (those jobs come straight back), with `threadId`,
|
|
11
|
+
* `categoryId`, `badge`, a `collapseId` (the collapse key, else the
|
|
12
|
+
* notification id, so a resend after a timeout replaces rather than repeats),
|
|
13
|
+
* and neutral text when the person hides previews or the type says so. The
|
|
14
|
+
* payload's `data` is the link, the notification id and the type, never the
|
|
15
|
+
* notification's own `data`. Tickets are recorded per device; a
|
|
16
|
+
* `DeviceNotRegistered` forgets the device.
|
|
17
|
+
*
|
|
18
|
+
* Email: rendered once at first claim and stored, so every retry sends the
|
|
19
|
+
* same bytes with the same idempotency key. From the tenant's verified
|
|
20
|
+
* domain, or the app's shared one with the tenant's name and reply-to;
|
|
21
|
+
* transactional and other mail from separate domains; RFC 8058 unsubscribe
|
|
22
|
+
* headers on everything that isn't transactional; a secret link swapped in
|
|
23
|
+
* at send time. The provider's id is kept the moment a send returns, before
|
|
24
|
+
* the job settles, so a webhook that comes first finds it.
|
|
25
|
+
*
|
|
26
|
+
* Links (a type's `render().url`) must be paths on the app's own origin
|
|
27
|
+
* (`/messages/42`): anything else (another host, `//host`, `javascript:`) is
|
|
28
|
+
* left out of the push and the email.
|
|
29
|
+
*/
|
|
30
|
+
import type { NotificationRegistry } from "../core/registry";
|
|
31
|
+
import type { NotificationBrand } from "../core/types";
|
|
32
|
+
import { createRunner, failureOf, type JobOutcome, type JobResult, type RunReport } from "../runner/index";
|
|
33
|
+
import { canonicalJson, defaultEmailTemplate, displayName, escapeHtml, headerSafe, isSafeAddress, type EmailTemplate } from "./email-render";
|
|
34
|
+
import { REQUEST_TIMEOUT_MS, SendError } from "./errors";
|
|
35
|
+
import { isReservedDomain } from "./dns";
|
|
36
|
+
import { EXPO_RECEIPTS_BATCH, EXPO_SEND_BATCH, refusedOutright, type PushMessage, type PushProvider, type PushTicket } from "./expo";
|
|
37
|
+
import type { EmailProvider } from "./resend";
|
|
38
|
+
import { SECRET_LINK_PLACEHOLDER, resolveSecretLink, type SecretLinks } from "./secret-links";
|
|
39
|
+
import { sha256Hex } from "./crypto";
|
|
40
|
+
import type { ClaimedJob, NotificationStore, PushTicketRow, RenderedEmail, SettleOutcome } from "./store";
|
|
41
|
+
import { createUnsubscribeToken, emailHash, unsubscribeHeaders } from "./unsubscribe";
|
|
42
|
+
|
|
43
|
+
export interface EmailOptions {
|
|
44
|
+
provider: EmailProvider;
|
|
45
|
+
/** The from name when a tenant has none ("Example"). */
|
|
46
|
+
appName: string;
|
|
47
|
+
/**
|
|
48
|
+
* The app's shared sending domains, verified in Resend: transactional mail
|
|
49
|
+
* and other mail go from separate ones, so one's reputation never holds up
|
|
50
|
+
* the other. `other` defaults to `transactional`.
|
|
51
|
+
*/
|
|
52
|
+
domains: { transactional: string; other?: string };
|
|
53
|
+
/** The address's local part. Default `notifications`. */
|
|
54
|
+
localPart?: string;
|
|
55
|
+
/** Type key → template. A type without one gets `defaultEmailTemplate`. */
|
|
56
|
+
templates?: Readonly<Record<string, EmailTemplate>>;
|
|
57
|
+
defaultTemplate?: EmailTemplate;
|
|
58
|
+
/** How each secret-link type's link is built (`resolveSecretLink`). */
|
|
59
|
+
secretLinks?: SecretLinks;
|
|
60
|
+
/** Required when any type sends email that isn't transactional. */
|
|
61
|
+
unsubscribe?: { url: string; secret: string };
|
|
62
|
+
/**
|
|
63
|
+
* The provider for one job. Default the app's `provider`. The seam for a
|
|
64
|
+
* tenant-owned key later (read from Vault by id); not built in v1.
|
|
65
|
+
*/
|
|
66
|
+
providerFor?: (job: ClaimedJob) => EmailProvider;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface NotificationRunnerOptions {
|
|
70
|
+
store: NotificationStore;
|
|
71
|
+
registry: NotificationRegistry;
|
|
72
|
+
push?: { provider: PushProvider };
|
|
73
|
+
email?: EmailOptions;
|
|
74
|
+
/** The app's origin, for absolute links in emails (`https://app.example.com`). */
|
|
75
|
+
appUrl?: string;
|
|
76
|
+
/** Jobs claimed per channel per run. Default 100. */
|
|
77
|
+
batchSize?: number;
|
|
78
|
+
/** How long a claim holds a job. Default 300 seconds. */
|
|
79
|
+
leaseSeconds?: number;
|
|
80
|
+
/**
|
|
81
|
+
* How long one `run()` may take (your function's wall-clock limit, less a
|
|
82
|
+
* margin). With it, a run claims again while the last claim was full, and
|
|
83
|
+
* stops claiming once the time left is under the lease. Without it, a run
|
|
84
|
+
* claims once per channel. At least `leaseSeconds`.
|
|
85
|
+
*/
|
|
86
|
+
runBudgetMs?: number;
|
|
87
|
+
maxAttempts?: number;
|
|
88
|
+
maxDeferrals?: number;
|
|
89
|
+
/** Safe lines only (ids, statuses, error codes). */
|
|
90
|
+
log?: (message: string) => void;
|
|
91
|
+
now?: () => number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NotificationRunner {
|
|
95
|
+
/** Sends what is due on every configured channel. */
|
|
96
|
+
run(options?: { signal?: AbortSignal }): Promise<{ push: RunReport | null; email: RunReport | null }>;
|
|
97
|
+
runPush(options?: { signal?: AbortSignal }): Promise<RunReport>;
|
|
98
|
+
runEmail(options?: { signal?: AbortSignal }): Promise<RunReport>;
|
|
99
|
+
/** Asks Expo for the receipts that are due and records them. */
|
|
100
|
+
checkReceipts(options?: { signal?: AbortSignal; budgetMs?: number }): Promise<{ checked: number; settled: number }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Job = ClaimedJob & { attempt: number };
|
|
104
|
+
|
|
105
|
+
function settleOf(outcome: JobOutcome, providerId: string | undefined): SettleOutcome {
|
|
106
|
+
const id = providerId ? { provider_id: providerId } : {};
|
|
107
|
+
switch (outcome.status) {
|
|
108
|
+
case "sent":
|
|
109
|
+
return { status: "sent", provider_id: providerId ?? null };
|
|
110
|
+
case "filtered":
|
|
111
|
+
return { status: "filtered" };
|
|
112
|
+
case "skipped":
|
|
113
|
+
return { status: "skipped", reason: /^[a-z_]{1,64}$/.test(outcome.reason) ? outcome.reason : "not_sent" };
|
|
114
|
+
case "failed":
|
|
115
|
+
return { status: "failed", error: outcome.error, ...id };
|
|
116
|
+
case "retry":
|
|
117
|
+
return { status: "retry", error: outcome.error, retry_at: outcome.retryAt.toISOString(), counted: outcome.counted, ...id };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A link a type rendered, if it is a path on the app's own origin (`/x`, never `//host` or `/\host`); else null. */
|
|
122
|
+
export function appPath(url: string | undefined): string | null {
|
|
123
|
+
if (typeof url !== "string" || url.length > 2000) return null;
|
|
124
|
+
if (!/^\/(?![/\\])/.test(url) || /[\u0000-\u001f\u007f\s\\]/.test(url)) return null;
|
|
125
|
+
return url;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emptyReport(): RunReport {
|
|
129
|
+
return { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function addReport(a: RunReport, b: RunReport): RunReport {
|
|
133
|
+
const out = { ...a };
|
|
134
|
+
for (const k of Object.keys(out) as (keyof RunReport)[]) out[k] += b[k];
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The runner.
|
|
140
|
+
*
|
|
141
|
+
* @stability Beta
|
|
142
|
+
*/
|
|
143
|
+
export function createNotificationRunner(options: NotificationRunnerOptions): NotificationRunner {
|
|
144
|
+
const { store, registry } = options;
|
|
145
|
+
const log = (m: string) => {
|
|
146
|
+
try {
|
|
147
|
+
options.log?.(m);
|
|
148
|
+
} catch {
|
|
149
|
+
// ignored
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const batch = options.batchSize ?? 100;
|
|
153
|
+
const lease = options.leaseSeconds ?? 300;
|
|
154
|
+
const now = () => options.now?.() ?? Date.now();
|
|
155
|
+
if (options.runBudgetMs !== undefined && !(options.runBudgetMs >= lease * 1000)) {
|
|
156
|
+
throw new Error("createNotificationRunner: runBudgetMs must be at least leaseSeconds (a run would claim jobs it can't finish)");
|
|
157
|
+
}
|
|
158
|
+
const email = options.email;
|
|
159
|
+
if (email) {
|
|
160
|
+
const needsUnsubscribe = Object.keys(registry.types).some((key) => registry.type(key).channels.email !== undefined && !registry.type(key).transactional && !registry.isCritical(key));
|
|
161
|
+
if (needsUnsubscribe && !email.unsubscribe) {
|
|
162
|
+
throw new Error("createNotificationRunner: a type sends email that isn't transactional, so email.unsubscribe ({ url, secret }) is required");
|
|
163
|
+
}
|
|
164
|
+
for (const [key, t] of Object.entries(registry.types)) {
|
|
165
|
+
if (t.secretLink && !email.secretLinks?.[key]) throw new Error(`createNotificationRunner: type ${key} needs a secret link builder in email.secretLinks`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const providerIds = new Map<string, string>();
|
|
169
|
+
|
|
170
|
+
const record = async (job: Job, outcome: JobOutcome) => {
|
|
171
|
+
const providerId = providerIds.get(job.id);
|
|
172
|
+
providerIds.delete(job.id);
|
|
173
|
+
return store.settle(job.id, job.claim_id, settleOf(outcome, providerId));
|
|
174
|
+
};
|
|
175
|
+
const common = {
|
|
176
|
+
record,
|
|
177
|
+
now: options.now,
|
|
178
|
+
log: options.log,
|
|
179
|
+
maxAttempts: options.maxAttempts,
|
|
180
|
+
maxDeferrals: options.maxDeferrals,
|
|
181
|
+
recordLabel: (job: Job) => `${job.channel} job ${job.id}`,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Push ---------------------------------------------------------------------
|
|
185
|
+
// Each claimed job's Expo batch (its devices never split), and when its lease runs out.
|
|
186
|
+
const pushChunk = new Map<string, number>();
|
|
187
|
+
const leaseEnds = new Map<string, number>();
|
|
188
|
+
// The batches go one after another (createRunner starts every group at once), so the lease check before each means something.
|
|
189
|
+
let pushTurn: Promise<unknown> = Promise.resolve();
|
|
190
|
+
const pushRunner = options.push
|
|
191
|
+
? createRunner<Job>({
|
|
192
|
+
...common,
|
|
193
|
+
label: (job) => `Push ${job.id}`,
|
|
194
|
+
claim: async ({ limit }) => {
|
|
195
|
+
const claimedAt = now();
|
|
196
|
+
const jobs = (await store.claim("push", limit, lease, options.maxAttempts ?? 8)) as Job[];
|
|
197
|
+
let chunk = 0;
|
|
198
|
+
let filled = 0;
|
|
199
|
+
for (const job of jobs) {
|
|
200
|
+
const n = Math.min(job.devices?.length ?? 0, EXPO_SEND_BATCH);
|
|
201
|
+
if (filled > 0 && filled + n > EXPO_SEND_BATCH) {
|
|
202
|
+
chunk++;
|
|
203
|
+
filled = 0;
|
|
204
|
+
}
|
|
205
|
+
filled += n;
|
|
206
|
+
pushChunk.set(job.id, chunk);
|
|
207
|
+
leaseEnds.set(job.id, claimedAt + lease * 1000);
|
|
208
|
+
}
|
|
209
|
+
return jobs;
|
|
210
|
+
},
|
|
211
|
+
// One group per Expo batch: each is sent, recorded and settled on its own.
|
|
212
|
+
group: (job) => `push:${pushChunk.get(job.id) ?? 0}`,
|
|
213
|
+
send: (jobs, { signal }) => {
|
|
214
|
+
const deadline = Math.min(...jobs.map((j) => leaseEnds.get(j.id) ?? Infinity));
|
|
215
|
+
for (const j of jobs) {
|
|
216
|
+
pushChunk.delete(j.id);
|
|
217
|
+
leaseEnds.delete(j.id);
|
|
218
|
+
}
|
|
219
|
+
const mine = pushTurn.then(() => sendPush(jobs, options.push!.provider, deadline, signal));
|
|
220
|
+
pushTurn = mine.catch(() => undefined);
|
|
221
|
+
return mine;
|
|
222
|
+
},
|
|
223
|
+
batchSize: batch,
|
|
224
|
+
})
|
|
225
|
+
: null;
|
|
226
|
+
|
|
227
|
+
/** Only strings and finite numbers, so a type can't put an object or a function in a push. */
|
|
228
|
+
function flatValues(values: Record<string, unknown> | undefined): Record<string, string | number> {
|
|
229
|
+
const out: Record<string, string | number> = {};
|
|
230
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
231
|
+
if (typeof value === "string" || (typeof value === "number" && Number.isFinite(value))) out[key] = value;
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pushMessage(job: Job, token: string): PushMessage {
|
|
237
|
+
const hidden = job.hide_previews || job.push_privacy === "generic";
|
|
238
|
+
const rendered = registry.render(job.type, job.data);
|
|
239
|
+
const text = hidden ? { title: registry.hiddenPreview.title, body: registry.hiddenPreview.body } : rendered;
|
|
240
|
+
const url = appPath(rendered.url);
|
|
241
|
+
// The type's push extras: ids always, display values and rich push only with previews shown.
|
|
242
|
+
const extras = { ...flatValues(rendered.push?.ids), ...(hidden ? {} : flatValues(rendered.push?.display)) };
|
|
243
|
+
return {
|
|
244
|
+
to: token,
|
|
245
|
+
title: text.title,
|
|
246
|
+
...(text.body ? { body: text.body } : {}),
|
|
247
|
+
data: { ...extras, notification_id: job.notification_id, type: job.type, ...(url ? { url } : {}), ...(job.thread_key ? { thread_key: job.thread_key } : {}) },
|
|
248
|
+
...(!hidden && rendered.push?.mutableContent ? { mutableContent: true } : {}),
|
|
249
|
+
sound: "default",
|
|
250
|
+
...(job.badge !== null && job.badge !== undefined ? { badge: job.badge } : {}),
|
|
251
|
+
channelId: job.category,
|
|
252
|
+
...(job.ios_category ? { categoryId: job.ios_category } : {}),
|
|
253
|
+
...(job.thread_key ? { threadId: job.thread_key } : {}),
|
|
254
|
+
collapseId: (job.collapse_key ?? job.notification_id).slice(0, 64),
|
|
255
|
+
tag: (job.collapse_key ?? job.notification_id).slice(0, 64),
|
|
256
|
+
priority: "high",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function sendPush(jobs: readonly Job[], provider: PushProvider, deadline: number, signal?: AbortSignal): Promise<JobResult[]> {
|
|
261
|
+
// A request started after this may outlive the lease, and another runner would send the same push again.
|
|
262
|
+
const lastStart = deadline - REQUEST_TIMEOUT_MS - 5_000;
|
|
263
|
+
const leaseRunningOut = () => new SendError("lease_running_out", { retryable: true, retryAfterSeconds: 1 });
|
|
264
|
+
const slots: { job: number; device: string; message: PushMessage }[] = [];
|
|
265
|
+
const results: (JobResult | null)[] = jobs.map((job) => (registry.has(job.type) ? null : { ok: false, retryable: false, error: "unknown_type" }));
|
|
266
|
+
jobs.forEach((job, j) => {
|
|
267
|
+
if (results[j]) return;
|
|
268
|
+
for (const d of job.devices ?? []) slots.push({ job: j, device: d.id, message: pushMessage(job, d.token) });
|
|
269
|
+
});
|
|
270
|
+
const tickets: (PushTicket | SendError)[] = new Array(slots.length);
|
|
271
|
+
for (let start = 0; start < slots.length; start += EXPO_SEND_BATCH) {
|
|
272
|
+
const chunk = slots.slice(start, start + EXPO_SEND_BATCH);
|
|
273
|
+
if (now() > lastStart) {
|
|
274
|
+
for (let i = 0; i < chunk.length; i++) tickets[start + i] = leaseRunningOut();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const got = await provider.send(chunk.map((s) => s.message), { signal });
|
|
279
|
+
got.forEach((t, i) => (tickets[start + i] = t));
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (refusedOutright(error) && chunk.length > 1) {
|
|
282
|
+
// One bad message can't hold up the rest: each on its own.
|
|
283
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
284
|
+
if (now() > lastStart) {
|
|
285
|
+
tickets[start + i] = leaseRunningOut();
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
tickets[start + i] = (await provider.send([chunk[i]!.message], { signal }))[0] ?? { status: "error", code: "NoTicket" };
|
|
290
|
+
} catch (one) {
|
|
291
|
+
tickets[start + i] = one instanceof SendError ? one : new SendError("Push failed", { retryable: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
const e = error instanceof SendError ? error : new SendError("Push failed", { retryable: true });
|
|
296
|
+
for (let i = 0; i < chunk.length; i++) tickets[start + i] = e;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Per job: its tickets recorded, and one result.
|
|
301
|
+
await Promise.all(
|
|
302
|
+
jobs.map(async (job, j) => {
|
|
303
|
+
if (results[j]) return;
|
|
304
|
+
const mine = slots.map((s, i) => ({ s, t: tickets[i]! })).filter((x) => x.s.job === j);
|
|
305
|
+
const rows: PushTicketRow[] = mine
|
|
306
|
+
.filter((x) => !(x.t instanceof SendError))
|
|
307
|
+
.map(({ s, t }) => {
|
|
308
|
+
const ticket = t as PushTicket;
|
|
309
|
+
return ticket.status === "ok"
|
|
310
|
+
? { device_id: s.device, ticket_id: ticket.id, status: "sent" as const, error: null }
|
|
311
|
+
: { device_id: s.device, ticket_id: null, status: "failed" as const, error: ticket.code };
|
|
312
|
+
});
|
|
313
|
+
if (rows.length) {
|
|
314
|
+
try {
|
|
315
|
+
await store.pushTickets(job.id, rows);
|
|
316
|
+
} catch {
|
|
317
|
+
log(`Recording push ${job.id}'s tickets failed`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (rows.some((r) => r.status === "sent")) {
|
|
321
|
+
results[j] = { ok: true };
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const httpError = mine.find((x) => x.t instanceof SendError)?.t as SendError | undefined;
|
|
325
|
+
if (httpError) {
|
|
326
|
+
results[j] = failureOf(httpError);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const codes = rows.map((r) => r.error);
|
|
330
|
+
if (codes.length && codes.every((c) => c === "DeviceNotRegistered")) results[j] = { ok: true, notSent: "device_not_registered" };
|
|
331
|
+
else if (codes.includes("MessageRateExceeded")) results[j] = { ok: false, retryable: true, retryAfterSeconds: 60, error: "Expo: MessageRateExceeded" };
|
|
332
|
+
else if (!codes.length) results[j] = { ok: true, notSent: "no_devices" };
|
|
333
|
+
else results[j] = { ok: false, retryable: false, error: `Expo: ${codes.find((c) => c !== "DeviceNotRegistered") ?? "Unknown"}` };
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
return results as JobResult[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Email --------------------------------------------------------------------
|
|
340
|
+
const emailRunner = email
|
|
341
|
+
? createRunner<Job>({
|
|
342
|
+
...common,
|
|
343
|
+
label: (job) => `Email ${job.id}`,
|
|
344
|
+
claim: ({ limit }) => store.claim("email", limit, lease, options.maxAttempts ?? 8) as Promise<Job[]>,
|
|
345
|
+
send: async ([job], { signal }) => [await sendEmail(job!, email, signal)],
|
|
346
|
+
batchSize: batch,
|
|
347
|
+
})
|
|
348
|
+
: null;
|
|
349
|
+
|
|
350
|
+
function fromAddress(job: Job, e: EmailOptions): string {
|
|
351
|
+
const local = e.localPart ?? "notifications";
|
|
352
|
+
const shared = job.transactional ? e.domains.transactional : (e.domains.other ?? e.domains.transactional);
|
|
353
|
+
// A tenant's verified domain, unless it is (or is under) one of the app's own: never a tenant's to send as.
|
|
354
|
+
const own = job.sender?.domain_verified && job.sender.domain && !isReservedDomain(job.sender.domain, [e.domains.transactional, e.domains.other]) ? job.sender.domain : null;
|
|
355
|
+
const domain = own ?? shared;
|
|
356
|
+
const brandName = typeof job.brand?.["name"] === "string" ? (job.brand["name"] as string) : null;
|
|
357
|
+
const name = displayName(job.sender?.from_name ?? brandName ?? e.appName) || displayName(e.appName);
|
|
358
|
+
return `${name} <${local}@${domain}>`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function render(job: Job, e: EmailOptions): Promise<RenderedEmail> {
|
|
362
|
+
const to = job.email;
|
|
363
|
+
if (!isSafeAddress(to)) throw new SendError("no_valid_address");
|
|
364
|
+
const t = registry.type(job.type);
|
|
365
|
+
let unsubscribeUrl: string | null = null;
|
|
366
|
+
let headers: Record<string, string> | undefined;
|
|
367
|
+
// Transactional mail has no unsubscribe, and nor does a critical type's (preferences can't turn it off).
|
|
368
|
+
if (!job.transactional && !job.critical && e.unsubscribe) {
|
|
369
|
+
const token = await createUnsubscribeToken(e.unsubscribe.secret, {
|
|
370
|
+
...(job.user_id ? { userId: job.user_id } : { emailHash: await emailHash(to) }),
|
|
371
|
+
category: job.category,
|
|
372
|
+
...(job.tenant_id ? { tenantId: job.tenant_id } : {}),
|
|
373
|
+
});
|
|
374
|
+
headers = unsubscribeHeaders(e.unsubscribe.url, token);
|
|
375
|
+
unsubscribeUrl = headers["List-Unsubscribe"]!.slice(1, -1);
|
|
376
|
+
}
|
|
377
|
+
const text = registry.render(job.type, job.data);
|
|
378
|
+
const path = appPath(text.url);
|
|
379
|
+
const url = path && options.appUrl ? new URL(path, new URL(options.appUrl).origin).toString() : null;
|
|
380
|
+
const template = e.templates?.[job.type] ?? e.defaultTemplate ?? defaultEmailTemplate;
|
|
381
|
+
const content = await template({
|
|
382
|
+
type: job.type,
|
|
383
|
+
category: job.category,
|
|
384
|
+
data: job.data,
|
|
385
|
+
text,
|
|
386
|
+
recipient: { email: to, name: job.recipient_name },
|
|
387
|
+
brand: (job.brand ?? {}) as NotificationBrand,
|
|
388
|
+
tenantId: job.tenant_id,
|
|
389
|
+
transactional: job.transactional,
|
|
390
|
+
secretLink: t.secretLink ? SECRET_LINK_PLACEHOLDER : null,
|
|
391
|
+
unsubscribeUrl,
|
|
392
|
+
url,
|
|
393
|
+
});
|
|
394
|
+
const replyTo = job.sender?.reply_to;
|
|
395
|
+
return {
|
|
396
|
+
from: fromAddress(job, e),
|
|
397
|
+
to: [to],
|
|
398
|
+
...(isSafeAddress(replyTo) ? { reply_to: replyTo } : {}),
|
|
399
|
+
subject: headerSafe(content.subject) || headerSafe(text.title) || "Notification",
|
|
400
|
+
html: content.html,
|
|
401
|
+
text: content.text,
|
|
402
|
+
...(headers ? { headers } : {}),
|
|
403
|
+
tags: [
|
|
404
|
+
{ name: "category", value: job.category.replace(/[^A-Za-z0-9_-]/g, "_") },
|
|
405
|
+
{ name: "stream", value: job.transactional ? "transactional" : "other" },
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function sendEmail(job: Job, e: EmailOptions, signal?: AbortSignal): Promise<JobResult> {
|
|
411
|
+
try {
|
|
412
|
+
if (!registry.has(job.type)) return { ok: false, retryable: false, error: "unknown_type" };
|
|
413
|
+
let rendered = job.rendered;
|
|
414
|
+
if (rendered) {
|
|
415
|
+
if (!job.rendered_hash || (await sha256Hex(canonicalJson(rendered))) !== job.rendered_hash) {
|
|
416
|
+
// Never send something other than the first render under its key.
|
|
417
|
+
return { ok: false, retryable: false, error: "rendered_email_changed" };
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
const fresh = await render(job, e);
|
|
421
|
+
const hash = await sha256Hex(canonicalJson(fresh));
|
|
422
|
+
const kept = await store.storeRendered(job.id, job.claim_id, fresh, hash);
|
|
423
|
+
if (!kept) return { ok: false, retryable: true, error: "claim_lost" };
|
|
424
|
+
rendered = kept.rendered;
|
|
425
|
+
}
|
|
426
|
+
let html = rendered.html;
|
|
427
|
+
let text = rendered.text;
|
|
428
|
+
if (registry.type(job.type).secretLink) {
|
|
429
|
+
const link = await resolveSecretLink(e.secretLinks ?? {}, job.type, {
|
|
430
|
+
data: job.data,
|
|
431
|
+
secret: job.secret,
|
|
432
|
+
notificationId: job.notification_id,
|
|
433
|
+
tenantId: job.tenant_id,
|
|
434
|
+
});
|
|
435
|
+
html = html.split(SECRET_LINK_PLACEHOLDER).join(escapeHtml(link));
|
|
436
|
+
text = text.split(SECRET_LINK_PLACEHOLDER).join(link);
|
|
437
|
+
}
|
|
438
|
+
const provider = e.providerFor?.(job) ?? e.provider;
|
|
439
|
+
const result = await provider.send(
|
|
440
|
+
{
|
|
441
|
+
from: rendered.from,
|
|
442
|
+
to: rendered.to,
|
|
443
|
+
...(rendered.reply_to ? { replyTo: rendered.reply_to } : {}),
|
|
444
|
+
subject: rendered.subject,
|
|
445
|
+
html,
|
|
446
|
+
text,
|
|
447
|
+
...(rendered.headers ? { headers: rendered.headers } : {}),
|
|
448
|
+
...(rendered.tags ? { tags: rendered.tags } : {}),
|
|
449
|
+
idempotencyKey: `genesis-notification-${job.id}`,
|
|
450
|
+
},
|
|
451
|
+
{ signal },
|
|
452
|
+
);
|
|
453
|
+
if (result.id) {
|
|
454
|
+
providerIds.set(job.id, result.id);
|
|
455
|
+
try {
|
|
456
|
+
await store.recordProviderId(job.id, job.claim_id, result.id);
|
|
457
|
+
} catch {
|
|
458
|
+
log(`Keeping email ${job.id}'s provider id failed; it is kept when it settles`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return result.alreadySent ? { ok: true, alreadyThere: true } : { ok: true };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error instanceof SendError && error.message === "no_valid_address") return { ok: true, notSent: "no_address" };
|
|
464
|
+
if (error instanceof Error && error.name === "SecretLinkError") return { ok: false, retryable: false, error: "secret_link_unavailable" };
|
|
465
|
+
return failureOf(error);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const none = async (): Promise<RunReport> => {
|
|
470
|
+
throw new Error("This channel isn't configured");
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// One claim, or (with runBudgetMs) claims while they come back full and the time left covers a lease.
|
|
474
|
+
async function drain(r: { run(o: { signal?: AbortSignal }): Promise<RunReport> }, signal?: AbortSignal): Promise<RunReport> {
|
|
475
|
+
const budget = options.runBudgetMs;
|
|
476
|
+
if (budget === undefined) return r.run({ signal });
|
|
477
|
+
const started = now();
|
|
478
|
+
let total = emptyReport();
|
|
479
|
+
while (budget - (now() - started) >= lease * 1000 && !signal?.aborted) {
|
|
480
|
+
const report = await r.run({ signal });
|
|
481
|
+
total = addReport(total, report);
|
|
482
|
+
if (report.claimed < batch) break;
|
|
483
|
+
}
|
|
484
|
+
return total;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
async run({ signal } = {}) {
|
|
489
|
+
const [push, mail] = await Promise.all([pushRunner ? drain(pushRunner, signal) : null, emailRunner ? drain(emailRunner, signal) : null]);
|
|
490
|
+
return { push, email: mail };
|
|
491
|
+
},
|
|
492
|
+
runPush: ({ signal } = {}) => (pushRunner ? drain(pushRunner, signal) : none()),
|
|
493
|
+
runEmail: ({ signal } = {}) => (emailRunner ? drain(emailRunner, signal) : none()),
|
|
494
|
+
async checkReceipts({ signal, budgetMs = 60_000 } = {}) {
|
|
495
|
+
const provider = options.push?.provider;
|
|
496
|
+
if (!provider) return { checked: 0, settled: 0 };
|
|
497
|
+
const started = Date.now();
|
|
498
|
+
let checked = 0;
|
|
499
|
+
let settled = 0;
|
|
500
|
+
for (;;) {
|
|
501
|
+
const due = await store.receiptsClaim(EXPO_RECEIPTS_BATCH);
|
|
502
|
+
if (!due.length) break;
|
|
503
|
+
checked += due.length;
|
|
504
|
+
let receipts;
|
|
505
|
+
try {
|
|
506
|
+
receipts = await provider.receipts(due.map((d) => d.ticket_id), { signal });
|
|
507
|
+
} catch (error) {
|
|
508
|
+
log(`Push receipts failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
const results = due.flatMap((d) => {
|
|
512
|
+
const r = receipts[d.ticket_id];
|
|
513
|
+
if (!r) return [];
|
|
514
|
+
return [{ id: d.id, status: r.status === "ok" ? ("delivered" as const) : ("failed" as const), error: r.status === "error" ? r.code : null }];
|
|
515
|
+
});
|
|
516
|
+
if (results.some((r) => r.error === "InvalidCredentials")) log("Push receipts: InvalidCredentials (the APNs or FCM credentials in EAS are wrong)");
|
|
517
|
+
if (results.length) await store.receiptsSettle(results);
|
|
518
|
+
settled += results.length;
|
|
519
|
+
if (due.length < EXPO_RECEIPTS_BATCH || Date.now() - started > budgetMs) break;
|
|
520
|
+
}
|
|
521
|
+
return { checked, settled };
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|