@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 +21 -0
- package/README.md +50 -0
- package/dist/index.cjs +626 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +117 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +579 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_courier.sql +34 -0
- package/dist/migrations/sql/002_seed_templates.sql +49 -0
- package/dist/migrations/sql/003_message_log_delivery.sql +12 -0
- package/dist/types.cjs +19 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +37 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +93 -0
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
|