@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.
- package/brain/signatures.md +17 -2
- package/dist/index.cjs +61 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -6
- package/dist/index.d.ts +20 -6
- package/dist/index.js +50 -19
- package/dist/index.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +4 -3
- package/dist/types.d.ts +4 -3
- package/package.json +2 -2
package/brain/signatures.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|