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