@fonderie/courier 5.2.2 → 7.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.
@@ -16,7 +16,7 @@ new CourierModule(config: ICourierConfig, store?: IStoreAdapter | undefined, bus
16
16
 
17
17
  function validateCourierConfig(config: ICourierConfig, registeredChannels: Iterable<string>): void
18
18
 
19
- function handleSendGridDelivery(req: Request, store: IStoreAdapter, webhookSecret?: string | undefined): Promise<Response>
19
+ function handleSendGridDelivery(req: Request, store: IStoreAdapter, publicKey?: string | undefined): Promise<Response>
20
20
 
21
21
  function handleMailgunDelivery(req: Request, store: IStoreAdapter, signingKey?: string | undefined): Promise<Response>
22
22
 
@@ -37,7 +37,7 @@ new PushChannel(config: IPushChannelConfig): PushChannel
37
37
 
38
38
  new EmailChannel(config: IEmailChannelConfig): EmailChannel
39
39
  .name: "email"
40
- .send(message: ICourierMessage, template: IRenderedTemplate): Promise<void>
40
+ .send(message: ICourierMessage, template: IRenderedTemplate): Promise<void | ISendResult>
41
41
 
42
42
  new DBTemplateResolver(store: IStoreAdapter, defaults?: DefaultTemplates | undefined): DBTemplateResolver
43
43
  .resolve(type: string, data: Record<string, unknown>, locale?: string | undefined): Promise<IRenderedTemplate>
@@ -122,7 +122,7 @@ interface ICourierMessage {
122
122
 
123
123
  interface ICourierChannel {
124
124
  name: string;
125
- send(message: ICourierMessage, template: IRenderedTemplate): Promise<void>;
125
+ send(message: ICourierMessage, template: IRenderedTemplate): Promise<ISendResult | void>;
126
126
  }
127
127
 
128
128
  interface IRenderedTemplate {
@@ -161,6 +161,7 @@ interface ICourierConfig {
161
161
  sendgrid?: string;
162
162
  mailgun?: string;
163
163
  };
164
+ allowUnverifiedMailtrap?: boolean;
164
165
  };
165
166
  }
166
167
 
package/dist/index.cjs CHANGED
@@ -75,6 +75,14 @@ async function insertMessageLog(entry, store) {
75
75
  );
76
76
  return row?.id ?? "";
77
77
  }
78
+ async function setMessageProviderId(id, providerMessageId, store) {
79
+ await store.query(
80
+ `UPDATE fonderie_message_log
81
+ SET provider_message_id = $2
82
+ WHERE id = $1`,
83
+ [id, providerMessageId]
84
+ );
85
+ }
78
86
  async function markMessageSent(id, store) {
79
87
  await store.query(
80
88
  `UPDATE fonderie_message_log
@@ -172,8 +180,13 @@ var Dispatcher = class {
172
180
  if (message.locale) logEntry.locale = message.locale;
173
181
  const logId = this.store ? await insertMessageLog(logEntry, this.store).catch(() => "") : "";
174
182
  try {
175
- await channel.send(message, template);
183
+ const result = await channel.send(message, template);
176
184
  if (this.store && logId) {
185
+ if (result?.providerMessageId) {
186
+ await setMessageProviderId(logId, result.providerMessageId, this.store).catch(
187
+ () => void 0
188
+ );
189
+ }
177
190
  markMessageSent(logId, this.store).catch(() => void 0);
178
191
  }
179
192
  } catch (err) {
@@ -320,12 +333,12 @@ var EmailChannel = class {
320
333
  return;
321
334
  }
322
335
  if (this.config.provider === "resend") {
323
- await this.sendViaResend(to, template);
324
- } else if (this.config.provider === "smtp") {
325
- await this.sendViaSMTP(to, template);
326
- } else {
327
- console.warn(`[courier:email] provider ${this.config.provider} not implemented`);
336
+ return this.sendViaResend(to, template);
337
+ }
338
+ if (this.config.provider === "smtp") {
339
+ return this.sendViaSMTP(to, template);
328
340
  }
341
+ console.warn(`[courier:email] provider ${this.config.provider} not implemented`);
329
342
  }
330
343
  async sendViaResend(to, template) {
331
344
  if (!this.config.apiKey) {
@@ -349,18 +362,23 @@ var EmailChannel = class {
349
362
  const body2 = await res.text();
350
363
  throw new Error(`[courier:email] Resend error ${res.status}: ${body2}`);
351
364
  }
365
+ const data = await res.json().catch(() => null);
366
+ return typeof data?.id === "string" ? { providerMessageId: data.id } : {};
352
367
  }
353
368
  async sendViaSMTP(to, template) {
354
369
  if (!this.transport) {
355
370
  throw new Error("SMTP transport not initialised \u2014 check smtp config");
356
371
  }
357
- await this.transport.sendMail({
372
+ const info = await this.transport.sendMail({
358
373
  from: this.config.from,
359
374
  to,
360
375
  subject: template.subject ?? "(no subject)",
361
376
  text: template.text,
362
377
  ...template.html !== void 0 ? { html: template.html } : {}
363
378
  });
379
+ const raw = typeof info?.messageId === "string" ? info.messageId : "";
380
+ const providerMessageId = raw.replace(/^<|>$/g, "");
381
+ return providerMessageId ? { providerMessageId } : {};
364
382
  }
365
383
  };
366
384
 
@@ -495,15 +513,20 @@ function wrapLayout(bodyHtml, layoutHtml = DEFAULT_EMAIL_LAYOUT) {
495
513
 
496
514
  // src/templates/resolver.ts
497
515
  var LAYOUT_TYPE = "_layout";
498
- function render(template, data) {
516
+ function escapeHtml(value) {
517
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
518
+ }
519
+ function render(template, data, opts = {}) {
499
520
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
500
521
  const value = data[key];
501
- return value !== void 0 && value !== null ? String(value) : "";
522
+ if (value === void 0 || value === null) return "";
523
+ const s = String(value);
524
+ return opts.escapeHtml ? escapeHtml(s) : s;
502
525
  });
503
526
  }
504
527
  function composeHtml(bodyHtml, layoutHtml, subject, data) {
505
528
  const wrapped = wrapLayout(bodyHtml, layoutHtml);
506
- return render(wrapped, { subject: subject ?? "", preheader: "", ...data });
529
+ return render(wrapped, { subject: subject ?? "", preheader: "", ...data }, { escapeHtml: true });
507
530
  }
508
531
  function renderFragment(frag, layoutHtml, data) {
509
532
  const subject = frag.subject ? render(frag.subject, data) : void 0;
@@ -647,29 +670,43 @@ function validateCourierConfig(config, registeredChannels) {
647
670
  // src/delivery.ts
648
671
  var import_node_crypto = require("crypto");
649
672
  var import_core = require("@fonderie/core");
650
- async function handleSendGridDelivery(req, store, webhookSecret) {
651
- if (webhookSecret) {
652
- const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
653
- const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
654
- const body2 = await req.text();
655
- if (!verifySendGridSignature(webhookSecret, ts, body2, sig)) {
656
- return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
657
- }
658
- const events2 = parseJson(body2);
659
- if (!Array.isArray(events2)) return Response.json({ ok: true });
660
- await processSendGridEvents(events2, store);
661
- return Response.json({ ok: true });
673
+ var TIMESTAMP_TOLERANCE_S = 5 * 60;
674
+ function isFreshTimestamp(unixSeconds) {
675
+ const ts = Number(unixSeconds);
676
+ if (!Number.isFinite(ts)) return false;
677
+ return Math.abs(Date.now() / 1e3 - ts) <= TIMESTAMP_TOLERANCE_S;
678
+ }
679
+ async function handleSendGridDelivery(req, store, publicKey) {
680
+ if (!publicKey) {
681
+ return Response.json({ error: "VERIFICATION_NOT_CONFIGURED" }, { status: 401 });
662
682
  }
663
- const events = await req.json();
683
+ const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
684
+ const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
685
+ const body2 = await req.text();
686
+ if (!isFreshTimestamp(ts)) {
687
+ return Response.json({ error: "STALE_TIMESTAMP" }, { status: 401 });
688
+ }
689
+ if (!verifySendGridSignature(publicKey, ts, body2, sig)) {
690
+ return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
691
+ }
692
+ const events = parseJson(body2);
664
693
  if (!Array.isArray(events)) return Response.json({ ok: true });
665
694
  await processSendGridEvents(events, store);
666
695
  return Response.json({ ok: true });
667
696
  }
668
- function verifySendGridSignature(secret, timestamp, body2, signature) {
697
+ function verifySendGridSignature(publicKeyB64, timestamp, body2, signature) {
669
698
  try {
670
- const payload = timestamp + body2;
671
- const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
672
- return (0, import_core.constantTimeEqual)(Buffer.from(signature, "base64"), Buffer.from(expected, "base64"));
699
+ const key = (0, import_node_crypto.createPublicKey)({
700
+ key: Buffer.from(publicKeyB64, "base64"),
701
+ format: "der",
702
+ type: "spki"
703
+ });
704
+ return (0, import_node_crypto.verify)(
705
+ "sha256",
706
+ Buffer.from(timestamp + body2),
707
+ key,
708
+ Buffer.from(signature, "base64")
709
+ );
673
710
  } catch {
674
711
  return false;
675
712
  }
@@ -698,12 +735,16 @@ async function processSendGridEvents(events, store) {
698
735
  }
699
736
  }
700
737
  async function handleMailgunDelivery(req, store, signingKey) {
738
+ if (!signingKey) {
739
+ return Response.json({ error: "VERIFICATION_NOT_CONFIGURED" }, { status: 401 });
740
+ }
701
741
  const body2 = await req.json();
702
- if (signingKey) {
703
- const { signature } = body2;
704
- if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
705
- return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
706
- }
742
+ const { signature } = body2;
743
+ if (!signature || !isFreshTimestamp(signature.timestamp)) {
744
+ return Response.json({ error: "STALE_TIMESTAMP" }, { status: 401 });
745
+ }
746
+ if (!verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
747
+ return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
707
748
  }
708
749
  const event = body2["event-data"];
709
750
  if (event) {
@@ -948,22 +989,29 @@ var CourierModule = class {
948
989
  install(app) {
949
990
  validateCourierConfig(this.config, this.dispatcher.channelNames());
950
991
  const store = this.store;
951
- const signingKeys = this.config.delivery?.signingKeys;
952
- app.addRoute(
953
- "POST",
954
- "/courier/delivery/sendgrid",
955
- (ctx) => handleSendGridDelivery(ctx.request, store, signingKeys?.sendgrid)
956
- );
957
- app.addRoute(
958
- "POST",
959
- "/courier/delivery/mailgun",
960
- (ctx) => handleMailgunDelivery(ctx.request, store, signingKeys?.mailgun)
961
- );
962
- app.addRoute(
963
- "POST",
964
- "/courier/delivery/mailtrap",
965
- (ctx) => handleMailtrapDelivery(ctx.request, store)
966
- );
992
+ const delivery = this.config.delivery;
993
+ const signingKeys = delivery?.signingKeys;
994
+ if (signingKeys?.sendgrid) {
995
+ app.addRoute(
996
+ "POST",
997
+ "/courier/delivery/sendgrid",
998
+ (ctx) => handleSendGridDelivery(ctx.request, store, signingKeys.sendgrid)
999
+ );
1000
+ }
1001
+ if (signingKeys?.mailgun) {
1002
+ app.addRoute(
1003
+ "POST",
1004
+ "/courier/delivery/mailgun",
1005
+ (ctx) => handleMailgunDelivery(ctx.request, store, signingKeys.mailgun)
1006
+ );
1007
+ }
1008
+ if (delivery?.allowUnverifiedMailtrap) {
1009
+ app.addRoute(
1010
+ "POST",
1011
+ "/courier/delivery/mailtrap",
1012
+ (ctx) => handleMailtrapDelivery(ctx.request, store)
1013
+ );
1014
+ }
967
1015
  if (this.config.adminToken) {
968
1016
  if (!store) {
969
1017
  throw new Error("[courier] adminToken requires @fonderie/store (db templates)");