@nymbalhq/email-resend 0.0.0-stage → 1.5.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/README.md +42 -2
- package/dist/index.d.ts +34 -0
- package/dist/index.js +200 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -3
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nymbalhq/email-resend
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `resend` email provider for Nymbal. It sends every transactional mail over Resend's SMTP relay, and takes
|
|
4
|
+
Resend's bounce and complaint webhooks, so a permanent bounce or a spam complaint stops alert mail to that address.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @nymbalhq/email-resend nodemailer
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// nymbal.config.ts
|
|
12
|
+
commerce: {
|
|
13
|
+
email: {
|
|
14
|
+
provider: 'resend',
|
|
15
|
+
config: {
|
|
16
|
+
from: 'shop@example.com', // an address on a domain verified in Resend
|
|
17
|
+
smtp: { pass: env('RESEND_API_KEY') },
|
|
18
|
+
webhookSecret: env('RESEND_WEBHOOK_SECRET'),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `smtp.host` defaults to `smtp.resend.com`, `smtp.port` to 587 (STARTTLS) and `smtp.user` to `resend`. For implicit
|
|
25
|
+
TLS set `port: 465, secure: true`.
|
|
26
|
+
- `smtp.pass` is a Resend API key with sending access.
|
|
27
|
+
- `smtp.pass` and `webhookSecret` may be left out of the config and saved in the admin's Settings instead.
|
|
28
|
+
- `webhookSecret` is the webhook's signing secret (`whsec_…`, Resend: Webhooks, the endpoint). Without it the store
|
|
29
|
+
refuses every webhook post and the email slot's health is `degraded`.
|
|
30
|
+
|
|
31
|
+
## Webhooks
|
|
32
|
+
|
|
33
|
+
In Resend, add a webhook endpoint at `<api origin>/api/webhooks/resend` subscribed to:
|
|
34
|
+
|
|
35
|
+
- `email.bounced`
|
|
36
|
+
- `email.complained`
|
|
37
|
+
|
|
38
|
+
A `Permanent` bounce is recorded as a hard bounce (`resend.bounced.v1`) and a complaint as `resend.complained.v1`,
|
|
39
|
+
one per recipient; both stop alert mail to the address. A `Transient` or `Undetermined` bounce is soft and stops
|
|
40
|
+
nothing. Any other event posted to the same URL is answered 200 and ignored.
|
|
41
|
+
|
|
42
|
+
Only a post whose Svix signature matches the secret, with a timestamp within five minutes of the server's clock, is
|
|
43
|
+
accepted. Resend's retry of a post keeps its `svix-id`, and the store records each id once.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Logger, EmailAdapter, AdapterContext, AdapterSetting } from '@nymbalhq/types';
|
|
2
|
+
|
|
3
|
+
interface ResendEmailAdapterConfig {
|
|
4
|
+
from?: string;
|
|
5
|
+
/** Resend's SMTP relay: `smtp.resend.com`, port 587 and user `resend` unless given; the password is the API key. */
|
|
6
|
+
smtp?: {
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
user?: string;
|
|
10
|
+
pass?: string;
|
|
11
|
+
secure?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The webhook's signing secret (`whsec_…`). Without it, or with one that decodes to no key, every webhook post is
|
|
15
|
+
* refused, and health says so.
|
|
16
|
+
*/
|
|
17
|
+
webhookSecret?: string;
|
|
18
|
+
}
|
|
19
|
+
declare function createResendEmailAdapter(deps: {
|
|
20
|
+
logger: Logger;
|
|
21
|
+
config?: ResendEmailAdapterConfig;
|
|
22
|
+
}): EmailAdapter;
|
|
23
|
+
|
|
24
|
+
/** What this package serves: the kernel refuses it on any other slot or provider. */
|
|
25
|
+
declare const slot = "email";
|
|
26
|
+
declare const provider = "resend";
|
|
27
|
+
|
|
28
|
+
/** The fields the admin's Settings may fill; without the webhook secret the slot runs, degraded, and refuses every post. */
|
|
29
|
+
declare const settings: AdapterSetting[];
|
|
30
|
+
/** The kernel's entry: `email: { provider: 'resend', config: ResendEmailAdapterConfig }`. */
|
|
31
|
+
declare function createAdapter(ctx: AdapterContext): EmailAdapter;
|
|
32
|
+
|
|
33
|
+
export { createAdapter, createResendEmailAdapter, provider, settings, slot };
|
|
34
|
+
export type { ResendEmailAdapterConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
ConfigError
|
|
4
|
+
} from "@nymbalhq/types";
|
|
5
|
+
|
|
6
|
+
// src/resend-adapter.ts
|
|
7
|
+
import { createSmtpTransportSender } from "@nymbalhq/email-smtp";
|
|
8
|
+
import {
|
|
9
|
+
AdapterError,
|
|
10
|
+
boundedHealthCheck,
|
|
11
|
+
EVT_CART_ABANDONED,
|
|
12
|
+
EVT_CUSTOMER_CREATED,
|
|
13
|
+
EVT_ORDER_PLACED,
|
|
14
|
+
EVT_ORDER_SHIPPED
|
|
15
|
+
} from "@nymbalhq/types";
|
|
16
|
+
|
|
17
|
+
// src/webhook.ts
|
|
18
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
19
|
+
import {
|
|
20
|
+
emailFeedbackType
|
|
21
|
+
} from "@nymbalhq/types";
|
|
22
|
+
var RESEND_BOUNCED = emailFeedbackType("resend", "bounced");
|
|
23
|
+
var RESEND_COMPLAINED = emailFeedbackType("resend", "complained");
|
|
24
|
+
var WINDOW_SECONDS = 300;
|
|
25
|
+
var SECRET_PREFIX = "whsec_";
|
|
26
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
function notVerified() {
|
|
28
|
+
return { verified: false, deliveryId: "", events: [] };
|
|
29
|
+
}
|
|
30
|
+
function header(headers, name) {
|
|
31
|
+
const value = headers[name];
|
|
32
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
33
|
+
}
|
|
34
|
+
function signingKey(secret) {
|
|
35
|
+
const key = Buffer.from(
|
|
36
|
+
secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret,
|
|
37
|
+
"base64"
|
|
38
|
+
);
|
|
39
|
+
return key.length > 0 ? key : void 0;
|
|
40
|
+
}
|
|
41
|
+
function signedWith(key, id, timestamp, rawBody, signatures) {
|
|
42
|
+
const expected = Buffer.from(
|
|
43
|
+
createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),
|
|
44
|
+
"utf8"
|
|
45
|
+
);
|
|
46
|
+
let matched = false;
|
|
47
|
+
for (const entry of signatures.split(" ")) {
|
|
48
|
+
const comma = entry.indexOf(",");
|
|
49
|
+
if (comma === -1 || entry.slice(0, comma) !== "v1") continue;
|
|
50
|
+
const given = Buffer.from(entry.slice(comma + 1), "utf8");
|
|
51
|
+
if (given.length === expected.length && timingSafeEqual(given, expected)) matched = true;
|
|
52
|
+
}
|
|
53
|
+
return matched;
|
|
54
|
+
}
|
|
55
|
+
function parse(rawBody) {
|
|
56
|
+
try {
|
|
57
|
+
const body = JSON.parse(rawBody.toString("utf8"));
|
|
58
|
+
return isObject(body) ? body : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function occurredAtOf(body) {
|
|
64
|
+
const createdAt = body["created_at"];
|
|
65
|
+
if (typeof createdAt !== "string") return null;
|
|
66
|
+
const at = new Date(createdAt);
|
|
67
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
68
|
+
}
|
|
69
|
+
function eventsOf(body) {
|
|
70
|
+
const data = body["data"];
|
|
71
|
+
if (!isObject(data)) return [];
|
|
72
|
+
const to = Array.isArray(data["to"]) ? data["to"] : [data["to"]];
|
|
73
|
+
const messageId = typeof data["email_id"] === "string" ? data["email_id"] : null;
|
|
74
|
+
const occurredAt = occurredAtOf(body);
|
|
75
|
+
if (body["type"] === "email.bounced") {
|
|
76
|
+
const bounceType = isObject(data["bounce"]) ? data["bounce"]["type"] : void 0;
|
|
77
|
+
const bounce = typeof bounceType === "string" && bounceType.toLowerCase() === "permanent" ? "hard" : "soft";
|
|
78
|
+
return to.map((recipient) => ({
|
|
79
|
+
type: RESEND_BOUNCED,
|
|
80
|
+
payload: { recipient, bounce, messageId, occurredAt }
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
if (body["type"] === "email.complained") {
|
|
84
|
+
return to.map((recipient) => ({
|
|
85
|
+
type: RESEND_COMPLAINED,
|
|
86
|
+
payload: { recipient, messageId, occurredAt }
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
function createResendWebhookReceiver(secret) {
|
|
92
|
+
return async (rawBody, headers) => {
|
|
93
|
+
const configured = secret();
|
|
94
|
+
const key = configured === void 0 ? void 0 : signingKey(configured);
|
|
95
|
+
const id = header(headers, "svix-id");
|
|
96
|
+
const timestamp = header(headers, "svix-timestamp");
|
|
97
|
+
const signatures = header(headers, "svix-signature");
|
|
98
|
+
if (key === void 0 || id === void 0 || timestamp === void 0 || signatures === void 0) {
|
|
99
|
+
return notVerified();
|
|
100
|
+
}
|
|
101
|
+
if (!signedWith(key, id, timestamp, rawBody, signatures)) return notVerified();
|
|
102
|
+
const signedAt = Number(timestamp);
|
|
103
|
+
if (!Number.isFinite(signedAt) || Math.abs(Date.now() / 1e3 - signedAt) > WINDOW_SECONDS) {
|
|
104
|
+
return notVerified();
|
|
105
|
+
}
|
|
106
|
+
const body = parse(rawBody);
|
|
107
|
+
if (body === null) return notVerified();
|
|
108
|
+
return { verified: true, deliveryId: id, events: eventsOf(body) };
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/resend-adapter.ts
|
|
113
|
+
var RELAY_HOST = "smtp.resend.com";
|
|
114
|
+
var RELAY_PORT = 587;
|
|
115
|
+
var RELAY_USER = "resend";
|
|
116
|
+
var NO_SECRET = "webhookSecret not set, or not a base64 key";
|
|
117
|
+
var NO_PASS = "Resend SMTP login incomplete: smtp.pass (the API key) not set";
|
|
118
|
+
var set = (value) => typeof value === "string" && value !== "";
|
|
119
|
+
function transportConfig(cfg) {
|
|
120
|
+
const smtp = cfg.smtp;
|
|
121
|
+
return {
|
|
122
|
+
...cfg.from !== void 0 && { from: cfg.from },
|
|
123
|
+
smtp: {
|
|
124
|
+
host: smtp?.host ?? RELAY_HOST,
|
|
125
|
+
port: smtp?.port ?? RELAY_PORT,
|
|
126
|
+
user: smtp?.user ?? RELAY_USER,
|
|
127
|
+
...smtp?.pass !== void 0 && { pass: smtp.pass },
|
|
128
|
+
...smtp?.secure !== void 0 && { secure: smtp.secure }
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function createResendEmailAdapter(deps) {
|
|
133
|
+
const log = deps.logger.child({ adapter: "email", provider: "resend" });
|
|
134
|
+
let cfg = deps.config ?? {};
|
|
135
|
+
const sender = createSmtpTransportSender(transportConfig(cfg), log);
|
|
136
|
+
const secret = () => set(cfg.webhookSecret) && signingKey(cfg.webhookSecret) !== void 0 ? cfg.webhookSecret : void 0;
|
|
137
|
+
const receive = createResendWebhookReceiver(secret);
|
|
138
|
+
return {
|
|
139
|
+
kind: "email",
|
|
140
|
+
providerName: "resend",
|
|
141
|
+
capabilities: () => ["transactional", "list-sync"],
|
|
142
|
+
producesEvents: [RESEND_BOUNCED, RESEND_COMPLAINED],
|
|
143
|
+
consumesEvents: [EVT_ORDER_PLACED, EVT_ORDER_SHIPPED, EVT_CUSTOMER_CREATED, EVT_CART_ABANDONED],
|
|
144
|
+
async initialize(rawConfig) {
|
|
145
|
+
cfg = { ...cfg, ...rawConfig };
|
|
146
|
+
sender.configure(transportConfig(cfg));
|
|
147
|
+
},
|
|
148
|
+
healthCheck: () => boundedHealthCheck(async () => {
|
|
149
|
+
const smtp = await sender.verify();
|
|
150
|
+
const problems = [
|
|
151
|
+
...set(cfg.smtp?.pass) ? [] : [`${NO_PASS}: Resend refuses every send`],
|
|
152
|
+
...secret() === void 0 ? [`${NO_SECRET}: every Resend webhook post is refused`] : []
|
|
153
|
+
];
|
|
154
|
+
if (problems.length === 0) return smtp;
|
|
155
|
+
return smtp.status === "healthy" ? { status: "degraded", detail: problems.join("; ") } : {
|
|
156
|
+
status: smtp.status,
|
|
157
|
+
detail: [smtp.detail ?? "SMTP verify failed", ...problems].join("; ")
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
sendTransactional: (params) => {
|
|
161
|
+
if (!set(cfg.smtp?.pass)) {
|
|
162
|
+
return Promise.reject(
|
|
163
|
+
new AdapterError("email.misconfigured", NO_PASS, { context: { missing: ["smtp.pass"] } })
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return sender.sendTransactional(params);
|
|
167
|
+
},
|
|
168
|
+
syncCustomerToList: (customer) => sender.syncCustomerToList(customer),
|
|
169
|
+
removeCustomerFromList: (customerId) => sender.removeCustomerFromList(customerId),
|
|
170
|
+
receiveWebhook: (rawBody, headers) => receive(rawBody, headers)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/index.ts
|
|
175
|
+
var slot = "email";
|
|
176
|
+
var provider = "resend";
|
|
177
|
+
var settings = [
|
|
178
|
+
{ path: "smtp.pass", label: "Resend API key", secret: true },
|
|
179
|
+
{ path: "webhookSecret", label: "Webhook signing secret", secret: true, optional: true }
|
|
180
|
+
];
|
|
181
|
+
function createAdapter(ctx) {
|
|
182
|
+
if (ctx.slot !== "email") {
|
|
183
|
+
throw new ConfigError(`@nymbalhq/email-resend serves email, not ${ctx.slot}`, {
|
|
184
|
+
code: "adapter.wrong_slot",
|
|
185
|
+
context: { slot: ctx.slot }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return createResendEmailAdapter({
|
|
189
|
+
logger: ctx.logger,
|
|
190
|
+
config: ctx.config
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export {
|
|
194
|
+
createAdapter,
|
|
195
|
+
createResendEmailAdapter,
|
|
196
|
+
provider,
|
|
197
|
+
settings,
|
|
198
|
+
slot
|
|
199
|
+
};
|
|
200
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/resend-adapter.ts","../src/webhook.ts"],"sourcesContent":["import {\n ConfigError,\n type AdapterContext,\n type AdapterSetting,\n type EmailAdapter,\n} from '@nymbalhq/types'\nimport { createResendEmailAdapter, type ResendEmailAdapterConfig } from './resend-adapter.js'\n\n/** What this package serves: the kernel refuses it on any other slot or provider. */\nexport const slot = 'email'\nexport const provider = 'resend'\n\nexport { createResendEmailAdapter, type ResendEmailAdapterConfig }\n\n/** The fields the admin's Settings may fill; without the webhook secret the slot runs, degraded, and refuses every post. */\nexport const settings: AdapterSetting[] = [\n { path: 'smtp.pass', label: 'Resend API key', secret: true },\n { path: 'webhookSecret', label: 'Webhook signing secret', secret: true, optional: true },\n]\n\n/** The kernel's entry: `email: { provider: 'resend', config: ResendEmailAdapterConfig }`. */\nexport function createAdapter(ctx: AdapterContext): EmailAdapter {\n if (ctx.slot !== 'email') {\n throw new ConfigError(`@nymbalhq/email-resend serves email, not ${ctx.slot}`, {\n code: 'adapter.wrong_slot',\n context: { slot: ctx.slot },\n })\n }\n return createResendEmailAdapter({\n logger: ctx.logger,\n config: ctx.config as unknown as ResendEmailAdapterConfig,\n })\n}\n","import { createSmtpTransportSender, type SmtpTransportConfig } from '@nymbalhq/email-smtp'\nimport {\n AdapterError,\n boundedHealthCheck,\n EVT_CART_ABANDONED,\n EVT_CUSTOMER_CREATED,\n EVT_ORDER_PLACED,\n EVT_ORDER_SHIPPED,\n type EmailAdapter,\n type Logger,\n} from '@nymbalhq/types'\nimport {\n createResendWebhookReceiver,\n RESEND_BOUNCED,\n RESEND_COMPLAINED,\n signingKey,\n} from './webhook.js'\n\nexport interface ResendEmailAdapterConfig {\n from?: string\n /** Resend's SMTP relay: `smtp.resend.com`, port 587 and user `resend` unless given; the password is the API key. */\n smtp?: {\n host?: string\n port?: number\n user?: string\n pass?: string\n secure?: boolean\n }\n /**\n * The webhook's signing secret (`whsec_…`). Without it, or with one that decodes to no key, every webhook post is\n * refused, and health says so.\n */\n webhookSecret?: string\n}\n\nconst RELAY_HOST = 'smtp.resend.com'\nconst RELAY_PORT = 587\nconst RELAY_USER = 'resend'\nconst NO_SECRET = 'webhookSecret not set, or not a base64 key'\nconst NO_PASS = 'Resend SMTP login incomplete: smtp.pass (the API key) not set'\n\nconst set = (value: unknown): value is string => typeof value === 'string' && value !== ''\n\nfunction transportConfig(cfg: ResendEmailAdapterConfig): SmtpTransportConfig {\n const smtp = cfg.smtp\n return {\n ...(cfg.from !== undefined && { from: cfg.from }),\n smtp: {\n host: smtp?.host ?? RELAY_HOST,\n port: smtp?.port ?? RELAY_PORT,\n user: smtp?.user ?? RELAY_USER,\n ...(smtp?.pass !== undefined && { pass: smtp.pass }),\n ...(smtp?.secure !== undefined && { secure: smtp.secure }),\n },\n }\n}\n\nexport function createResendEmailAdapter(deps: {\n logger: Logger\n config?: ResendEmailAdapterConfig\n}): EmailAdapter {\n const log = deps.logger.child({ adapter: 'email', provider: 'resend' })\n let cfg: ResendEmailAdapterConfig = deps.config ?? {}\n const sender = createSmtpTransportSender(transportConfig(cfg), log)\n const secret = () =>\n set(cfg.webhookSecret) && signingKey(cfg.webhookSecret) !== undefined\n ? cfg.webhookSecret\n : undefined\n const receive = createResendWebhookReceiver(secret)\n\n return {\n kind: 'email',\n providerName: 'resend',\n capabilities: () => ['transactional', 'list-sync'],\n producesEvents: [RESEND_BOUNCED, RESEND_COMPLAINED],\n consumesEvents: [EVT_ORDER_PLACED, EVT_ORDER_SHIPPED, EVT_CUSTOMER_CREATED, EVT_CART_ABANDONED],\n async initialize(rawConfig) {\n cfg = { ...cfg, ...(rawConfig as ResendEmailAdapterConfig) }\n sender.configure(transportConfig(cfg))\n },\n healthCheck: () =>\n boundedHealthCheck(async () => {\n const smtp = await sender.verify()\n const problems = [\n ...(set(cfg.smtp?.pass) ? [] : [`${NO_PASS}: Resend refuses every send`]),\n ...(secret() === undefined ? [`${NO_SECRET}: every Resend webhook post is refused`] : []),\n ]\n if (problems.length === 0) return smtp\n // A relay that is down, or refuses the login, keeps its status; each missing field is named beside it.\n return smtp.status === 'healthy'\n ? { status: 'degraded', detail: problems.join('; ') }\n : {\n status: smtp.status,\n detail: [smtp.detail ?? 'SMTP verify failed', ...problems].join('; '),\n }\n }),\n sendTransactional: (params) => {\n if (!set(cfg.smtp?.pass)) {\n return Promise.reject(\n new AdapterError('email.misconfigured', NO_PASS, { context: { missing: ['smtp.pass'] } }),\n )\n }\n return sender.sendTransactional(params)\n },\n syncCustomerToList: (customer) => sender.syncCustomerToList(customer),\n removeCustomerFromList: (customerId) => sender.removeCustomerFromList(customerId),\n receiveWebhook: (rawBody, headers) => receive(rawBody, headers),\n }\n}\n","import { createHmac, timingSafeEqual } from 'node:crypto'\nimport {\n emailFeedbackType,\n type WebhookDelivery,\n type WebhookEventDraft,\n type WebhookHeaders,\n} from '@nymbalhq/types'\n\nexport const RESEND_BOUNCED = emailFeedbackType('resend', 'bounced')\nexport const RESEND_COMPLAINED = emailFeedbackType('resend', 'complained')\n\n/** How far `svix-timestamp` may be from now, either way: Svix's own tolerance. */\nconst WINDOW_SECONDS = 300\nconst SECRET_PREFIX = 'whsec_'\n\ntype Json = Record<string, unknown>\n\nconst isObject = (value: unknown): value is Json =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\nfunction notVerified(): WebhookDelivery {\n return { verified: false, deliveryId: '', events: [] }\n}\n\nfunction header(headers: WebhookHeaders, name: string): string | undefined {\n const value = headers[name]\n return typeof value === 'string' && value !== '' ? value : undefined\n}\n\n/**\n * The HMAC key: the base64 after `whsec_`, as Resend's dashboard shows the secret, or a bare base64 secret. A secret\n * that decodes to no bytes (`whsec_` alone, text that is not base64) is no key: an empty one lets anyone sign.\n */\nexport function signingKey(secret: string): Buffer | undefined {\n const key = Buffer.from(\n secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret,\n 'base64',\n )\n return key.length > 0 ? key : undefined\n}\n\n/** Whether any `v1,<base64>` entry of the space-separated `svix-signature` is the HMAC of `id.timestamp.body`. */\nfunction signedWith(\n key: Buffer,\n id: string,\n timestamp: string,\n rawBody: Buffer,\n signatures: string,\n): boolean {\n const expected = Buffer.from(\n createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64'),\n 'utf8',\n )\n let matched = false\n for (const entry of signatures.split(' ')) {\n const comma = entry.indexOf(',')\n if (comma === -1 || entry.slice(0, comma) !== 'v1') continue\n const given = Buffer.from(entry.slice(comma + 1), 'utf8')\n if (given.length === expected.length && timingSafeEqual(given, expected)) matched = true\n }\n return matched\n}\n\nfunction parse(rawBody: Buffer): Json | null {\n try {\n const body: unknown = JSON.parse(rawBody.toString('utf8'))\n return isObject(body) ? body : null\n } catch {\n return null\n }\n}\n\n/** The top-level `created_at`, when Resend recorded the event, as ISO 8601; null leaves the kernel to refuse it. */\nfunction occurredAtOf(body: Json): string | null {\n const createdAt = body['created_at']\n if (typeof createdAt !== 'string') return null\n const at = new Date(createdAt)\n return Number.isNaN(at.getTime()) ? null : at.toISOString()\n}\n\n/**\n * A `Permanent` bounce, in any casing, is hard and any other bounce soft; one event per `data.to` address. Types\n * other than bounces and complaints carry none. Recipients are handed on as given: the kernel normalises them, and\n * refuses a delivery whose payload is malformed.\n */\nfunction eventsOf(body: Json): WebhookEventDraft[] {\n const data = body['data']\n if (!isObject(data)) return []\n const to: unknown[] = Array.isArray(data['to']) ? (data['to'] as unknown[]) : [data['to']]\n const messageId = typeof data['email_id'] === 'string' ? data['email_id'] : null\n const occurredAt = occurredAtOf(body)\n if (body['type'] === 'email.bounced') {\n const bounceType = isObject(data['bounce']) ? data['bounce']['type'] : undefined\n const bounce =\n typeof bounceType === 'string' && bounceType.toLowerCase() === 'permanent' ? 'hard' : 'soft'\n return to.map((recipient) => ({\n type: RESEND_BOUNCED,\n payload: { recipient, bounce, messageId, occurredAt },\n }))\n }\n if (body['type'] === 'email.complained') {\n return to.map((recipient) => ({\n type: RESEND_COMPLAINED,\n payload: { recipient, messageId, occurredAt },\n }))\n }\n return []\n}\n\n/**\n * Resend's webhook posts, verified as Svix signs them. The signature covers the id, the timestamp and the body, so a\n * captured post replays only byte for byte, and the route's claim on `svix-id` dedupes that.\n */\nexport function createResendWebhookReceiver(\n secret: () => string | undefined,\n): (rawBody: Buffer, headers: WebhookHeaders) => Promise<WebhookDelivery> {\n return async (rawBody, headers) => {\n const configured = secret()\n const key = configured === undefined ? undefined : signingKey(configured)\n const id = header(headers, 'svix-id')\n const timestamp = header(headers, 'svix-timestamp')\n const signatures = header(headers, 'svix-signature')\n if (\n key === undefined ||\n id === undefined ||\n timestamp === undefined ||\n signatures === undefined\n ) {\n return notVerified()\n }\n if (!signedWith(key, id, timestamp, rawBody, signatures)) return notVerified()\n const signedAt = Number(timestamp)\n if (!Number.isFinite(signedAt) || Math.abs(Date.now() / 1000 - signedAt) > WINDOW_SECONDS) {\n return notVerified()\n }\n const body = parse(rawBody)\n if (body === null) return notVerified()\n\n return { verified: true, deliveryId: id, events: eventsOf(body) }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nymbalhq/email-resend",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Resend email for Nymbal: the `resend` email provider (SMTP relay, bounce and complaint webhooks)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/nymbalhq/nymbal.git",
|
|
9
|
+
"directory": "packages/adapters/email/resend"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@nymbalhq/email-smtp": "^1.5.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@nymbalhq/types": "^1.5.0",
|
|
34
|
+
"nodemailer": "^6.9.16"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@nymbalhq/types": "1.5.0",
|
|
38
|
+
"@types/nodemailer": "^6.4.17",
|
|
39
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
40
|
+
"fast-check": "^4.10.2",
|
|
41
|
+
"nodemailer": "^6.9.16",
|
|
42
|
+
"tsup": "^9.0.0",
|
|
43
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
44
|
+
"vitest": "^3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"dev": "tsup --watch",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit --rootDir ../../../.."
|
|
50
|
+
}
|
|
6
51
|
}
|