@fonderie/courier 3.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,6 +63,17 @@ 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`
@@ -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) {
@@ -568,17 +581,43 @@ var FSTemplateResolver = class {
568
581
  }
569
582
  };
570
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
+
571
610
  // src/delivery.ts
572
611
  var import_node_crypto = require("crypto");
573
612
  async function handleSendGridDelivery(req, store, webhookSecret) {
574
613
  if (webhookSecret) {
575
614
  const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
576
615
  const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
577
- const body = await req.text();
578
- if (!verifySendGridSignature(webhookSecret, ts, body, sig)) {
616
+ const body2 = await req.text();
617
+ if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
579
618
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
580
619
  }
581
- const events2 = parseJson(body);
620
+ const events2 = parseJson(body2);
582
621
  if (!Array.isArray(events2)) return Response.json({ ok: true });
583
622
  await processSendGridEvents(events2, store);
584
623
  return Response.json({ ok: true });
@@ -588,9 +627,9 @@ async function handleSendGridDelivery(req, store, webhookSecret) {
588
627
  await processSendGridEvents(events, store);
589
628
  return Response.json({ ok: true });
590
629
  }
591
- function verifySendGridSignature(secret, timestamp, body, signature) {
630
+ function verifySendGridSignature(secret, timestamp, body2, signature) {
592
631
  try {
593
- const payload = timestamp + body;
632
+ const payload = timestamp + body2;
594
633
  const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
595
634
  const sigBuf = Buffer.from(signature, "base64");
596
635
  const expBuf = Buffer.from(expected, "base64");
@@ -624,14 +663,14 @@ async function processSendGridEvents(events, store) {
624
663
  }
625
664
  }
626
665
  async function handleMailgunDelivery(req, store, signingKey) {
627
- const body = await req.json();
666
+ const body2 = await req.json();
628
667
  if (signingKey) {
629
- const { signature } = body;
668
+ const { signature } = body2;
630
669
  if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
631
670
  return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
632
671
  }
633
672
  }
634
- const event = body["event-data"];
673
+ const event = body2["event-data"];
635
674
  if (event) {
636
675
  await processMailgunEvent(event, store);
637
676
  }
@@ -702,6 +741,150 @@ function parseJson(text) {
702
741
  }
703
742
  }
704
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
+
705
888
  // src/module.ts
706
889
  var CourierModule = class {
707
890
  constructor(config, store, bus) {
@@ -726,7 +909,12 @@ var CourierModule = class {
726
909
  name = "@fonderie/courier";
727
910
  deps = ["@fonderie/events"];
728
911
  dispatcher;
912
+ // Report config problems for app.checkProductionReadiness() (data, not warn).
913
+ checkReadiness() {
914
+ return collectCourierConfigProblems(this.config, this.dispatcher.channelNames());
915
+ }
729
916
  install(app) {
917
+ validateCourierConfig(this.config, this.dispatcher.channelNames());
730
918
  const store = this.store;
731
919
  const signingKeys = this.config.delivery?.signingKeys;
732
920
  app.addRoute(
@@ -744,6 +932,14 @@ var CourierModule = class {
744
932
  "/courier/delivery/mailtrap",
745
933
  (ctx) => handleMailtrapDelivery(ctx.request, store)
746
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
+ }
747
943
  }
748
944
  };
749
945
  function createTemplateResolver(source, config, store) {
@@ -772,8 +968,16 @@ var Channel = {
772
968
  FSTemplateResolver,
773
969
  PushChannel,
774
970
  SmsChannel,
971
+ buildTemplateAdminRoutes,
972
+ deleteTemplate,
973
+ getTemplateEntry,
775
974
  handleMailgunDelivery,
776
975
  handleMailtrapDelivery,
777
- handleSendGridDelivery
976
+ handleSendGridDelivery,
977
+ listTemplateEntries,
978
+ listTemplateRevisions,
979
+ rollbackTemplate,
980
+ setTemplate,
981
+ validateCourierConfig
778
982
  });
779
983
  //# sourceMappingURL=index.cjs.map