@fonderie/courier 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fonderie, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @fonderie/courier
2
+
3
+ Transactional messaging: one brick that delivers email, SMS, and push
4
+ through pluggable channels, with templates resolved from the database or
5
+ the filesystem and every send logged.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @fonderie/courier
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ import { FonderieApp, defineConfig } from '@fonderie/core';
17
+ import { CourierModule } from '@fonderie/courier';
18
+
19
+ const app = await new FonderieApp(defineConfig({}))
20
+ .register(new CourierModule())
21
+ .boot();
22
+ ```
23
+
24
+ ```ts
25
+ import { EmailChannel, SmsChannel, PushChannel, DBTemplateResolver } from '@fonderie/courier';
26
+ ```
27
+
28
+ Delivery webhooks for SendGrid, Mailgun, and Mailtrap are handled by the
29
+ exported `handle*Delivery` functions; `IMessageLog` tracks status per send.
30
+
31
+ ## Why this exists
32
+
33
+ You've shipped this plumbing before — auth, teams, billing, messaging —
34
+ and the next project will ask for it again. Fonderie packages it once:
35
+ plain TypeScript modules for
36
+ [`@fonderie/core`](https://github.com/fonderie-js/sdk/tree/main/packages/core),
37
+ PostgreSQL-backed, self-hosted, MIT. No external control plane, no
38
+ per-seat anything. Register the modules you need; skip the ones you don't.
39
+
40
+ **This package owns** how the product speaks to humans. Outbound email, SMS, and push
41
+ with templates and delivery logs — other bricks emit intents, this one
42
+ delivers them.
43
+
44
+ Browse the whole set at
45
+ [fonderie-js/sdk](https://github.com/fonderie-js/sdk) · follow
46
+ [@fonderiejs](https://x.com/fonderiejs)
47
+
48
+ ## License
49
+
50
+ MIT © Fonderie, Inc.
package/dist/index.cjs ADDED
@@ -0,0 +1,626 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ Channel: () => Channel,
34
+ CourierModule: () => CourierModule,
35
+ DBTemplateResolver: () => DBTemplateResolver,
36
+ Dispatcher: () => Dispatcher,
37
+ EmailChannel: () => EmailChannel,
38
+ FSTemplateResolver: () => FSTemplateResolver,
39
+ PushChannel: () => PushChannel,
40
+ SmsChannel: () => SmsChannel,
41
+ handleMailgunDelivery: () => handleMailgunDelivery,
42
+ handleMailtrapDelivery: () => handleMailtrapDelivery,
43
+ handleSendGridDelivery: () => handleSendGridDelivery
44
+ });
45
+ module.exports = __toCommonJS(index_exports);
46
+
47
+ // src/module.ts
48
+ var import_events = require("@fonderie/events");
49
+
50
+ // src/log.ts
51
+ async function insertMessageLog(entry, store) {
52
+ const [row] = await store.query(
53
+ `INSERT INTO fonderie_message_log
54
+ (message_type, channel, recipient, locale, provider, provider_message_id)
55
+ VALUES ($1, $2, $3, $4, $5, $6)
56
+ RETURNING id`,
57
+ [
58
+ entry.messageType,
59
+ entry.channel,
60
+ entry.recipient,
61
+ entry.locale ?? null,
62
+ entry.provider ?? null,
63
+ entry.providerMessageId ?? null
64
+ ]
65
+ );
66
+ return row?.id ?? "";
67
+ }
68
+ async function markMessageSent(id, store) {
69
+ await store.query(
70
+ `UPDATE fonderie_message_log
71
+ SET status = 'sent', sent_at = now(), attempts = attempts + 1
72
+ WHERE id = $1`,
73
+ [id]
74
+ );
75
+ }
76
+ async function markMessageFailed(id, error, store) {
77
+ await store.query(
78
+ `UPDATE fonderie_message_log
79
+ SET status = 'failed', error = $2, attempts = attempts + 1
80
+ WHERE id = $1`,
81
+ [id, error]
82
+ );
83
+ }
84
+ async function markMessageDelivered(providerMessageId, store) {
85
+ await store.query(
86
+ `UPDATE fonderie_message_log
87
+ SET status = 'delivered'
88
+ WHERE provider_message_id = $1`,
89
+ [providerMessageId]
90
+ );
91
+ }
92
+ async function markMessageOpened(providerMessageId, store) {
93
+ await store.query(
94
+ `UPDATE fonderie_message_log
95
+ SET status = 'opened', opened_at = now()
96
+ WHERE provider_message_id = $1 AND opened_at IS NULL`,
97
+ [providerMessageId]
98
+ );
99
+ }
100
+ async function markMessageClicked(providerMessageId, store) {
101
+ await store.query(
102
+ `UPDATE fonderie_message_log
103
+ SET status = 'clicked', clicked_at = now()
104
+ WHERE provider_message_id = $1 AND clicked_at IS NULL`,
105
+ [providerMessageId]
106
+ );
107
+ }
108
+ async function markMessageBounced(providerMessageId, reason, store) {
109
+ await store.query(
110
+ `UPDATE fonderie_message_log
111
+ SET status = 'bounced', bounced_at = now(), bounce_reason = $2
112
+ WHERE provider_message_id = $1`,
113
+ [providerMessageId, reason]
114
+ );
115
+ }
116
+
117
+ // src/dispatcher.ts
118
+ function resolveRecipient(message, channel) {
119
+ if (channel === "email") return message.recipient.email ?? "";
120
+ if (channel === "sms") return message.recipient.phone ?? "";
121
+ if (channel === "push") return message.recipient.deviceToken ?? "";
122
+ return message.recipient.email ?? message.recipient.phone ?? "";
123
+ }
124
+ var Dispatcher = class {
125
+ constructor(config, resolver, store) {
126
+ this.config = config;
127
+ this.resolver = resolver;
128
+ this.store = store;
129
+ }
130
+ config;
131
+ resolver;
132
+ store;
133
+ channels = /* @__PURE__ */ new Map();
134
+ registerChannel(channel) {
135
+ this.channels.set(channel.name, channel);
136
+ return this;
137
+ }
138
+ async dispatch(message) {
139
+ const channelNames = this.config.channels[message.type];
140
+ if (!channelNames || channelNames.length === 0) {
141
+ console.warn(`[courier] no channels configured for message type: ${message.type}`);
142
+ return;
143
+ }
144
+ const template = await this.resolver.resolve(message.type, message.data, message.locale);
145
+ await Promise.allSettled(
146
+ channelNames.map(async (name) => {
147
+ const channel = this.channels.get(name);
148
+ if (!channel) {
149
+ console.warn(`[courier] channel "${name}" not registered`);
150
+ return;
151
+ }
152
+ const logEntry = {
153
+ messageType: message.type,
154
+ channel: name,
155
+ recipient: resolveRecipient(message, name)
156
+ };
157
+ if (message.locale) logEntry.locale = message.locale;
158
+ const logId = this.store ? await insertMessageLog(logEntry, this.store).catch(() => "") : "";
159
+ try {
160
+ await channel.send(message, template);
161
+ if (this.store && logId) {
162
+ markMessageSent(logId, this.store).catch(() => void 0);
163
+ }
164
+ } catch (err) {
165
+ const errMsg = err instanceof Error ? err.message : String(err);
166
+ console.error(`[courier:${name}] failed to send ${message.type}:`, err);
167
+ if (this.store && logId) {
168
+ markMessageFailed(logId, errMsg, this.store).catch(() => void 0);
169
+ }
170
+ }
171
+ })
172
+ );
173
+ }
174
+ };
175
+
176
+ // src/channels/sms.ts
177
+ var SmsChannel = class {
178
+ constructor(config) {
179
+ this.config = config;
180
+ }
181
+ config;
182
+ name = "sms";
183
+ async send(message, template) {
184
+ const to = message.recipient.phone;
185
+ if (!to) {
186
+ console.warn("[courier:sms] no phone number for recipient \u2014 skipping");
187
+ return;
188
+ }
189
+ if (this.config.provider === "twilio") {
190
+ await this.sendViaTwilio(to, template.text);
191
+ } else if (this.config.provider === "vonage") {
192
+ await this.sendViaVonage(to, template.text);
193
+ } else {
194
+ console.warn(`[courier:sms] provider ${this.config.provider} not implemented`);
195
+ }
196
+ }
197
+ async sendViaTwilio(to, text) {
198
+ const { accountSid, authToken } = this.config;
199
+ if (!accountSid || !authToken) {
200
+ throw new Error("Twilio accountSid and authToken required");
201
+ }
202
+ const res = await fetch(
203
+ `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
204
+ {
205
+ method: "POST",
206
+ headers: {
207
+ Authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`,
208
+ "Content-Type": "application/x-www-form-urlencoded"
209
+ },
210
+ body: new URLSearchParams({ From: this.config.from, To: to, Body: text })
211
+ }
212
+ );
213
+ if (!res.ok) {
214
+ const body = await res.text();
215
+ throw new Error(`[courier:sms] Twilio error ${res.status}: ${body}`);
216
+ }
217
+ }
218
+ async sendViaVonage(to, text) {
219
+ const { apiKey, apiSecret } = this.config;
220
+ if (!apiKey || !apiSecret) {
221
+ throw new Error("Vonage apiKey and apiSecret required");
222
+ }
223
+ const res = await fetch("https://rest.nexmo.com/sms/json", {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ body: JSON.stringify({
227
+ api_key: apiKey,
228
+ api_secret: apiSecret,
229
+ from: this.config.from,
230
+ to,
231
+ text
232
+ })
233
+ });
234
+ if (!res.ok) {
235
+ const body = await res.text();
236
+ throw new Error(`[courier:sms] Vonage error ${res.status}: ${body}`);
237
+ }
238
+ }
239
+ };
240
+
241
+ // src/channels/push.ts
242
+ var PushChannel = class {
243
+ constructor(config) {
244
+ this.config = config;
245
+ }
246
+ config;
247
+ name = "push";
248
+ async send(message, template) {
249
+ const token = message.recipient.deviceToken;
250
+ if (!token) {
251
+ console.warn("[courier:push] no device token for recipient \u2014 skipping");
252
+ return;
253
+ }
254
+ if (this.config.provider === "fcm") {
255
+ await this.sendViaFCM(token, template);
256
+ }
257
+ }
258
+ async sendViaFCM(token, template) {
259
+ const apiKey = this.config.serviceAccount["apiKey"];
260
+ if (!apiKey) {
261
+ throw new Error("[courier:push] FCM apiKey is required in serviceAccount");
262
+ }
263
+ const res = await fetch("https://fcm.googleapis.com/fcm/send", {
264
+ method: "POST",
265
+ headers: {
266
+ Authorization: `key=${apiKey}`,
267
+ "Content-Type": "application/json"
268
+ },
269
+ body: JSON.stringify({
270
+ to: token,
271
+ notification: {
272
+ title: template.subject,
273
+ body: template.text
274
+ }
275
+ })
276
+ });
277
+ if (!res.ok) {
278
+ const body = await res.text();
279
+ throw new Error(`[courier:push] FCM error ${res.status}: ${body}`);
280
+ }
281
+ }
282
+ };
283
+
284
+ // src/channels/email.ts
285
+ var import_nodemailer = __toESM(require("nodemailer"), 1);
286
+ var EmailChannel = class {
287
+ constructor(config) {
288
+ this.config = config;
289
+ if (config.provider === "smtp" && config.smtp) {
290
+ this.transport = import_nodemailer.default.createTransport({
291
+ host: config.smtp.host,
292
+ port: config.smtp.port,
293
+ secure: config.smtp.secure,
294
+ auth: { user: config.smtp.user, pass: config.smtp.pass }
295
+ });
296
+ }
297
+ }
298
+ config;
299
+ name = "email";
300
+ transport = null;
301
+ async send(message, template) {
302
+ const to = message.recipient.email;
303
+ if (!to) {
304
+ console.warn("[courier:email] no email address for recipient \u2014 skipping");
305
+ return;
306
+ }
307
+ if (this.config.provider === "resend") {
308
+ await this.sendViaResend(to, template);
309
+ } else if (this.config.provider === "smtp") {
310
+ await this.sendViaSMTP(to, template);
311
+ } else {
312
+ console.warn(`[courier:email] provider ${this.config.provider} not implemented`);
313
+ }
314
+ }
315
+ async sendViaResend(to, template) {
316
+ if (!this.config.apiKey) {
317
+ throw new Error("Resend apiKey is required");
318
+ }
319
+ const res = await fetch("https://api.resend.com/emails", {
320
+ method: "POST",
321
+ headers: {
322
+ Authorization: `Bearer ${this.config.apiKey}`,
323
+ "Content-Type": "application/json"
324
+ },
325
+ body: JSON.stringify({
326
+ from: this.config.from,
327
+ to,
328
+ subject: template.subject ?? "(no subject)",
329
+ html: template.html,
330
+ text: template.text
331
+ })
332
+ });
333
+ if (!res.ok) {
334
+ const body = await res.text();
335
+ throw new Error(`[courier:email] Resend error ${res.status}: ${body}`);
336
+ }
337
+ }
338
+ async sendViaSMTP(to, template) {
339
+ if (!this.transport) {
340
+ throw new Error("SMTP transport not initialised \u2014 check smtp config");
341
+ }
342
+ await this.transport.sendMail({
343
+ from: this.config.from,
344
+ to,
345
+ subject: template.subject ?? "(no subject)",
346
+ html: template.html,
347
+ text: template.text
348
+ });
349
+ }
350
+ };
351
+
352
+ // src/templates/resolver.ts
353
+ function render(template, data) {
354
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
355
+ const value = data[key];
356
+ return value !== void 0 && value !== null ? String(value) : "";
357
+ });
358
+ }
359
+ var DBTemplateResolver = class {
360
+ constructor(store) {
361
+ this.store = store;
362
+ }
363
+ store;
364
+ async resolve(type, data, locale) {
365
+ const [row] = await this.store.query(
366
+ `SELECT subject, html, text
367
+ FROM fonderie_courier_templates
368
+ WHERE type = $1 AND active = true
369
+ ORDER BY (locale = $2)::int DESC, (locale IS NULL)::int DESC
370
+ LIMIT 1`,
371
+ [type, locale ?? null]
372
+ );
373
+ if (!row) {
374
+ return { text: `${type}: ${JSON.stringify(data)}` };
375
+ }
376
+ return {
377
+ text: render(row.text, data),
378
+ ...row.subject ? { subject: render(row.subject, data) } : {},
379
+ ...row.html ? { html: render(row.html, data) } : {}
380
+ };
381
+ }
382
+ };
383
+ var FSTemplateResolver = class {
384
+ constructor(directory) {
385
+ this.directory = directory;
386
+ }
387
+ directory;
388
+ async resolve(type, data, locale) {
389
+ const { readFile } = await import("fs/promises");
390
+ const { join } = await import("path");
391
+ const readOptional = async (path) => {
392
+ try {
393
+ return await readFile(path, "utf8");
394
+ } catch {
395
+ return null;
396
+ }
397
+ };
398
+ const localePrefix = locale ? `${type}.${locale}` : null;
399
+ const [text, html, subject] = await Promise.all([
400
+ localePrefix ? readOptional(join(this.directory, `${localePrefix}.txt`)).then(
401
+ (v) => v ?? readOptional(join(this.directory, `${type}.txt`))
402
+ ) : readOptional(join(this.directory, `${type}.txt`)),
403
+ localePrefix ? readOptional(join(this.directory, `${localePrefix}.html`)).then(
404
+ (v) => v ?? readOptional(join(this.directory, `${type}.html`))
405
+ ) : readOptional(join(this.directory, `${type}.html`)),
406
+ localePrefix ? readOptional(join(this.directory, `${localePrefix}.subject.txt`)).then(
407
+ (v) => v ?? readOptional(join(this.directory, `${type}.subject.txt`))
408
+ ) : readOptional(join(this.directory, `${type}.subject.txt`))
409
+ ]);
410
+ return {
411
+ text: text ? render(text, data) : `${type}: ${JSON.stringify(data)}`,
412
+ ...subject ? { subject: render(subject, data) } : {},
413
+ ...html ? { html: render(html, data) } : {}
414
+ };
415
+ }
416
+ };
417
+
418
+ // src/delivery.ts
419
+ var import_node_crypto = require("crypto");
420
+ async function handleSendGridDelivery(req, store, webhookSecret) {
421
+ if (webhookSecret) {
422
+ const sig = req.headers.get("x-twilio-email-event-webhook-signature") ?? "";
423
+ const ts = req.headers.get("x-twilio-email-event-webhook-timestamp") ?? "";
424
+ const body = await req.text();
425
+ if (!verifySendGridSignature(webhookSecret, ts, body, sig)) {
426
+ return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
427
+ }
428
+ const events2 = parseJson(body);
429
+ if (!Array.isArray(events2)) return Response.json({ ok: true });
430
+ await processSendGridEvents(events2, store);
431
+ return Response.json({ ok: true });
432
+ }
433
+ const events = await req.json();
434
+ if (!Array.isArray(events)) return Response.json({ ok: true });
435
+ await processSendGridEvents(events, store);
436
+ return Response.json({ ok: true });
437
+ }
438
+ function verifySendGridSignature(secret, timestamp, body, signature) {
439
+ try {
440
+ const payload = timestamp + body;
441
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
442
+ const sigBuf = Buffer.from(signature, "base64");
443
+ const expBuf = Buffer.from(expected, "base64");
444
+ if (sigBuf.length !== expBuf.length) return false;
445
+ return (0, import_node_crypto.timingSafeEqual)(sigBuf, expBuf);
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+ async function processSendGridEvents(events, store) {
451
+ for (const ev of events) {
452
+ const msgId = ev["sg_message_id"];
453
+ if (!msgId || typeof msgId !== "string") continue;
454
+ const id = msgId.split(".")[0] ?? msgId;
455
+ switch (ev.event) {
456
+ case "delivered":
457
+ await markMessageDelivered(id, store);
458
+ break;
459
+ case "open":
460
+ await markMessageOpened(id, store);
461
+ break;
462
+ case "click":
463
+ await markMessageClicked(id, store);
464
+ break;
465
+ case "bounce":
466
+ case "blocked":
467
+ case "dropped":
468
+ await markMessageBounced(id, typeof ev["reason"] === "string" ? ev["reason"] : ev.event, store);
469
+ break;
470
+ }
471
+ }
472
+ }
473
+ async function handleMailgunDelivery(req, store, signingKey) {
474
+ const body = await req.json();
475
+ if (signingKey) {
476
+ const { signature } = body;
477
+ if (!signature || !verifyMailgunSignature(signingKey, signature.timestamp, signature.token, signature.signature)) {
478
+ return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
479
+ }
480
+ }
481
+ const event = body["event-data"];
482
+ if (event) {
483
+ await processMailgunEvent(event, store);
484
+ }
485
+ return Response.json({ ok: true });
486
+ }
487
+ function verifyMailgunSignature(signingKey, timestamp, token, signature) {
488
+ try {
489
+ const value = timestamp + token;
490
+ const expected = (0, import_node_crypto.createHmac)("sha256", signingKey).update(value).digest("hex");
491
+ const expBuf = Buffer.from(expected, "hex");
492
+ const sigBuf = Buffer.from(signature, "hex");
493
+ if (expBuf.length !== sigBuf.length) return false;
494
+ return (0, import_node_crypto.timingSafeEqual)(expBuf, sigBuf);
495
+ } catch {
496
+ return false;
497
+ }
498
+ }
499
+ async function processMailgunEvent(event, store) {
500
+ const msgId = event.message?.headers?.["message-id"];
501
+ if (!msgId) return;
502
+ switch (event.event) {
503
+ case "delivered":
504
+ await markMessageDelivered(msgId, store);
505
+ break;
506
+ case "opened":
507
+ await markMessageOpened(msgId, store);
508
+ break;
509
+ case "clicked":
510
+ await markMessageClicked(msgId, store);
511
+ break;
512
+ case "failed":
513
+ case "bounced": {
514
+ const reason = event["delivery-status"]?.message ?? event.event;
515
+ await markMessageBounced(msgId, reason, store);
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ async function handleMailtrapDelivery(req, store) {
521
+ const events = await req.json();
522
+ if (!Array.isArray(events)) return Response.json({ ok: true });
523
+ for (const ev of events) {
524
+ const msgId = ev.message_id;
525
+ if (!msgId) continue;
526
+ switch (ev.event) {
527
+ case "delivery":
528
+ await markMessageDelivered(msgId, store);
529
+ break;
530
+ case "open":
531
+ await markMessageOpened(msgId, store);
532
+ break;
533
+ case "click":
534
+ await markMessageClicked(msgId, store);
535
+ break;
536
+ case "bounce":
537
+ case "soft_bounce":
538
+ await markMessageBounced(msgId, ev.event, store);
539
+ break;
540
+ }
541
+ }
542
+ return Response.json({ ok: true });
543
+ }
544
+ function parseJson(text) {
545
+ try {
546
+ return JSON.parse(text);
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ // src/module.ts
553
+ var CourierModule = class {
554
+ constructor(config, store, bus) {
555
+ this.config = config;
556
+ this.store = store;
557
+ const templateSource = config.templates?.source ?? "db";
558
+ const resolver = createTemplateResolver(templateSource, config, store);
559
+ this.dispatcher = new Dispatcher(config, resolver, store);
560
+ if (config.email) this.dispatcher.registerChannel(new EmailChannel(config.email));
561
+ if (config.sms) this.dispatcher.registerChannel(new SmsChannel(config.sms));
562
+ if (config.push) this.dispatcher.registerChannel(new PushChannel(config.push));
563
+ bus?.on(
564
+ import_events.NOTIFICATION_EVENT,
565
+ async (msg) => {
566
+ await this.dispatcher.dispatch(msg);
567
+ },
568
+ "courier"
569
+ );
570
+ }
571
+ config;
572
+ store;
573
+ name = "@fonderie/courier";
574
+ deps = ["@fonderie/events"];
575
+ dispatcher;
576
+ install(app) {
577
+ const store = this.store;
578
+ const signingKeys = this.config.delivery?.signingKeys;
579
+ app.addRoute(
580
+ "POST",
581
+ "/courier/delivery/sendgrid",
582
+ (ctx) => handleSendGridDelivery(ctx.request, store, signingKeys?.sendgrid)
583
+ );
584
+ app.addRoute(
585
+ "POST",
586
+ "/courier/delivery/mailgun",
587
+ (ctx) => handleMailgunDelivery(ctx.request, store, signingKeys?.mailgun)
588
+ );
589
+ app.addRoute(
590
+ "POST",
591
+ "/courier/delivery/mailtrap",
592
+ (ctx) => handleMailtrapDelivery(ctx.request, store)
593
+ );
594
+ }
595
+ };
596
+ function createTemplateResolver(source, config, store) {
597
+ if (source === "fs") {
598
+ return new FSTemplateResolver(config.templates?.directory ?? "./templates");
599
+ }
600
+ if (!store) {
601
+ throw new Error("[courier] store is required for DB template resolution");
602
+ }
603
+ return new DBTemplateResolver(store);
604
+ }
605
+
606
+ // src/config.ts
607
+ var Channel = {
608
+ EMAIL: "email",
609
+ SMS: "sms",
610
+ PUSH: "push"
611
+ };
612
+ // Annotate the CommonJS export names for ESM import in node:
613
+ 0 && (module.exports = {
614
+ Channel,
615
+ CourierModule,
616
+ DBTemplateResolver,
617
+ Dispatcher,
618
+ EmailChannel,
619
+ FSTemplateResolver,
620
+ PushChannel,
621
+ SmsChannel,
622
+ handleMailgunDelivery,
623
+ handleMailtrapDelivery,
624
+ handleSendGridDelivery
625
+ });
626
+ //# sourceMappingURL=index.cjs.map