@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/dist/index.js CHANGED
@@ -89,6 +89,11 @@ var Dispatcher = class {
89
89
  this.channels.set(channel.name, channel);
90
90
  return this;
91
91
  }
92
+ // Names of the currently-registered channels — used by the boot-time config
93
+ // guard to detect message types routed to a channel with no provider.
94
+ channelNames() {
95
+ return [...this.channels.keys()];
96
+ }
92
97
  async dispatch(message) {
93
98
  const channelNames = this.config.channels[message.type];
94
99
  if (!channelNames || channelNames.length === 0) {
@@ -165,8 +170,8 @@ var SmsChannel = class {
165
170
  }
166
171
  );
167
172
  if (!res.ok) {
168
- const body = await res.text();
169
- throw new Error(`[courier:sms] Twilio error ${res.status}: ${body}`);
173
+ const body2 = await res.text();
174
+ throw new Error(`[courier:sms] Twilio error ${res.status}: ${body2}`);
170
175
  }
171
176
  }
172
177
  async sendViaVonage(to, text) {
@@ -186,8 +191,8 @@ var SmsChannel = class {
186
191
  })
187
192
  });
188
193
  if (!res.ok) {
189
- const body = await res.text();
190
- throw new Error(`[courier:sms] Vonage error ${res.status}: ${body}`);
194
+ const body2 = await res.text();
195
+ throw new Error(`[courier:sms] Vonage error ${res.status}: ${body2}`);
191
196
  }
192
197
  }
193
198
  };
@@ -229,8 +234,8 @@ var PushChannel = class {
229
234
  })
230
235
  });
231
236
  if (!res.ok) {
232
- const body = await res.text();
233
- throw new Error(`[courier:push] FCM error ${res.status}: ${body}`);
237
+ const body2 = await res.text();
238
+ throw new Error(`[courier:push] FCM error ${res.status}: ${body2}`);
234
239
  }
235
240
  }
236
241
  };
@@ -285,8 +290,8 @@ var EmailChannel = class {
285
290
  })
286
291
  });
287
292
  if (!res.ok) {
288
- const body = await res.text();
289
- throw new Error(`[courier:email] Resend error ${res.status}: ${body}`);
293
+ const body2 = await res.text();
294
+ throw new Error(`[courier:email] Resend error ${res.status}: ${body2}`);
290
295
  }
291
296
  }
292
297
  async sendViaSMTP(to, template) {
@@ -303,13 +308,144 @@ var EmailChannel = class {
303
308
  }
304
309
  };
305
310
 
311
+ // src/templates/layout.ts
312
+ var EMAIL_THEME = {
313
+ brand: "Fonderie",
314
+ accent: "#171717",
315
+ // primary action (button) background — the product's primary
316
+ accentText: "#ffffff",
317
+ // text on the accent
318
+ brandAccent: "#171717",
319
+ // brand highlight (top rule) — monochrome near-black
320
+ link: "#009767",
321
+ // links — the accessible (darkened) brand teal
322
+ ink: "#171717",
323
+ // body copy
324
+ muted: "#5c5c5c",
325
+ // secondary copy
326
+ line: "#e0e0e0",
327
+ // hairline borders
328
+ canvas: "#fafafa",
329
+ // page background behind the card
330
+ card: "#ffffff"
331
+ // the card itself
332
+ };
333
+ var FONT_SANS = "Inter, 'Inter Fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif";
334
+ var FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
335
+ var LAYOUT_CONTENT_SLOT = "{{content}}";
336
+ var DEFAULT_EMAIL_LAYOUT = `<!DOCTYPE html>
337
+ <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">
338
+ <head>
339
+ <meta charset="utf-8">
340
+ <meta name="viewport" content="width=device-width, initial-scale=1">
341
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
342
+ <meta name="color-scheme" content="light dark">
343
+ <meta name="supported-color-schemes" content="light dark">
344
+ <title>{{subject}}</title>
345
+ <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
346
+ <style>
347
+ body { margin: 0; padding: 0; width: 100% !important; background: ${EMAIL_THEME.canvas}; }
348
+ .email-body { background: ${EMAIL_THEME.canvas}; }
349
+ .email-container { width: 100%; max-width: 560px; margin: 0 auto; }
350
+ .email-card {
351
+ background: ${EMAIL_THEME.card};
352
+ border: 1px solid ${EMAIL_THEME.line};
353
+ border-radius: 8px;
354
+ overflow: hidden;
355
+ }
356
+ .email-accent { height: 3px; background: ${EMAIL_THEME.brandAccent}; font-size: 0; line-height: 0; }
357
+ .email-header { padding: 28px 32px 0 32px; }
358
+ .email-brand { font: 800 19px/1 ${FONT_SANS}; color: ${EMAIL_THEME.ink}; letter-spacing: -0.03em; }
359
+ .email-content {
360
+ padding: 20px 32px 8px 32px;
361
+ font: 400 16px/1.7 ${FONT_SANS};
362
+ color: ${EMAIL_THEME.ink};
363
+ letter-spacing: -0.01em;
364
+ }
365
+ .email-content h1 { margin: 0 0 12px 0; font-size: 22px; line-height: 1.3; font-weight: 700; letter-spacing: -0.03em; }
366
+ .email-content p { margin: 0 0 16px 0; }
367
+ .email-content a { color: ${EMAIL_THEME.link}; }
368
+ .email-footer {
369
+ padding: 16px 32px 28px 32px;
370
+ font: 400 13px/1.6 ${FONT_SANS};
371
+ color: ${EMAIL_THEME.muted};
372
+ letter-spacing: -0.01em;
373
+ }
374
+ .btn {
375
+ display: inline-block;
376
+ background: ${EMAIL_THEME.accent};
377
+ color: ${EMAIL_THEME.accentText} !important;
378
+ text-decoration: none;
379
+ font: 600 16px/1 ${FONT_SANS};
380
+ letter-spacing: -0.01em;
381
+ padding: 13px 26px;
382
+ border-radius: 6px;
383
+ }
384
+ .pin-code {
385
+ display: inline-block;
386
+ font: 700 30px/1 ${FONT_MONO};
387
+ letter-spacing: 0.28em;
388
+ color: ${EMAIL_THEME.ink};
389
+ background: ${EMAIL_THEME.canvas};
390
+ border: 1px solid ${EMAIL_THEME.line};
391
+ border-radius: 6px;
392
+ padding: 14px 22px 14px 30px;
393
+ }
394
+ .muted { color: ${EMAIL_THEME.muted}; }
395
+ @media only screen and (max-width: 599px) {
396
+ .email-header, .email-content, .email-footer { padding-left: 22px !important; padding-right: 22px !important; }
397
+ .pin-code { font-size: 26px !important; letter-spacing: 0.2em !important; }
398
+ }
399
+ </style>
400
+ </head>
401
+ <body class="email-body">
402
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;">{{preheader}}</div>
403
+ <table role="presentation" class="email-body" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${EMAIL_THEME.canvas};">
404
+ <tr>
405
+ <td align="center" style="padding: 32px 16px;">
406
+ <table role="presentation" class="email-container" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
407
+ <tr>
408
+ <td class="email-card" style="background:${EMAIL_THEME.card};border:1px solid ${EMAIL_THEME.line};border-radius:8px;">
409
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
410
+ <tr><td class="email-accent" style="height:3px;background:${EMAIL_THEME.brandAccent};font-size:0;line-height:0;">&nbsp;</td></tr>
411
+ <tr><td class="email-header" style="padding:28px 32px 0 32px;">
412
+ <span class="email-brand" style="font-weight:800;font-size:19px;color:${EMAIL_THEME.ink};letter-spacing:-0.03em;">${EMAIL_THEME.brand}</span>
413
+ </td></tr>
414
+ <tr><td class="email-content" style="padding:20px 32px 8px 32px;color:${EMAIL_THEME.ink};">
415
+ ${LAYOUT_CONTENT_SLOT}
416
+ </td></tr>
417
+ <tr><td class="email-footer" style="padding:16px 32px 28px 32px;color:${EMAIL_THEME.muted};font-size:13px;">
418
+ You're receiving this because someone used this address at ${EMAIL_THEME.brand}. If that wasn't you, you can ignore it.
419
+ </td></tr>
420
+ </table>
421
+ </td>
422
+ </tr>
423
+ </table>
424
+ </td>
425
+ </tr>
426
+ </table>
427
+ </body>
428
+ </html>`;
429
+ function wrapLayout(bodyHtml, layoutHtml = DEFAULT_EMAIL_LAYOUT) {
430
+ const trimmed = bodyHtml.trimStart().toLowerCase();
431
+ if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
432
+ return bodyHtml;
433
+ }
434
+ return layoutHtml.replace(LAYOUT_CONTENT_SLOT, bodyHtml);
435
+ }
436
+
306
437
  // src/templates/resolver.ts
438
+ var LAYOUT_TYPE = "_layout";
307
439
  function render(template, data) {
308
440
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
309
441
  const value = data[key];
310
442
  return value !== void 0 && value !== null ? String(value) : "";
311
443
  });
312
444
  }
445
+ function composeHtml(bodyHtml, layoutHtml, subject, data) {
446
+ const wrapped = wrapLayout(bodyHtml, layoutHtml);
447
+ return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
448
+ }
313
449
  var DBTemplateResolver = class {
314
450
  constructor(store) {
315
451
  this.store = store;
@@ -317,22 +453,39 @@ var DBTemplateResolver = class {
317
453
  store;
318
454
  async resolve(type, data, locale) {
319
455
  const [row] = await this.store.query(
456
+ // Serve the exact locale, else the neutral NULL default — never a
457
+ // sibling region (en-CA must not fall back to en-US); the WHERE
458
+ // excludes other locales so legal/jurisdictional copy can't bleed.
320
459
  `SELECT subject, html, text
321
460
  FROM fonderie_courier_templates
322
- WHERE type = $1 AND active = true
323
- ORDER BY (locale = $2)::int DESC, (locale IS NULL)::int DESC
461
+ WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
462
+ ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
324
463
  LIMIT 1`,
325
464
  [type, locale ?? null]
326
465
  );
327
466
  if (!row) {
328
467
  return { text: `${type}: ${JSON.stringify(data)}` };
329
468
  }
469
+ const subject = row.subject ? render(row.subject, data) : void 0;
470
+ const layoutHtml = row.html ? await this.layout(locale) : void 0;
330
471
  return {
331
472
  text: render(row.text, data),
332
- ...row.subject ? { subject: render(row.subject, data) } : {},
333
- ...row.html ? { html: render(row.html, data) } : {}
473
+ ...subject ? { subject } : {},
474
+ ...row.html ? { html: composeHtml(row.html, layoutHtml, subject, data) } : {}
334
475
  };
335
476
  }
477
+ // Optional founder-supplied layout shell; undefined → built-in default.
478
+ async layout(locale) {
479
+ const [row] = await this.store.query(
480
+ `SELECT html
481
+ FROM fonderie_courier_templates
482
+ WHERE type = $1 AND active = true AND (locale = $2 OR locale IS NULL)
483
+ ORDER BY (locale IS NOT DISTINCT FROM $2) DESC
484
+ LIMIT 1`,
485
+ [LAYOUT_TYPE, locale ?? null]
486
+ );
487
+ return row?.html ?? void 0;
488
+ }
336
489
  };
337
490
  var FSTemplateResolver = class {
338
491
  constructor(directory) {
@@ -350,7 +503,8 @@ var FSTemplateResolver = class {
350
503
  }
351
504
  };
352
505
  const localePrefix = locale ? `${type}.${locale}` : null;
353
- const [text, html, subject] = await Promise.all([
506
+ const layoutPrefix = locale ? `${LAYOUT_TYPE}.${locale}` : null;
507
+ const [text, html, subject, layout] = await Promise.all([
354
508
  localePrefix ? readOptional(join(this.directory, `${localePrefix}.txt`)).then(
355
509
  (v) => v ?? readOptional(join(this.directory, `${type}.txt`))
356
510
  ) : readOptional(join(this.directory, `${type}.txt`)),
@@ -359,27 +513,57 @@ var FSTemplateResolver = class {
359
513
  ) : readOptional(join(this.directory, `${type}.html`)),
360
514
  localePrefix ? readOptional(join(this.directory, `${localePrefix}.subject.txt`)).then(
361
515
  (v) => v ?? readOptional(join(this.directory, `${type}.subject.txt`))
362
- ) : readOptional(join(this.directory, `${type}.subject.txt`))
516
+ ) : readOptional(join(this.directory, `${type}.subject.txt`)),
517
+ layoutPrefix ? readOptional(join(this.directory, `${layoutPrefix}.html`)).then(
518
+ (v) => v ?? readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
519
+ ) : readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
363
520
  ]);
521
+ const renderedSubject = subject ? render(subject, data) : void 0;
364
522
  return {
365
523
  text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
366
- ...subject ? { subject: render(subject, data) } : {},
367
- ...html ? { html: render(html, data) } : {}
524
+ ...renderedSubject ? { subject: renderedSubject } : {},
525
+ ...html ? { html: composeHtml(html, layout ?? void 0, renderedSubject, data) } : {}
368
526
  };
369
527
  }
370
528
  };
371
529
 
530
+ // src/config-guard.ts
531
+ var MODULE = "@fonderie/courier";
532
+ function collectCourierConfigProblems(config, registeredChannels) {
533
+ const registered = new Set(registeredChannels);
534
+ const gaps = /* @__PURE__ */ new Map();
535
+ for (const [type, channels] of Object.entries(config.channels ?? {})) {
536
+ for (const channel of channels) {
537
+ if (!registered.has(channel)) {
538
+ const types = gaps.get(channel) ?? [];
539
+ types.push(type);
540
+ gaps.set(channel, types);
541
+ }
542
+ }
543
+ }
544
+ return [...gaps].map(([channel, types]) => ({
545
+ module: MODULE,
546
+ severity: "warning",
547
+ 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).`
548
+ }));
549
+ }
550
+ function validateCourierConfig(config, registeredChannels) {
551
+ for (const problem of collectCourierConfigProblems(config, registeredChannels)) {
552
+ console.warn(`[courier] ${problem.message}`);
553
+ }
554
+ }
555
+
372
556
  // src/delivery.ts
373
557
  import { createHmac, timingSafeEqual } from "crypto";
374
558
  async function handleSendGridDelivery(req, store, webhookSecret) {
375
559
  if (webhookSecret) {
376
560
  const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
377
561
  const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
378
- const body = await req.text();
379
- if (!verifySendGridSignature(webhookSecret, ts, body, sig)) {
562
+ const body2 = await req.text();
563
+ if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
380
564
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
381
565
  }
382
- const events2 = parseJson(body);
566
+ const events2 = parseJson(body2);
383
567
  if (!Array.isArray(events2)) return Response.json({ ok: true });
384
568
  await processSendGridEvents(events2, store);
385
569
  return Response.json({ ok: true });
@@ -389,9 +573,9 @@ async function handleSendGridDelivery(req, store, webhookSecret) {
389
573
  await processSendGridEvents(events, store);
390
574
  return Response.json({ ok: true });
391
575
  }
392
- function verifySendGridSignature(secret, timestamp, body, signature) {
576
+ function verifySendGridSignature(secret, timestamp, body2, signature) {
393
577
  try {
394
- const payload = timestamp + body;
578
+ const payload = timestamp + body2;
395
579
  const expected = createHmac("sha256", secret).update(payload).digest("base64");
396
580
  const sigBuf = Buffer.from(signature, "base64");
397
581
  const expBuf = Buffer.from(expected, "base64");
@@ -425,14 +609,14 @@ async function processSendGridEvents(events, store) {
425
609
  }
426
610
  }
427
611
  async function handleMailgunDelivery(req, store, signingKey) {
428
- const body = await req.json();
612
+ const body2 = await req.json();
429
613
  if (signingKey) {
430
- const { signature } = body;
614
+ const { signature } = body2;
431
615
  if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
432
616
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
433
617
  }
434
618
  }
435
- const event = body["event-data"];
619
+ const event = body2["event-data"];
436
620
  if (event) {
437
621
  await processMailgunEvent(event, store);
438
622
  }
@@ -503,6 +687,150 @@ function parseJson(text) {
503
687
  }
504
688
  }
505
689
 
690
+ // src/templates/admin-routes.ts
691
+ import { setApiResponse, HTTP } from "@fonderie/core";
692
+ import { VersionConflictError } from "@fonderie/store";
693
+
694
+ // src/templates/admin.ts
695
+ import { versionedWrite, versionedRollback } from "@fonderie/store";
696
+ var ENTRY_COLS = `type, locale, subject, html, text, active, version, updated_by AS "updatedBy", updated_at AS "updatedAt"`;
697
+ var TEMPLATE_RESOURCE = {
698
+ table: "fonderie_courier_templates",
699
+ revisions: "fonderie_courier_template_revisions",
700
+ channel: "fonderie_courier_templates_changed",
701
+ keyColumns: ["type", "locale"],
702
+ contentColumns: ["subject", "html", "text"],
703
+ metaColumns: ["active"],
704
+ returning: ENTRY_COLS
705
+ };
706
+ async function setTemplate(opts, store) {
707
+ const data = {
708
+ subject: opts.subject ?? null,
709
+ html: opts.html ?? null,
710
+ text: opts.text
711
+ };
712
+ if (opts.active !== void 0) data["active"] = opts.active;
713
+ return versionedWrite(TEMPLATE_RESOURCE, store, {
714
+ key: opts.type,
715
+ scope: opts.locale ?? null,
716
+ data,
717
+ ...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
718
+ actor: opts.actor ?? null
719
+ });
720
+ }
721
+ async function rollbackTemplate(opts, store) {
722
+ return versionedRollback(TEMPLATE_RESOURCE, store, {
723
+ key: opts.type,
724
+ scope: opts.locale ?? null,
725
+ toVersion: opts.toVersion,
726
+ actor: opts.actor ?? null
727
+ });
728
+ }
729
+ async function listTemplateRevisions(type, locale, store) {
730
+ return store.query(
731
+ `SELECT type, locale, subject, html, text, version, actor, created_at AS "createdAt"
732
+ FROM fonderie_courier_template_revisions
733
+ WHERE type = $1 AND locale IS NOT DISTINCT FROM $2
734
+ ORDER BY version DESC`,
735
+ [type, locale]
736
+ );
737
+ }
738
+ async function getTemplateEntry(type, locale, store) {
739
+ const [row] = await store.query(
740
+ `SELECT ${ENTRY_COLS} FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2`,
741
+ [type, locale]
742
+ );
743
+ return row ?? null;
744
+ }
745
+ async function listTemplateEntries(store) {
746
+ return store.query(
747
+ `SELECT ${ENTRY_COLS} FROM fonderie_courier_templates ORDER BY type, locale NULLS FIRST`
748
+ );
749
+ }
750
+ async function deleteTemplate(type, locale, store) {
751
+ const rows = await store.query(
752
+ `DELETE FROM fonderie_courier_templates WHERE type = $1 AND locale IS NOT DISTINCT FROM $2 RETURNING type`,
753
+ [type, locale]
754
+ );
755
+ return rows.length > 0;
756
+ }
757
+
758
+ // src/templates/admin-routes.ts
759
+ function guarded(adminToken, handler) {
760
+ return async (ctx, next) => {
761
+ const header = ctx.request.headers.get("authorization") ?? "";
762
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
763
+ if (!token || token !== adminToken) {
764
+ return setApiResponse(HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
765
+ }
766
+ return handler(ctx, next);
767
+ };
768
+ }
769
+ var actorOf = (ctx) => ctx.request.headers.get("x-actor") || "admin-token";
770
+ var typeOf = (ctx) => ctx.meta.params?.["type"] ?? "";
771
+ var localeOf = (ctx) => new URL(ctx.request.url).searchParams.get("locale");
772
+ var body = (ctx) => ctx.meta["body"] ?? {};
773
+ function conflictOr(err) {
774
+ if (err instanceof VersionConflictError) {
775
+ return setApiResponse(HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
776
+ currentVersion: err.currentVersion
777
+ });
778
+ }
779
+ throw err;
780
+ }
781
+ function buildTemplateAdminRoutes(store, adminToken) {
782
+ const g = (h) => guarded(adminToken, h);
783
+ return [
784
+ ["GET", "/admin/templates", g(async () => {
785
+ return setApiResponse(HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
786
+ })],
787
+ ["GET", "/admin/templates/:type", g(async (ctx) => {
788
+ const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store);
789
+ return row ? setApiResponse(HTTP.OK, "TEMPLATE", "Template", row) : setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "No such template");
790
+ })],
791
+ ["PUT", "/admin/templates/:type", g(async (ctx) => {
792
+ const b = body(ctx);
793
+ if (typeof b["text"] !== "string") {
794
+ return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
795
+ }
796
+ try {
797
+ const opts = {
798
+ type: typeOf(ctx),
799
+ text: b["text"],
800
+ locale: localeOf(ctx),
801
+ actor: actorOf(ctx)
802
+ };
803
+ if (typeof b["subject"] === "string") opts.subject = b["subject"];
804
+ if (typeof b["html"] === "string") opts.html = b["html"];
805
+ if (typeof b["active"] === "boolean") opts.active = b["active"];
806
+ if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
807
+ return setApiResponse(HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
808
+ } catch (err) {
809
+ return conflictOr(err);
810
+ }
811
+ })],
812
+ ["DELETE", "/admin/templates/:type", g(async (ctx) => {
813
+ const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store);
814
+ return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
815
+ })],
816
+ ["GET", "/admin/templates/:type/revisions", g(async (ctx) => {
817
+ return setApiResponse(HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
818
+ })],
819
+ ["POST", "/admin/templates/:type/rollback", g(async (ctx) => {
820
+ const b = body(ctx);
821
+ const toVersion = Number(b["toVersion"]);
822
+ if (!Number.isInteger(toVersion)) {
823
+ return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
824
+ }
825
+ const row = await rollbackTemplate(
826
+ { type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) },
827
+ store
828
+ );
829
+ return setApiResponse(HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
830
+ })]
831
+ ];
832
+ }
833
+
506
834
  // src/module.ts
507
835
  var CourierModule = class {
508
836
  constructor(config, store, bus) {
@@ -527,7 +855,12 @@ var CourierModule = class {
527
855
  name = "@fonderie/courier";
528
856
  deps = ["@fonderie/events"];
529
857
  dispatcher;
858
+ // Report config problems for app.checkProductionReadiness() (data, not warn).
859
+ checkReadiness() {
860
+ return collectCourierConfigProblems(this.config, this.dispatcher.channelNames());
861
+ }
530
862
  install(app) {
863
+ validateCourierConfig(this.config, this.dispatcher.channelNames());
531
864
  const store = this.store;
532
865
  const signingKeys = this.config.delivery?.signingKeys;
533
866
  app.addRoute(
@@ -545,6 +878,14 @@ var CourierModule = class {
545
878
  "/courier/delivery/mailtrap",
546
879
  (ctx) => handleMailtrapDelivery(ctx.request, store)
547
880
  );
881
+ if (this.config.adminToken) {
882
+ if (!store) {
883
+ throw new Error("[courier] adminToken requires @fonderie/store (db templates)");
884
+ }
885
+ for (const [method, path, handler] of buildTemplateAdminRoutes(store, this.config.adminToken)) {
886
+ app.addRoute(method, path, handler);
887
+ }
888
+ }
548
889
  }
549
890
  };
550
891
  function createTemplateResolver(source, config, store) {
@@ -572,8 +913,16 @@ export {
572
913
  FSTemplateResolver,
573
914
  PushChannel,
574
915
  SmsChannel,
916
+ buildTemplateAdminRoutes,
917
+ deleteTemplate,
918
+ getTemplateEntry,
575
919
  handleMailgunDelivery,
576
920
  handleMailtrapDelivery,
577
- handleSendGridDelivery
921
+ handleSendGridDelivery,
922
+ listTemplateEntries,
923
+ listTemplateRevisions,
924
+ rollbackTemplate,
925
+ setTemplate,
926
+ validateCourierConfig
578
927
  };
579
928
  //# sourceMappingURL=index.js.map