@kizunajs/resend 2.0.0-beta.12

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.MD ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sondre Ørland
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @kizunajs/resend
2
+
3
+ `@kizunajs/resend` connects your API to Resend. Your route and job handlers send email and newsletters, and Resend's webhook events run functions you write.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @kizunajs/resend resend
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ // kizuna.config.ts
15
+ import { resendPlugin } from '@kizunajs/resend';
16
+
17
+ export default defineConfig({
18
+ adapter: expressAdapter(),
19
+ routes,
20
+ plugins: [
21
+ resendPlugin({
22
+ apiKey: process.env.RESEND_API_KEY,
23
+ from: 'Kizuna <hello@example.com>',
24
+ }),
25
+ ],
26
+ });
27
+ ```
28
+
29
+ Every route and job handler reaches it at `plugins.resend`:
30
+
31
+ ```ts
32
+ await plugins.resend.sendEmail({
33
+ to: user.email,
34
+ subject: 'Welcome',
35
+ html: `<p>Welcome, ${user.name}.</p>`,
36
+ });
37
+ ```
38
+
39
+ ## Documentation
40
+
41
+ [Resend](https://kizunajs.com/docs/resend)
package/dist/index.cjs ADDED
@@ -0,0 +1,434 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let resend = require("resend");
3
+ let kizunajs_plugin = require("kizunajs/plugin");
4
+ let zod = require("zod");
5
+ let kizunajs_schemas = require("kizunajs/schemas");
6
+ //#region src/options.ts
7
+ /**
8
+ * A list an app sends to: the Resend segment its contacts sit in, and the topic
9
+ * they opt in and out of.
10
+ */
11
+ const ResendListSchema = zod.z.object({
12
+ /**
13
+ * The segment the list's contacts are added to, and broadcasts go to.
14
+ */
15
+ segmentId: zod.z.string(),
16
+ /**
17
+ * The topic a contact opts in and out of, so unsubscribing from one list
18
+ * leaves the others alone.
19
+ */
20
+ topicId: zod.z.string().optional()
21
+ });
22
+ /**
23
+ * The loosest of Zod's email patterns, so the plugin never refuses an address
24
+ * Resend would accept.
25
+ */
26
+ const EmailSchema = zod.z.email({ pattern: zod.z.regexes.unicodeEmail });
27
+ /**
28
+ * A sender as Resend takes it: an address, or a name and an address in angle
29
+ * brackets.
30
+ */
31
+ const isSender = (value) => {
32
+ const named = /^[^<>]+<([^<>]+)>$/.exec(value.trim());
33
+ return EmailSchema.safeParse(named === null ? value.trim() : named[1]).success;
34
+ };
35
+ const SenderSchema = zod.z.string().refine(isSender, { error: "must be an address, or a name and an address like `Kizuna <hello@example.com>`" });
36
+ const ResendPluginOptionsSchema = zod.z.object({
37
+ /**
38
+ * The Resend API key.
39
+ */
40
+ apiKey: zod.z.string(),
41
+ /**
42
+ * The sender every email and broadcast uses unless it names its own.
43
+ *
44
+ * @example
45
+ * from: 'Kizuna <hello@example.com>',
46
+ */
47
+ from: SenderSchema,
48
+ /**
49
+ * The lists `subscribe`, `unsubscribe` and `broadcast` name, keyed by
50
+ * the name handlers use.
51
+ *
52
+ * @example
53
+ * lists: {
54
+ * weekly: {
55
+ * segmentId: 'seg_123',
56
+ * topicId: 'top_456',
57
+ * },
58
+ * },
59
+ */
60
+ lists: zod.z.record(zod.z.string(), ResendListSchema).optional(),
61
+ /**
62
+ * The webhook's signing secret. The webhook route is served when it's set.
63
+ */
64
+ webhookSecret: zod.z.string().optional(),
65
+ /**
66
+ * What runs for each webhook event, keyed by its type.
67
+ */
68
+ on: zod.z.custom((value) => typeof value === "object" && value !== null, { error: "must map event types to functions" }).optional(),
69
+ /**
70
+ * Catch every email outside production and forward it to `forwardTo`
71
+ * instead of its real recipients.
72
+ *
73
+ * @example
74
+ * intercept: {
75
+ * enabled: process.env.NODE_ENV !== 'production',
76
+ * forwardTo: process.env.EMAIL_FORWARD_TO,
77
+ * deliverTo: ['@example.com'],
78
+ * },
79
+ */
80
+ intercept: zod.z.object({
81
+ /**
82
+ * Whether emails are intercepted.
83
+ */
84
+ enabled: zod.z.boolean(),
85
+ /**
86
+ * Where every email is forwarded, one address or several.
87
+ * Required when `enabled` is true.
88
+ */
89
+ forwardTo: zod.z.union([EmailSchema, zod.z.array(EmailSchema).min(1)]).optional(),
90
+ /**
91
+ * Addresses, or whole domains like `@example.com`, whose emails are
92
+ * delivered as normal, with `forwardTo` in bcc.
93
+ */
94
+ deliverTo: zod.z.array(zod.z.string()).optional(),
95
+ /**
96
+ * Put in front of every intercepted email's subject.
97
+ *
98
+ * @example
99
+ * subjectPrefix: '[dev]',
100
+ */
101
+ subjectPrefix: zod.z.string().optional()
102
+ }).refine((intercept) => !intercept.enabled || intercept.forwardTo !== void 0, {
103
+ error: "is required when intercept is enabled",
104
+ path: ["forwardTo"]
105
+ }).optional(),
106
+ /**
107
+ * Passed to the Resend client as it is.
108
+ */
109
+ resend: zod.z.custom().optional()
110
+ }).refine((options) => options.on === void 0 || options.webhookSecret !== void 0, {
111
+ error: "is required when `on` is set",
112
+ path: ["webhookSecret"]
113
+ });
114
+ //#endregion
115
+ //#region src/requests.ts
116
+ /**
117
+ * A call to Resend that answered with an error.
118
+ */
119
+ var ResendRequestError = class extends Error {
120
+ action;
121
+ error;
122
+ constructor(action, error) {
123
+ super(`[kizuna/resend] ${action} failed: ${error.message}`);
124
+ this.action = action;
125
+ this.error = error;
126
+ }
127
+ };
128
+ /**
129
+ * How many times a rate-limited call is tried again.
130
+ */
131
+ const RATE_LIMIT_RETRIES = 3;
132
+ const wait = (milliseconds) => new Promise((resolve) => {
133
+ setTimeout(resolve, milliseconds);
134
+ });
135
+ /**
136
+ * Call Resend, retrying when it's rate limited, and throw its error.
137
+ */
138
+ const call = async (action, request) => {
139
+ let response = await request();
140
+ for (let retry = 0; retry < RATE_LIMIT_RETRIES && response.error?.name === "rate_limit_exceeded"; retry++) {
141
+ const seconds = Number(response.headers?.["retry-after"] ?? 1);
142
+ await wait((Number.isFinite(seconds) ? seconds : 1) * 1e3);
143
+ response = await request();
144
+ }
145
+ if (response.error !== null) throw new ResendRequestError(action, response.error);
146
+ return response.data;
147
+ };
148
+ /**
149
+ * Every item of a paginated Resend list.
150
+ */
151
+ const everyPage = async (action, request) => {
152
+ const items = [];
153
+ let after;
154
+ for (;;) {
155
+ const page = await call(action, () => request(after));
156
+ items.push(...page.data);
157
+ const last = page.data.at(-1);
158
+ if (!page.has_more || last === void 0) return items;
159
+ after = last.id;
160
+ }
161
+ };
162
+ const listOf = (addresses) => addresses === void 0 ? [] : Array.isArray(addresses) ? addresses : [addresses];
163
+ /**
164
+ * The email as `intercept` sends it: to the `deliverTo` addresses it names, and
165
+ * to `forwardTo` in their place or in bcc. Headers keep who it was for.
166
+ */
167
+ const intercepted = (email, intercept) => {
168
+ if (!intercept.enabled) return email;
169
+ const forwardTo = listOf(intercept.forwardTo);
170
+ const deliverTo = (intercept.deliverTo ?? []).map((entry) => entry.toLowerCase());
171
+ const delivered = (address) => {
172
+ const lowered = address.toLowerCase();
173
+ return deliverTo.some((entry) => entry.startsWith("@") ? lowered.endsWith(entry) : lowered === entry);
174
+ };
175
+ const to = listOf(email.to).filter(delivered);
176
+ const cc = listOf(email.cc).filter(delivered);
177
+ const bcc = listOf(email.bcc).filter(delivered);
178
+ const originals = {
179
+ "X-Intercepted-To": listOf(email.to),
180
+ "X-Intercepted-Cc": listOf(email.cc),
181
+ "X-Intercepted-Bcc": listOf(email.bcc)
182
+ };
183
+ const headers = {
184
+ ...email.headers,
185
+ ...Object.fromEntries(Object.entries(originals).filter(([, addresses]) => addresses.length > 0).map(([name, addresses]) => [name, addresses.join(", ")]))
186
+ };
187
+ return {
188
+ ...email,
189
+ to: to.length > 0 ? to : forwardTo,
190
+ cc,
191
+ bcc: to.length > 0 ? [...bcc, ...forwardTo] : bcc,
192
+ subject: intercept.subjectPrefix === void 0 ? email.subject : `${intercept.subjectPrefix} ${email.subject}`,
193
+ headers
194
+ };
195
+ };
196
+ /**
197
+ * What handlers reach at `plugins.resend`.
198
+ */
199
+ const resendExports = (resend, options) => {
200
+ const listNamed = (name) => {
201
+ const list = options.lists?.[name];
202
+ if (list === void 0) throw new Error(`[kizuna/resend] No list is named '${name}'. Declare it under \`lists\`.`);
203
+ return list;
204
+ };
205
+ return {
206
+ /**
207
+ * The Resend client, for anything the plugin doesn't cover.
208
+ */
209
+ client: resend,
210
+ /**
211
+ * Send one email.
212
+ */
213
+ sendEmail: (email) => {
214
+ const addressed = options.intercept === void 0 ? email : intercepted(email, options.intercept);
215
+ return call("Sending the email", () => resend.emails.send({
216
+ from: options.from,
217
+ ...addressed
218
+ }));
219
+ },
220
+ /**
221
+ * Add a contact to a list, creating the contact when it's new.
222
+ */
223
+ subscribe: async ({ email, list: name, firstName, lastName }) => {
224
+ const list = listNamed(name);
225
+ const topics = list.topicId === void 0 ? [] : [{
226
+ id: list.topicId,
227
+ subscription: "opt_in"
228
+ }];
229
+ const found = await resend.contacts.get({ email });
230
+ if (found.error?.name === "not_found") return { contactId: (await call("Creating the contact", () => resend.contacts.create({
231
+ email,
232
+ firstName,
233
+ lastName,
234
+ segments: [{ id: list.segmentId }],
235
+ topics
236
+ }))).id };
237
+ if (found.error !== null) throw new ResendRequestError("Looking up the contact", found.error);
238
+ await call("Updating the contact", () => resend.contacts.update({
239
+ email,
240
+ unsubscribed: false,
241
+ firstName,
242
+ lastName
243
+ }));
244
+ await call("Adding the contact to the segment", () => resend.contacts.segments.add({
245
+ email,
246
+ segmentId: list.segmentId
247
+ }));
248
+ if (topics.length > 0) await call("Opting the contact in to the topic", () => resend.contacts.topics.update({
249
+ email,
250
+ topics
251
+ }));
252
+ return { contactId: found.data.id };
253
+ },
254
+ /**
255
+ * Move a contact to a new address, keeping its name, its lists and its
256
+ * topics. Resend can't change a contact's email, so this creates the new
257
+ * contact and removes the old one.
258
+ */
259
+ changeEmail: async ({ from, to }) => {
260
+ const contact = await call("Looking up the contact", () => resend.contacts.get({ email: from }));
261
+ const segments = await everyPage("Listing the contact segments", (after) => resend.contacts.segments.list({
262
+ email: from,
263
+ limit: 100,
264
+ ...after === void 0 ? {} : { after }
265
+ }));
266
+ const topics = await everyPage("Listing the contact topics", (after) => resend.contacts.topics.list({
267
+ email: from,
268
+ limit: 100,
269
+ ...after === void 0 ? {} : { after }
270
+ }));
271
+ const created = await call("Creating the contact", () => resend.contacts.create({
272
+ email: to,
273
+ firstName: contact.first_name ?? void 0,
274
+ lastName: contact.last_name ?? void 0,
275
+ unsubscribed: contact.unsubscribed,
276
+ segments: segments.map((segment) => ({ id: segment.id })),
277
+ topics: topics.map((topic) => ({
278
+ id: topic.id,
279
+ subscription: topic.subscription
280
+ }))
281
+ }));
282
+ await call("Removing the old contact", () => resend.contacts.remove({ email: from }));
283
+ return { contactId: created.id };
284
+ },
285
+ /**
286
+ * Take a contact off one list, or off everything.
287
+ */
288
+ unsubscribe: async ({ email, list: name }) => {
289
+ if (name === void 0) {
290
+ await call("Unsubscribing the contact", () => resend.contacts.update({
291
+ email,
292
+ unsubscribed: true
293
+ }));
294
+ return;
295
+ }
296
+ const { segmentId, topicId } = listNamed(name);
297
+ if (topicId === void 0) {
298
+ await call("Removing the contact from the segment", () => resend.contacts.segments.remove({
299
+ email,
300
+ segmentId
301
+ }));
302
+ return;
303
+ }
304
+ await call("Opting the contact out of the topic", () => resend.contacts.topics.update({
305
+ email,
306
+ topics: [{
307
+ id: topicId,
308
+ subscription: "opt_out"
309
+ }]
310
+ }));
311
+ },
312
+ /**
313
+ * Send a broadcast to a list, now or at `scheduledAt`.
314
+ */
315
+ sendBroadcast: ({ list: name, from, scheduledAt, ...content }) => {
316
+ const list = listNamed(name);
317
+ return call("Sending the broadcast", () => resend.broadcasts.create({
318
+ ...content,
319
+ from: from ?? options.from,
320
+ segmentId: list.segmentId,
321
+ topicId: list.topicId,
322
+ send: true,
323
+ scheduledAt
324
+ }));
325
+ }
326
+ };
327
+ };
328
+ //#endregion
329
+ //#region src/webhooks.ts
330
+ /**
331
+ * The webhook event functions, with `jobs` and `plugins` typed from `Config`.
332
+ *
333
+ * @example
334
+ * export const resendEvents = defineResendEvents<Config>({
335
+ * 'email.bounced': async ({ event, jobs }) => {
336
+ * await jobs.users.markUndeliverable.queue({
337
+ * input: {
338
+ * email: event.data.to[0],
339
+ * },
340
+ * });
341
+ * },
342
+ * });
343
+ */
344
+ const defineResendEvents = (handlers) => handlers;
345
+ /**
346
+ * Checks each delivery's signature, then runs the function for its event.
347
+ */
348
+ const webhookRoute = (resend, webhookSecret, on) => (0, kizunajs_plugin.route)({
349
+ method: "POST",
350
+ path: "/webhooks",
351
+ auth: false,
352
+ rawBody: true,
353
+ summary: "Receive Resend webhook events",
354
+ headers: zod.z.object({
355
+ "svix-id": zod.z.string(),
356
+ "svix-timestamp": zod.z.string(),
357
+ "svix-signature": zod.z.string()
358
+ }),
359
+ body: zod.z.unknown(),
360
+ responses: {
361
+ 204: zod.z.void(),
362
+ 400: kizunajs_schemas.ProblemDetailsSchema
363
+ }
364
+ }).handler(async (args) => {
365
+ const { rawBody, headers, throwError } = args;
366
+ let event;
367
+ try {
368
+ event = resend.webhooks.verify({
369
+ payload: rawBody,
370
+ headers: {
371
+ id: headers["svix-id"],
372
+ timestamp: headers["svix-timestamp"],
373
+ signature: headers["svix-signature"]
374
+ },
375
+ webhookSecret
376
+ });
377
+ } catch {
378
+ return throwError({
379
+ status: 400,
380
+ body: { detail: "The webhook signature doesn't match its body." }
381
+ });
382
+ }
383
+ const handle = on[event.type];
384
+ const { jobs, plugins } = args;
385
+ await handle?.({
386
+ event,
387
+ deliveryId: headers["svix-id"],
388
+ jobs,
389
+ plugins
390
+ });
391
+ return {
392
+ status: 204,
393
+ body: void 0
394
+ };
395
+ });
396
+ //#endregion
397
+ //#region src/plugin.ts
398
+ /**
399
+ * Send email, run newsletters and broadcasts, and receive webhooks through
400
+ * Resend. Handlers reach it at `plugins.resend`.
401
+ *
402
+ * @example
403
+ * ```ts
404
+ * export default defineConfig({
405
+ * routes,
406
+ * plugins: [
407
+ * resendPlugin({
408
+ * apiKey: process.env.RESEND_API_KEY,
409
+ * from: 'Kizuna <hello@example.com>',
410
+ * }),
411
+ * ],
412
+ * });
413
+ * ```
414
+ */
415
+ const resendPlugin = (0, kizunajs_plugin.definePlugin)({
416
+ slug: "resend",
417
+ basePath: "/resend",
418
+ options: ResendPluginOptionsSchema,
419
+ setup: ({ options }) => {
420
+ const resend$1 = new resend.Resend(options.apiKey, options.resend);
421
+ const routes = {};
422
+ if (options.webhookSecret !== void 0) routes.webhook = webhookRoute(resend$1, options.webhookSecret, options.on ?? {});
423
+ return {
424
+ exports: resendExports(resend$1, options),
425
+ routes
426
+ };
427
+ }
428
+ });
429
+ //#endregion
430
+ exports.ResendListSchema = ResendListSchema;
431
+ exports.ResendPluginOptionsSchema = ResendPluginOptionsSchema;
432
+ exports.ResendRequestError = ResendRequestError;
433
+ exports.defineResendEvents = defineResendEvents;
434
+ exports.resendPlugin = resendPlugin;