@hogsend/plugin-postmark 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 +2 -2
- package/src/__tests__/domains.test.ts +267 -0
- package/src/index.ts +203 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/plugin-postmark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"postmark": "^4.0.7",
|
|
26
|
-
"@hogsend/core": "^0.
|
|
26
|
+
"@hogsend/core": "^0.12.1"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.0.0",
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mocked-fetch fixtures for Postmark's account-token Domains API. NEVER hits
|
|
5
|
+
* the real service — every test stubs `globalThis.fetch`. The send wire's
|
|
6
|
+
* ServerClient is mocked too so `createPostmarkProvider` never opens a socket.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
vi.mock("postmark", async () => {
|
|
10
|
+
const actual = await vi.importActual<typeof import("postmark")>("postmark");
|
|
11
|
+
class MockServerClient {
|
|
12
|
+
sendEmail = vi.fn();
|
|
13
|
+
sendEmailBatch = vi.fn();
|
|
14
|
+
}
|
|
15
|
+
return { ...actual, ServerClient: MockServerClient };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const { createPostmarkProvider } = await import("../index.js");
|
|
19
|
+
|
|
20
|
+
const DOMAIN_ID = 1234;
|
|
21
|
+
|
|
22
|
+
const DOMAIN_DETAIL = {
|
|
23
|
+
ID: DOMAIN_ID,
|
|
24
|
+
Name: "mysite.com",
|
|
25
|
+
SPFVerified: true,
|
|
26
|
+
DKIMVerified: false,
|
|
27
|
+
WeakDKIM: false,
|
|
28
|
+
DKIMHost: "",
|
|
29
|
+
DKIMTextValue: "",
|
|
30
|
+
DKIMPendingHost: "20260609._domainkey.mysite.com",
|
|
31
|
+
DKIMPendingTextValue: "k=rsa; p=MIGfMA0GCSq...",
|
|
32
|
+
DKIMRevokedHost: "",
|
|
33
|
+
DKIMRevokedTextValue: "",
|
|
34
|
+
SafeToRemoveRevokedKeyFromDNS: false,
|
|
35
|
+
DKIMUpdateStatus: "Pending",
|
|
36
|
+
ReturnPathDomain: "pm-bounces.mysite.com",
|
|
37
|
+
ReturnPathDomainVerified: false,
|
|
38
|
+
ReturnPathDomainCNAMEValue: "pm.mtasv.net",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DOMAIN_LIST = {
|
|
42
|
+
TotalCount: 2,
|
|
43
|
+
Domains: [
|
|
44
|
+
{ ID: DOMAIN_ID, Name: "mysite.com" },
|
|
45
|
+
{ ID: 99, Name: "other.com" },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
50
|
+
return new Response(JSON.stringify(body), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fetchMock = vi.fn();
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
fetchMock.mockReset();
|
|
60
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.unstubAllGlobals();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("capability gate", () => {
|
|
68
|
+
it("omits `domains` entirely when accountToken is absent", () => {
|
|
69
|
+
const provider = createPostmarkProvider({ serverToken: "pm_server" });
|
|
70
|
+
expect(provider.domains).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("exposes `domains` when accountToken is configured", () => {
|
|
74
|
+
const provider = createPostmarkProvider({
|
|
75
|
+
serverToken: "pm_server",
|
|
76
|
+
accountToken: "pm_account",
|
|
77
|
+
});
|
|
78
|
+
expect(provider.domains).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const provider = createPostmarkProvider({
|
|
83
|
+
serverToken: "pm_server",
|
|
84
|
+
accountToken: "pm_account",
|
|
85
|
+
});
|
|
86
|
+
// biome-ignore lint/style/noNonNullAssertion: accountToken is set above
|
|
87
|
+
const domains = provider.domains!;
|
|
88
|
+
|
|
89
|
+
function assertAccountTokenHeader(): void {
|
|
90
|
+
for (const call of fetchMock.mock.calls) {
|
|
91
|
+
const init = call[1] as RequestInit;
|
|
92
|
+
const headers = init.headers as Record<string, string>;
|
|
93
|
+
expect(headers["X-Postmark-Account-Token"]).toBe("pm_account");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("createPostmarkProvider(...).domains — get", () => {
|
|
98
|
+
it("resolves name → ID via the list, synthesizes DKIM + return-path records", async () => {
|
|
99
|
+
fetchMock
|
|
100
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
|
|
101
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
|
|
102
|
+
|
|
103
|
+
const status = await domains.get("mysite.com");
|
|
104
|
+
expect(status).not.toBeNull();
|
|
105
|
+
expect(status?.domain).toBe("mysite.com");
|
|
106
|
+
expect(status?.providerId).toBe("postmark");
|
|
107
|
+
expect(status?.raw).toEqual(DOMAIN_DETAIL);
|
|
108
|
+
|
|
109
|
+
const [listUrl] = fetchMock.mock.calls[0] as [string];
|
|
110
|
+
expect(listUrl).toContain("https://api.postmarkapp.com/domains?count=");
|
|
111
|
+
const [detailUrl] = fetchMock.mock.calls[1] as [string];
|
|
112
|
+
expect(detailUrl).toBe(`https://api.postmarkapp.com/domains/${DOMAIN_ID}`);
|
|
113
|
+
assertAccountTokenHeader();
|
|
114
|
+
|
|
115
|
+
// DKIM record synthesized from the PENDING host/value (rotation pending).
|
|
116
|
+
const dkim = status?.records.find((r) => r.purpose === "dkim");
|
|
117
|
+
expect(dkim).toMatchObject({
|
|
118
|
+
type: "TXT",
|
|
119
|
+
name: "20260609._domainkey.mysite.com",
|
|
120
|
+
value: "k=rsa; p=MIGfMA0GCSq...",
|
|
121
|
+
status: "pending",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Return-path CNAME.
|
|
125
|
+
const rp = status?.records.find((r) => r.purpose === "return_path");
|
|
126
|
+
expect(rp).toMatchObject({
|
|
127
|
+
type: "CNAME",
|
|
128
|
+
name: "pm-bounces.mysite.com",
|
|
129
|
+
value: "pm.mtasv.net",
|
|
130
|
+
status: "pending",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uses the active DKIM host/value when no rotation is pending", async () => {
|
|
135
|
+
fetchMock
|
|
136
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
|
|
137
|
+
.mockResolvedValueOnce(
|
|
138
|
+
jsonResponse({
|
|
139
|
+
...DOMAIN_DETAIL,
|
|
140
|
+
DKIMVerified: true,
|
|
141
|
+
DKIMHost: "pm._domainkey.mysite.com",
|
|
142
|
+
DKIMTextValue: "k=rsa; p=ACTIVE",
|
|
143
|
+
DKIMPendingHost: "",
|
|
144
|
+
DKIMPendingTextValue: "",
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const status = await domains.get("mysite.com");
|
|
149
|
+
const dkim = status?.records.find((r) => r.purpose === "dkim");
|
|
150
|
+
expect(dkim).toMatchObject({
|
|
151
|
+
name: "pm._domainkey.mysite.com",
|
|
152
|
+
value: "k=rsa; p=ACTIVE",
|
|
153
|
+
status: "verified",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("state is verified ONLY when both DKIM and return-path are verified", async () => {
|
|
158
|
+
for (const [dkimV, rpV, expected] of [
|
|
159
|
+
[true, true, "verified"],
|
|
160
|
+
[true, false, "pending"],
|
|
161
|
+
[false, true, "pending"],
|
|
162
|
+
[false, false, "pending"],
|
|
163
|
+
] as const) {
|
|
164
|
+
fetchMock.mockReset();
|
|
165
|
+
fetchMock
|
|
166
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
|
|
167
|
+
.mockResolvedValueOnce(
|
|
168
|
+
jsonResponse({
|
|
169
|
+
...DOMAIN_DETAIL,
|
|
170
|
+
DKIMVerified: dkimV,
|
|
171
|
+
ReturnPathDomainVerified: rpV,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
const status = await domains.get("mysite.com");
|
|
175
|
+
expect(status?.state).toBe(expected);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns null when the provider doesn't know the domain", async () => {
|
|
180
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST));
|
|
181
|
+
const status = await domains.get("unknown.com");
|
|
182
|
+
expect(status).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("createPostmarkProvider(...).domains — create", () => {
|
|
187
|
+
it("POSTs /domains with the account token and normalizes", async () => {
|
|
188
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
|
|
189
|
+
|
|
190
|
+
const status = await domains.create("mysite.com");
|
|
191
|
+
|
|
192
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
193
|
+
expect(url).toBe("https://api.postmarkapp.com/domains");
|
|
194
|
+
expect(init.method).toBe("POST");
|
|
195
|
+
expect(JSON.parse(init.body as string)).toEqual({ Name: "mysite.com" });
|
|
196
|
+
assertAccountTokenHeader();
|
|
197
|
+
expect(status.domain).toBe("mysite.com");
|
|
198
|
+
expect(status.state).toBe("pending");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("falls through to lookup on 422 already-exists (idempotent)", async () => {
|
|
202
|
+
fetchMock
|
|
203
|
+
.mockResolvedValueOnce(
|
|
204
|
+
jsonResponse(
|
|
205
|
+
{ ErrorCode: 503, Message: "This domain already exists." },
|
|
206
|
+
422,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
|
|
210
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_DETAIL));
|
|
211
|
+
|
|
212
|
+
const status = await domains.create("mysite.com");
|
|
213
|
+
expect(status.domain).toBe("mysite.com");
|
|
214
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws on other API errors", async () => {
|
|
218
|
+
fetchMock.mockResolvedValueOnce(
|
|
219
|
+
jsonResponse({ ErrorCode: 10, Message: "Bad account token" }, 401),
|
|
220
|
+
);
|
|
221
|
+
await expect(domains.create("mysite.com")).rejects.toThrow(
|
|
222
|
+
/Bad account token/,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("createPostmarkProvider(...).domains — verify", () => {
|
|
228
|
+
it("runs verifyDkim + verifyReturnPath, then re-gets", async () => {
|
|
229
|
+
fetchMock
|
|
230
|
+
.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST))
|
|
231
|
+
.mockResolvedValueOnce(jsonResponse({ ...DOMAIN_DETAIL }))
|
|
232
|
+
.mockResolvedValueOnce(jsonResponse({ ...DOMAIN_DETAIL }))
|
|
233
|
+
.mockResolvedValueOnce(
|
|
234
|
+
jsonResponse({
|
|
235
|
+
...DOMAIN_DETAIL,
|
|
236
|
+
DKIMVerified: true,
|
|
237
|
+
ReturnPathDomainVerified: true,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// biome-ignore lint/style/noNonNullAssertion: verify is implemented for Postmark
|
|
242
|
+
const status = await domains.verify!("mysite.com");
|
|
243
|
+
|
|
244
|
+
const calls = fetchMock.mock.calls.map((c) => [
|
|
245
|
+
(c[1] as RequestInit).method ?? "GET",
|
|
246
|
+
c[0] as string,
|
|
247
|
+
]);
|
|
248
|
+
expect(calls).toEqual([
|
|
249
|
+
["GET", expect.stringContaining("/domains?count=")],
|
|
250
|
+
["PUT", `https://api.postmarkapp.com/domains/${DOMAIN_ID}/verifyDkim`],
|
|
251
|
+
[
|
|
252
|
+
"PUT",
|
|
253
|
+
`https://api.postmarkapp.com/domains/${DOMAIN_ID}/verifyReturnPath`,
|
|
254
|
+
],
|
|
255
|
+
["GET", `https://api.postmarkapp.com/domains/${DOMAIN_ID}`],
|
|
256
|
+
]);
|
|
257
|
+
expect(status.state).toBe("verified");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("createPostmarkProvider(...).domains — records", () => {
|
|
262
|
+
it("returns [] for an unknown domain", async () => {
|
|
263
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(DOMAIN_LIST));
|
|
264
|
+
const records = await domains.records("unknown.com");
|
|
265
|
+
expect(records).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BatchEmailItem,
|
|
3
3
|
type BounceClass,
|
|
4
|
+
type DnsRecord,
|
|
5
|
+
type DomainStatus,
|
|
6
|
+
type DomainsCapability,
|
|
4
7
|
defineEmailProvider,
|
|
5
8
|
type EmailEvent,
|
|
6
9
|
type EmailEventType,
|
|
@@ -27,6 +30,14 @@ export interface PostmarkConfig {
|
|
|
27
30
|
messageStream?: string;
|
|
28
31
|
/** HTTP Basic creds the webhook URL must present. Unset → webhooks rejected. */
|
|
29
32
|
webhookBasicAuth?: { user: string; pass: string };
|
|
33
|
+
/**
|
|
34
|
+
* Postmark ACCOUNT API token — NOT the server token. Postmark's Domains API
|
|
35
|
+
* authenticates with `X-Postmark-Account-Token`, an account-level credential
|
|
36
|
+
* the per-server token cannot substitute for. When absent, the provider OMITS
|
|
37
|
+
* the `domains` capability entirely (the engine/CLI degrade gracefully:
|
|
38
|
+
* `supported: false`, admin domain POSTs return 501).
|
|
39
|
+
*/
|
|
40
|
+
accountToken?: string;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
/** Postmark wants comma-joined recipient strings; omit the field when empty. */
|
|
@@ -86,6 +97,13 @@ export function createPostmarkProvider(cfg: PostmarkConfig): EmailProvider {
|
|
|
86
97
|
|
|
87
98
|
return defineEmailProvider({
|
|
88
99
|
meta: { id: "postmark", name: "Postmark" },
|
|
100
|
+
|
|
101
|
+
// Sending-domain management — gated on the ACCOUNT token (the Domains API
|
|
102
|
+
// does not accept the server token). Absent ⇒ no `domains` member at all,
|
|
103
|
+
// so the engine's capability gate stays closed.
|
|
104
|
+
...(cfg.accountToken
|
|
105
|
+
? { domains: createPostmarkDomains({ accountToken: cfg.accountToken }) }
|
|
106
|
+
: {}),
|
|
89
107
|
capabilities: {
|
|
90
108
|
// Forced off per-send above → the engine TRUSTS native tracking is off.
|
|
91
109
|
nativeTracking: false,
|
|
@@ -137,6 +155,191 @@ export function createPostmarkProvider(cfg: PostmarkConfig): EmailProvider {
|
|
|
137
155
|
});
|
|
138
156
|
}
|
|
139
157
|
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Domains capability (Postmark account-token Domains API)
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const DOMAINS_BASE_URL = "https://api.postmarkapp.com";
|
|
163
|
+
|
|
164
|
+
/** A domain as Postmark's `GET /domains/:id` detail reports it. */
|
|
165
|
+
interface PostmarkDomainDetail {
|
|
166
|
+
ID?: number;
|
|
167
|
+
Name?: string;
|
|
168
|
+
DKIMVerified?: boolean;
|
|
169
|
+
DKIMHost?: string;
|
|
170
|
+
DKIMTextValue?: string;
|
|
171
|
+
DKIMPendingHost?: string;
|
|
172
|
+
DKIMPendingTextValue?: string;
|
|
173
|
+
ReturnPathDomain?: string;
|
|
174
|
+
ReturnPathDomainVerified?: boolean;
|
|
175
|
+
ReturnPathDomainCNAMEValue?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function postmarkErrorMessage(status: number, body: unknown): string {
|
|
179
|
+
if (
|
|
180
|
+
body &&
|
|
181
|
+
typeof body === "object" &&
|
|
182
|
+
"Message" in body &&
|
|
183
|
+
typeof (body as { Message: unknown }).Message === "string"
|
|
184
|
+
) {
|
|
185
|
+
return `Postmark domains API ${status}: ${(body as { Message: string }).Message}`;
|
|
186
|
+
}
|
|
187
|
+
return `Postmark domains API request failed with status ${status}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Synthesize neutral {@link DnsRecord}s from a Postmark domain detail. Postmark
|
|
192
|
+
* has no records array — DKIM (a TXT, preferring the PENDING host/value during
|
|
193
|
+
* a rotation) and the Return-Path CNAME are reconstructed from the flat fields.
|
|
194
|
+
*/
|
|
195
|
+
function postmarkRecords(detail: PostmarkDomainDetail): DnsRecord[] {
|
|
196
|
+
const records: DnsRecord[] = [];
|
|
197
|
+
|
|
198
|
+
const dkimName = detail.DKIMPendingHost || detail.DKIMHost || "";
|
|
199
|
+
const dkimValue = detail.DKIMPendingTextValue || detail.DKIMTextValue || "";
|
|
200
|
+
if (dkimName && dkimValue) {
|
|
201
|
+
records.push({
|
|
202
|
+
type: "TXT",
|
|
203
|
+
name: dkimName,
|
|
204
|
+
value: dkimValue,
|
|
205
|
+
purpose: "dkim",
|
|
206
|
+
status: detail.DKIMVerified ? "verified" : "pending",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (detail.ReturnPathDomain && detail.ReturnPathDomainCNAMEValue) {
|
|
211
|
+
records.push({
|
|
212
|
+
type: "CNAME",
|
|
213
|
+
name: detail.ReturnPathDomain,
|
|
214
|
+
value: detail.ReturnPathDomainCNAMEValue,
|
|
215
|
+
purpose: "return_path",
|
|
216
|
+
status: detail.ReturnPathDomainVerified ? "verified" : "pending",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return records;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function postmarkDomainStatus(detail: PostmarkDomainDetail): DomainStatus {
|
|
224
|
+
return {
|
|
225
|
+
domain: detail.Name ?? "",
|
|
226
|
+
// Verified ONLY when both DKIM and the return path check out — Postmark has
|
|
227
|
+
// no single domain-level status flag.
|
|
228
|
+
state:
|
|
229
|
+
detail.DKIMVerified && detail.ReturnPathDomainVerified
|
|
230
|
+
? "verified"
|
|
231
|
+
: "pending",
|
|
232
|
+
records: postmarkRecords(detail),
|
|
233
|
+
providerId: "postmark",
|
|
234
|
+
checkedAt: new Date().toISOString(),
|
|
235
|
+
raw: detail,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The Postmark implementation of the {@link DomainsCapability} contract — a
|
|
241
|
+
* dumb wire over `https://api.postmarkapp.com/domains`, authenticated with the
|
|
242
|
+
* ACCOUNT token. Plain `fetch`, deliberately NOT the `postmark` SDK's account
|
|
243
|
+
* client (keeps the opt-in surface minimal).
|
|
244
|
+
*/
|
|
245
|
+
function createPostmarkDomains(cfg: {
|
|
246
|
+
accountToken: string;
|
|
247
|
+
}): DomainsCapability {
|
|
248
|
+
const api = async (
|
|
249
|
+
path: string,
|
|
250
|
+
init?: { method?: string; body?: unknown },
|
|
251
|
+
): Promise<{ ok: boolean; status: number; body: unknown }> => {
|
|
252
|
+
const res = await fetch(`${DOMAINS_BASE_URL}${path}`, {
|
|
253
|
+
method: init?.method ?? "GET",
|
|
254
|
+
headers: {
|
|
255
|
+
Accept: "application/json",
|
|
256
|
+
"X-Postmark-Account-Token": cfg.accountToken,
|
|
257
|
+
...(init?.body !== undefined
|
|
258
|
+
? { "Content-Type": "application/json" }
|
|
259
|
+
: {}),
|
|
260
|
+
},
|
|
261
|
+
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
262
|
+
});
|
|
263
|
+
let body: unknown;
|
|
264
|
+
try {
|
|
265
|
+
body = await res.json();
|
|
266
|
+
} catch {
|
|
267
|
+
body = undefined;
|
|
268
|
+
}
|
|
269
|
+
return { ok: res.ok, status: res.status, body };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Resolve a domain name → Postmark domain ID via `GET /domains`. */
|
|
273
|
+
const findId = async (domain: string): Promise<number | null> => {
|
|
274
|
+
const res = await api("/domains?count=500&offset=0");
|
|
275
|
+
if (!res.ok) throw new Error(postmarkErrorMessage(res.status, res.body));
|
|
276
|
+
const list =
|
|
277
|
+
res.body && typeof res.body === "object" && "Domains" in res.body
|
|
278
|
+
? (res.body as { Domains: Array<{ ID?: number; Name?: string }> })
|
|
279
|
+
.Domains
|
|
280
|
+
: [];
|
|
281
|
+
const match = (list ?? []).find((d) => d.Name === domain);
|
|
282
|
+
return match?.ID ?? null;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** Fetch + normalize `GET /domains/:id`. */
|
|
286
|
+
const getById = async (id: number): Promise<DomainStatus> => {
|
|
287
|
+
const res = await api(`/domains/${id}`);
|
|
288
|
+
if (!res.ok) throw new Error(postmarkErrorMessage(res.status, res.body));
|
|
289
|
+
return postmarkDomainStatus(res.body as PostmarkDomainDetail);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const get = async (domain: string): Promise<DomainStatus | null> => {
|
|
293
|
+
const id = await findId(domain);
|
|
294
|
+
if (id === null) return null;
|
|
295
|
+
return getById(id);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
async create(domain: string): Promise<DomainStatus> {
|
|
300
|
+
const res = await api("/domains", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
body: { Name: domain },
|
|
303
|
+
});
|
|
304
|
+
if (res.ok) {
|
|
305
|
+
return postmarkDomainStatus(res.body as PostmarkDomainDetail);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Idempotent create: a 422 "already exists" falls through to lookup.
|
|
309
|
+
const message =
|
|
310
|
+
res.body && typeof res.body === "object" && "Message" in res.body
|
|
311
|
+
? String((res.body as { Message: unknown }).Message)
|
|
312
|
+
: "";
|
|
313
|
+
if (res.status === 422 && /already exists/i.test(message)) {
|
|
314
|
+
const existing = await get(domain);
|
|
315
|
+
if (existing) return existing;
|
|
316
|
+
}
|
|
317
|
+
throw new Error(postmarkErrorMessage(res.status, res.body));
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
get,
|
|
321
|
+
|
|
322
|
+
async records(domain: string): Promise<DnsRecord[]> {
|
|
323
|
+
const status = await get(domain);
|
|
324
|
+
return status?.records ?? [];
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async verify(domain: string): Promise<DomainStatus> {
|
|
328
|
+
const id = await findId(domain);
|
|
329
|
+
if (id === null) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`domain "${domain}" is not registered with Postmark — run create first`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
// Run BOTH verification passes best-effort (Postmark 422s a pass that is
|
|
335
|
+
// already verified / not yet ready); the re-get below is the truth.
|
|
336
|
+
await api(`/domains/${id}/verifyDkim`, { method: "PUT" });
|
|
337
|
+
await api(`/domains/${id}/verifyReturnPath`, { method: "PUT" });
|
|
338
|
+
return getById(id);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
140
343
|
/**
|
|
141
344
|
* Postmark `Bounce.TypeCode` values, mapped to provider-neutral bounce classes.
|
|
142
345
|
* The raw string `Type` is preserved in `bounce.code` so nothing is lost.
|