@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 +3 -3
- package/src/__tests__/domains.test.ts +255 -0
- package/src/domains.ts +194 -0
- package/src/index.ts +5 -0
- package/src/provider.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/plugin-resend",
|
|
3
|
-
"version": "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.
|
|
28
|
-
"@hogsend/email": "^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
|
},
|