@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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -2
  3. package/dist/blocks.d.ts +10 -0
  4. package/dist/blocks.js +35 -0
  5. package/dist/blocks.js.map +1 -0
  6. package/dist/chunk-2BG336II.js +124 -0
  7. package/dist/chunk-2BG336II.js.map +1 -0
  8. package/dist/chunk-7PHA3WVG.js +51 -0
  9. package/dist/chunk-7PHA3WVG.js.map +1 -0
  10. package/dist/chunk-EWZB35V6.js +113 -0
  11. package/dist/chunk-EWZB35V6.js.map +1 -0
  12. package/dist/chunk-UXNWI7OS.js +118 -0
  13. package/dist/chunk-UXNWI7OS.js.map +1 -0
  14. package/dist/email-render-CID_pXDy.d.ts +53 -0
  15. package/dist/email.d.ts +46 -0
  16. package/dist/email.js +64 -0
  17. package/dist/email.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.js +151 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/native.d.ts +92 -0
  22. package/dist/native.js +170 -0
  23. package/dist/native.js.map +1 -0
  24. package/dist/react.d.ts +98 -0
  25. package/dist/react.js +4 -0
  26. package/dist/react.js.map +1 -0
  27. package/dist/runner.d.ts +174 -0
  28. package/dist/runner.js +3 -0
  29. package/dist/runner.js.map +1 -0
  30. package/dist/server.d.ts +658 -0
  31. package/dist/server.js +995 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/supabase.d.ts +118 -0
  34. package/dist/supabase.js +3 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/types-BilguCPW.d.ts +332 -0
  37. package/dist/web.d.ts +79 -0
  38. package/dist/web.js +233 -0
  39. package/dist/web.js.map +1 -0
  40. package/functions/genesis-notifications/index.ts +60 -0
  41. package/functions/genesis-notifications/notifications.ts +18 -0
  42. package/migrations/0001_genesis_notifications.sql +2057 -0
  43. package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
  44. package/package.json +191 -3
  45. package/src/blocks.ts +42 -0
  46. package/src/core/index.ts +43 -0
  47. package/src/core/registry.ts +354 -0
  48. package/src/core/types.ts +90 -0
  49. package/src/core/validate.ts +73 -0
  50. package/src/email/index.tsx +142 -0
  51. package/src/index.ts +6 -0
  52. package/src/native/index.tsx +283 -0
  53. package/src/react/index.tsx +226 -0
  54. package/src/runner/index.ts +299 -0
  55. package/src/server/crypto.ts +44 -0
  56. package/src/server/dns.ts +106 -0
  57. package/src/server/email-render.ts +90 -0
  58. package/src/server/errors.ts +61 -0
  59. package/src/server/expo.ts +133 -0
  60. package/src/server/handler.ts +127 -0
  61. package/src/server/index.ts +57 -0
  62. package/src/server/notify.ts +100 -0
  63. package/src/server/rate.ts +47 -0
  64. package/src/server/resend.ts +143 -0
  65. package/src/server/runner.ts +524 -0
  66. package/src/server/secret-links.ts +53 -0
  67. package/src/server/store.ts +150 -0
  68. package/src/server/unsubscribe.ts +78 -0
  69. package/src/server/webhook.ts +55 -0
  70. package/src/supabase/index.ts +210 -0
  71. package/src/web/index.tsx +428 -0
@@ -0,0 +1,658 @@
1
+ import { a as Channel, p as NotificationTypeRow, R as Recipient, l as NotificationRegistry, r as NotifyInput, s as NotifyResult } from './types-BilguCPW.js';
2
+ import { RunReport } from './runner.js';
3
+ import { a as EmailTemplate } from './email-render-CID_pXDy.js';
4
+ export { b as EmailContent, E as EmailTemplateContext, c as canonicalJson, d as defaultEmailTemplate, e as displayName, f as escapeHtml, h as headerSafe, i as isSafeAddress } from './email-render-CID_pXDy.js';
5
+
6
+ /**
7
+ * The database half, through the service role: what the runner and the
8
+ * handler call. `supabaseNotificationStore(admin)` wraps migration 0001's
9
+ * functions; tests (and an app with another queue) pass their own.
10
+ */
11
+
12
+ type RpcResult = {
13
+ data: unknown;
14
+ error: {
15
+ message?: string;
16
+ code?: string | null;
17
+ } | null;
18
+ };
19
+ /** The part of a Supabase client the store uses, signed in with the service role (`SUPABASE_SECRET_KEY`). */
20
+ interface RpcClient {
21
+ rpc: (fn: any, args?: any) => PromiseLike<RpcResult>;
22
+ }
23
+ /** A database refusal, with its SQLSTATE; never the row it refused. */
24
+ declare class NotificationStoreError extends Error {
25
+ readonly operation: string;
26
+ readonly code: string | null;
27
+ constructor(operation: string, code: string | null);
28
+ }
29
+ interface ClaimedDevice {
30
+ id: string;
31
+ token: string;
32
+ platform: string;
33
+ }
34
+ interface SenderIdentity {
35
+ from_name: string | null;
36
+ reply_to: string | null;
37
+ domain: string | null;
38
+ domain_verified: boolean;
39
+ }
40
+ /** One claimed send job, as genesis_notifications_claim hands it over. */
41
+ interface ClaimedJob {
42
+ id: string;
43
+ claim_id: string;
44
+ notification_id: string;
45
+ channel: Exclude<Channel, "in_app">;
46
+ attempt: number;
47
+ deferrals: number;
48
+ redelivery: boolean;
49
+ type: string;
50
+ category: string;
51
+ tenant_id: string | null;
52
+ user_id: string | null;
53
+ email: string | null;
54
+ recipient_name: string | null;
55
+ data: Record<string, unknown>;
56
+ thread_key: string | null;
57
+ collapse_key: string | null;
58
+ badge: number | null;
59
+ brand: Record<string, unknown> | null;
60
+ transactional: boolean;
61
+ /** Preferences can't turn it off, so its email has no unsubscribe link. */
62
+ critical: boolean;
63
+ push_privacy: "default" | "generic";
64
+ ios_category: string | null;
65
+ hide_previews: boolean;
66
+ devices: ClaimedDevice[] | null;
67
+ sender: SenderIdentity | null;
68
+ rendered: RenderedEmail | null;
69
+ rendered_hash: string | null;
70
+ secret: string | null;
71
+ }
72
+ /** An email as first rendered: what every retry sends. */
73
+ interface RenderedEmail {
74
+ from: string;
75
+ to: string[];
76
+ reply_to?: string;
77
+ subject: string;
78
+ html: string;
79
+ text: string;
80
+ headers?: Record<string, string>;
81
+ tags?: {
82
+ name: string;
83
+ value: string;
84
+ }[];
85
+ }
86
+ type SettleOutcome = {
87
+ status: "sent";
88
+ provider_id?: string | null;
89
+ } | {
90
+ status: "skipped";
91
+ reason: string;
92
+ } | {
93
+ status: "filtered";
94
+ } | {
95
+ status: "failed";
96
+ error: string;
97
+ provider_id?: string | null;
98
+ } | {
99
+ status: "retry";
100
+ error: string;
101
+ retry_at: string;
102
+ counted: boolean;
103
+ provider_id?: string | null;
104
+ };
105
+ interface PushTicketRow {
106
+ device_id: string;
107
+ ticket_id: string | null;
108
+ status: "sent" | "failed";
109
+ error: string | null;
110
+ }
111
+ interface NotificationStore {
112
+ /** `maxAttempts`: a job whose lease ran out that many times fails (`lease_expired`) instead of coming back. */
113
+ claim(channel: "push" | "email", limit: number, leaseSeconds: number, maxAttempts?: number): Promise<ClaimedJob[]>;
114
+ storeRendered(id: string, claimId: string, rendered: RenderedEmail, hash: string): Promise<{
115
+ rendered: RenderedEmail;
116
+ hash: string;
117
+ } | null>;
118
+ settle(id: string, claimId: string, outcome: SettleOutcome): Promise<boolean>;
119
+ /** Keeps the provider's id the moment a send returns, before it settles, so an early webhook finds the job. False when the claim is gone. */
120
+ recordProviderId(id: string, claimId: string, providerId: string): Promise<boolean>;
121
+ pushTickets(deliveryId: string, tickets: PushTicketRow[]): Promise<void>;
122
+ receiptsClaim(limit: number): Promise<{
123
+ id: number;
124
+ ticket_id: string;
125
+ }[]>;
126
+ receiptsSettle(results: {
127
+ id: number;
128
+ status: "delivered" | "failed";
129
+ error: string | null;
130
+ }[]): Promise<void>;
131
+ emailEvent(providerId: string, event: "delivered" | "bounced" | "complained", permanent: boolean): Promise<boolean>;
132
+ /** For a login (`userId`), or someone with none by the SHA-256 of their address; for one tenant's mail (`tenantId`), or mail sent for none. */
133
+ unsubscribe(userId: string | null, emailHash: string | null, category: string, tenantId: string | null): Promise<boolean>;
134
+ syncTypes(rows: NotificationTypeRow[]): Promise<number>;
135
+ }
136
+ /**
137
+ * The store over migration 0001's functions.
138
+ *
139
+ * @stability Beta
140
+ */
141
+ declare function supabaseNotificationStore(client: RpcClient): NotificationStore;
142
+
143
+ /**
144
+ * `notify()`: what a domain calls, from its server. It checks the input
145
+ * (the registry knows the type, the recipients are well formed, `data`
146
+ * holds no secret) and calls `genesis_notify`, which decides channels from
147
+ * preferences and queues the sends. A domain's own SQL can call
148
+ * `genesis_notify` directly with the same arguments.
149
+ */
150
+
151
+ interface NotifyOptions {
152
+ /** Checks the type against the registry before the database does. */
153
+ registry?: NotificationRegistry;
154
+ }
155
+ /**
156
+ * Queue a notification for each recipient. Resolves one result per recipient
157
+ * that exists (a deleted login is left out); the same idempotency key again
158
+ * (for the same type and tenant) resolves `created: false` and sends nothing more.
159
+ *
160
+ * @stability Beta
161
+ */
162
+ declare function notify<K extends string>(client: RpcClient, input: NotifyInput<K>, options?: NotifyOptions): Promise<NotifyResult[]>;
163
+ interface CancelInput {
164
+ /** The type the notifications were made with: a key is a domain's, per type. */
165
+ type: string;
166
+ /** The key the notifications were made with. */
167
+ idempotencyKey: string;
168
+ /** The tenant they were made for. Default: those made with no tenant (never another tenant's). */
169
+ tenantId?: string | null;
170
+ /** Only these recipients (as `notify()` takes them). Default: everyone the key reached. */
171
+ recipients?: readonly Recipient[];
172
+ /** Archive the feed rows too. Default false. */
173
+ archive?: boolean;
174
+ }
175
+ /**
176
+ * Stop what hasn't gone out for a type, tenant and idempotency key (a release
177
+ * rolled back, an event undone): every pending send job is marked cancelled
178
+ * and never claimed. A job already sent is left alone; one being sent right
179
+ * now ends cancelled unless that attempt goes through (it is never retried).
180
+ * With `archive`, the feed rows are archived too.
181
+ *
182
+ * @stability Beta
183
+ */
184
+ declare function cancelNotification(client: RpcClient, input: CancelInput): Promise<{
185
+ cancelled: number;
186
+ archived: number;
187
+ }>;
188
+ /**
189
+ * Mirror the registry into `notification_types` (run it on deploy, or the
190
+ * server's first request): types left out are retired, never deleted.
191
+ *
192
+ * @stability Beta
193
+ */
194
+ declare function syncNotificationTypes(client: RpcClient, registry: NotificationRegistry): Promise<number>;
195
+
196
+ /**
197
+ * A send failure with a message that is safe to store and log (a status and
198
+ * a provider's error code, never content, an address or a secret). The
199
+ * runner turns it into a retry or a failure.
200
+ */
201
+ declare class SendError extends Error {
202
+ name: string;
203
+ readonly retryable: boolean;
204
+ readonly retryAfterSeconds: number | undefined;
205
+ readonly status: number | undefined;
206
+ constructor(message: string, options?: {
207
+ retryable?: boolean;
208
+ retryAfterSeconds?: number;
209
+ status?: number;
210
+ });
211
+ }
212
+ /** Whether an HTTP status is worth retrying unchanged: 408, 425, 429 and 5xx. */
213
+ declare function isRetryableStatus(status: number): boolean;
214
+ /** Seconds from a Retry-After header (delta-seconds or an HTTP date), else `fallback`. 1 s to a day. */
215
+ declare function retryAfterSeconds(header: string | null | undefined, fallback?: number, now?: number): number;
216
+ /** Every request gives up after this long. */
217
+ declare const REQUEST_TIMEOUT_MS = 10000;
218
+ interface HttpDeps {
219
+ /** Default the global `fetch`. */
220
+ fetch?: typeof fetch;
221
+ timeoutMs?: number;
222
+ }
223
+
224
+ /**
225
+ * Expo's push service: https://docs.expo.dev/push-notifications/sending-notifications/
226
+ *
227
+ * POST /--/api/v2/push/send up to 100 messages; one ticket each, in order
228
+ * POST /--/api/v2/push/getReceipts up to 1000 ticket ids; a receipt for each one ready
229
+ *
230
+ * Nothing here logs; an error names a status or Expo's error code, never a
231
+ * token or a message.
232
+ */
233
+
234
+ declare const EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
235
+ declare const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
236
+ /** Expo takes up to 100 messages per send. */
237
+ declare const EXPO_SEND_BATCH = 100;
238
+ /** And up to 1000 ticket ids per receipts request. */
239
+ declare const EXPO_RECEIPTS_BATCH = 1000;
240
+ /** One message for Expo. */
241
+ interface PushMessage {
242
+ to: string;
243
+ title?: string;
244
+ subtitle?: string;
245
+ body?: string;
246
+ /** Reaches the app (`notification.request.content.data`): ids and the link, never content. */
247
+ data?: Record<string, string | number | boolean | null>;
248
+ sound?: "default" | null;
249
+ badge?: number;
250
+ /** Android: the notification channel (one per category). */
251
+ channelId?: string;
252
+ /** The device category whose actions it shows (Reply, Mark as read). */
253
+ categoryId?: string;
254
+ /** iOS: groups notifications on the lock screen (a conversation). */
255
+ threadId?: string;
256
+ /** Replaces an earlier one with the same id, in transit (and on screen on iOS). */
257
+ collapseId?: string;
258
+ /** Android: replaces one already on screen with the same tag. */
259
+ tag?: string;
260
+ ttl?: number;
261
+ priority?: "default" | "normal" | "high";
262
+ /** iOS: lets the app's Notification Service Extension change it (rich push). */
263
+ mutableContent?: boolean;
264
+ }
265
+ type PushTicket = {
266
+ status: "ok";
267
+ id: string;
268
+ } | {
269
+ status: "error";
270
+ code: string;
271
+ };
272
+ type PushReceipt = {
273
+ status: "ok";
274
+ } | {
275
+ status: "error";
276
+ code: string;
277
+ };
278
+ /** Sends pushes and reads receipts; `expoPushProvider` is the built-in one. */
279
+ interface PushProvider {
280
+ readonly name: string;
281
+ /** At most `EXPO_SEND_BATCH` messages; one ticket per message, in order. Throws a `SendError` when the request failed. */
282
+ send(messages: readonly PushMessage[], options?: {
283
+ signal?: AbortSignal;
284
+ }): Promise<PushTicket[]>;
285
+ /** Receipts for at most `EXPO_RECEIPTS_BATCH` tickets; ids with none yet are left out. */
286
+ receipts(ids: readonly string[], options?: {
287
+ signal?: AbortSignal;
288
+ }): Promise<Record<string, PushReceipt>>;
289
+ }
290
+ interface ExpoOptions extends HttpDeps {
291
+ /** Expo's access token, for "Enhanced Security for Push Notifications" (turn it on). */
292
+ accessToken?: string;
293
+ sendUrl?: string;
294
+ receiptsUrl?: string;
295
+ }
296
+ /** An Expo error code (`DeviceNotRegistered`), safe to store; anything else is `Unknown`. */
297
+ declare function expoErrorCode(value: {
298
+ details?: {
299
+ error?: unknown;
300
+ };
301
+ } | undefined): string;
302
+ /** Expo refused the request itself (a 4xx other than 429): resend its messages one by one. */
303
+ declare function refusedOutright(error: unknown): boolean;
304
+ /**
305
+ * Expo's push service as a `PushProvider`.
306
+ *
307
+ * @stability Beta
308
+ */
309
+ declare function expoPushProvider(options?: ExpoOptions): PushProvider;
310
+
311
+ /**
312
+ * Resend's REST API as an `EmailProvider`. One key per app (`RESEND_API_KEY`);
313
+ * each tenant gets its own sender identity (`senderFor`), not its own key.
314
+ *
315
+ * Retries are byte-identical (the runner stores the first render), and every
316
+ * send carries an idempotency key, so Resend sends once:
317
+ * - 409 `invalid_idempotent_request`: the key was used with another body. The
318
+ * stored render never changes, so the body differed in what is built at send
319
+ * time: a secret link builder that isn't deterministic (it must give the same
320
+ * link for the same notification every time). The job fails loudly
321
+ * (`idempotency_body_changed`); it is never counted as sent.
322
+ * - 409 `concurrent_idempotent_requests`: another attempt is in flight: retry soon.
323
+ *
324
+ * Requests are paced (`perSecond`, default 5) under Resend's default limit of
325
+ * 10 requests a second per team, which every key of the team shares.
326
+ */
327
+
328
+ /** One email, ready to send. Addresses and headers are already checked. */
329
+ interface EmailMessage {
330
+ from: string;
331
+ to: string[];
332
+ replyTo?: string;
333
+ subject: string;
334
+ html: string;
335
+ text: string;
336
+ /** Extra headers (`List-Unsubscribe`, `List-Unsubscribe-Post`). */
337
+ headers?: Record<string, string>;
338
+ /** Provider tags: name and value are letters, digits, "_" or "-". */
339
+ tags?: {
340
+ name: string;
341
+ value: string;
342
+ }[];
343
+ /** Stable across retries of one send job. */
344
+ idempotencyKey: string;
345
+ }
346
+ interface EmailSendResult {
347
+ /** The provider's id for the email (what its webhooks name). */
348
+ id: string | null;
349
+ /** The provider says it was sent before (the same idempotency key). */
350
+ alreadySent?: boolean;
351
+ }
352
+ /** Sends one email. Throws a `SendError` on failure. */
353
+ interface EmailProvider {
354
+ readonly name: string;
355
+ send(message: EmailMessage, options?: {
356
+ signal?: AbortSignal;
357
+ }): Promise<EmailSendResult>;
358
+ }
359
+ declare const RESEND_API = "https://api.resend.com/emails";
360
+ /** Requests a second the provider starts by default: half Resend's default team limit (10). */
361
+ declare const RESEND_DEFAULT_PER_SECOND = 5;
362
+ interface ResendOptions extends HttpDeps {
363
+ apiKey: string;
364
+ endpoint?: string;
365
+ /** Requests started per second, at most. Default `RESEND_DEFAULT_PER_SECOND` (5). Raise it only if Resend raised your team's limit. */
366
+ perSecond?: number;
367
+ /** For tests: the clock and the wait the pacer uses. */
368
+ now?: () => number;
369
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
370
+ }
371
+ /**
372
+ * Resend as an `EmailProvider`.
373
+ *
374
+ * @stability Beta
375
+ */
376
+ declare function resendProvider(options: ResendOptions): EmailProvider;
377
+
378
+ /**
379
+ * Secret links: a link whose query is a credential (a one-time sign-in, an
380
+ * invite, a response's edit link). They never go in `notifications.data` or
381
+ * a push. The domain stores a reference (`{ response_id }`) and, for a token
382
+ * that exists only hashed, passes the plaintext as `notify({ secret })`; the
383
+ * runner builds the link at send time, for the email only.
384
+ */
385
+ /** What a secret link is built from. */
386
+ interface SecretLinkRef {
387
+ /** The notification's `data` (references, never secrets). */
388
+ data: Record<string, unknown>;
389
+ /** The plaintext from `notify({ secret })`, decrypted for this send only. */
390
+ secret: string | null;
391
+ notificationId: string;
392
+ tenantId: string | null;
393
+ }
394
+ /** Type key → how its link is built. A builder signs the link with its own key, or puts `secret` in it. */
395
+ type SecretLinks = Readonly<Record<string, (ref: SecretLinkRef) => string | Promise<string>>>;
396
+ /** No builder for a type whose emails need one, or a builder that gave back something that isn't an https link. */
397
+ declare class SecretLinkError extends Error {
398
+ constructor(message: string);
399
+ }
400
+ /**
401
+ * The secret link for one notification, built server-side at send time.
402
+ * Throws a `SecretLinkError` when there is no builder for `type` or it gives
403
+ * something other than an `https:` URL (`http:` only for localhost).
404
+ *
405
+ * @stability Beta
406
+ */
407
+ declare function resolveSecretLink(links: SecretLinks, type: string, ref: SecretLinkRef): Promise<string>;
408
+ /** What a template puts where the secret link goes; the runner swaps it in at send time, so the stored render never holds it. */
409
+ declare const SECRET_LINK_PLACEHOLDER = "https://genesis-secret-link.invalid/";
410
+
411
+ /**
412
+ * `createNotificationRunner`: sends what `notify()` queued, on the shared
413
+ * runner (`@marktiderman/genesis-notifications/runner`). Run `run()` every
414
+ * minute and `checkReceipts()` every 15 minutes (pg_cron with pg_net calling
415
+ * the handler, or any scheduler).
416
+ *
417
+ * Push: the claimed jobs' devices in Expo batches of 100 (a job's devices
418
+ * never split across two), each batch sent, its tickets recorded and its jobs
419
+ * settled on its own, and no batch started once its lease is about to run
420
+ * out (those jobs come straight back), with `threadId`,
421
+ * `categoryId`, `badge`, a `collapseId` (the collapse key, else the
422
+ * notification id, so a resend after a timeout replaces rather than repeats),
423
+ * and neutral text when the person hides previews or the type says so. The
424
+ * payload's `data` is the link, the notification id and the type, never the
425
+ * notification's own `data`. Tickets are recorded per device; a
426
+ * `DeviceNotRegistered` forgets the device.
427
+ *
428
+ * Email: rendered once at first claim and stored, so every retry sends the
429
+ * same bytes with the same idempotency key. From the tenant's verified
430
+ * domain, or the app's shared one with the tenant's name and reply-to;
431
+ * transactional and other mail from separate domains; RFC 8058 unsubscribe
432
+ * headers on everything that isn't transactional; a secret link swapped in
433
+ * at send time. The provider's id is kept the moment a send returns, before
434
+ * the job settles, so a webhook that comes first finds it.
435
+ *
436
+ * Links (a type's `render().url`) must be paths on the app's own origin
437
+ * (`/messages/42`): anything else (another host, `//host`, `javascript:`) is
438
+ * left out of the push and the email.
439
+ */
440
+
441
+ interface EmailOptions {
442
+ provider: EmailProvider;
443
+ /** The from name when a tenant has none ("Example"). */
444
+ appName: string;
445
+ /**
446
+ * The app's shared sending domains, verified in Resend: transactional mail
447
+ * and other mail go from separate ones, so one's reputation never holds up
448
+ * the other. `other` defaults to `transactional`.
449
+ */
450
+ domains: {
451
+ transactional: string;
452
+ other?: string;
453
+ };
454
+ /** The address's local part. Default `notifications`. */
455
+ localPart?: string;
456
+ /** Type key → template. A type without one gets `defaultEmailTemplate`. */
457
+ templates?: Readonly<Record<string, EmailTemplate>>;
458
+ defaultTemplate?: EmailTemplate;
459
+ /** How each secret-link type's link is built (`resolveSecretLink`). */
460
+ secretLinks?: SecretLinks;
461
+ /** Required when any type sends email that isn't transactional. */
462
+ unsubscribe?: {
463
+ url: string;
464
+ secret: string;
465
+ };
466
+ /**
467
+ * The provider for one job. Default the app's `provider`. The seam for a
468
+ * tenant-owned key later (read from Vault by id); not built in v1.
469
+ */
470
+ providerFor?: (job: ClaimedJob) => EmailProvider;
471
+ }
472
+ interface NotificationRunnerOptions {
473
+ store: NotificationStore;
474
+ registry: NotificationRegistry;
475
+ push?: {
476
+ provider: PushProvider;
477
+ };
478
+ email?: EmailOptions;
479
+ /** The app's origin, for absolute links in emails (`https://app.example.com`). */
480
+ appUrl?: string;
481
+ /** Jobs claimed per channel per run. Default 100. */
482
+ batchSize?: number;
483
+ /** How long a claim holds a job. Default 300 seconds. */
484
+ leaseSeconds?: number;
485
+ /**
486
+ * How long one `run()` may take (your function's wall-clock limit, less a
487
+ * margin). With it, a run claims again while the last claim was full, and
488
+ * stops claiming once the time left is under the lease. Without it, a run
489
+ * claims once per channel. At least `leaseSeconds`.
490
+ */
491
+ runBudgetMs?: number;
492
+ maxAttempts?: number;
493
+ maxDeferrals?: number;
494
+ /** Safe lines only (ids, statuses, error codes). */
495
+ log?: (message: string) => void;
496
+ now?: () => number;
497
+ }
498
+ interface NotificationRunner {
499
+ /** Sends what is due on every configured channel. */
500
+ run(options?: {
501
+ signal?: AbortSignal;
502
+ }): Promise<{
503
+ push: RunReport | null;
504
+ email: RunReport | null;
505
+ }>;
506
+ runPush(options?: {
507
+ signal?: AbortSignal;
508
+ }): Promise<RunReport>;
509
+ runEmail(options?: {
510
+ signal?: AbortSignal;
511
+ }): Promise<RunReport>;
512
+ /** Asks Expo for the receipts that are due and records them. */
513
+ checkReceipts(options?: {
514
+ signal?: AbortSignal;
515
+ budgetMs?: number;
516
+ }): Promise<{
517
+ checked: number;
518
+ settled: number;
519
+ }>;
520
+ }
521
+ /**
522
+ * The runner.
523
+ *
524
+ * @stability Beta
525
+ */
526
+ declare function createNotificationRunner(options: NotificationRunnerOptions): NotificationRunner;
527
+
528
+ interface NotificationsHandlerOptions {
529
+ runner: NotificationRunner;
530
+ store: NotificationStore;
531
+ /** The bearer secret a scheduler sends to /run and /receipts (32 characters or more). */
532
+ cronSecret: string;
533
+ /** The unsubscribe signing secret (the runner's `email.unsubscribe.secret`). */
534
+ unsubscribeSecret?: string;
535
+ /** Resend's webhook signing secret (`whsec_…`). */
536
+ resendWebhookSecret?: string;
537
+ /** Words on the unsubscribe page. */
538
+ copy?: Partial<{
539
+ title: string;
540
+ confirm: string;
541
+ button: string;
542
+ done: string;
543
+ invalid: string;
544
+ }>;
545
+ log?: (message: string) => void;
546
+ }
547
+ /**
548
+ * The handler.
549
+ *
550
+ * @stability Beta
551
+ */
552
+ declare function createNotificationsHandler(options: NotificationsHandlerOptions): (request: Request) => Promise<Response>;
553
+
554
+ /**
555
+ * A request pacer: at most `perSecond` requests start in any second, spaced
556
+ * evenly, however many callers wait at once. Each provider instance has its
557
+ * own; two runners running at once each keep to it, so set it for that.
558
+ */
559
+ interface RateLimiter {
560
+ /** Resolves when the caller may start its request; rejects if `signal` aborts first. */
561
+ acquire(signal?: AbortSignal): Promise<void>;
562
+ }
563
+ interface RateLimiterOptions {
564
+ perSecond: number;
565
+ now?: () => number;
566
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
567
+ }
568
+ /** @stability Beta */
569
+ declare function createRateLimiter(options: RateLimiterOptions): RateLimiter;
570
+
571
+ interface UnsubscribeClaims {
572
+ /** The login, when the recipient has one. */
573
+ userId?: string;
574
+ /** SHA-256 (hex) of the lower-cased, trimmed address, when they have none (`emailHash`). */
575
+ emailHash?: string;
576
+ category: string;
577
+ /** The tenant the email came from; the unsubscribe holds for it only. */
578
+ tenantId?: string;
579
+ }
580
+ /** What the suppression list keys an address by: SHA-256 of it lower-cased and trimmed (as `genesis_notifications_email_hash`). */
581
+ declare function emailHash(email: string): Promise<string>;
582
+ /** A signed, URL-safe token for `claims`. */
583
+ declare function createUnsubscribeToken(secret: string, claims: UnsubscribeClaims): Promise<string>;
584
+ /** The claims of a token this secret signed, or null for anything else. */
585
+ declare function verifyUnsubscribeToken(secret: string, token: string): Promise<UnsubscribeClaims | null>;
586
+ /** The RFC 8058 headers for one email. `url` is your unsubscribe endpoint; the token goes in its query. */
587
+ declare function unsubscribeHeaders(url: string, token: string): Record<string, string>;
588
+
589
+ /** How far a webhook's timestamp may be from now: five minutes, as Svix checks. */
590
+ declare const WEBHOOK_TOLERANCE_SECONDS = 300;
591
+ /** Whether `body` was signed with `secret`; false for anything stale, unsigned or malformed. */
592
+ declare function verifyResendWebhook(secret: string, headers: Headers, body: string, now?: number): Promise<boolean>;
593
+ /** What the notifier does with an email event; null for the events it doesn't use. */
594
+ declare function emailEventOf(payload: unknown): {
595
+ providerId: string;
596
+ event: "delivered" | "bounced" | "complained";
597
+ permanent: boolean;
598
+ } | null;
599
+
600
+ /**
601
+ * Is a sender domain the tenant's, and ready to send from? An ownership TXT
602
+ * record holding the tenant's own token (so one tenant can't verify another's
603
+ * domain), SPF on Resend's return-path host, DKIM at Resend's selector, and a
604
+ * DMARC record, read through DNS over HTTPS so it runs anywhere `fetch` does.
605
+ * The app's own shared sending domains (and anything under them) are never a
606
+ * tenant's.
607
+ */
608
+
609
+ interface DomainCheck {
610
+ /** `_genesis-notifications.<domain>` holds `genesis-notifications-verify=<token>`. */
611
+ ownership: boolean;
612
+ spf: boolean;
613
+ dkim: boolean;
614
+ dmarc: boolean;
615
+ /** The domain is (or is under) one of the app's own shared domains: never a tenant's. */
616
+ reserved: boolean;
617
+ /** Owned, all three records, and not reserved. */
618
+ ok: boolean;
619
+ }
620
+ interface DomainCheckOptions extends HttpDeps {
621
+ /** The sender's `domain_token` (from `notification_senders`; made when the domain was set). */
622
+ token: string;
623
+ /** The app's shared sending domains (`email.domains`): a tenant may never verify one, or one under it. */
624
+ reservedDomains: readonly string[];
625
+ /** Default Cloudflare's JSON resolver. */
626
+ resolver?: string;
627
+ /** Resend's DKIM selector. Default `resend`. */
628
+ dkimSelector?: string;
629
+ /** Where the SPF record lives. Default `send.<domain>` (Resend's return path). */
630
+ spfHost?: string;
631
+ /** What the SPF record includes. Default `amazonses.com` (Resend's). */
632
+ spfInclude?: string;
633
+ }
634
+ /** The ownership record's host. */
635
+ declare const SENDER_OWNERSHIP_HOST = "_genesis-notifications";
636
+ /**
637
+ * The TXT record a tenant adds to prove `domain` is theirs: show it beside
638
+ * Resend's SPF and DKIM records in your sender settings.
639
+ *
640
+ * @stability Beta
641
+ */
642
+ declare function senderOwnershipRecord(domain: string, token: string): {
643
+ type: "TXT";
644
+ name: string;
645
+ value: string;
646
+ };
647
+ /** Whether `domain` is one of `reserved`, or under one. */
648
+ declare function isReservedDomain(domain: string, reserved: readonly (string | undefined | null)[]): boolean;
649
+ /**
650
+ * Checks ownership, SPF, DKIM and DMARC for `domain`. Record the result with
651
+ * `genesis_notifications_set_sender_dns(tenant, domain, token, ok ? 'verified' : 'failed', result)`
652
+ * with the same token, so a check made before the tenant changed its domain changes nothing.
653
+ *
654
+ * @stability Beta
655
+ */
656
+ declare function checkSenderDomain(domain: string, options: DomainCheckOptions): Promise<DomainCheck>;
657
+
658
+ export { type CancelInput, type ClaimedDevice, type ClaimedJob, type DomainCheck, type DomainCheckOptions, EXPO_RECEIPTS_BATCH, EXPO_RECEIPTS_URL, EXPO_SEND_BATCH, EXPO_SEND_URL, type EmailMessage, type EmailOptions, type EmailProvider, type EmailSendResult, EmailTemplate, type ExpoOptions, type HttpDeps, type NotificationRunner, type NotificationRunnerOptions, type NotificationStore, NotificationStoreError, type NotificationsHandlerOptions, type NotifyOptions, type PushMessage, type PushProvider, type PushReceipt, type PushTicket, type PushTicketRow, REQUEST_TIMEOUT_MS, RESEND_API, RESEND_DEFAULT_PER_SECOND, type RateLimiter, type RateLimiterOptions, type RenderedEmail, type ResendOptions, type RpcClient, SECRET_LINK_PLACEHOLDER, SENDER_OWNERSHIP_HOST, SecretLinkError, type SecretLinkRef, type SecretLinks, SendError, type SenderIdentity, type SettleOutcome, type UnsubscribeClaims, WEBHOOK_TOLERANCE_SECONDS, cancelNotification, checkSenderDomain, createNotificationRunner, createNotificationsHandler, createRateLimiter, createUnsubscribeToken, emailEventOf, emailHash, expoErrorCode, expoPushProvider, isReservedDomain, isRetryableStatus, notify, refusedOutright, resendProvider, resolveSecretLink, retryAfterSeconds, senderOwnershipRecord, supabaseNotificationStore, syncNotificationTypes, unsubscribeHeaders, verifyResendWebhook, verifyUnsubscribeToken };