@hogsend/plugin-resend 0.11.0 → 0.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/plugin-resend",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,8 +24,8 @@
24
24
  "dependencies": {
25
25
  "resend": "^6.12.3",
26
26
  "svix": "^1.94.0",
27
- "@hogsend/core": "^0.11.0",
28
- "@hogsend/email": "^0.11.0"
27
+ "@hogsend/core": "^0.12.1",
28
+ "@hogsend/email": "^0.12.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
@@ -0,0 +1,255 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createResendDomains } from "../domains.js";
3
+
4
+ /**
5
+ * Mocked-fetch fixtures for the Resend Domains REST API. NEVER hits the real
6
+ * service — every test stubs `globalThis.fetch`.
7
+ */
8
+
9
+ const DOMAIN_ID = "d91cd9bd-1176-453e-8fc1-35364d380206";
10
+
11
+ const DOMAIN_DETAIL = {
12
+ object: "domain",
13
+ id: DOMAIN_ID,
14
+ name: "mysite.com",
15
+ status: "not_started",
16
+ created_at: "2026-06-09T00:00:00.000Z",
17
+ region: "us-east-1",
18
+ records: [
19
+ {
20
+ record: "SPF",
21
+ name: "send",
22
+ type: "MX",
23
+ ttl: "Auto",
24
+ status: "not_started",
25
+ value: "feedback-smtp.us-east-1.amazonses.com",
26
+ priority: 10,
27
+ },
28
+ {
29
+ record: "SPF",
30
+ name: "send",
31
+ type: "TXT",
32
+ ttl: "Auto",
33
+ status: "pending",
34
+ value: "v=spf1 include:amazonses.com ~all",
35
+ },
36
+ {
37
+ record: "DKIM",
38
+ name: "resend._domainkey",
39
+ type: "TXT",
40
+ ttl: "Auto",
41
+ status: "verified",
42
+ value: "p=MIGfMA0GCSqGSIb3...",
43
+ },
44
+ {
45
+ record: "DKIM",
46
+ name: "broken._domainkey",
47
+ type: "TXT",
48
+ ttl: "Auto",
49
+ status: "failure",
50
+ value: "p=BROKEN",
51
+ },
52
+ ],
53
+ };
54
+
55
+ const DOMAIN_LIST = {
56
+ data: [
57
+ { id: DOMAIN_ID, name: "mysite.com", status: "not_started" },
58
+ { id: "other-id", name: "other.com", status: "verified" },
59
+ ],
60
+ };
61
+
62
+ function jsonResponse(body: unknown, status = 200): Response {
63
+ return new Response(JSON.stringify(body), {
64
+ status,
65
+ headers: { "Content-Type": "application/json" },
66
+ });
67
+ }
68
+
69
+ const fetchMock = vi.fn();
70
+
71
+ beforeEach(() => {
72
+ fetchMock.mockReset();
73
+ vi.stubGlobal("fetch", fetchMock);
74
+ });
75
+
76
+ afterEach(() => {
77
+ vi.unstubAllGlobals();
78
+ });
79
+
80
+ const domains = createResendDomains({ apiKey: "re_test_key" });
81
+
82
+ describe("createResendDomains — create", () => {
83
+ it("POSTs /domains with the bearer token and normalizes the response", async () => {
84
+ fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL, 201));
85
+
86
+ const status = await domains.create("mysite.com");
87
+
88
+ expect(fetchMock).toHaveBeenCalledTimes(1);
89
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
90
+ expect(url).toBe("https://api.resend.com/domains");
91
+ expect(init.method).toBe("POST");
92
+ expect((init.headers as Record<string, string>).Authorization).toBe(
93
+ "Bearer re_test_key",
94
+ );
95
+ expect(JSON.parse(init.body as string)).toEqual({ name: "mysite.com" });
96
+
97
+ expect(status.domain).toBe("mysite.com");
98
+ expect(status.providerId).toBe("resend");
99
+ expect(status.state).toBe("pending"); // not_started → pending
100
+ expect(status.raw).toEqual(DOMAIN_DETAIL);
101
+ expect(typeof status.checkedAt).toBe("string");
102
+ });
103
+
104
+ it("falls through to lookup when the domain already exists (idempotent)", async () => {
105
+ fetchMock
106
+ .mockResolvedValueOnce(
107
+ jsonResponse(
108
+ {
109
+ statusCode: 409,
110
+ name: "conflict",
111
+ message: "Domain already exists",
112
+ },
113
+ 409,
114
+ ),
115
+ )
116
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
117
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
118
+
119
+ const status = await domains.create("mysite.com");
120
+ expect(status.domain).toBe("mysite.com");
121
+ expect(fetchMock).toHaveBeenCalledTimes(3);
122
+ });
123
+
124
+ it("throws on other API errors", async () => {
125
+ fetchMock.mockResolvedValueOnce(
126
+ jsonResponse({ statusCode: 401, message: "API key is invalid" }, 401),
127
+ );
128
+ await expect(domains.create("mysite.com")).rejects.toThrow(
129
+ /API key is invalid/,
130
+ );
131
+ });
132
+ });
133
+
134
+ describe("createResendDomains — get", () => {
135
+ it("resolves name → id via the list, then normalizes the detail", async () => {
136
+ fetchMock
137
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
138
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
139
+
140
+ const status = await domains.get("mysite.com");
141
+ expect(status).not.toBeNull();
142
+ expect(status?.domain).toBe("mysite.com");
143
+
144
+ // The detail GET hit /domains/:id.
145
+ const [detailUrl] = fetchMock.mock.calls[1] as [string];
146
+ expect(detailUrl).toBe(`https://api.resend.com/domains/${DOMAIN_ID}`);
147
+ });
148
+
149
+ it("returns null when the provider doesn't know the domain", async () => {
150
+ fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST));
151
+ const status = await domains.get("unknown.com");
152
+ expect(status).toBeNull();
153
+ expect(fetchMock).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it("normalizes records: purpose + status mapping table", async () => {
157
+ fetchMock
158
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
159
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
160
+
161
+ const status = await domains.get("mysite.com");
162
+ const records = status?.records ?? [];
163
+ expect(records).toHaveLength(4);
164
+
165
+ // SPF + MX type → purpose spf (record kind wins), priority preserved.
166
+ expect(records[0]).toMatchObject({
167
+ type: "MX",
168
+ name: "send",
169
+ purpose: "spf",
170
+ priority: 10,
171
+ status: "pending", // not_started → pending
172
+ });
173
+ // SPF TXT.
174
+ expect(records[1]).toMatchObject({
175
+ type: "TXT",
176
+ purpose: "spf",
177
+ status: "pending",
178
+ });
179
+ // DKIM verified.
180
+ expect(records[2]).toMatchObject({
181
+ name: "resend._domainkey",
182
+ purpose: "dkim",
183
+ status: "verified",
184
+ });
185
+ // DKIM failure → failed.
186
+ expect(records[3]).toMatchObject({ purpose: "dkim", status: "failed" });
187
+ });
188
+
189
+ it("maps domain status → DomainVerificationState", async () => {
190
+ for (const [provider, expected] of [
191
+ ["verified", "verified"],
192
+ ["failure", "failed"],
193
+ ["pending", "pending"],
194
+ ["temporary_failure", "pending"],
195
+ ["not_started", "pending"],
196
+ ] as const) {
197
+ fetchMock.mockReset();
198
+ fetchMock
199
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
200
+ .mockResolvedValueOnce(
201
+ jsonResponse({ ...DOMAIN_DETAIL, status: provider }),
202
+ );
203
+ const status = await domains.get("mysite.com");
204
+ expect(status?.state).toBe(expected);
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("createResendDomains — records", () => {
210
+ it("returns the normalized record list", async () => {
211
+ fetchMock
212
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
213
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
214
+ const records = await domains.records("mysite.com");
215
+ expect(records).toHaveLength(4);
216
+ });
217
+
218
+ it("returns [] for an unknown domain", async () => {
219
+ fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST));
220
+ const records = await domains.records("unknown.com");
221
+ expect(records).toEqual([]);
222
+ });
223
+ });
224
+
225
+ describe("createResendDomains — verify", () => {
226
+ it("POSTs /domains/:id/verify then re-fetches the status", async () => {
227
+ fetchMock
228
+ .mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
229
+ .mockResolvedValueOnce(jsonResponse({ object: "domain", id: DOMAIN_ID }))
230
+ .mockResolvedValueOnce(
231
+ jsonResponse({ ...DOMAIN_DETAIL, status: "verified" }),
232
+ );
233
+
234
+ // biome-ignore lint/style/noNonNullAssertion: verify is implemented for Resend
235
+ const status = await domains.verify!("mysite.com");
236
+
237
+ const [verifyUrl, verifyInit] = fetchMock.mock.calls[1] as [
238
+ string,
239
+ RequestInit,
240
+ ];
241
+ expect(verifyUrl).toBe(
242
+ `https://api.resend.com/domains/${DOMAIN_ID}/verify`,
243
+ );
244
+ expect(verifyInit.method).toBe("POST");
245
+ expect(status.state).toBe("verified");
246
+ });
247
+
248
+ it("throws when the domain is unknown", async () => {
249
+ fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST));
250
+ // biome-ignore lint/style/noNonNullAssertion: verify is implemented for Resend
251
+ await expect(domains.verify!("unknown.com")).rejects.toThrow(
252
+ /not registered/,
253
+ );
254
+ });
255
+ });
package/src/domains.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type {
2
+ DnsRecord,
3
+ DnsRecordPurpose,
4
+ DnsRecordStatus,
5
+ DomainStatus,
6
+ DomainsCapability,
7
+ DomainVerificationState,
8
+ } from "@hogsend/core";
9
+
10
+ /**
11
+ * The Resend implementation of the {@link DomainsCapability} contract — a dumb
12
+ * wire over the Resend Domains REST API (`https://api.resend.com/domains`).
13
+ * Plain `fetch` with the bearer token; all caching/policy lives in the engine's
14
+ * `DomainStatusService`, never here.
15
+ */
16
+ export interface ResendDomainsConfig {
17
+ apiKey: string;
18
+ }
19
+
20
+ const BASE_URL = "https://api.resend.com";
21
+
22
+ /** Resend's per-record verification statuses → neutral {@link DnsRecordStatus}. */
23
+ const RECORD_STATUS: Record<string, DnsRecordStatus> = {
24
+ verified: "verified",
25
+ failure: "failed",
26
+ not_started: "pending",
27
+ pending: "pending",
28
+ temporary_failure: "pending",
29
+ };
30
+
31
+ /** Resend's domain statuses → neutral {@link DomainVerificationState}. */
32
+ const DOMAIN_STATE: Record<string, DomainVerificationState> = {
33
+ verified: "verified",
34
+ failure: "failed",
35
+ not_started: "pending",
36
+ pending: "pending",
37
+ temporary_failure: "pending",
38
+ };
39
+
40
+ /** A record as Resend's `GET /domains/:id` reports it. */
41
+ interface ResendRecord {
42
+ record?: string; // "SPF" | "DKIM"
43
+ type?: string; // "TXT" | "CNAME" | "MX"
44
+ name?: string;
45
+ value?: string;
46
+ ttl?: string | number;
47
+ priority?: number;
48
+ status?: string;
49
+ }
50
+
51
+ interface ResendDomainPayload {
52
+ id?: string;
53
+ name?: string;
54
+ status?: string;
55
+ records?: ResendRecord[];
56
+ }
57
+
58
+ function recordPurpose(r: ResendRecord): DnsRecordPurpose {
59
+ const kind = (r.record ?? "").toUpperCase();
60
+ if (kind === "SPF") return "spf";
61
+ if (kind === "DKIM") return "dkim";
62
+ if ((r.type ?? "").toUpperCase() === "MX") return "mx";
63
+ return "other";
64
+ }
65
+
66
+ function toDnsRecord(r: ResendRecord): DnsRecord {
67
+ const type = (r.type ?? "TXT").toUpperCase();
68
+ return {
69
+ type: type === "CNAME" ? "CNAME" : type === "MX" ? "MX" : "TXT",
70
+ name: r.name ?? "",
71
+ value: r.value ?? "",
72
+ ...(typeof r.ttl === "number" ? { ttl: r.ttl } : {}),
73
+ ...(typeof r.priority === "number" ? { priority: r.priority } : {}),
74
+ purpose: recordPurpose(r),
75
+ status: RECORD_STATUS[r.status ?? ""] ?? "unknown",
76
+ };
77
+ }
78
+
79
+ function toDomainStatus(payload: ResendDomainPayload): DomainStatus {
80
+ return {
81
+ domain: payload.name ?? "",
82
+ state: DOMAIN_STATE[payload.status ?? ""] ?? "pending",
83
+ records: (payload.records ?? []).map(toDnsRecord),
84
+ providerId: "resend",
85
+ checkedAt: new Date().toISOString(),
86
+ raw: payload,
87
+ };
88
+ }
89
+
90
+ function errorMessage(status: number, body: unknown): string {
91
+ if (
92
+ body &&
93
+ typeof body === "object" &&
94
+ "message" in body &&
95
+ typeof (body as { message: unknown }).message === "string"
96
+ ) {
97
+ return `Resend domains API ${status}: ${(body as { message: string }).message}`;
98
+ }
99
+ return `Resend domains API request failed with status ${status}`;
100
+ }
101
+
102
+ /** Build the Resend {@link DomainsCapability}. */
103
+ export function createResendDomains(
104
+ config: ResendDomainsConfig,
105
+ ): DomainsCapability {
106
+ const api = async (
107
+ path: string,
108
+ init?: { method?: string; body?: unknown },
109
+ ): Promise<{ ok: boolean; status: number; body: unknown }> => {
110
+ const res = await fetch(`${BASE_URL}${path}`, {
111
+ method: init?.method ?? "GET",
112
+ headers: {
113
+ Authorization: `Bearer ${config.apiKey}`,
114
+ ...(init?.body !== undefined
115
+ ? { "Content-Type": "application/json" }
116
+ : {}),
117
+ },
118
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
119
+ });
120
+ let body: unknown;
121
+ try {
122
+ body = await res.json();
123
+ } catch {
124
+ body = undefined;
125
+ }
126
+ return { ok: res.ok, status: res.status, body };
127
+ };
128
+
129
+ /** Resolve a domain name → Resend domain id via `GET /domains`. */
130
+ const findId = async (domain: string): Promise<string | null> => {
131
+ const res = await api("/domains");
132
+ if (!res.ok) throw new Error(errorMessage(res.status, res.body));
133
+ const data =
134
+ res.body && typeof res.body === "object" && "data" in res.body
135
+ ? (res.body as { data: ResendDomainPayload[] }).data
136
+ : [];
137
+ const match = (data ?? []).find((d) => d.name === domain);
138
+ return match?.id ?? null;
139
+ };
140
+
141
+ /** Fetch + normalize `GET /domains/:id`. */
142
+ const getById = async (id: string): Promise<DomainStatus> => {
143
+ const res = await api(`/domains/${id}`);
144
+ if (!res.ok) throw new Error(errorMessage(res.status, res.body));
145
+ return toDomainStatus(res.body as ResendDomainPayload);
146
+ };
147
+
148
+ const get = async (domain: string): Promise<DomainStatus | null> => {
149
+ const id = await findId(domain);
150
+ if (id === null) return null;
151
+ return getById(id);
152
+ };
153
+
154
+ return {
155
+ async create(domain: string): Promise<DomainStatus> {
156
+ const res = await api("/domains", {
157
+ method: "POST",
158
+ body: { name: domain },
159
+ });
160
+ if (res.ok) return toDomainStatus(res.body as ResendDomainPayload);
161
+
162
+ // Idempotent create: an "already exists" conflict falls through to lookup.
163
+ const message =
164
+ res.body && typeof res.body === "object" && "message" in res.body
165
+ ? String((res.body as { message: unknown }).message)
166
+ : "";
167
+ if (res.status === 409 || /already exists/i.test(message)) {
168
+ const existing = await get(domain);
169
+ if (existing) return existing;
170
+ }
171
+ throw new Error(errorMessage(res.status, res.body));
172
+ },
173
+
174
+ get,
175
+
176
+ async records(domain: string): Promise<DnsRecord[]> {
177
+ const status = await get(domain);
178
+ return status?.records ?? [];
179
+ },
180
+
181
+ async verify(domain: string): Promise<DomainStatus> {
182
+ const id = await findId(domain);
183
+ if (id === null) {
184
+ throw new Error(
185
+ `domain "${domain}" is not registered with Resend — run create first`,
186
+ );
187
+ }
188
+ const res = await api(`/domains/${id}/verify`, { method: "POST" });
189
+ if (!res.ok) throw new Error(errorMessage(res.status, res.body));
190
+ // The verify response carries no records — re-fetch the fresh status.
191
+ return getById(id);
192
+ },
193
+ };
194
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  // Client
2
2
  export { createResendClient } from "./client.js";
3
+ // Sending-domain capability (Resend Domains REST API)
4
+ export {
5
+ createResendDomains,
6
+ type ResendDomainsConfig,
7
+ } from "./domains.js";
3
8
  // EmailProvider (the provider contract + Resend implementation)
4
9
  export {
5
10
  createResendProvider,
package/src/provider.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { RetryOptions } from "@hogsend/email";
2
2
  import { createResendClient } from "./client.js";
3
+ import { createResendDomains } from "./domains.js";
3
4
  import { sendBatchEmails, sendEmail } from "./send.js";
4
5
  import {
5
6
  type BatchEmailItem,
@@ -39,6 +40,10 @@ export function createResendProvider(
39
40
  signedWebhooks: true,
40
41
  },
41
42
 
43
+ // Sending-domain management (Resend Domains REST API). Presence of this
44
+ // member is the engine's capability gate for /v1/admin/domain.
45
+ domains: createResendDomains({ apiKey: config.apiKey }),
46
+
42
47
  async send(options: SendEmailOptions): Promise<SendResult> {
43
48
  return sendEmail({ client, options, retryOptions });
44
49
  },