@fonderie/courier 2.0.0 → 4.0.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/brain/outcomes.md CHANGED
@@ -9,6 +9,19 @@ downloading tarballs.
9
9
 
10
10
  ## Database tables (after all migrations)
11
11
 
12
+ ### `fonderie_courier_template_revisions`
13
+
14
+ ```sql
15
+ type TEXT NOT NULL
16
+ locale TEXT
17
+ subject TEXT
18
+ html TEXT
19
+ text TEXT NOT NULL
20
+ version INT NOT NULL
21
+ actor TEXT
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
23
+ ```
24
+
12
25
  ### `fonderie_courier_templates`
13
26
 
14
27
  ```sql
@@ -21,6 +34,8 @@ text TEXT NOT NULL
21
34
  active BOOLEAN NOT NULL DEFAULT true
22
35
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
23
36
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
37
+ version INT NOT NULL DEFAULT 1
38
+ updated_by TEXT
24
39
  -- UNIQUE (type, locale)
25
40
  ```
26
41
 
@@ -48,8 +63,20 @@ bounce_reason TEXT
48
63
 
49
64
  Raw SQL ships in `node_modules/@fonderie/courier/dist/migrations/sql/` — read it there if you must; never download tarballs.
50
65
 
66
+ ## HTTP routes registered
67
+
68
+ | Method | Path | Middleware chain (auth / validation / handler) |
69
+ |---|---|---|
70
+ | GET | `/admin/templates` | `g(async () => { return setApiResponse(HTTP.OK, 'TEMPLATES_LISTED', 'Templates', await listTemplateEntries(store)); })` |
71
+ | DELETE | `/admin/templates/:type` | `g(async (ctx) => { const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store); return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? 'DELETED' : 'NOT_FOUND', ok ? 'Deleted' : 'No such template'); })` |
72
+ | GET | `/admin/templates/:type` | `g(async (ctx) => { const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store); return row ? setApiResponse(HTTP.OK, 'TEMPLATE', 'Template', row) : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such template'); })` |
73
+ | PUT | `/admin/templates/:type` | `g(async (ctx) => { const b = body(ctx); if (typeof b['text'] !== 'string') { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.text (string) is required'); } try { const opts: Parameters<typeof setTemplate>[0] = { type: typeOf(ctx), text: b['text'], locale: localeOf(ctx), actor: actorOf(ctx), }; if (typeof b['subject'] === 'string') opts.subject = b['subject']; if (typeof b['html'] === 'string') opts.html = b['html']; if (typeof b['active'] === 'boolean') opts.active = b['active']; if (typeof b['ifVersion'] === 'number') opts.ifVersion = b['ifVersion']; return setApiResponse(HTTP.OK, 'TEMPLATE_SET', 'Template saved', await setTemplate(opts, store)); } catch (err) { return conflictOr(err); } })` |
74
+ | GET | `/admin/templates/:type/revisions` | `g(async (ctx) => { return setApiResponse(HTTP.OK, 'REVISIONS', 'Template revisions', await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store)); })` |
75
+ | POST | `/admin/templates/:type/rollback` | `g(async (ctx) => { const b = body(ctx); const toVersion = Number(b['toVersion']); if (!Number.isInteger(toVersion)) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.toVersion (int) is required'); } const row = await rollbackTemplate( { type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) }, store, ); return setApiResponse(HTTP.OK, 'ROLLED_BACK', `Rolled back to v${toVersion}`, row); })` |
76
+
51
77
  ## Migration statements not replayed (verify in raw SQL)
52
78
 
53
79
  - `ELSE`
54
80
  - `END IF`
55
- - `END $$`
81
+ - `END`
82
+ - `$fn$ LANGUAGE plpgsql`
@@ -11,8 +11,11 @@ new CourierModule(config: ICourierConfig, store?: IStoreAdapter | undefined, bus
11
11
  .name: "@fonderie/courier"
12
12
  .deps: string[]
13
13
  .dispatcher: Dispatcher
14
+ .checkReadiness(): IReadinessProblem[]
14
15
  .install(app: IFonderieApp): void
15
16
 
17
+ function validateCourierConfig(config: ICourierConfig, registeredChannels: Iterable<string>): void
18
+
16
19
  function handleSendGridDelivery(req: Request, store: IStoreAdapter, webhookSecret?: string | undefined): Promise<Response>
17
20
 
18
21
  function handleMailgunDelivery(req: Request, store: IStoreAdapter, signingKey?: string | undefined): Promise<Response>
@@ -21,6 +24,7 @@ function handleMailtrapDelivery(req: Request, store: IStoreAdapter): Promise<Res
21
24
 
22
25
  new Dispatcher(config: ICourierConfig, resolver: ITemplateResolver, store?: IStoreAdapter | undefined): Dispatcher
23
26
  .registerChannel(channel: ICourierChannel): Dispatcher
27
+ .channelNames(): string[]
24
28
  .dispatch(message: ICourierMessage): Promise<void>
25
29
 
26
30
  new SmsChannel(config: ISmsChannelConfig): SmsChannel
@@ -41,6 +45,43 @@ new DBTemplateResolver(store: IStoreAdapter): DBTemplateResolver
41
45
  new FSTemplateResolver(directory: string): FSTemplateResolver
42
46
  .resolve(type: string, data: Record<string, unknown>, locale?: string | undefined): Promise<IRenderedTemplate>
43
47
 
48
+ function setTemplate(opts: { type: string; text: string; locale?: string | null; subject?: string | null; html?: string | null; active?: boolean; ifVersion?: number; actor?: string; }, store: IStoreAdapter): Promise<...>
49
+
50
+ function rollbackTemplate(opts: { type: string; locale?: string | null; toVersion: number; actor?: string; }, store: IStoreAdapter): Promise<ITemplateEntry>
51
+
52
+ function listTemplateRevisions(type: string, locale: string | null, store: IStoreAdapter): Promise<ITemplateRevision[]>
53
+
54
+ function getTemplateEntry(type: string, locale: string | null, store: IStoreAdapter): Promise<ITemplateEntry | null>
55
+
56
+ function listTemplateEntries(store: IStoreAdapter): Promise<ITemplateEntry[]>
57
+
58
+ function deleteTemplate(type: string, locale: string | null, store: IStoreAdapter): Promise<boolean>
59
+
60
+ function buildTemplateAdminRoutes(store: IStoreAdapter, adminToken: string): [string, string, Middleware][]
61
+
62
+ interface ITemplateEntry {
63
+ type: string;
64
+ locale: string | null;
65
+ subject: string | null;
66
+ html: string | null;
67
+ text: string;
68
+ active: boolean;
69
+ version: number;
70
+ updatedBy: string | null;
71
+ updatedAt: string;
72
+ }
73
+
74
+ interface ITemplateRevision {
75
+ type: string;
76
+ locale: string | null;
77
+ subject: string | null;
78
+ html: string | null;
79
+ text: string;
80
+ version: number;
81
+ actor: string | null;
82
+ createdAt: string;
83
+ }
84
+
44
85
  interface IMessageLog {
45
86
  id: string;
46
87
  messageType: string;
@@ -95,6 +136,7 @@ interface ICourierConfig {
95
136
  sms?: ISmsChannelConfig;
96
137
  push?: IPushChannelConfig;
97
138
  email?: IEmailChannelConfig;
139
+ adminToken?: string;
98
140
  templates?: {
99
141
  source: 'db' | 'fs';
100
142
  directory?: string;
package/dist/index.cjs CHANGED
@@ -38,9 +38,17 @@ __export(index_exports, {
38
38
  FSTemplateResolver: () => FSTemplateResolver,
39
39
  PushChannel: () => PushChannel,
40
40
  SmsChannel: () => SmsChannel,
41
+ buildTemplateAdminRoutes: () => buildTemplateAdminRoutes,
42
+ deleteTemplate: () => deleteTemplate,
43
+ getTemplateEntry: () => getTemplateEntry,
41
44
  handleMailgunDelivery: () => handleMailgunDelivery,
42
45
  handleMailtrapDelivery: () => handleMailtrapDelivery,
43
- handleSendGridDelivery: () => handleSendGridDelivery
46
+ handleSendGridDelivery: () => handleSendGridDelivery,
47
+ listTemplateEntries: () => listTemplateEntries,
48
+ listTemplateRevisions: () => listTemplateRevisions,
49
+ rollbackTemplate: () => rollbackTemplate,
50
+ setTemplate: () => setTemplate,
51
+ validateCourierConfig: () => validateCourierConfig
44
52
  });
45
53
  module.exports = __toCommonJS(index_exports);
46
54
 
@@ -135,6 +143,11 @@ var Dispatcher = class {
135
143
  this.channels.set(channel.name, channel);
136
144
  return this;
137
145
  }
146
+ // Names of the currently-registered channels — used by the boot-time config
147
+ // guard to detect message types routed to a channel with no provider.
148
+ channelNames() {
149
+ return [...this.channels.keys()];
150
+ }
138
151
  async dispatch(message) {
139
152
  const channelNames = this.config.channels[message.type];
140
153
  if (!channelNames || channelNames.length === 0) {
@@ -211,8 +224,8 @@ var SmsChannel = class {
211
224
  }
212
225
  );
213
226
  if (!res.ok) {
214
- const body = await res.text();
215
- throw new Error(`[courier:sms] Twilio error ${res.status}: ${body}`);
227
+ const body2 = await res.text();
228
+ throw new Error(`[courier:sms] Twilio error ${res.status}: ${body2}`);
216
229
  }
217
230
  }
218
231
  async sendViaVonage(to, text) {
@@ -232,8 +245,8 @@ var SmsChannel = class {
232
245
  })
233
246
  });
234
247
  if (!res.ok) {
235
- const body = await res.text();
236
- throw new Error(`[courier:sms] Vonage error ${res.status}: ${body}`);
248
+ const body2 = await res.text();
249
+ throw new Error(`[courier:sms] Vonage error ${res.status}: ${body2}`);
237
250
  }
238
251
  }
239
252
  };
@@ -275,8 +288,8 @@ var PushChannel = class {
275
288
  })
276
289
  });
277
290
  if (!res.ok) {
278
- const body = await res.text();
279
- throw new Error(`[courier:push] FCM error ${res.status}: ${body}`);
291
+ const body2 = await res.text();
292
+ throw new Error(`[courier:push] FCM error ${res.status}: ${body2}`);
280
293
  }
281
294
  }
282
295
  };
@@ -331,8 +344,8 @@ var EmailChannel = class {
331
344
  })
332
345
  });
333
346
  if (!res.ok) {
334
- const body = await res.text();
335
- throw new Error(`[courier:email] Resend error ${res.status}: ${body}`);
347
+ const body2 = await res.text();
348
+ throw new Error(`[courier:email] Resend error ${res.status}: ${body2}`);
336
349
  }
337
350
  }
338
351
  async sendViaSMTP(to, template) {
@@ -349,13 +362,144 @@ var EmailChannel = class {
349
362
  }
350
363
  };
351
364
 
365
+ // src/templates/layout.ts
366
+ var EMAIL_THEME = {
367
+ brand: "Fonderie",
368
+ accent: "#171717",
369
+ // primary action (button) background — the product's primary
370
+ accentText: "#ffffff",
371
+ // text on the accent
372
+ brandAccent: "#171717",
373
+ // brand highlight (top rule) — monochrome near-black
374
+ link: "#009767",
375
+ // links — the accessible (darkened) brand teal
376
+ ink: "#171717",
377
+ // body copy
378
+ muted: "#5c5c5c",
379
+ // secondary copy
380
+ line: "#e0e0e0",
381
+ // hairline borders
382
+ canvas: "#fafafa",
383
+ // page background behind the card
384
+ card: "#ffffff"
385
+ // the card itself
386
+ };
387
+ var FONT_SANS = "Inter, 'Inter Fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif";
388
+ var FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
389
+ var LAYOUT_CONTENT_SLOT = "{{content}}";
390
+ var DEFAULT_EMAIL_LAYOUT = `<!DOCTYPE html>
391
+ <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
392
+ <head>
393
+ <meta charset="utf-8">
394
+ <meta name="viewport" content="width=device-width, initial-scale=1">
395
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
396
+ <meta name="color-scheme" content="light dark">
397
+ <meta name="supported-color-schemes" content="light dark">
398
+ <title>{{subject}}</title>
399
+ <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
400
+ <style>
401
+ body { margin: 0; padding: 0; width: 100% !important; background: ${EMAIL_THEME.canvas}; }
402
+ .email-body { background: ${EMAIL_THEME.canvas}; }
403
+ .email-container { width: 100%; max-width: 560px; margin: 0 auto; }
404
+ .email-card {
405
+ background: ${EMAIL_THEME.card};
406
+ border: 1px solid ${EMAIL_THEME.line};
407
+ border-radius: 8px;
408
+ overflow: hidden;
409
+ }
410
+ .email-accent { height: 3px; background: ${EMAIL_THEME.brandAccent}; font-size: 0; line-height: 0; }
411
+ .email-header { padding: 28px 32px 0 32px; }
412
+ .email-brand { font: 800 19px/1 ${FONT_SANS}; color: ${EMAIL_THEME.ink}; letter-spacing: -0.03em; }
413
+ .email-content {
414
+ padding: 20px 32px 8px 32px;
415
+ font: 400 16px/1.7 ${FONT_SANS};
416
+ color: ${EMAIL_THEME.ink};
417
+ letter-spacing: -0.01em;
418
+ }
419
+ .email-content h1 { margin: 0 0 12px 0; font-size: 22px; line-height: 1.3; font-weight: 700; letter-spacing: -0.03em; }
420
+ .email-content p { margin: 0 0 16px 0; }
421
+ .email-content a { color: ${EMAIL_THEME.link}; }
422
+ .email-footer {
423
+ padding: 16px 32px 28px 32px;
424
+ font: 400 13px/1.6 ${FONT_SANS};
425
+ color: ${EMAIL_THEME.muted};
426
+ letter-spacing: -0.01em;
427
+ }
428
+ .btn {
429
+ display: inline-block;
430
+ background: ${EMAIL_THEME.accent};
431
+ color: ${EMAIL_THEME.accentText} !important;
432
+ text-decoration: none;
433
+ font: 600 16px/1 ${FONT_SANS};
434
+ letter-spacing: -0.01em;
435
+ padding: 13px 26px;
436
+ border-radius: 6px;
437
+ }
438
+ .pin-code {
439
+ display: inline-block;
440
+ font: 700 30px/1 ${FONT_MONO};
441
+ letter-spacing: 0.28em;
442
+ color: ${EMAIL_THEME.ink};
443
+ background: ${EMAIL_THEME.canvas};
444
+ border: 1px solid ${EMAIL_THEME.line};
445
+ border-radius: 6px;
446
+ padding: 14px 22px 14px 30px;
447
+ }
448
+ .muted { color: ${EMAIL_THEME.muted}; }
449
+ @media only screen and (max-width: 599px) {
450
+ .email-header, .email-content, .email-footer { padding-left: 22px !important; padding-right: 22px !important; }
451
+ .pin-code { font-size: 26px !important; letter-spacing: 0.2em !important; }
452
+ }
453
+ </style>
454
+ </head>
455
+ <body class="email-body">
456
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;">{{preheader}}</div>
457
+ <table role="presentation" class="email-body" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${EMAIL_THEME.canvas};">
458
+ <tr>
459
+ <td align="center" style="padding: 32px 16px;">
460
+ <table role="presentation" class="email-container" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
461
+ <tr>
462
+ <td class="email-card" style="background:${EMAIL_THEME.card};border:1px solid ${EMAIL_THEME.line};border-radius:8px;">
463
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
464
+ <tr><td class="email-accent" style="height:3px;background:${EMAIL_THEME.brandAccent};font-size:0;line-height:0;">&nbsp;</td></tr>
465
+ <tr><td class="email-header" style="padding:28px 32px 0 32px;">
466
+ <span class="email-brand" style="font-weight:800;font-size:19px;color:${EMAIL_THEME.ink};letter-spacing:-0.03em;">${EMAIL_THEME.brand}</span>
467
+ </td></tr>
468
+ <tr><td class="email-content" style="padding:20px 32px 8px 32px;color:${EMAIL_THEME.ink};">
469
+ ${LAYOUT_CONTENT_SLOT}
470
+ </td></tr>
471
+ <tr><td class="email-footer" style="padding:16px 32px 28px 32px;color:${EMAIL_THEME.muted};font-size:13px;">
472
+ You're receiving this because someone used this address at ${EMAIL_THEME.brand}. If that wasn't you, you can ignore it.
473
+ </td></tr>
474
+ </table>
475
+ </td>
476
+ </tr>
477
+ </table>
478
+ </td>
479
+ </tr>
480
+ </table>
481
+ </body>
482
+ </html>`;
483
+ function wrapLayout(bodyHtml, layoutHtml = DEFAULT_EMAIL_LAYOUT) {
484
+ const trimmed = bodyHtml.trimStart().toLowerCase();
485
+ if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
486
+ return bodyHtml;
487
+ }
488
+ return layoutHtml.replace(LAYOUT_CONTENT_SLOT, bodyHtml);
489
+ }
490
+
352
491
  // src/templates/resolver.ts
492
+ var LAYOUT_TYPE = "_layout";
353
493
  function render(template, data) {
354
494
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
355
495
  const value = data[key];
356
496
  return value !== void 0 && value !== null ? String(value) : "";
357
497
  });
358
498
  }
499
+ function composeHtml(bodyHtml, layoutHtml, subject, data) {
500
+ const wrapped = wrapLayout(bodyHtml, layoutHtml);
501
+ return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
502
+ }
359
503
  var DBTemplateResolver = class {
360
504
  constructor(store) {
361
505
  this.store = store;
@@ -363,22 +507,39 @@ var DBTemplateResolver = class {
363
507
  store;
364
508
  async resolve(type, data, locale) {
365
509
  const [row] = await this.store.query(
510
+ // Serve the exact locale, else the neutral NULL default — never a
511
+ // sibling region (en-CA must not fall back to en-US); the WHERE
512
+ // excludes other locales so legal/jurisdictional copy can't bleed.
366
513
  `SELECT subject, html, text
367
514
  FROM fonderie_courier_templates
368
- WHERE type = $1 AND active = true
369
- ORDER BY (locale = $2)::int DESC, (locale IS NULL)::int DESC
515
+ WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
516
+ ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
370
517
  LIMIT 1`,
371
518
  [type, locale ?? null]
372
519
  );
373
520
  if (!row) {
374
521
  return { text: `${type}: ${JSON.stringify(data)}` };
375
522
  }
523
+ const subject = row.subject ? render(row.subject, data) : void 0;
524
+ const layoutHtml = row.html ? await this.layout(locale) : void 0;
376
525
  return {
377
526
  text: render(row.text, data),
378
- ...row.subject ? { subject: render(row.subject, data) } : {},
379
- ...row.html ? { html: render(row.html, data) } : {}
527
+ ...subject ? { subject } : {},
528
+ ...row.html ? { html: composeHtml(row.html, layoutHtml, subject, data) } : {}
380
529
  };
381
530
  }
531
+ // Optional founder-supplied layout shell; undefined → built-in default.
532
+ async layout(locale) {
533
+ const [row] = await this.store.query(
534
+ `SELECT html
535
+ FROM fonderie_courier_templates
536
+ WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
537
+ ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
538
+ LIMIT 1`,
539
+ [LAYOUT_TYPE, locale ?? null]
540
+ );
541
+ return row?.html ?? void 0;
542
+ }
382
543
  };
383
544
  var FSTemplateResolver = class {
384
545
  constructor(directory) {
@@ -396,7 +557,8 @@ var FSTemplateResolver = class {
396
557
  }
397
558
  };
398
559
  const localePrefix = locale ? `${type}.${locale}` : null;
399
- const [text, html, subject] = await Promise.all([
560
+ const layoutPrefix = locale ? `${LAYOUT_TYPE}.${locale}` : null;
561
+ const [text, html, subject, layout] = await Promise.all([
400
562
  localePrefix ? readOptional(join(this.directory, `${localePrefix}.txt`)).then(
401
563
  (v) => v ?? readOptional(join(this.directory, `${type}.txt`))
402
564
  ) : readOptional(join(this.directory, `${type}.txt`)),
@@ -405,27 +567,57 @@ var FSTemplateResolver = class {
405
567
  ) : readOptional(join(this.directory, `${type}.html`)),
406
568
  localePrefix ? readOptional(join(this.directory, `${localePrefix}.subject.txt`)).then(
407
569
  (v) => v ?? readOptional(join(this.directory, `${type}.subject.txt`))
408
- ) : readOptional(join(this.directory, `${type}.subject.txt`))
570
+ ) : readOptional(join(this.directory, `${type}.subject.txt`)),
571
+ layoutPrefix ? readOptional(join(this.directory, `${layoutPrefix}.html`)).then(
572
+ (v) => v ?? readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
573
+ ) : readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
409
574
  ]);
575
+ const renderedSubject = subject ? render(subject, data) : void 0;
410
576
  return {
411
577
  text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
412
- ...subject ? { subject: render(subject, data) } : {},
413
- ...html ? { html: render(html, data) } : {}
578
+ ...renderedSubject ? { subject: renderedSubject } : {},
579
+ ...html ? { html: composeHtml(html, layout ?? void 0, renderedSubject, data) } : {}
414
580
  };
415
581
  }
416
582
  };
417
583
 
584
+ // src/config-guard.ts
585
+ var MODULE = "@fonderie/courier";
586
+ function collectCourierConfigProblems(config, registeredChannels) {
587
+ const registered = new Set(registeredChannels);
588
+ const gaps = /* @__PURE__ */ new Map();
589
+ for (const [type, channels] of Object.entries(config.channels ?? {})) {
590
+ for (const channel of channels) {
591
+ if (!registered.has(channel)) {
592
+ const types = gaps.get(channel) ?? [];
593
+ types.push(type);
594
+ gaps.set(channel, types);
595
+ }
596
+ }
597
+ }
598
+ return [...gaps].map(([channel, types]) => ({
599
+ module: MODULE,
600
+ severity: "warning",
601
+ message: `${types.length} message type(s) route to the '${channel}' channel but no '${channel}' provider is registered \u2014 these will be silently dropped: ${types.join(", ")}. Configure \`config.${channel}\` (or register a channel).`
602
+ }));
603
+ }
604
+ function validateCourierConfig(config, registeredChannels) {
605
+ for (const problem of collectCourierConfigProblems(config, registeredChannels)) {
606
+ console.warn(`[courier] ${problem.message}`);
607
+ }
608
+ }
609
+
418
610
  // src/delivery.ts
419
611
  var import_node_crypto = require("crypto");
420
612
  async function handleSendGridDelivery(req, store, webhookSecret) {
421
613
  if (webhookSecret) {
422
614
  const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
423
615
  const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
424
- const body = await req.text();
425
- if (!verifySendGridSignature(webhookSecret, ts, body, sig)) {
616
+ const body2 = await req.text();
617
+ if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
426
618
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
427
619
  }
428
- const events2 = parseJson(body);
620
+ const events2 = parseJson(body2);
429
621
  if (!Array.isArray(events2)) return Response.json({ ok: true });
430
622
  await processSendGridEvents(events2, store);
431
623
  return Response.json({ ok: true });
@@ -435,9 +627,9 @@ async function handleSendGridDelivery(req, store, webhookSecret) {
435
627
  await processSendGridEvents(events, store);
436
628
  return Response.json({ ok: true });
437
629
  }
438
- function verifySendGridSignature(secret, timestamp, body, signature) {
630
+ function verifySendGridSignature(secret, timestamp, body2, signature) {
439
631
  try {
440
- const payload = timestamp + body;
632
+ const payload = timestamp + body2;
441
633
  const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
442
634
  const sigBuf = Buffer.from(signature, "base64");
443
635
  const expBuf = Buffer.from(expected, "base64");
@@ -471,14 +663,14 @@ async function processSendGridEvents(events, store) {
471
663
  }
472
664
  }
473
665
  async function handleMailgunDelivery(req, store, signingKey) {
474
- const body = await req.json();
666
+ const body2 = await req.json();
475
667
  if (signingKey) {
476
- const { signature } = body;
668
+ const { signature } = body2;
477
669
  if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
478
670
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
479
671
  }
480
672
  }
481
- const event = body["event-data"];
673
+ const event = body2["event-data"];
482
674
  if (event) {
483
675
  await processMailgunEvent(event, store);
484
676
  }
@@ -549,6 +741,150 @@ function parseJson(text) {
549
741
  }
550
742
  }
551
743
 
744
+ // src/templates/admin-routes.ts
745
+ var import_core = require("@fonderie/core");
746
+ var import_store2 = require("@fonderie/store");
747
+
748
+ // src/templates/admin.ts
749
+ var import_store = require("@fonderie/store");
750
+ var ENTRY_COLS = `type, locale, subject, html, text, active, version, updated_by AS "updatedBy", updated_at AS "updatedAt"`;
751
+ var TEMPLATE_RESOURCE = {
752
+ table: "fonderie_courier_templates",
753
+ revisions: "fonderie_courier_template_revisions",
754
+ channel: "fonderie_courier_templates_changed",
755
+ keyColumns: ["type", "locale"],
756
+ contentColumns: ["subject", "html", "text"],
757
+ metaColumns: ["active"],
758
+ returning: ENTRY_COLS
759
+ };
760
+ async function setTemplate(opts, store) {
761
+ const data = {
762
+ subject: opts.subject ?? null,
763
+ html: opts.html ?? null,
764
+ text: opts.text
765
+ };
766
+ if (opts.active !== void 0) data["active"] = opts.active;
767
+ return (0, import_store.versionedWrite)(TEMPLATE_RESOURCE, store, {
768
+ key: opts.type,
769
+ scope: opts.locale ?? null,
770
+ data,
771
+ ...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
772
+ actor: opts.actor ?? null
773
+ });
774
+ }
775
+ async function rollbackTemplate(opts, store) {
776
+ return (0, import_store.versionedRollback)(TEMPLATE_RESOURCE, store, {
777
+ key: opts.type,
778
+ scope: opts.locale ?? null,
779
+ toVersion: opts.toVersion,
780
+ actor: opts.actor ?? null
781
+ });
782
+ }
783
+ async function listTemplateRevisions(type, locale, store) {
784
+ return store.query(
785
+ `SELECT type, locale, subject, html, text, version, actor, created_at AS "createdAt"
786
+ FROM fonderie_courier_template_revisions
787
+ WHERE type = $1 AND locale IS NOT DISTINCT FROM $2
788
+ ORDER BY version DESC`,
789
+ [type, locale]
790
+ );
791
+ }
792
+ async function getTemplateEntry(type, locale, store) {
793
+ const [row] = await store.query(
794
+ `SELECT ${ENTRY_COLS} FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2`,
795
+ [type, locale]
796
+ );
797
+ return row ?? null;
798
+ }
799
+ async function listTemplateEntries(store) {
800
+ return store.query(
801
+ `SELECT ${ENTRY_COLS} FROM fonderie_courier_templates ORDER BY type, locale NULLS FIRST`
802
+ );
803
+ }
804
+ async function deleteTemplate(type, locale, store) {
805
+ const rows = await store.query(
806
+ `DELETE FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2 RETURNING type`,
807
+ [type, locale]
808
+ );
809
+ return rows.length > 0;
810
+ }
811
+
812
+ // src/templates/admin-routes.ts
813
+ function guarded(adminToken, handler) {
814
+ return async (ctx, next) => {
815
+ const header = ctx.request.headers.get("authorization") ?? "";
816
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
817
+ if (!token || token !== adminToken) {
818
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
819
+ }
820
+ return handler(ctx, next);
821
+ };
822
+ }
823
+ var actorOf = (ctx) => ctx.request.headers.get("x-actor") || "admin-token";
824
+ var typeOf = (ctx) => ctx.meta.params?.["type"] ?? "";
825
+ var localeOf = (ctx) => new URL(ctx.request.url).searchParams.get("locale");
826
+ var body = (ctx) => ctx.meta["body"] ?? {};
827
+ function conflictOr(err) {
828
+ if (err instanceof import_store2.VersionConflictError) {
829
+ return (0, import_core.setApiResponse)(import_core.HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
830
+ currentVersion: err.currentVersion
831
+ });
832
+ }
833
+ throw err;
834
+ }
835
+ function buildTemplateAdminRoutes(store, adminToken) {
836
+ const g = (h) => guarded(adminToken, h);
837
+ return [
838
+ ["GET", "/admin/templates", g(async () => {
839
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
840
+ })],
841
+ ["GET", "/admin/templates/:type", g(async (ctx) => {
842
+ const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store);
843
+ return row ? (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATE", "Template", row) : (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "No such template");
844
+ })],
845
+ ["PUT", "/admin/templates/:type", g(async (ctx) => {
846
+ const b = body(ctx);
847
+ if (typeof b["text"] !== "string") {
848
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
849
+ }
850
+ try {
851
+ const opts = {
852
+ type: typeOf(ctx),
853
+ text: b["text"],
854
+ locale: localeOf(ctx),
855
+ actor: actorOf(ctx)
856
+ };
857
+ if (typeof b["subject"] === "string") opts.subject = b["subject"];
858
+ if (typeof b["html"] === "string") opts.html = b["html"];
859
+ if (typeof b["active"] === "boolean") opts.active = b["active"];
860
+ if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
861
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
862
+ } catch (err) {
863
+ return conflictOr(err);
864
+ }
865
+ })],
866
+ ["DELETE", "/admin/templates/:type", g(async (ctx) => {
867
+ const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store);
868
+ return (0, import_core.setApiResponse)(ok ? import_core.HTTP.OK : import_core.HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
869
+ })],
870
+ ["GET", "/admin/templates/:type/revisions", g(async (ctx) => {
871
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
872
+ })],
873
+ ["POST", "/admin/templates/:type/rollback", g(async (ctx) => {
874
+ const b = body(ctx);
875
+ const toVersion = Number(b["toVersion"]);
876
+ if (!Number.isInteger(toVersion)) {
877
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
878
+ }
879
+ const row = await rollbackTemplate(
880
+ { type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) },
881
+ store
882
+ );
883
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
884
+ })]
885
+ ];
886
+ }
887
+
552
888
  // src/module.ts
553
889
  var CourierModule = class {
554
890
  constructor(config, store, bus) {
@@ -573,7 +909,12 @@ var CourierModule = class {
573
909
  name = "@fonderie/courier";
574
910
  deps = ["@fonderie/events"];
575
911
  dispatcher;
912
+ // Report config problems for app.checkProductionReadiness() (data, not warn).
913
+ checkReadiness() {
914
+ return collectCourierConfigProblems(this.config, this.dispatcher.channelNames());
915
+ }
576
916
  install(app) {
917
+ validateCourierConfig(this.config, this.dispatcher.channelNames());
577
918
  const store = this.store;
578
919
  const signingKeys = this.config.delivery?.signingKeys;
579
920
  app.addRoute(
@@ -591,6 +932,14 @@ var CourierModule = class {
591
932
  "/courier/delivery/mailtrap",
592
933
  (ctx) => handleMailtrapDelivery(ctx.request, store)
593
934
  );
935
+ if (this.config.adminToken) {
936
+ if (!store) {
937
+ throw new Error("[courier] adminToken requires @fonderie/store (db templates)");
938
+ }
939
+ for (const [method, path, handler] of buildTemplateAdminRoutes(store, this.config.adminToken)) {
940
+ app.addRoute(method, path, handler);
941
+ }
942
+ }
594
943
  }
595
944
  };
596
945
  function createTemplateResolver(source, config, store) {
@@ -619,8 +968,16 @@ var Channel = {
619
968
  FSTemplateResolver,
620
969
  PushChannel,
621
970
  SmsChannel,
971
+ buildTemplateAdminRoutes,
972
+ deleteTemplate,
973
+ getTemplateEntry,
622
974
  handleMailgunDelivery,
623
975
  handleMailtrapDelivery,
624
- handleSendGridDelivery
976
+ handleSendGridDelivery,
977
+ listTemplateEntries,
978
+ listTemplateRevisions,
979
+ rollbackTemplate,
980
+ setTemplate,
981
+ validateCourierConfig
625
982
  });
626
983
  //# sourceMappingURL=index.cjs.map