@fonderie/courier 5.1.0 → 5.2.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.
@@ -39,12 +39,18 @@ new EmailChannel(config: IEmailChannelConfig): EmailChannel
39
39
  .name: "email"
40
40
  .send(message: ICourierMessage, template: IRenderedTemplate): Promise<void>
41
41
 
42
- new DBTemplateResolver(store: IStoreAdapter): DBTemplateResolver
42
+ new DBTemplateResolver(store: IStoreAdapter, defaults?: DefaultTemplates | undefined): DBTemplateResolver
43
43
  .resolve(type: string, data: Record<string, unknown>, locale?: string | undefined): Promise<IRenderedTemplate>
44
44
 
45
- new FSTemplateResolver(directory: string): FSTemplateResolver
45
+ new FSTemplateResolver(directory: string, defaults?: DefaultTemplates | undefined): FSTemplateResolver
46
46
  .resolve(type: string, data: Record<string, unknown>, locale?: string | undefined): Promise<IRenderedTemplate>
47
47
 
48
+ new DefaultTemplates(maps?: DefaultTemplateMap[]): DefaultTemplates
49
+ .get(type: string): IDefaultTemplate | undefined
50
+ .size: number
51
+
52
+ function renderFragment(frag: { subject?: string | null; text: string; html?: string | null; }, layoutHtml: string | undefined, data: Record<string, unknown>): IRenderedTemplate
53
+
48
54
  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
55
 
50
56
  function rollbackTemplate(opts: { type: string; locale?: string | null; toVersion: number; actor?: string; }, store: IStoreAdapter): Promise<ITemplateEntry>
@@ -129,6 +135,14 @@ interface ITemplateResolver {
129
135
  resolve(type: string, data: Record<string, unknown>, locale?: string): Promise<IRenderedTemplate>;
130
136
  }
131
137
 
138
+ interface IDefaultTemplate {
139
+ subject?: string;
140
+ text: string;
141
+ html?: string;
142
+ }
143
+
144
+ type DefaultTemplateMap = Record<string, IDefaultTemplate>;
145
+
132
146
  const Channel: { readonly EMAIL: "email"; readonly SMS: "sms"; readonly PUSH: "push"; }
133
147
 
134
148
  interface ICourierConfig {
@@ -140,6 +154,7 @@ interface ICourierConfig {
140
154
  templates?: {
141
155
  source: 'db' | 'fs';
142
156
  directory?: string;
157
+ defaults?: DefaultTemplateMap | DefaultTemplateMap[];
143
158
  };
144
159
  delivery?: {
145
160
  signingKeys?: {
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  Channel: () => Channel,
34
34
  CourierModule: () => CourierModule,
35
35
  DBTemplateResolver: () => DBTemplateResolver,
36
+ DefaultTemplates: () => DefaultTemplates,
36
37
  Dispatcher: () => Dispatcher,
37
38
  EmailChannel: () => EmailChannel,
38
39
  FSTemplateResolver: () => FSTemplateResolver,
@@ -46,6 +47,7 @@ __export(index_exports, {
46
47
  handleSendGridDelivery: () => handleSendGridDelivery,
47
48
  listTemplateEntries: () => listTemplateEntries,
48
49
  listTemplateRevisions: () => listTemplateRevisions,
50
+ renderFragment: () => renderFragment,
49
51
  rollbackTemplate: () => rollbackTemplate,
50
52
  setTemplate: () => setTemplate,
51
53
  validateCourierConfig: () => validateCourierConfig
@@ -503,11 +505,35 @@ function composeHtml(bodyHtml, layoutHtml, subject, data) {
503
505
  const wrapped = wrapLayout(bodyHtml, layoutHtml);
504
506
  return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
505
507
  }
508
+ function renderFragment(frag, layoutHtml, data) {
509
+ const subject = frag.subject ? render(frag.subject, data) : void 0;
510
+ return {
511
+ text: render(frag.text, data),
512
+ ...subject ? { subject } : {},
513
+ ...frag.html ? { html: composeHtml(frag.html, layoutHtml, subject, data) } : {}
514
+ };
515
+ }
516
+ var DefaultTemplates = class {
517
+ map = /* @__PURE__ */ new Map();
518
+ constructor(maps = []) {
519
+ for (const m of maps) {
520
+ for (const [key, tmpl] of Object.entries(m)) this.map.set(key, tmpl);
521
+ }
522
+ }
523
+ get(type) {
524
+ return this.map.get(type);
525
+ }
526
+ get size() {
527
+ return this.map.size;
528
+ }
529
+ };
506
530
  var DBTemplateResolver = class {
507
- constructor(store) {
531
+ constructor(store, defaults) {
508
532
  this.store = store;
533
+ this.defaults = defaults;
509
534
  }
510
535
  store;
536
+ defaults;
511
537
  async resolve(type, data, locale) {
512
538
  const [row] = await this.store.query(
513
539
  // Serve the exact locale, else the neutral NULL default — never a
@@ -521,15 +547,14 @@ var DBTemplateResolver = class {
521
547
  [type, locale ?? null]
522
548
  );
523
549
  if (!row) {
550
+ const def = this.defaults?.get(type);
551
+ if (def) {
552
+ return renderFragment(def, def.html ? await this.layout(locale) : void 0, data);
553
+ }
524
554
  return { text: `${type}: ${JSON.stringify(data)}` };
525
555
  }
526
- const subject = row.subject ? render(row.subject, data) : void 0;
527
556
  const layoutHtml = row.html ? await this.layout(locale) : void 0;
528
- return {
529
- text: render(row.text, data),
530
- ...subject ? { subject } : {},
531
- ...row.html ? { html: composeHtml(row.html, layoutHtml, subject, data) } : {}
532
- };
557
+ return renderFragment(row, layoutHtml, data);
533
558
  }
534
559
  // Optional founder-supplied layout shell; undefined → built-in default.
535
560
  async layout(locale) {
@@ -545,10 +570,12 @@ var DBTemplateResolver = class {
545
570
  }
546
571
  };
547
572
  var FSTemplateResolver = class {
548
- constructor(directory) {
573
+ constructor(directory, defaults) {
549
574
  this.directory = directory;
575
+ this.defaults = defaults;
550
576
  }
551
577
  directory;
578
+ defaults;
552
579
  async resolve(type, data, locale) {
553
580
  const { readFile } = await import("fs/promises");
554
581
  const { join } = await import("path");
@@ -575,6 +602,13 @@ var FSTemplateResolver = class {
575
602
  (v) => v ?? readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
576
603
  ) : readOptional(join(this.directory, `${LAYOUT_TYPE}.html`))
577
604
  ]);
605
+ if (text === null && html === null && subject === null) {
606
+ const def = this.defaults?.get(type);
607
+ if (def) {
608
+ return renderFragment(def, def.html ? layout ?? void 0 : void 0, data);
609
+ }
610
+ return { text: `${type}: ${JSON.stringify(data)}` };
611
+ }
578
612
  const renderedSubject = subject ? render(subject, data) : void 0;
579
613
  return {
580
614
  text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
@@ -612,6 +646,7 @@ function validateCourierConfig(config, registeredChannels) {
612
646
 
613
647
  // src/delivery.ts
614
648
  var import_node_crypto = require("crypto");
649
+ var import_core = require("@fonderie/core");
615
650
  async function handleSendGridDelivery(req, store, webhookSecret) {
616
651
  if (webhookSecret) {
617
652
  const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
@@ -634,10 +669,7 @@ function verifySendGridSignature(secret, timestamp, body2, signature) {
634
669
  try {
635
670
  const payload = timestamp + body2;
636
671
  const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
637
- const sigBuf = Buffer.from(signature, "base64");
638
- const expBuf = Buffer.from(expected, "base64");
639
- if (sigBuf.length !== expBuf.length) return false;
640
- return (0, import_node_crypto.timingSafeEqual)(sigBuf, expBuf);
672
+ return (0, import_core.constantTimeEqual)(Buffer.from(signature, "base64"), Buffer.from(expected, "base64"));
641
673
  } catch {
642
674
  return false;
643
675
  }
@@ -683,10 +715,7 @@ function verifyMailgunSignature(signingKey, timestamp, token, signature) {
683
715
  try {
684
716
  const value = timestamp + token;
685
717
  const expected = (0, import_node_crypto.createHmac)("sha256", signingKey).update(value).digest("hex");
686
- const expBuf = Buffer.from(expected, "hex");
687
- const sigBuf = Buffer.from(signature, "hex");
688
- if (expBuf.length !== sigBuf.length) return false;
689
- return (0, import_node_crypto.timingSafeEqual)(expBuf, sigBuf);
718
+ return (0, import_core.constantTimeEqual)(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
690
719
  } catch {
691
720
  return false;
692
721
  }
@@ -745,7 +774,7 @@ function parseJson(text) {
745
774
  }
746
775
 
747
776
  // src/templates/admin-routes.ts
748
- var import_core = require("@fonderie/core");
777
+ var import_core2 = require("@fonderie/core");
749
778
  var import_middlewares = require("@fonderie/core/middlewares");
750
779
  var import_store2 = require("@fonderie/store");
751
780
 
@@ -824,7 +853,7 @@ var localeOf = (ctx) => new URL(ctx.request.url).searchParams.get("locale");
824
853
  var body = (ctx) => ctx.meta["body"] ?? {};
825
854
  function conflictOr(err) {
826
855
  if (err instanceof import_store2.VersionConflictError) {
827
- return (0, import_core.setApiResponse)(import_core.HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
856
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
828
857
  currentVersion: err.currentVersion
829
858
  });
830
859
  }
@@ -834,16 +863,16 @@ function buildTemplateAdminRoutes(store, adminToken) {
834
863
  const g = (h) => guarded(adminToken, h);
835
864
  return [
836
865
  ["GET", "/admin/templates", g(async () => {
837
- return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
866
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.OK, "TEMPLATES_LISTED", "Templates", await listTemplateEntries(store));
838
867
  })],
839
868
  ["GET", "/admin/templates/:type", g(async (ctx) => {
840
869
  const row = await getTemplateEntry(typeOf(ctx), localeOf(ctx), store);
841
- 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");
870
+ return row ? (0, import_core2.setApiResponse)(import_core2.HTTP.OK, "TEMPLATE", "Template", row) : (0, import_core2.setApiResponse)(import_core2.HTTP.NOT_FOUND, "NOT_FOUND", "No such template");
842
871
  })],
843
872
  ["PUT", "/admin/templates/:type", g(async (ctx) => {
844
873
  const b = body(ctx);
845
874
  if (typeof b["text"] !== "string") {
846
- return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
875
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.UNPROCESSABLE, "INVALID", "body.text (string) is required");
847
876
  }
848
877
  try {
849
878
  const opts = {
@@ -856,29 +885,29 @@ function buildTemplateAdminRoutes(store, adminToken) {
856
885
  if (typeof b["html"] === "string") opts.html = b["html"];
857
886
  if (typeof b["active"] === "boolean") opts.active = b["active"];
858
887
  if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
859
- return (0, import_core.setApiResponse)(import_core.HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
888
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.OK, "TEMPLATE_SET", "Template saved", await setTemplate(opts, store));
860
889
  } catch (err) {
861
890
  return conflictOr(err);
862
891
  }
863
892
  })],
864
893
  ["DELETE", "/admin/templates/:type", g(async (ctx) => {
865
894
  const ok = await deleteTemplate(typeOf(ctx), localeOf(ctx), store);
866
- return (0, import_core.setApiResponse)(ok ? import_core.HTTP.OK : import_core.HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
895
+ return (0, import_core2.setApiResponse)(ok ? import_core2.HTTP.OK : import_core2.HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such template");
867
896
  })],
868
897
  ["GET", "/admin/templates/:type/revisions", g(async (ctx) => {
869
- return (0, import_core.setApiResponse)(import_core.HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
898
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.OK, "REVISIONS", "Template revisions", await listTemplateRevisions(typeOf(ctx), localeOf(ctx), store));
870
899
  })],
871
900
  ["POST", "/admin/templates/:type/rollback", g(async (ctx) => {
872
901
  const b = body(ctx);
873
902
  const toVersion = Number(b["toVersion"]);
874
903
  if (!Number.isInteger(toVersion)) {
875
- return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
904
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
876
905
  }
877
906
  const row = await rollbackTemplate(
878
907
  { type: typeOf(ctx), locale: localeOf(ctx), toVersion, actor: actorOf(ctx) },
879
908
  store
880
909
  );
881
- return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
910
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
882
911
  })]
883
912
  ];
884
913
  }
@@ -946,13 +975,15 @@ var CourierModule = class {
946
975
  }
947
976
  };
948
977
  function createTemplateResolver(source, config, store) {
978
+ const input = config.templates?.defaults;
979
+ const defaults = new DefaultTemplates(input ? Array.isArray(input) ? input : [input] : []);
949
980
  if (source === "fs") {
950
- return new FSTemplateResolver(config.templates?.directory ?? "./templates");
981
+ return new FSTemplateResolver(config.templates?.directory ?? "./templates", defaults);
951
982
  }
952
983
  if (!store) {
953
984
  throw new Error("[courier] store is required for DB template resolution");
954
985
  }
955
- return new DBTemplateResolver(store);
986
+ return new DBTemplateResolver(store, defaults);
956
987
  }
957
988
 
958
989
  // src/config.ts
@@ -966,6 +997,7 @@ var Channel = {
966
997
  Channel,
967
998
  CourierModule,
968
999
  DBTemplateResolver,
1000
+ DefaultTemplates,
969
1001
  Dispatcher,
970
1002
  EmailChannel,
971
1003
  FSTemplateResolver,
@@ -979,6 +1011,7 @@ var Channel = {
979
1011
  handleSendGridDelivery,
980
1012
  listTemplateEntries,
981
1013
  listTemplateRevisions,
1014
+ renderFragment,
982
1015
  rollbackTemplate,
983
1016
  setTemplate,
984
1017
  validateCourierConfig