@volter/twin-postmark 0.1.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/LICENSE +202 -0
- package/README.md +144 -0
- package/client/postmark-mirror.css +79 -0
- package/client/postmark-mirror.tsx +221 -0
- package/dist/client/postmark-mirror.bundle.js +321 -0
- package/dist/client/postmark-mirror.css +79 -0
- package/dist/client/postmark-mirror.d.ts +18 -0
- package/dist/client/postmark-mirror.js +153 -0
- package/dist/client/postmark-mirror.tsx +221 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +54 -0
- package/dist/src/postmark-capabilities.d.ts +12 -0
- package/dist/src/postmark-capabilities.js +1502 -0
- package/dist/src/postmark-conformance.d.ts +33 -0
- package/dist/src/postmark-conformance.js +265 -0
- package/dist/src/postmark-connector.d.ts +167 -0
- package/dist/src/postmark-connector.js +251 -0
- package/dist/src/postmark-events.d.ts +85 -0
- package/dist/src/postmark-events.js +169 -0
- package/dist/src/postmark-mirror-ui.d.ts +58 -0
- package/dist/src/postmark-mirror-ui.js +207 -0
- package/dist/src/postmark-perform-harness.d.ts +9 -0
- package/dist/src/postmark-perform-harness.js +24 -0
- package/dist/src/postmark-server.d.ts +14 -0
- package/dist/src/postmark-server.js +29 -0
- package/dist/src/postmark-twin.d.ts +82 -0
- package/dist/src/postmark-twin.js +1575 -0
- package/dist/test-fixtures/postmark-swagger-operations.json +846 -0
- package/package.json +76 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +89 -0
- package/src/postmark-capabilities.ts +1737 -0
- package/src/postmark-conformance.ts +282 -0
- package/src/postmark-connector.ts +312 -0
- package/src/postmark-events.ts +189 -0
- package/src/postmark-mirror-ui.ts +213 -0
- package/src/postmark-perform-harness.ts +21 -0
- package/src/postmark-server.ts +37 -0
- package/src/postmark-twin.ts +1520 -0
- package/test-fixtures/postmark-swagger-operations.json +846 -0
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
// Postmark API twin REQUEST HANDLER — the canonical api.postmarkapp.com surface for the twin,
|
|
2
|
+
// backed by the event/action-log kernel. Contract:
|
|
3
|
+
// handlePostmarkTwinRequest({method, path, body}) -> {status, body}. Response SHAPES mirror
|
|
4
|
+
// real Postmark (PascalCase fields, `{ErrorCode, Message}` on every response, UUID MessageIDs,
|
|
5
|
+
// integer resource ids, `{TotalCount, <Collection>}` list envelopes) so the real `postmark`
|
|
6
|
+
// SDK works unmodified. HTTP wrapper: postmark-server.ts → createPostmarkTwinServer.
|
|
7
|
+
//
|
|
8
|
+
// State lives in the action log: writes are local actions, reads are the projection. No real
|
|
9
|
+
// Postmark is ever called.
|
|
10
|
+
//
|
|
11
|
+
// ── GROUNDING (where every shape below comes from) ────────────────────────────────────
|
|
12
|
+
// 1. The OFFICIAL `postmark` npm SDK's own compiled route map + TypeScript models
|
|
13
|
+
// (node_modules/postmark/dist/client/{ServerClient,AccountClient}.js and
|
|
14
|
+
// dist/client/models/**/*.d.ts) — a first-party artifact, read during this pack's build.
|
|
15
|
+
// That is the source for every path, HTTP verb, field name, casing and list envelope.
|
|
16
|
+
// 2. developer.postmarkapp.com's published API-error-code table (163 rows) — the source for
|
|
17
|
+
// every `ErrorCode` number + its HTTP status below.
|
|
18
|
+
// 3. LIVE read-only probes of api.postmarkapp.com made during this pack's build with the
|
|
19
|
+
// documented public test token `POSTMARK_API_TEST` (and unauthenticated) — marked [WIRE].
|
|
20
|
+
// These corrected three things the docs alone got wrong, all encoded below:
|
|
21
|
+
// · `Subject` is NOT a required field on POST /email (a send without one succeeds);
|
|
22
|
+
// · the 401 Message text is endpoint-scoped ("valid Server token" vs "valid Account
|
|
23
|
+
// token"), not the merged generic string the docs table shows;
|
|
24
|
+
// · `POSTMARK_API_TEST` is /email-ONLY — anywhere else it is 403 + ErrorCode 10 — and a
|
|
25
|
+
// send made with it answers "Test job accepted" rather than "OK".
|
|
26
|
+
// Anything NOT grounded in (1)-(3) is marked `⚠ doc-unverified` in a comment and never
|
|
27
|
+
// asserted beyond what a verify() actually proves. Human-readable `Message` strings are the
|
|
28
|
+
// vendor's published text where the table gives it, and descriptive twin text otherwise.
|
|
29
|
+
//
|
|
30
|
+
// AUTH. Postmark authenticates with `X-Postmark-Server-Token` on server-scoped endpoints and
|
|
31
|
+
// `X-Postmark-Account-Token` on account-scoped ones (SDK: ClientOptions.AuthHeaderNames) —
|
|
32
|
+
// NOT a bearer token. A twin fakes auth locally, so any non-empty token is accepted; a
|
|
33
|
+
// missing or empty token fails like the vendor: 401 `{ErrorCode: 10, Message}`.
|
|
34
|
+
//
|
|
35
|
+
// THE meaty part: a SENT EMAIL is STORED (twin state, never SMTP-delivered) and progresses
|
|
36
|
+
// through a DETERMINISTIC OFFLINE delivery lifecycle that writes real `MessageEvents` onto the
|
|
37
|
+
// stored message, mints Bounce records + suppressions on a bounce, and POSTs Postmark's real
|
|
38
|
+
// webhook payloads (RecordType: Delivery/Open/Click/Bounce/SpamComplaint) to registered
|
|
39
|
+
// webhooks (postmark-events.ts).
|
|
40
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
41
|
+
import { deliveryPlan, emitPostmarkInboundHook, emitPostmarkWebhook, type PostmarkWebhookDelivery } from './postmark-events.ts';
|
|
42
|
+
|
|
43
|
+
const SERVICE = 'postmark';
|
|
44
|
+
|
|
45
|
+
export type PostmarkRequest = {
|
|
46
|
+
method: string;
|
|
47
|
+
path: string;
|
|
48
|
+
body?: string;
|
|
49
|
+
occurredAt?: string;
|
|
50
|
+
root?: string;
|
|
51
|
+
readOnly?: boolean;
|
|
52
|
+
/** Injected webhook deliverer (fake in tests, HTTP live). Keeps verify() offline. */
|
|
53
|
+
deliver?: PostmarkWebhookDelivery;
|
|
54
|
+
/**
|
|
55
|
+
* Request headers (case-insensitive). When PRESENT (the HTTP server always supplies them),
|
|
56
|
+
* the twin enforces Postmark's token auth: a missing/empty `X-Postmark-Server-Token` (or
|
|
57
|
+
* `X-Postmark-Account-Token` on account routes) → 401 `{ErrorCode:10}`. When the whole
|
|
58
|
+
* object is ABSENT, auth is not exercised — the in-process verify()/mirror path is a
|
|
59
|
+
* trusted local call, mirroring the resend pack's convention.
|
|
60
|
+
*/
|
|
61
|
+
headers?: Record<string, string>;
|
|
62
|
+
};
|
|
63
|
+
export type PostmarkResponse = { status: number; body: unknown; headers?: Record<string, string> };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The Postmark `ErrorCode` values this twin emits, each with the HTTP status the vendor
|
|
67
|
+
* pairs it with. Every number + status here is transcribed from developer.postmarkapp.com's
|
|
68
|
+
* published API-error-codes table (see the GROUNDING note above). Note how few of these are
|
|
69
|
+
* 404: Postmark answers "not found" for a KNOWN route with 422 + a per-domain code, and
|
|
70
|
+
* reserves 404 for a route that does not exist at all.
|
|
71
|
+
*/
|
|
72
|
+
export const POSTMARK_ERRORS = {
|
|
73
|
+
ok: 0,
|
|
74
|
+
badToken: 10, // 401 — bad or missing API token (or wrong token type)
|
|
75
|
+
bulkNotApproved: 14, // 422 — Bulk API requires approval
|
|
76
|
+
invalidEmailRequest: 300, // 422 — send validation (From, recipients, body, limits)
|
|
77
|
+
invalidJson: 402, // 422 — Invalid JSON
|
|
78
|
+
invalidRequestField: 403, // 422 — Invalid request field(s)
|
|
79
|
+
inactiveRecipient: 406, // 422 — recipient suppressed (hard bounce / spam complaint)
|
|
80
|
+
tooManyBatchMessages: 410, // 422 — over the 500-message batch cap
|
|
81
|
+
signatureNotFound: 501, // 422 — sender signature not found
|
|
82
|
+
signatureNoData: 502, // 422 — no update data or signature data received
|
|
83
|
+
publicDomainSignature: 503, // 422 — can't use a public-domain address
|
|
84
|
+
signatureExists: 504, // 422 — signature already exists
|
|
85
|
+
domainNotFound: 510, // 422 — domain not found
|
|
86
|
+
domainExists: 512, // 422 — domain already exists
|
|
87
|
+
domainNameRequired: 514, // 422 — Name required to create a domain
|
|
88
|
+
fromEmailRequired: 520, // 422 — FromEmail required to create a signature
|
|
89
|
+
invalidEmailValue: 522, // 422 — not a valid email address / domain
|
|
90
|
+
serverInboundDomainInUse: 602, // 422 — inbound domain already in use
|
|
91
|
+
serverNameExists: 603, // 422 — server name already exists
|
|
92
|
+
serverNameInvalid: 608, // 422 — server name invalid or missing
|
|
93
|
+
serverNoData: 609, // 422 — no server data received
|
|
94
|
+
serverNotFound: 1453, // 422 — this server was not found. NB the published table
|
|
95
|
+
// files this under its SMTP-Tokens block, but it is
|
|
96
|
+
// the only "server was not found" code Postmark
|
|
97
|
+
// publishes, so it is what a server-id miss returns.
|
|
98
|
+
messageNotFound: 701, // 422 — message not found / can't be bypassed or retried
|
|
99
|
+
inboundRuleNoData: 809, // 422 — no trigger data received
|
|
100
|
+
inboundRuleExists: 810, // 422 — this inbound rule already exists
|
|
101
|
+
inboundRuleNotFound: 812, // 422 — this inbound rule was not found
|
|
102
|
+
bounceNotFound: 1001, // 422 — bounce not found / dump no longer available
|
|
103
|
+
bounceCannotActivate: 1003, // 422 — this bounce type cannot be reactivated
|
|
104
|
+
templateNotFound: 1101, // 422 — no TemplateId/TemplateAlias, or it did not resolve
|
|
105
|
+
templateNoData: 1109, // 422 — no template data received
|
|
106
|
+
streamTypeInvalid: 1221, // 422 — invalid MessageStreamType
|
|
107
|
+
streamNameRequired: 1223, // 422 — a valid Name must be provided
|
|
108
|
+
streamNotFound: 1226, // 422 — message stream for the provided ID was not found
|
|
109
|
+
streamIdInvalid: 1227, // 422 — ID must start with a letter, ≤30 chars
|
|
110
|
+
streamCannotArchiveDefault: 1229, // 422 — can't archive the default transactional/inbound stream
|
|
111
|
+
streamIdExists: 1230, // 422 — the ID already exists on this server
|
|
112
|
+
streamCannotArchive: 1241, // 422 — stream is unable to be archived at this time
|
|
113
|
+
streamCannotUnarchive: 1232, // 422 — this stream can no longer be unarchived
|
|
114
|
+
streamIdReservedPrefix: 1233, // 422 — the ID must not start with `pm-`
|
|
115
|
+
sendStreamNotFound: 1235, // 422 — the stream provided does not exist on this server
|
|
116
|
+
dataRemovalIdInvalid: 1301, // 422 — missing or incorrect data-removal request ID
|
|
117
|
+
webhookArchivedStream: 1350, // 422 — cannot create a webhook on an archived stream
|
|
118
|
+
webhookNotFound: 1352, // 422 — webhook for the provided ID was not found
|
|
119
|
+
webhookUrlRequired: 1354, // 422 — the request must contain a valid Url field
|
|
120
|
+
suppressionAuthority: 1406, // 200 (per-item) — not allowed to change this suppression
|
|
121
|
+
suppressionInvalidEmail: 1408, // 422 / per-item — an invalid email address was provided
|
|
122
|
+
suppressionNoBody: 1409, // 422 — a proper request body must be provided
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
/** Postmark's failure envelope: `{ ErrorCode, Message }` at the given HTTP status. */
|
|
126
|
+
function err(message: string, status: number, errorCode: number): PostmarkResponse {
|
|
127
|
+
return { status, body: { ErrorCode: errorCode, Message: message } };
|
|
128
|
+
}
|
|
129
|
+
/** The overwhelmingly common Postmark failure: HTTP 422 + a per-domain ErrorCode. */
|
|
130
|
+
function fail(errorCode: number, message: string): PostmarkResponse {
|
|
131
|
+
return err(message, 422, errorCode);
|
|
132
|
+
}
|
|
133
|
+
/** Postmark's plain success acknowledgement: `{ ErrorCode: 0, Message: '…' }`. */
|
|
134
|
+
function ack(message: string): PostmarkResponse {
|
|
135
|
+
return { status: 200, body: { ErrorCode: POSTMARK_ERRORS.ok, Message: message } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The documented batch cap, live-confirmed by the vendor's own 410 message text.
|
|
139
|
+
const MAX_BATCH = 500;
|
|
140
|
+
// Postmark's documented public test token. [WIRE] It is accepted ONLY on /email; every other
|
|
141
|
+
// path answers 403 + ErrorCode 10, and a send made with it says "Test job accepted".
|
|
142
|
+
const TEST_TOKEN = 'POSTMARK_API_TEST';
|
|
143
|
+
|
|
144
|
+
// Case-insensitive header lookup (Postmark's own header names are case-insensitive).
|
|
145
|
+
function header(headers: Record<string, string> | undefined, name: string): string | undefined {
|
|
146
|
+
if (!headers) return undefined;
|
|
147
|
+
const want = name.toLowerCase();
|
|
148
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === want) return v;
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Postmark token auth. `scope` picks the header the vendor requires for that route family.
|
|
154
|
+
* Absent `headers` ⇒ auth not exercised (trusted in-process call). Present ⇒ the token must
|
|
155
|
+
* be there and non-empty, else the vendor's 401 `{ErrorCode: 10}` with the ENDPOINT-SCOPED
|
|
156
|
+
* message text [WIRE]. A twin fakes auth, so the token's VALUE is never checked against a
|
|
157
|
+
* real account — except for the documented test token, whose /email-only restriction IS
|
|
158
|
+
* modeled (403 + ErrorCode 10 anywhere else) because a caller can trip it for real.
|
|
159
|
+
*/
|
|
160
|
+
function authError(req: PostmarkRequest, scope: 'server' | 'account', isEmailRoute: boolean): PostmarkResponse | null {
|
|
161
|
+
if (req.headers === undefined) return null;
|
|
162
|
+
const name = scope === 'account' ? 'x-postmark-account-token' : 'x-postmark-server-token';
|
|
163
|
+
const token = (header(req.headers, name) ?? '').trim();
|
|
164
|
+
if (token === '') {
|
|
165
|
+
return err(
|
|
166
|
+
`Request does not contain a valid ${scope === 'account' ? 'Account' : 'Server'} token.`,
|
|
167
|
+
401,
|
|
168
|
+
POSTMARK_ERRORS.badToken,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (token === TEST_TOKEN && !isEmailRoute) {
|
|
172
|
+
return err('The Postmark Test API Token may only be used on the /email endpoint.', 403, POSTMARK_ERRORS.badToken);
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/** True when the caller authenticated with Postmark's public test token. */
|
|
177
|
+
function usingTestToken(req: PostmarkRequest): boolean {
|
|
178
|
+
return (header(req.headers, 'x-postmark-server-token') ?? '').trim() === TEST_TOKEN;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function asObject(v: unknown): Record<string, unknown> {
|
|
182
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
183
|
+
}
|
|
184
|
+
function nowIso(occurredAt?: string): string {
|
|
185
|
+
return occurredAt ?? new Date().toISOString();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── projection helpers ──────────────────────────────────────────────────────────────
|
|
189
|
+
/** ALL rows of a type, INCLUDING soft-deleted ones (the id ratchet must see tombstones). */
|
|
190
|
+
function allRows(type: string, root?: string) {
|
|
191
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type && !r.id.startsWith('_'));
|
|
192
|
+
}
|
|
193
|
+
/** Live (non-soft-deleted) rows of a type. */
|
|
194
|
+
function rows(type: string, root?: string) {
|
|
195
|
+
return allRows(type, root).filter((r) => (r as Record<string, unknown>).deleted !== true);
|
|
196
|
+
}
|
|
197
|
+
/** The emitted body: drop kernel meta + twin-internal `_`-prefixed fields. */
|
|
198
|
+
function view(r: Record<string, unknown>): Record<string, unknown> {
|
|
199
|
+
const out: Record<string, unknown> = {};
|
|
200
|
+
for (const [k, v] of Object.entries(r)) {
|
|
201
|
+
if (k === 'type' || k === 'id' || k === 'updatedAt' || k.startsWith('_')) continue;
|
|
202
|
+
out[k] = v;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
|
|
207
|
+
return rows(type, root).find((r) => r.id === id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Mint the next INTEGER id for a type (Postmark's TemplateId / Bounce ID / Webhook ID /
|
|
212
|
+
* Domain ID … are integers). Ratchets over EVERY row of the type INCLUDING soft-deleted
|
|
213
|
+
* tombstones, so a delete→recreate can never re-issue a retired id — the dirty-state bug
|
|
214
|
+
* class ADDING_A_TWIN §6 calls out (gcs generations, supermemory re-add). Deterministic
|
|
215
|
+
* from a fresh root.
|
|
216
|
+
*/
|
|
217
|
+
function nextIntId(type: string, root?: string): number {
|
|
218
|
+
let max = 0;
|
|
219
|
+
for (const r of allRows(type, root)) {
|
|
220
|
+
const n = Number(r.id);
|
|
221
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
222
|
+
}
|
|
223
|
+
return max + 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist. */
|
|
227
|
+
function stableHex(seed: string, n: number): string {
|
|
228
|
+
let out = '';
|
|
229
|
+
for (let round = 0; out.length < n; round += 1) {
|
|
230
|
+
let h = 0x811c9dc5;
|
|
231
|
+
const s = `${round}:${seed}`;
|
|
232
|
+
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
233
|
+
out += h.toString(16).padStart(8, '0');
|
|
234
|
+
}
|
|
235
|
+
return out.slice(0, n);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A Postmark MessageID: a plain UUID, no vendor prefix (SDK: MessageSendingResponse).
|
|
240
|
+
*
|
|
241
|
+
* DETERMINISTIC (R9): the shape stays the vendor's 8-4-4-4-12 hex, but the bytes come from
|
|
242
|
+
* the WORLD instant plus the count of rows the type already holds — never `crypto.randomUUID`.
|
|
243
|
+
* Two identical worlds therefore mint identical MessageIDs and a resource-level replay
|
|
244
|
+
* (`GET /messages/outbound` after a `POST /email`) is byte-identical across roots. The row
|
|
245
|
+
* count is part of the seed, so several sends at one frozen instant still get distinct ids.
|
|
246
|
+
*/
|
|
247
|
+
function newMessageId(type: 'message' | 'inbound_message', req: PostmarkRequest): string {
|
|
248
|
+
const seed = `${type}:${req.occurredAt ?? ''}:${allRows(type, req.root).length}`;
|
|
249
|
+
const hex = stableHex(seed, 32);
|
|
250
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Monotonic per-process write counter, threaded onto every write as the twin-internal `_seq`.
|
|
255
|
+
*
|
|
256
|
+
* WHY THIS EXISTS (do not remove it). The kernel dedupes an action by
|
|
257
|
+
* `operation:subjectId:occurredAt:contentHash` where `occurredAt` has MILLISECOND resolution
|
|
258
|
+
* (control-plane/src/serve.ts `applyTwinWrite`). That is the right behavior for a genuinely
|
|
259
|
+
* re-issued identical write — but a REST twin's repeated identical STATE TRANSITIONS are not
|
|
260
|
+
* replays, they are separate API calls that must each apply. Postmark has no idempotency key
|
|
261
|
+
* on any of these endpoints, so `PUT /domains/1/verifyDKIM` → `POST …/rotateDKIM` →
|
|
262
|
+
* `PUT …/verifyDKIM` is three real calls; when the two verifyDKIM calls landed in the same
|
|
263
|
+
* millisecond with byte-identical fields, the kernel swallowed the second and the domain
|
|
264
|
+
* stayed stuck at `DKIMUpdateStatus: 'Pending'`. The same bug silently dropped a re-created
|
|
265
|
+
* suppression after a bounce reactivation. `_seq` makes each call's content genuinely
|
|
266
|
+
* distinct, so nothing is ever lost; it is `_`-prefixed, so `view()`/the conformance
|
|
267
|
+
* `emitted()` strip it and it never reaches the wire.
|
|
268
|
+
*/
|
|
269
|
+
// PROTOCOL 2: the ordinal comes from the TREE, not the process — a per-subject `_seq` read off the row
|
|
270
|
+
// this write lands on. A module counter drifted with process uptime, so two identical worlds stamped the
|
|
271
|
+
// same call differently; the tree's own count is the same in both.
|
|
272
|
+
/** The next per-subject ordinal, read off the row this write lands on. */
|
|
273
|
+
function nextSeqFor(type: string, id: string, root?: string): number {
|
|
274
|
+
const current = projectResources(SERVICE, root).find((r) => r.type === type && r.id === id) as { _seq?: unknown } | undefined;
|
|
275
|
+
return (typeof current?._seq === 'number' ? current._seq : 0) + 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function writeResource(
|
|
279
|
+
type: string,
|
|
280
|
+
id: string,
|
|
281
|
+
fields: Record<string, unknown>,
|
|
282
|
+
op: string,
|
|
283
|
+
req: PostmarkRequest,
|
|
284
|
+
): Promise<Record<string, unknown>> {
|
|
285
|
+
const { resource } = await applyTwinWrite(
|
|
286
|
+
SERVICE,
|
|
287
|
+
{
|
|
288
|
+
operation: op,
|
|
289
|
+
subjectType: type,
|
|
290
|
+
subjectId: id,
|
|
291
|
+
fields: { ...fields, _seq: nextSeqFor(type, id, req.root) },
|
|
292
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
293
|
+
actor: { kind: 'agent' },
|
|
294
|
+
},
|
|
295
|
+
req.root,
|
|
296
|
+
);
|
|
297
|
+
return view(resource as unknown as Record<string, unknown>);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── bootstrap: a Postmark server always has its default message streams ──────────────
|
|
301
|
+
// A real Postmark server is created with three streams — `outbound` (transactional),
|
|
302
|
+
// `broadcast`, and `inbound` — so `GET /message-streams` is never empty on a live account.
|
|
303
|
+
// The twin materializes them lazily on first touch (idempotent; no-op once present).
|
|
304
|
+
const DEFAULT_STREAMS = [
|
|
305
|
+
{ ID: 'outbound', Name: 'Transactional Stream', MessageStreamType: 'Transactional', Description: 'Default Transactional Stream' },
|
|
306
|
+
{ ID: 'broadcast', Name: 'Broadcast Stream', MessageStreamType: 'Broadcast', Description: 'Default Broadcast Stream' },
|
|
307
|
+
{ ID: 'inbound', Name: 'Inbound Stream', MessageStreamType: 'Inbound', Description: 'Default Inbound Stream' },
|
|
308
|
+
] as const;
|
|
309
|
+
/** Postmark refuses to archive the default transactional + inbound streams (ErrorCode 1229). */
|
|
310
|
+
const UNARCHIVABLE_STREAMS = new Set(['outbound', 'inbound']);
|
|
311
|
+
const DEFAULT_SERVER_ID = 1;
|
|
312
|
+
|
|
313
|
+
async function bootstrap(req: PostmarkRequest): Promise<void> {
|
|
314
|
+
if (req.readOnly) return; // a read-only mirror serves what was pulled; it never writes
|
|
315
|
+
const at = nowIso(req.occurredAt);
|
|
316
|
+
// Materialize each default INDIVIDUALLY, keyed on its own id.
|
|
317
|
+
//
|
|
318
|
+
// §9 found a real dirty-state bug here: these guards used to be `allRows(type).length === 0`,
|
|
319
|
+
// i.e. "has this type any rows at all?". After `syncPostmarkFromReal` pulled a SINGLE
|
|
320
|
+
// real stream (or a single real server), the type was non-empty and the defaults were
|
|
321
|
+
// never created — so on a pulled twin `GET /server` answered "server not found" and a
|
|
322
|
+
// plain `POST /email` (which defaults to the `outbound` stream) died with ErrorCode 1235.
|
|
323
|
+
// Per-id checks make bootstrap correct over prior state, not just from empty.
|
|
324
|
+
for (const s of DEFAULT_STREAMS) {
|
|
325
|
+
if (getRow('message_stream', s.ID, req.root)) continue;
|
|
326
|
+
await writeResource('message_stream', s.ID, {
|
|
327
|
+
ID: s.ID, ServerID: DEFAULT_SERVER_ID, Name: s.Name, Description: s.Description,
|
|
328
|
+
MessageStreamType: s.MessageStreamType, CreatedAt: at, UpdatedAt: null, ArchivedAt: null,
|
|
329
|
+
SubscriptionManagementConfiguration: { UnsubscribeHandlingType: s.MessageStreamType === 'Broadcast' ? 'Postmark' : 'None' },
|
|
330
|
+
}, 'message_stream.bootstrap', req);
|
|
331
|
+
}
|
|
332
|
+
if (!getRow('server', String(DEFAULT_SERVER_ID), req.root)) {
|
|
333
|
+
await writeResource('server', String(DEFAULT_SERVER_ID), serverRecord(DEFAULT_SERVER_ID, 'Twin Server', {}), 'server.bootstrap', req);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The Server object shape Postmark returns (SDK: models/server/Server.d.ts). */
|
|
338
|
+
function serverRecord(id: number, name: string, over: Record<string, unknown>): Record<string, unknown> {
|
|
339
|
+
return {
|
|
340
|
+
ID: id, Name: name, ApiTokens: [`postmark-twin-server-token-${id}`],
|
|
341
|
+
ServerLink: `https://account.postmarkapp.com/servers/${id}/streams`,
|
|
342
|
+
Color: 'blue', SmtpApiActivated: true, RawEmailEnabled: false, DeliveryType: 'Live',
|
|
343
|
+
InboundAddress: `twin${id}inbound@inbound.postmarkapp.com`,
|
|
344
|
+
InboundHookUrl: '', BounceHookUrl: '', OpenHookUrl: '', DeliveryHookUrl: '', ClickHookUrl: '',
|
|
345
|
+
PostFirstOpenOnly: false, InboundDomain: '', InboundHash: `twinhash${id}`, InboundSpamThreshold: 5,
|
|
346
|
+
TrackOpens: false, TrackLinks: 'None', IncludeBounceContentInHook: false, EnableSmtpApiErrorHooks: false,
|
|
347
|
+
...pickServerFields(over),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── recipient parsing ───────────────────────────────────────────────────────────────
|
|
352
|
+
/** Postmark takes recipients as a COMMA-SEPARATED STRING (`To: "a@b.dev, Name <c@d.dev>"`). */
|
|
353
|
+
function parseRecipients(v: unknown): Array<{ Email: string; Name: string }> {
|
|
354
|
+
if (typeof v !== 'string' || v.trim() === '') return [];
|
|
355
|
+
return v.split(',').map((raw) => {
|
|
356
|
+
const s = raw.trim();
|
|
357
|
+
const m = /^(.*)<([^>]+)>$/.exec(s);
|
|
358
|
+
if (m) return { Email: m[2]!.trim(), Name: m[1]!.trim().replace(/^"|"$/g, '') };
|
|
359
|
+
return { Email: s, Name: '' };
|
|
360
|
+
}).filter((r) => r.Email !== '');
|
|
361
|
+
}
|
|
362
|
+
function emailsOf(list: Array<{ Email: string }>): string[] {
|
|
363
|
+
return list.map((r) => r.Email);
|
|
364
|
+
}
|
|
365
|
+
/** The bare address of a `From`-style value (which may be `Name <addr>`). */
|
|
366
|
+
function bareAddress(v: unknown): string {
|
|
367
|
+
return parseRecipients(typeof v === 'string' ? v : '')[0]?.Email ?? '';
|
|
368
|
+
}
|
|
369
|
+
const looksLikeEmail = (s: string) => /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/.test(s);
|
|
370
|
+
|
|
371
|
+
// ── suppressions ────────────────────────────────────────────────────────────────────
|
|
372
|
+
const suppressionId = (stream: string, email: string) => `${stream}::${email.toLowerCase()}`;
|
|
373
|
+
|
|
374
|
+
function isSuppressed(stream: string, email: string, root?: string): boolean {
|
|
375
|
+
return getRow('suppression', suppressionId(stream, email), root) !== undefined;
|
|
376
|
+
}
|
|
377
|
+
async function suppress(stream: string, email: string, reason: string, origin: string, req: PostmarkRequest): Promise<void> {
|
|
378
|
+
await writeResource('suppression', suppressionId(stream, email), {
|
|
379
|
+
EmailAddress: email, SuppressionReason: reason, Origin: origin,
|
|
380
|
+
CreatedAt: nowIso(req.occurredAt), MessageStream: stream, deleted: false,
|
|
381
|
+
}, 'suppression.create', req);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── send validation ─────────────────────────────────────────────────────────────────
|
|
385
|
+
/**
|
|
386
|
+
* Validate a send payload the way Postmark really does [WIRE, probed against the live API
|
|
387
|
+
* with the public test token]:
|
|
388
|
+
* · `From` is validated FIRST — an empty body answers "Invalid 'From' value.", not a
|
|
389
|
+
* recipient error;
|
|
390
|
+
* · zero recipients across To/Cc/Bcc → "Zero recipients specified.";
|
|
391
|
+
* · a non-templated send needs HtmlBody or TextBody;
|
|
392
|
+
* · `Subject` is NOT required — a send with no Subject SUCCEEDS on the real API. (This is
|
|
393
|
+
* the single most commonly-assumed-wrong field on this endpoint; do not "fix" it.)
|
|
394
|
+
* Every failure is 422 `{ErrorCode: 300}`.
|
|
395
|
+
*/
|
|
396
|
+
function validateSend(p: Record<string, unknown>, templated: boolean): PostmarkResponse | null {
|
|
397
|
+
if (typeof p.From !== 'string' || !looksLikeEmail(bareAddress(p.From))) {
|
|
398
|
+
return fail(POSTMARK_ERRORS.invalidEmailRequest, "Invalid 'From' value.");
|
|
399
|
+
}
|
|
400
|
+
const recipients = [...parseRecipients(p.To), ...parseRecipients(p.Cc), ...parseRecipients(p.Bcc)];
|
|
401
|
+
if (recipients.length === 0) return fail(POSTMARK_ERRORS.invalidEmailRequest, 'Zero recipients specified.');
|
|
402
|
+
const bad = recipients.find((r) => !looksLikeEmail(r.Email));
|
|
403
|
+
if (bad) return fail(POSTMARK_ERRORS.invalidEmailRequest, `Error parsing 'To': Illegal email address '${bad.Email}'.`);
|
|
404
|
+
// Postmark caps each of To/Cc/Bcc at 50 recipients.
|
|
405
|
+
for (const [field, list] of [['To', p.To], ['Cc', p.Cc], ['Bcc', p.Bcc]] as const) {
|
|
406
|
+
if (parseRecipients(list).length > 50) {
|
|
407
|
+
return fail(POSTMARK_ERRORS.invalidEmailRequest, `Error parsing '${field}': you may only send to 50 recipients per field.`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (!templated) {
|
|
411
|
+
const hasHtml = typeof p.HtmlBody === 'string' && p.HtmlBody !== '';
|
|
412
|
+
const hasText = typeof p.TextBody === 'string' && p.TextBody !== '';
|
|
413
|
+
if (!hasHtml && !hasText) return fail(POSTMARK_ERRORS.invalidEmailRequest, 'Provide either email TextBody or HtmlBody or both.');
|
|
414
|
+
}
|
|
415
|
+
if (p.Attachments !== undefined) {
|
|
416
|
+
if (!Array.isArray(p.Attachments)) return fail(POSTMARK_ERRORS.invalidEmailRequest, "Error parsing 'Attachments': expected an array.");
|
|
417
|
+
for (const raw of p.Attachments) {
|
|
418
|
+
const a = asObject(raw);
|
|
419
|
+
if (typeof a.Name !== 'string' || a.Name === '') return fail(POSTMARK_ERRORS.invalidEmailRequest, "Error parsing 'Attachments': each attachment requires a Name.");
|
|
420
|
+
if (typeof a.Content !== 'string' || a.Content === '') return fail(POSTMARK_ERRORS.invalidEmailRequest, "Error parsing 'Attachments': each attachment requires base64 Content.");
|
|
421
|
+
if (typeof a.ContentType !== 'string' || a.ContentType === '') return fail(POSTMARK_ERRORS.invalidEmailRequest, "Error parsing 'Attachments': each attachment requires a ContentType.");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── template resolution + rendering ─────────────────────────────────────────────────
|
|
428
|
+
/** Postmark resolves a template by numeric TemplateId OR by string Alias. */
|
|
429
|
+
function resolveTemplate(idOrAlias: unknown, root?: string): Record<string, unknown> | undefined {
|
|
430
|
+
const key = String(idOrAlias ?? '');
|
|
431
|
+
if (key === '') return undefined;
|
|
432
|
+
const list = rows('template', root);
|
|
433
|
+
return list.find((t) => String(t.TemplateId) === key) ?? list.find((t) => t.Alias != null && String(t.Alias) === key);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Postmark's Mustachio rendering, modeled for the `{{ token }}` interpolation a transactional
|
|
438
|
+
* template actually uses (dotted paths included). An unknown key renders EMPTY — Postmark's
|
|
439
|
+
* rendering is non-strict, so a missing model key is not an error.
|
|
440
|
+
*/
|
|
441
|
+
function renderTemplate(source: unknown, model: Record<string, unknown>): string {
|
|
442
|
+
if (typeof source !== 'string') return '';
|
|
443
|
+
return source.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => {
|
|
444
|
+
let cur: unknown = model;
|
|
445
|
+
for (const seg of path.split('.')) {
|
|
446
|
+
if (cur && typeof cur === 'object' && seg in (cur as Record<string, unknown>)) cur = (cur as Record<string, unknown>)[seg];
|
|
447
|
+
else return '';
|
|
448
|
+
}
|
|
449
|
+
return cur === null || cur === undefined ? '' : String(cur);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** Every `{{ token }}` a template's content references — Postmark's SuggestedTemplateModel. */
|
|
453
|
+
function suggestedModel(...sources: unknown[]): Record<string, unknown> {
|
|
454
|
+
const out: Record<string, unknown> = {};
|
|
455
|
+
for (const s of sources) {
|
|
456
|
+
if (typeof s !== 'string') continue;
|
|
457
|
+
for (const m of s.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
|
|
458
|
+
const key = m[1]!.split('.')[0]!;
|
|
459
|
+
if (!(key in out)) out[key] = `${key}_Value`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── the send path ───────────────────────────────────────────────────────────────────
|
|
466
|
+
/**
|
|
467
|
+
* Send ONE email: store it as twin state, then run its DETERMINISTIC OFFLINE delivery
|
|
468
|
+
* lifecycle — appending real `MessageEvents` to the stored message, minting a Bounce record
|
|
469
|
+
* + suppression on a bounce, and POSTing Postmark's real webhook payloads. Returns the
|
|
470
|
+
* vendor's `MessageSendingResponse`.
|
|
471
|
+
*/
|
|
472
|
+
async function sendOne(p: Record<string, unknown>, req: PostmarkRequest): Promise<PostmarkResponse> {
|
|
473
|
+
const stream = typeof p.MessageStream === 'string' && p.MessageStream !== '' ? p.MessageStream : 'outbound';
|
|
474
|
+
if (!getRow('message_stream', stream, req.root)) {
|
|
475
|
+
return fail(POSTMARK_ERRORS.sendStreamNotFound, 'The stream provided does not exist on this server.');
|
|
476
|
+
}
|
|
477
|
+
const to = parseRecipients(p.To);
|
|
478
|
+
const cc = parseRecipients(p.Cc);
|
|
479
|
+
const bcc = parseRecipients(p.Bcc);
|
|
480
|
+
const recipients = [...emailsOf(to), ...emailsOf(cc), ...emailsOf(bcc)];
|
|
481
|
+
|
|
482
|
+
// Postmark refuses to send to a SUPPRESSED (inactive) recipient: 422 `{ErrorCode: 406}`.
|
|
483
|
+
// Genuinely stateful — it only fires over PRIOR state (an earlier hard bounce/spam
|
|
484
|
+
// complaint, or an explicit suppression POST), so a fresh-root-only test cannot reach it.
|
|
485
|
+
const blocked = recipients.filter((e) => isSuppressed(stream, e, req.root));
|
|
486
|
+
if (blocked.length) {
|
|
487
|
+
return fail(
|
|
488
|
+
POSTMARK_ERRORS.inactiveRecipient,
|
|
489
|
+
`You tried to send to recipient(s) that have been marked as inactive. Found inactive addresses: ${blocked.join(', ')}. Inactive recipients are ones that have generated a hard bounce or a spam complaint.`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const at = nowIso(req.occurredAt);
|
|
494
|
+
const messageId = newMessageId('message', req);
|
|
495
|
+
await writeResource('message', messageId, {
|
|
496
|
+
MessageID: messageId,
|
|
497
|
+
Tag: typeof p.Tag === 'string' ? p.Tag : '',
|
|
498
|
+
To: to, Cc: cc, Bcc: bcc, Recipients: recipients,
|
|
499
|
+
ReceivedAt: at,
|
|
500
|
+
From: String(p.From ?? ''),
|
|
501
|
+
Subject: typeof p.Subject === 'string' ? p.Subject : '',
|
|
502
|
+
Attachments: Array.isArray(p.Attachments) ? p.Attachments.map((a) => String(asObject(a).Name ?? '')) : [],
|
|
503
|
+
Status: 'Sent',
|
|
504
|
+
TrackOpens: p.TrackOpens === true,
|
|
505
|
+
TrackLinks: typeof p.TrackLinks === 'string' && p.TrackLinks !== '' ? p.TrackLinks : 'None',
|
|
506
|
+
Metadata: asObject(p.Metadata),
|
|
507
|
+
MessageStream: stream,
|
|
508
|
+
Sandboxed: false,
|
|
509
|
+
HtmlBody: typeof p.HtmlBody === 'string' ? p.HtmlBody : null,
|
|
510
|
+
TextBody: typeof p.TextBody === 'string' ? p.TextBody : null,
|
|
511
|
+
ReplyTo: typeof p.ReplyTo === 'string' ? p.ReplyTo : '',
|
|
512
|
+
Headers: Array.isArray(p.Headers) ? p.Headers : [],
|
|
513
|
+
MessageEvents: [],
|
|
514
|
+
}, 'message.send', req);
|
|
515
|
+
|
|
516
|
+
await runDelivery(messageId, req);
|
|
517
|
+
|
|
518
|
+
const body: Record<string, unknown> = {
|
|
519
|
+
To: emailsOf(to).join(', '),
|
|
520
|
+
SubmittedAt: at,
|
|
521
|
+
MessageID: messageId,
|
|
522
|
+
ErrorCode: POSTMARK_ERRORS.ok,
|
|
523
|
+
// [WIRE] the real API answers "Test job accepted" for a test-token send, "OK" otherwise.
|
|
524
|
+
Message: usingTestToken(req) ? 'Test job accepted' : 'OK',
|
|
525
|
+
};
|
|
526
|
+
if (cc.length) body.Cc = emailsOf(cc).join(', ');
|
|
527
|
+
if (bcc.length) body.Bcc = emailsOf(bcc).join(', ');
|
|
528
|
+
return { status: 200, body };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Drive the stored message through its deterministic OFFLINE delivery outcome: append the
|
|
533
|
+
* vendor's `MessageEvents`, flip `Status`, mint a Bounce row + suppression for a bounce or
|
|
534
|
+
* spam complaint, and POST each event as Postmark's real webhook payload.
|
|
535
|
+
*/
|
|
536
|
+
async function runDelivery(messageId: string, req: PostmarkRequest): Promise<void> {
|
|
537
|
+
const msg = getRow('message', messageId, req.root);
|
|
538
|
+
if (!msg) return;
|
|
539
|
+
const at = nowIso(req.occurredAt);
|
|
540
|
+
const stream = String(msg.MessageStream ?? 'outbound');
|
|
541
|
+
const recipient = (msg.Recipients as string[] | undefined)?.[0] ?? '';
|
|
542
|
+
const plan = deliveryPlan({
|
|
543
|
+
recipient,
|
|
544
|
+
trackOpens: msg.TrackOpens === true,
|
|
545
|
+
trackLinks: String(msg.TrackLinks ?? 'None'),
|
|
546
|
+
});
|
|
547
|
+
const client = { Name: 'Twin Mail', Company: 'Volter', Family: 'Twin' };
|
|
548
|
+
const geo = { CountryISOCode: 'US', Country: 'United States' };
|
|
549
|
+
|
|
550
|
+
const events: Array<Record<string, unknown>> = [];
|
|
551
|
+
for (const kind of plan.events) {
|
|
552
|
+
if (kind === 'Delivered') {
|
|
553
|
+
events.push({ Recipient: recipient, Type: 'Delivered', ReceivedAt: at, Details: { DeliveryMessage: 'smtp;250 2.0.0 OK', DestinationServer: 'twin.local (127.0.0.1)', DestinationIP: '127.0.0.1' } });
|
|
554
|
+
await emitPostmarkWebhook(stream, 'Delivery', {
|
|
555
|
+
RecordType: 'Delivery', ServerID: DEFAULT_SERVER_ID, MessageStream: stream,
|
|
556
|
+
MessageID: messageId, Recipient: recipient, Tag: msg.Tag ?? '', DeliveredAt: at,
|
|
557
|
+
Details: 'smtp;250 2.0.0 OK', Metadata: msg.Metadata ?? {},
|
|
558
|
+
}, req);
|
|
559
|
+
} else if (kind === 'Opened') {
|
|
560
|
+
events.push({ Recipient: recipient, Type: 'Opened', ReceivedAt: at, Details: { Summary: 'Opened by the twin recipient' } });
|
|
561
|
+
await emitPostmarkWebhook(stream, 'Open', {
|
|
562
|
+
RecordType: 'Open', MessageStream: stream, FirstOpen: true, MessageID: messageId,
|
|
563
|
+
Recipient: recipient, Tag: msg.Tag ?? '', ReceivedAt: at, Platform: 'WebMail',
|
|
564
|
+
ReadSeconds: 5, UserAgent: 'postmark-twin', Client: client, OS: client, Geo: geo,
|
|
565
|
+
Metadata: msg.Metadata ?? {},
|
|
566
|
+
}, req);
|
|
567
|
+
} else if (kind === 'LinkClicked') {
|
|
568
|
+
events.push({ Recipient: recipient, Type: 'LinkClicked', ReceivedAt: at, Details: { Summary: 'Clicked by the twin recipient', Link: 'https://twin.test/link', ClickLocation: 'HTML' } });
|
|
569
|
+
await emitPostmarkWebhook(stream, 'Click', {
|
|
570
|
+
RecordType: 'Click', MessageStream: stream, MessageID: messageId, Recipient: recipient,
|
|
571
|
+
Tag: msg.Tag ?? '', ReceivedAt: at, ClickLocation: 'HTML', OriginalLink: 'https://twin.test/link',
|
|
572
|
+
Platform: 'Desktop', UserAgent: 'postmark-twin', Client: client, OS: client, Geo: geo,
|
|
573
|
+
Metadata: msg.Metadata ?? {},
|
|
574
|
+
}, req);
|
|
575
|
+
} else if (kind === 'Bounced' || kind === 'SpamComplaint') {
|
|
576
|
+
const isSpam = kind === 'SpamComplaint';
|
|
577
|
+
const bounceId = nextIntId('bounce', req.root);
|
|
578
|
+
const bounce = {
|
|
579
|
+
RecordType: 'Bounce',
|
|
580
|
+
ID: bounceId,
|
|
581
|
+
Type: isSpam ? 'SpamComplaint' : 'HardBounce',
|
|
582
|
+
TypeCode: isSpam ? 100001 : 1, // SDK: BounceTypeCode.SpamComplaint / .HardBounce
|
|
583
|
+
Name: isSpam ? 'Spam complaint' : 'Hard bounce',
|
|
584
|
+
Tag: msg.Tag ?? '',
|
|
585
|
+
MessageID: messageId,
|
|
586
|
+
ServerID: DEFAULT_SERVER_ID,
|
|
587
|
+
Description: isSpam
|
|
588
|
+
? 'The subscriber explicitly marked this message as spam.'
|
|
589
|
+
: 'The server was unable to deliver your message (ex: unknown user, mailbox not found).',
|
|
590
|
+
Details: isSpam ? 'Test spam complaint' : 'Test bounce',
|
|
591
|
+
Email: recipient,
|
|
592
|
+
From: bareAddress(msg.From),
|
|
593
|
+
BouncedAt: at,
|
|
594
|
+
DumpAvailable: true,
|
|
595
|
+
Inactive: true,
|
|
596
|
+
CanActivate: !isSpam, // Postmark cannot reactivate a spam complaint (ErrorCode 1003)
|
|
597
|
+
Subject: String(msg.Subject ?? ''),
|
|
598
|
+
MessageStream: stream,
|
|
599
|
+
};
|
|
600
|
+
await writeResource('bounce', String(bounceId), bounce, 'bounce.record', req);
|
|
601
|
+
await suppress(stream, recipient, isSpam ? 'SpamComplaint' : 'HardBounce', 'Recipient', req);
|
|
602
|
+
events.push({ Recipient: recipient, Type: isSpam ? 'SpamComplaint' : 'Bounced', ReceivedAt: at, Details: { Summary: bounce.Description, BounceID: bounceId } });
|
|
603
|
+
await emitPostmarkWebhook(stream, isSpam ? 'SpamComplaint' : 'Bounce', { ...bounce, RecordType: isSpam ? 'SpamComplaint' : 'Bounce' }, req);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await writeResource('message', messageId, { MessageEvents: events, Status: plan.status }, 'message.deliver', req);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── stats ───────────────────────────────────────────────────────────────────────────
|
|
610
|
+
function eventsOf(m: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
611
|
+
return Array.isArray(m.MessageEvents) ? m.MessageEvents as Array<Record<string, unknown>> : [];
|
|
612
|
+
}
|
|
613
|
+
function messageEvents(m: Record<string, unknown>, type: string): number {
|
|
614
|
+
return eventsOf(m).filter((e) => e.Type === type).length;
|
|
615
|
+
}
|
|
616
|
+
function eventCount(root: string | undefined, type: string): number {
|
|
617
|
+
return rows('message', root).reduce((n, m) => n + messageEvents(m, type), 0);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** `GET /stats/outbound` — computed from the SAME projected messages the Messages API serves. */
|
|
621
|
+
function outboundStatistics(root?: string): Record<string, unknown> {
|
|
622
|
+
const msgs = rows('message', root);
|
|
623
|
+
const sent = msgs.length;
|
|
624
|
+
const bounced = eventCount(root, 'Bounced');
|
|
625
|
+
const spam = eventCount(root, 'SpamComplaint');
|
|
626
|
+
const opens = eventCount(root, 'Opened');
|
|
627
|
+
const clicks = eventCount(root, 'LinkClicked');
|
|
628
|
+
const withOpenTracking = msgs.filter((m) => m.TrackOpens === true).length;
|
|
629
|
+
const withLinkTracking = msgs.filter((m) => m.TrackLinks !== 'None').length;
|
|
630
|
+
const rate = (n: number) => (sent === 0 ? 0 : Number(((n / sent) * 100).toFixed(3)));
|
|
631
|
+
return {
|
|
632
|
+
Sent: sent,
|
|
633
|
+
Bounced: bounced,
|
|
634
|
+
SMTPApiErrors: 0,
|
|
635
|
+
BounceRate: rate(bounced),
|
|
636
|
+
SpamComplaints: spam,
|
|
637
|
+
SpamComplaintsRate: rate(spam),
|
|
638
|
+
Opens: opens,
|
|
639
|
+
UniqueOpens: msgs.filter((m) => messageEvents(m, 'Opened') > 0).length,
|
|
640
|
+
Tracked: withOpenTracking + withLinkTracking,
|
|
641
|
+
WithLinkTracking: withLinkTracking,
|
|
642
|
+
WithOpenTracking: withOpenTracking,
|
|
643
|
+
TotalTrackedLinksSent: withLinkTracking,
|
|
644
|
+
UniqueLinksClicked: msgs.filter((m) => messageEvents(m, 'LinkClicked') > 0).length,
|
|
645
|
+
TotalClicks: clicks,
|
|
646
|
+
WithClientRecorded: opens,
|
|
647
|
+
WithPlatformRecorded: opens,
|
|
648
|
+
WithReadTimeRecorded: opens,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The `{ Days: [{Date, …}], <totals> }` envelope every per-day stats endpoint returns. */
|
|
653
|
+
function dayCounts(root: string | undefined, totals: Record<string, number>, perDay: (m: Record<string, unknown>) => Record<string, number>): Record<string, unknown> {
|
|
654
|
+
const byDay = new Map<string, Record<string, number>>();
|
|
655
|
+
for (const m of rows('message', root)) {
|
|
656
|
+
const day = String(m.ReceivedAt ?? '').slice(0, 10) || '1970-01-01';
|
|
657
|
+
const acc = byDay.get(day) ?? {};
|
|
658
|
+
for (const [k, v] of Object.entries(perDay(m))) acc[k] = (acc[k] ?? 0) + v;
|
|
659
|
+
byDay.set(day, acc);
|
|
660
|
+
}
|
|
661
|
+
const Days = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, counts]) => ({ Date: date, ...counts }));
|
|
662
|
+
return { Days, ...totals };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── tracking events (opens / clicks) ────────────────────────────────────────────────
|
|
666
|
+
function trackingRows(root: string | undefined, type: 'Opened' | 'LinkClicked'): Array<Record<string, unknown>> {
|
|
667
|
+
const client = { Name: 'Twin Mail', Company: 'Volter', Family: 'Twin' };
|
|
668
|
+
const out: Array<Record<string, unknown>> = [];
|
|
669
|
+
for (const m of rows('message', root)) {
|
|
670
|
+
for (const e of eventsOf(m)) {
|
|
671
|
+
if (e.Type !== type) continue;
|
|
672
|
+
const base = {
|
|
673
|
+
MessageID: m.MessageID, MessageStream: m.MessageStream, Recipient: e.Recipient,
|
|
674
|
+
Tag: m.Tag ?? '', ReceivedAt: e.ReceivedAt, UserAgent: 'postmark-twin',
|
|
675
|
+
Client: client, OS: client, Geo: { CountryISOCode: 'US', Country: 'United States' },
|
|
676
|
+
};
|
|
677
|
+
out.push(type === 'Opened'
|
|
678
|
+
? { RecordType: 'Open', ...base, Platform: 'WebMail', FirstOpen: true, ReadSeconds: 5 }
|
|
679
|
+
: { RecordType: 'Click', ...base, Platform: 'Desktop', ClickLocation: 'HTML', OriginalLink: 'https://twin.test/link' });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── pagination ──────────────────────────────────────────────────────────────────────
|
|
686
|
+
function paginate<T>(items: T[], query: Record<string, string>): T[] {
|
|
687
|
+
const offset = Number(query.offset ?? '0') || 0;
|
|
688
|
+
const raw = Number(query.count ?? '');
|
|
689
|
+
const count = Number.isFinite(raw) && raw > 0 ? raw : items.length;
|
|
690
|
+
return items.slice(offset, offset + count);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── route handler ───────────────────────────────────────────────────────────────────
|
|
694
|
+
/** Route families that authenticate with the ACCOUNT token rather than the server token. */
|
|
695
|
+
const ACCOUNT_ROUTES = new Set(['servers', 'domains', 'senders', 'data-removals']);
|
|
696
|
+
|
|
697
|
+
export async function handlePostmarkTwinRequest(req: PostmarkRequest): Promise<PostmarkResponse> {
|
|
698
|
+
const method = req.method.toUpperCase();
|
|
699
|
+
const rawPath = req.path.split('?');
|
|
700
|
+
const path = rawPath[0]!.replace(/\/+$/, '') || '/';
|
|
701
|
+
const query = Object.fromEntries(new URLSearchParams(rawPath[1] ?? ''));
|
|
702
|
+
const seg = path.replace(/^\/+/, '').split('/');
|
|
703
|
+
// Postmark's own SDK spells some route families in camelCase (`/deliveryStats`,
|
|
704
|
+
// `/triggers/inboundRules`, `/domains/:id/verifyDKIM`) while its docs spell them lower —
|
|
705
|
+
// the real API accepts both, so the twin routes on a CASE-FOLDED family segment. Only the
|
|
706
|
+
// literal route words are folded; resource IDS keep their case (see `idAt`).
|
|
707
|
+
const family = (seg[0] ?? '').toLowerCase();
|
|
708
|
+
const idAt = (i: number) => decodeURIComponent(seg[i] ?? '');
|
|
709
|
+
|
|
710
|
+
if (req.readOnly && method !== 'GET') {
|
|
711
|
+
return err('twin is read-only; omit readOnly to accept writes', 405, 405);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// `PUT /templates/push` is an ACCOUNT-token route living under the `templates` prefix.
|
|
715
|
+
const accountScoped = ACCOUNT_ROUTES.has(family) || (family === 'templates' && (seg[1] ?? '').toLowerCase() === 'push');
|
|
716
|
+
const auth = authError(req, accountScoped ? 'account' : 'server', family === 'email');
|
|
717
|
+
if (auth) return auth;
|
|
718
|
+
|
|
719
|
+
// Postmark answers a malformed JSON body with 422 `{ErrorCode: 402, Message:'Invalid JSON'}`
|
|
720
|
+
// [WIRE — note the vendor's message has no trailing period].
|
|
721
|
+
let parsed: unknown = {};
|
|
722
|
+
if (req.body) {
|
|
723
|
+
try {
|
|
724
|
+
parsed = JSON.parse(req.body);
|
|
725
|
+
} catch {
|
|
726
|
+
return fail(POSTMARK_ERRORS.invalidJson, 'Invalid JSON');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const body = asObject(parsed);
|
|
730
|
+
|
|
731
|
+
if (path === '/' || path === '') {
|
|
732
|
+
return { status: 200, body: { ErrorCode: 0, Message: 'OK', Service: 'postmark', Object: 'twin' } };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
await bootstrap(req);
|
|
736
|
+
|
|
737
|
+
// ── twin-only seed routes (NOT vendor surface; never counted as coverage) ─────────
|
|
738
|
+
// Postmark's inbound surface is fed by MX-routed real mail, which a local twin cannot
|
|
739
|
+
// receive. `POST /_twin/inbound` is the twin's seam for putting an inbound message into
|
|
740
|
+
// state so the real `/messages/inbound*` reads have something faithful to serve. Kept
|
|
741
|
+
// under a `_twin/` prefix a real client never calls (ADDING_A_TWIN §6 seed-route rule).
|
|
742
|
+
if (family === '_twin' && seg[1] === 'inbound' && method === 'POST') {
|
|
743
|
+
const id = newMessageId('inbound_message', req);
|
|
744
|
+
const from = String(body.From ?? 'sender@twin.test');
|
|
745
|
+
const to = String(body.To ?? 'twin1inbound@inbound.postmarkapp.com');
|
|
746
|
+
const at = nowIso(req.occurredAt);
|
|
747
|
+
const rec = (v: string) => ({ Email: v, Name: '', MailboxHash: '' });
|
|
748
|
+
const m = await writeResource('inbound_message', id, {
|
|
749
|
+
MessageID: id, From: from, FromName: String(body.FromName ?? ''), FromFull: rec(from),
|
|
750
|
+
To: to, ToFull: [rec(to)], Cc: '', CcFull: [], Bcc: '', BccFull: [], ReplyTo: '',
|
|
751
|
+
OriginalRecipient: to, Subject: String(body.Subject ?? ''), Date: at,
|
|
752
|
+
MailboxHash: String(body.MailboxHash ?? ''), Tag: String(body.Tag ?? ''),
|
|
753
|
+
Status: 'Processed', Attachments: [], MessageStream: 'inbound',
|
|
754
|
+
TextBody: String(body.TextBody ?? ''), HtmlBody: String(body.HtmlBody ?? ''),
|
|
755
|
+
StrippedTextReply: '', Headers: [], BlockedReason: '',
|
|
756
|
+
}, 'inbound_message.receive', req);
|
|
757
|
+
// A message in state is only half of what MX-routed mail does on the real vendor: the
|
|
758
|
+
// other half is the POST to the server's `InboundHookUrl`. Without it a consumer can
|
|
759
|
+
// only find an arrival by polling, which is precisely the behavior an inbound webhook
|
|
760
|
+
// exists to replace, so the seed route drives both.
|
|
761
|
+
await emitPostmarkInboundHook(m, req);
|
|
762
|
+
return { status: 200, body: m };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ── EMAIL ────────────────────────────────────────────────────────────────────────
|
|
766
|
+
if (family === 'email') {
|
|
767
|
+
if (seg.length === 1 && method === 'POST') {
|
|
768
|
+
const v = validateSend(body, false);
|
|
769
|
+
return v ?? sendOne(body, req);
|
|
770
|
+
}
|
|
771
|
+
if (seg.length === 2 && seg[1] === 'withTemplate' && method === 'POST') {
|
|
772
|
+
return sendWithTemplate(body, req);
|
|
773
|
+
}
|
|
774
|
+
// POST /email/batch — a bare ARRAY in, a bare ARRAY out. [WIRE] Postmark answers 200 with
|
|
775
|
+
// PER-MESSAGE results: a bad entry becomes an `{ErrorCode, Message}` element rather than
|
|
776
|
+
// failing the whole batch. Only the >500 cap is a top-level 422.
|
|
777
|
+
if (seg.length === 2 && seg[1] === 'batch' && method === 'POST') {
|
|
778
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
779
|
+
if (list.length === 0) return fail(POSTMARK_ERRORS.invalidEmailRequest, 'Provide a non-empty array of messages.');
|
|
780
|
+
if (list.length > MAX_BATCH) return fail(POSTMARK_ERRORS.tooManyBatchMessages, `You may only send up to ${MAX_BATCH} messages in a single batched request.`);
|
|
781
|
+
const out: unknown[] = [];
|
|
782
|
+
for (const item of list) {
|
|
783
|
+
const p = asObject(item);
|
|
784
|
+
const v = validateSend(p, false);
|
|
785
|
+
out.push(v ? v.body : (await sendOne(p, req)).body);
|
|
786
|
+
}
|
|
787
|
+
return { status: 200, body: out };
|
|
788
|
+
}
|
|
789
|
+
// POST /email/batchWithTemplates — `{ Messages: [...] }` in, a bare array out.
|
|
790
|
+
if (seg.length === 2 && seg[1] === 'batchWithTemplates' && method === 'POST') {
|
|
791
|
+
const list = Array.isArray(body.Messages) ? body.Messages : [];
|
|
792
|
+
if (list.length === 0) return fail(POSTMARK_ERRORS.invalidEmailRequest, "Provide a non-empty 'Messages' array.");
|
|
793
|
+
if (list.length > MAX_BATCH) return fail(POSTMARK_ERRORS.tooManyBatchMessages, `You may only send up to ${MAX_BATCH} messages in a single batched request.`);
|
|
794
|
+
const out: unknown[] = [];
|
|
795
|
+
for (const item of list) out.push((await sendWithTemplate(asObject(item), req)).body);
|
|
796
|
+
return { status: 200, body: out };
|
|
797
|
+
}
|
|
798
|
+
// The Bulk API is approval-gated on a real account, and this twin does not model it, so
|
|
799
|
+
// the vendor's own refusal (ErrorCode 14) is the faithful answer — but ONLY on the two
|
|
800
|
+
// routes the vendor actually publishes. §9 caught the earlier `seg[1] === 'bulk'` guard
|
|
801
|
+
// answering 422 for EVERY verb under the subtree, which meant an unmodeled route there
|
|
802
|
+
// could never 404 and quietly contradicted the unmodeled-ops-fail-like-the-vendor rule.
|
|
803
|
+
if ((seg.length === 2 && seg[1] === 'bulk' && method === 'POST')
|
|
804
|
+
|| (seg.length === 3 && seg[1] === 'bulk' && method === 'GET')) {
|
|
805
|
+
return fail(POSTMARK_ERRORS.bulkNotApproved, 'This endpoint requires approval to access. Contact support to use the Bulk API.');
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── MESSAGES ─────────────────────────────────────────────────────────────────────
|
|
810
|
+
if (family === 'messages' && seg[1] === 'outbound') {
|
|
811
|
+
if (seg[2] === 'opens' && method === 'GET') {
|
|
812
|
+
let items = trackingRows(req.root, 'Opened');
|
|
813
|
+
if (seg.length === 4) items = items.filter((o) => o.MessageID === idAt(3));
|
|
814
|
+
if (query.recipient) items = items.filter((o) => String(o.Recipient).includes(query.recipient!));
|
|
815
|
+
if (query.tag) items = items.filter((o) => o.Tag === query.tag);
|
|
816
|
+
return { status: 200, body: { TotalCount: items.length, Opens: paginate(items, query) } };
|
|
817
|
+
}
|
|
818
|
+
if (seg[2] === 'clicks' && method === 'GET') {
|
|
819
|
+
let items = trackingRows(req.root, 'LinkClicked');
|
|
820
|
+
if (seg.length === 4) items = items.filter((c) => c.MessageID === idAt(3));
|
|
821
|
+
if (query.recipient) items = items.filter((c) => String(c.Recipient).includes(query.recipient!));
|
|
822
|
+
if (query.tag) items = items.filter((c) => c.Tag === query.tag);
|
|
823
|
+
return { status: 200, body: { TotalCount: items.length, Clicks: paginate(items, query) } };
|
|
824
|
+
}
|
|
825
|
+
if (seg.length === 2 && method === 'GET') {
|
|
826
|
+
let items = rows('message', req.root).map(view).sort((a, b) => String(b.ReceivedAt ?? '').localeCompare(String(a.ReceivedAt ?? '')));
|
|
827
|
+
if (query.recipient) items = items.filter((m) => (m.Recipients as string[]).some((r) => r.includes(query.recipient!)));
|
|
828
|
+
if (query.fromEmail) items = items.filter((m) => bareAddress(m.From).includes(query.fromEmail!));
|
|
829
|
+
if (query.tag) items = items.filter((m) => m.Tag === query.tag);
|
|
830
|
+
if (query.subject) items = items.filter((m) => String(m.Subject ?? '').includes(query.subject!));
|
|
831
|
+
if (query.status) items = items.filter((m) => String(m.Status ?? '').toLowerCase() === query.status!.toLowerCase());
|
|
832
|
+
if (query.messageStream) items = items.filter((m) => m.MessageStream === query.messageStream);
|
|
833
|
+
return { status: 200, body: { TotalCount: items.length, Messages: paginate(items, query).map(outboundSummary) } };
|
|
834
|
+
}
|
|
835
|
+
if (seg.length === 4 && seg[3] === 'details' && method === 'GET') {
|
|
836
|
+
const m = getRow('message', idAt(2), req.root);
|
|
837
|
+
if (!m) return fail(POSTMARK_ERRORS.messageNotFound, 'This message was not found.');
|
|
838
|
+
const v = view(m);
|
|
839
|
+
return { status: 200, body: { ...outboundSummary(v), TextBody: v.TextBody, HtmlBody: v.HtmlBody, Body: renderRawBody(v), MessageEvents: v.MessageEvents ?? [] } };
|
|
840
|
+
}
|
|
841
|
+
if (seg.length === 4 && seg[3] === 'dump' && method === 'GET') {
|
|
842
|
+
const m = getRow('message', idAt(2), req.root);
|
|
843
|
+
if (!m) return fail(POSTMARK_ERRORS.messageNotFound, 'This message was not found.');
|
|
844
|
+
return { status: 200, body: { Body: renderRawBody(view(m)) } };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (family === 'messages' && seg[1] === 'inbound') {
|
|
848
|
+
if (seg.length === 2 && method === 'GET') {
|
|
849
|
+
let items = rows('inbound_message', req.root).map(view).map(inboundSummary);
|
|
850
|
+
if (query.recipient) items = items.filter((m) => String(m.To ?? '').includes(query.recipient!));
|
|
851
|
+
if (query.fromEmail) items = items.filter((m) => String(m.From ?? '').includes(query.fromEmail!));
|
|
852
|
+
if (query.subject) items = items.filter((m) => String(m.Subject ?? '').includes(query.subject!));
|
|
853
|
+
if (query.mailboxHash) items = items.filter((m) => m.MailboxHash === query.mailboxHash);
|
|
854
|
+
if (query.status) items = items.filter((m) => String(m.Status ?? '').toLowerCase() === query.status!.toLowerCase());
|
|
855
|
+
return { status: 200, body: { TotalCount: items.length, InboundMessages: paginate(items, query) } };
|
|
856
|
+
}
|
|
857
|
+
if (seg.length === 4 && seg[3] === 'details' && method === 'GET') {
|
|
858
|
+
const m = getRow('inbound_message', idAt(2), req.root);
|
|
859
|
+
return m ? { status: 200, body: view(m) } : fail(POSTMARK_ERRORS.messageNotFound, 'This message was not found.');
|
|
860
|
+
}
|
|
861
|
+
if (seg.length === 4 && (seg[3] === 'bypass' || seg[3] === 'retry') && method === 'PUT') {
|
|
862
|
+
const m = getRow('inbound_message', idAt(2), req.root);
|
|
863
|
+
if (!m) return fail(POSTMARK_ERRORS.messageNotFound, 'This message was not found, or cannot be bypassed or retried.');
|
|
864
|
+
const bypass = seg[3] === 'bypass';
|
|
865
|
+
if (bypass && m.Status !== 'Blocked') return fail(POSTMARK_ERRORS.messageNotFound, 'This message was not found, or cannot be bypassed or retried.');
|
|
866
|
+
await writeResource('inbound_message', idAt(2), { Status: bypass ? 'Processed' : 'Queued', BlockedReason: '' }, bypass ? 'inbound_message.bypass' : 'inbound_message.retry', req);
|
|
867
|
+
return ack(bypass ? 'Successfully bypassed message.' : 'Successfully rescheduled failed message.');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── BOUNCES ──────────────────────────────────────────────────────────────────────
|
|
872
|
+
if (family === 'deliverystats' && seg.length === 1 && method === 'GET') {
|
|
873
|
+
const bounces = rows('bounce', req.root);
|
|
874
|
+
const byType = new Map<string, { Type: string; Name: string; Count: number }>();
|
|
875
|
+
for (const b of bounces) {
|
|
876
|
+
const key = String(b.Type);
|
|
877
|
+
const cur = byType.get(key) ?? { Type: key, Name: String(b.Name ?? key), Count: 0 };
|
|
878
|
+
cur.Count += 1;
|
|
879
|
+
byType.set(key, cur);
|
|
880
|
+
}
|
|
881
|
+
// The first element is the "All" total and — per the vendor — carries NO `Type` key.
|
|
882
|
+
return { status: 200, body: { InactiveMails: rows('suppression', req.root).length, Bounces: [{ Name: 'All', Count: bounces.length }, ...byType.values()] } };
|
|
883
|
+
}
|
|
884
|
+
if (family === 'bounces') {
|
|
885
|
+
if (seg.length === 1 && method === 'GET') {
|
|
886
|
+
let items = rows('bounce', req.root).map(view).sort((a, b) => Number(b.ID) - Number(a.ID));
|
|
887
|
+
if (query.type) items = items.filter((b) => b.Type === query.type);
|
|
888
|
+
if (query.emailFilter) items = items.filter((b) => String(b.Email ?? '').includes(query.emailFilter!));
|
|
889
|
+
if (query.inactive !== undefined) items = items.filter((b) => String(b.Inactive) === query.inactive);
|
|
890
|
+
if (query.messageID) items = items.filter((b) => b.MessageID === query.messageID);
|
|
891
|
+
if (query.tag) items = items.filter((b) => b.Tag === query.tag);
|
|
892
|
+
return { status: 200, body: { TotalCount: items.length, Bounces: paginate(items, query) } };
|
|
893
|
+
}
|
|
894
|
+
if (seg.length === 2 && method === 'GET') {
|
|
895
|
+
const b = getRow('bounce', idAt(1), req.root);
|
|
896
|
+
return b ? { status: 200, body: view(b) } : fail(POSTMARK_ERRORS.bounceNotFound, 'The bounce was not found.');
|
|
897
|
+
}
|
|
898
|
+
if (seg.length === 3 && seg[2] === 'dump' && method === 'GET') {
|
|
899
|
+
const b = getRow('bounce', idAt(1), req.root);
|
|
900
|
+
if (!b) return fail(POSTMARK_ERRORS.bounceNotFound, 'The bounce was not found, or its dump is no longer available.');
|
|
901
|
+
return { status: 200, body: { Body: `Return-Path: <>\r\nSubject: Undelivered Mail Returned to Sender\r\nTo: ${b.From}\r\n\r\n${b.Description}\r\n` } };
|
|
902
|
+
}
|
|
903
|
+
// PUT /bounces/:id/activate — reactivate a bounced (inactive) address.
|
|
904
|
+
if (seg.length === 3 && seg[2] === 'activate' && method === 'PUT') {
|
|
905
|
+
const b = getRow('bounce', idAt(1), req.root);
|
|
906
|
+
if (!b) return fail(POSTMARK_ERRORS.bounceNotFound, 'The bounce was not found.');
|
|
907
|
+
if (b.CanActivate !== true) return fail(POSTMARK_ERRORS.bounceCannotActivate, 'Due to the type of bounce, this address cannot be reactivated.');
|
|
908
|
+
await writeResource('bounce', idAt(1), { Inactive: false }, 'bounce.activate', req);
|
|
909
|
+
// Reactivating clears the suppression, so the address becomes sendable again.
|
|
910
|
+
const sid = suppressionId(String(b.MessageStream ?? 'outbound'), String(b.Email ?? ''));
|
|
911
|
+
if (getRow('suppression', sid, req.root)) await writeResource('suppression', sid, { deleted: true }, 'suppression.delete', req);
|
|
912
|
+
return { status: 200, body: { Message: 'OK', Bounce: view(getRow('bounce', idAt(1), req.root)!) } };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ── TEMPLATES ────────────────────────────────────────────────────────────────────
|
|
917
|
+
if (family === 'templates') {
|
|
918
|
+
// `PUT /templates/push` is a REAL account-token route this twin does not model yet. It has
|
|
919
|
+
// to be intercepted BEFORE the `/templates/:idOrAlias` branch below, which would otherwise
|
|
920
|
+
// read "push" as a template alias and answer "template not found" — an unmodeled op
|
|
921
|
+
// failing with the WRONG error rather than like the vendor (§9 finding 7). 404 is the
|
|
922
|
+
// honest answer for a route the twin does not serve.
|
|
923
|
+
if (seg.length === 2 && seg[1]?.toLowerCase() === 'push') {
|
|
924
|
+
return err('Cannot PUT /templates/push — the template-push API is not modeled by this twin', 404, POSTMARK_ERRORS.invalidRequestField);
|
|
925
|
+
}
|
|
926
|
+
if (seg.length === 2 && seg[1] === 'validate' && method === 'POST') {
|
|
927
|
+
const section = (content: unknown) => ({
|
|
928
|
+
ContentIsValid: typeof content !== 'string' || !/\{\{[^}]*$/.test(content),
|
|
929
|
+
ValidationErrors: [],
|
|
930
|
+
RenderedContent: renderTemplate(content, asObject(body.TestRenderModel)),
|
|
931
|
+
});
|
|
932
|
+
const html = section(body.HtmlBody);
|
|
933
|
+
const text = section(body.TextBody);
|
|
934
|
+
const subject = section(body.Subject);
|
|
935
|
+
return {
|
|
936
|
+
status: 200,
|
|
937
|
+
body: {
|
|
938
|
+
AllContentIsValid: html.ContentIsValid && text.ContentIsValid && subject.ContentIsValid,
|
|
939
|
+
HtmlBody: html, TextBody: text, Subject: subject,
|
|
940
|
+
SuggestedTemplateModel: suggestedModel(body.HtmlBody, body.TextBody, body.Subject),
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
if (seg.length === 1 && method === 'GET') {
|
|
945
|
+
let items = rows('template', req.root).map(view).sort((a, b) => Number(a.TemplateId) - Number(b.TemplateId));
|
|
946
|
+
if (query.templateType) items = items.filter((t) => t.TemplateType === query.templateType);
|
|
947
|
+
if (query.layoutTemplate) items = items.filter((t) => t.LayoutTemplate === query.layoutTemplate);
|
|
948
|
+
const page = paginate(items, query).map((t) => ({
|
|
949
|
+
Active: t.Active, TemplateId: t.TemplateId, Name: t.Name, Alias: t.Alias,
|
|
950
|
+
TemplateType: t.TemplateType, LayoutTemplate: t.LayoutTemplate,
|
|
951
|
+
}));
|
|
952
|
+
return { status: 200, body: { TotalCount: items.length, Templates: page } };
|
|
953
|
+
}
|
|
954
|
+
if (seg.length === 1 && method === 'POST') {
|
|
955
|
+
if (typeof body.Name !== 'string' || body.Name === '') return fail(POSTMARK_ERRORS.templateNoData, 'A template Name is required.');
|
|
956
|
+
const templateType = body.TemplateType === 'Layout' ? 'Layout' : 'Standard';
|
|
957
|
+
const alias = typeof body.Alias === 'string' && body.Alias !== '' ? body.Alias : null;
|
|
958
|
+
if (alias && rows('template', req.root).some((t) => t.Alias === alias)) {
|
|
959
|
+
// ⚠ doc-unverified CODE: the published table has no alias-conflict entry, so the
|
|
960
|
+
// refusal is right but the number is borrowed. The verify pins the 422 + message, not
|
|
961
|
+
// the code (§9).
|
|
962
|
+
return fail(POSTMARK_ERRORS.templateNotFound, `A template with the alias '${alias}' already exists on this server.`);
|
|
963
|
+
}
|
|
964
|
+
const id = nextIntId('template', req.root);
|
|
965
|
+
await writeResource('template', String(id), {
|
|
966
|
+
TemplateId: id, Name: body.Name, Alias: alias, TemplateType: templateType,
|
|
967
|
+
Subject: typeof body.Subject === 'string' ? body.Subject : '',
|
|
968
|
+
HtmlBody: typeof body.HtmlBody === 'string' ? body.HtmlBody : null,
|
|
969
|
+
TextBody: typeof body.TextBody === 'string' ? body.TextBody : null,
|
|
970
|
+
LayoutTemplate: typeof body.LayoutTemplate === 'string' ? body.LayoutTemplate : null,
|
|
971
|
+
Active: true, AssociatedServerId: DEFAULT_SERVER_ID,
|
|
972
|
+
}, 'template.create', req);
|
|
973
|
+
// The create/edit response is the SHORT template shape (no bodies) — SDK: TemplateInList.
|
|
974
|
+
return { status: 200, body: { TemplateId: id, Name: body.Name, Active: true, Alias: alias, TemplateType: templateType, LayoutTemplate: typeof body.LayoutTemplate === 'string' ? body.LayoutTemplate : null } };
|
|
975
|
+
}
|
|
976
|
+
if (seg.length === 2) {
|
|
977
|
+
const t = resolveTemplate(idAt(1), req.root);
|
|
978
|
+
const missing = fail(POSTMARK_ERRORS.templateNotFound, 'The template associated with this request was not found.');
|
|
979
|
+
if (method === 'GET') return t ? { status: 200, body: view(t) } : missing;
|
|
980
|
+
if (method === 'PUT') {
|
|
981
|
+
if (!t) return missing;
|
|
982
|
+
const patch: Record<string, unknown> = {};
|
|
983
|
+
for (const k of ['Name', 'Subject', 'HtmlBody', 'TextBody', 'Alias', 'LayoutTemplate']) if (k in body) patch[k] = body[k];
|
|
984
|
+
if (Object.keys(patch).length === 0) return fail(POSTMARK_ERRORS.templateNoData, 'No template data received.');
|
|
985
|
+
if (typeof patch.Alias === 'string' && rows('template', req.root).some((o) => o.Alias === patch.Alias && String(o.TemplateId) !== String(t.TemplateId))) {
|
|
986
|
+
return fail(POSTMARK_ERRORS.templateNotFound, `A template with the alias '${patch.Alias}' already exists on this server.`);
|
|
987
|
+
}
|
|
988
|
+
await writeResource('template', String(t.TemplateId), patch, 'template.update', req);
|
|
989
|
+
const after = getRow('template', String(t.TemplateId), req.root)!;
|
|
990
|
+
return { status: 200, body: { TemplateId: after.TemplateId, Name: after.Name, Active: after.Active, Alias: after.Alias, TemplateType: after.TemplateType, LayoutTemplate: after.LayoutTemplate } };
|
|
991
|
+
}
|
|
992
|
+
if (method === 'DELETE') {
|
|
993
|
+
if (!t) return missing;
|
|
994
|
+
await writeResource('template', String(t.TemplateId), { deleted: true }, 'template.delete', req);
|
|
995
|
+
return ack('Template deleted.');
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ── MESSAGE STREAMS (+ suppressions, which are nested under a stream) ─────────────
|
|
1001
|
+
if (family === 'message-streams') {
|
|
1002
|
+
if (seg.length >= 3 && seg[2] === 'suppressions') return suppressionsRoute(seg, method, body, query, req);
|
|
1003
|
+
if (seg.length === 1 && method === 'GET') {
|
|
1004
|
+
let items = rows('message_stream', req.root).map(view);
|
|
1005
|
+
if (String(query.includeArchivedStreams ?? '') !== 'true') items = items.filter((s) => s.ArchivedAt == null);
|
|
1006
|
+
if (query.messageStreamType && query.messageStreamType !== 'All') items = items.filter((s) => s.MessageStreamType === query.messageStreamType);
|
|
1007
|
+
// NB: this is the one list endpoint where the vendor puts TotalCount LAST.
|
|
1008
|
+
return { status: 200, body: { MessageStreams: items, TotalCount: items.length } };
|
|
1009
|
+
}
|
|
1010
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1011
|
+
const id = typeof body.ID === 'string' ? body.ID.trim() : '';
|
|
1012
|
+
if (id === '' || !/^[A-Za-z][A-Za-z0-9-]{0,29}$/.test(id)) {
|
|
1013
|
+
return fail(POSTMARK_ERRORS.streamIdInvalid, 'The ID must be a non-empty string starting with a letter, up to 30 characters.');
|
|
1014
|
+
}
|
|
1015
|
+
if (/^pm-/i.test(id)) return fail(POSTMARK_ERRORS.streamIdReservedPrefix, 'The ID must not start with the pm- prefix.');
|
|
1016
|
+
if (allRows('message_stream', req.root).some((s) => s.id === id)) {
|
|
1017
|
+
return fail(POSTMARK_ERRORS.streamIdExists, 'The ID provided already exists for this server.');
|
|
1018
|
+
}
|
|
1019
|
+
const type = body.MessageStreamType;
|
|
1020
|
+
if (type !== 'Transactional' && type !== 'Broadcast' && type !== 'Inbound') {
|
|
1021
|
+
return fail(POSTMARK_ERRORS.streamTypeInvalid, 'The MessageStreamType associated with this request was invalid.');
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof body.Name !== 'string' || body.Name.trim() === '') {
|
|
1024
|
+
return fail(POSTMARK_ERRORS.streamNameRequired, 'A valid Name must be provided.');
|
|
1025
|
+
}
|
|
1026
|
+
const s = await writeResource('message_stream', id, {
|
|
1027
|
+
ID: id, ServerID: DEFAULT_SERVER_ID, Name: body.Name,
|
|
1028
|
+
Description: typeof body.Description === 'string' ? body.Description : '',
|
|
1029
|
+
MessageStreamType: type, CreatedAt: nowIso(req.occurredAt), UpdatedAt: null, ArchivedAt: null,
|
|
1030
|
+
SubscriptionManagementConfiguration: asObject(body.SubscriptionManagementConfiguration).UnsubscribeHandlingType
|
|
1031
|
+
? asObject(body.SubscriptionManagementConfiguration)
|
|
1032
|
+
: { UnsubscribeHandlingType: type === 'Broadcast' ? 'Postmark' : 'None' },
|
|
1033
|
+
}, 'message_stream.create', req);
|
|
1034
|
+
return { status: 200, body: s };
|
|
1035
|
+
}
|
|
1036
|
+
const streamMissing = fail(POSTMARK_ERRORS.streamNotFound, 'The message stream for the provided ID was not found.');
|
|
1037
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1038
|
+
const s = getRow('message_stream', idAt(1), req.root);
|
|
1039
|
+
return s ? { status: 200, body: view(s) } : streamMissing;
|
|
1040
|
+
}
|
|
1041
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
1042
|
+
const s = getRow('message_stream', idAt(1), req.root);
|
|
1043
|
+
if (!s) return streamMissing;
|
|
1044
|
+
const patch: Record<string, unknown> = {};
|
|
1045
|
+
for (const k of ['Name', 'Description', 'SubscriptionManagementConfiguration']) if (k in body) patch[k] = body[k];
|
|
1046
|
+
if (Object.keys(patch).length === 0) return fail(POSTMARK_ERRORS.streamNameRequired, 'A valid Name must be provided.');
|
|
1047
|
+
patch.UpdatedAt = nowIso(req.occurredAt);
|
|
1048
|
+
await writeResource('message_stream', idAt(1), patch, 'message_stream.update', req);
|
|
1049
|
+
return { status: 200, body: view(getRow('message_stream', idAt(1), req.root)!) };
|
|
1050
|
+
}
|
|
1051
|
+
if (seg.length === 3 && seg[2] === 'archive' && method === 'POST') {
|
|
1052
|
+
const s = getRow('message_stream', idAt(1), req.root);
|
|
1053
|
+
if (!s) return streamMissing;
|
|
1054
|
+
if (UNARCHIVABLE_STREAMS.has(idAt(1))) return fail(POSTMARK_ERRORS.streamCannotArchiveDefault, 'You cannot archive the default transactional and inbound streams.');
|
|
1055
|
+
if (s.ArchivedAt != null) return fail(POSTMARK_ERRORS.streamCannotArchive, 'Stream is unable to be archived at this time.');
|
|
1056
|
+
const purge = new Date(Date.parse(nowIso(req.occurredAt)) + 45 * 24 * 3600 * 1000).toISOString();
|
|
1057
|
+
await writeResource('message_stream', idAt(1), { ArchivedAt: nowIso(req.occurredAt), ExpectedPurgeDate: purge }, 'message_stream.archive', req);
|
|
1058
|
+
return { status: 200, body: { ID: idAt(1), ServerID: DEFAULT_SERVER_ID, ExpectedPurgeDate: purge } };
|
|
1059
|
+
}
|
|
1060
|
+
if (seg.length === 3 && seg[2] === 'unarchive' && method === 'POST') {
|
|
1061
|
+
const s = getRow('message_stream', idAt(1), req.root);
|
|
1062
|
+
if (!s) return streamMissing;
|
|
1063
|
+
// ⚠ doc-unverified CODE: the published table has no entry for "unarchive a stream that
|
|
1064
|
+
// was never archived" (1232 is the too-old-to-unarchive case). The 422 refusal is the
|
|
1065
|
+
// right shape — a no-op success would be a fake success — but the NUMBER is a guess, so
|
|
1066
|
+
// the capability verify deliberately does NOT pin it (§9).
|
|
1067
|
+
if (s.ArchivedAt == null) return fail(POSTMARK_ERRORS.streamCannotUnarchive, 'This message stream is not archived.');
|
|
1068
|
+
await writeResource('message_stream', idAt(1), { ArchivedAt: null, ExpectedPurgeDate: null }, 'message_stream.unarchive', req);
|
|
1069
|
+
return { status: 200, body: view(getRow('message_stream', idAt(1), req.root)!) };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ── WEBHOOKS ─────────────────────────────────────────────────────────────────────
|
|
1074
|
+
if (family === 'webhooks') {
|
|
1075
|
+
const webhookMissing = fail(POSTMARK_ERRORS.webhookNotFound, 'The webhook for the provided ID was not found.');
|
|
1076
|
+
if (seg.length === 1 && method === 'GET') {
|
|
1077
|
+
let items = rows('webhook', req.root).map(view);
|
|
1078
|
+
if (query.MessageStream) items = items.filter((w) => w.MessageStream === query.MessageStream);
|
|
1079
|
+
// NB: the webhooks list is the one collection with NO TotalCount.
|
|
1080
|
+
return { status: 200, body: { Webhooks: items } };
|
|
1081
|
+
}
|
|
1082
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1083
|
+
if (typeof body.Url !== 'string' || body.Url === '') return fail(POSTMARK_ERRORS.webhookUrlRequired, 'The request must contain a valid Url field.');
|
|
1084
|
+
const stream = typeof body.MessageStream === 'string' && body.MessageStream !== '' ? body.MessageStream : 'outbound';
|
|
1085
|
+
const s = getRow('message_stream', stream, req.root);
|
|
1086
|
+
if (!s) return fail(POSTMARK_ERRORS.streamNotFound, 'The message stream for the provided ID was not found.');
|
|
1087
|
+
if (s.ArchivedAt != null) return fail(POSTMARK_ERRORS.webhookArchivedStream, 'You cannot create a webhook using an archived MessageStream.');
|
|
1088
|
+
const id = nextIntId('webhook', req.root);
|
|
1089
|
+
const w = await writeResource('webhook', String(id), {
|
|
1090
|
+
ID: id, Url: body.Url, MessageStream: stream,
|
|
1091
|
+
HttpAuth: asObject(body.HttpAuth).Username ? asObject(body.HttpAuth) : null,
|
|
1092
|
+
HttpHeaders: Array.isArray(body.HttpHeaders) ? body.HttpHeaders : [],
|
|
1093
|
+
Triggers: normalizeTriggers(body.Triggers),
|
|
1094
|
+
}, 'webhook.create', req);
|
|
1095
|
+
return { status: 200, body: w };
|
|
1096
|
+
}
|
|
1097
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1098
|
+
const w = getRow('webhook', idAt(1), req.root);
|
|
1099
|
+
return w ? { status: 200, body: view(w) } : webhookMissing;
|
|
1100
|
+
}
|
|
1101
|
+
if (seg.length === 2 && method === 'PUT') {
|
|
1102
|
+
const w = getRow('webhook', idAt(1), req.root);
|
|
1103
|
+
if (!w) return webhookMissing;
|
|
1104
|
+
const patch: Record<string, unknown> = {};
|
|
1105
|
+
if (typeof body.Url === 'string') patch.Url = body.Url;
|
|
1106
|
+
if ('HttpAuth' in body) patch.HttpAuth = asObject(body.HttpAuth).Username ? asObject(body.HttpAuth) : null;
|
|
1107
|
+
if (Array.isArray(body.HttpHeaders)) patch.HttpHeaders = body.HttpHeaders;
|
|
1108
|
+
if ('Triggers' in body) patch.Triggers = normalizeTriggers(body.Triggers);
|
|
1109
|
+
await writeResource('webhook', idAt(1), patch, 'webhook.update', req);
|
|
1110
|
+
return { status: 200, body: view(getRow('webhook', idAt(1), req.root)!) };
|
|
1111
|
+
}
|
|
1112
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
1113
|
+
if (!getRow('webhook', idAt(1), req.root)) return webhookMissing;
|
|
1114
|
+
await writeResource('webhook', idAt(1), { deleted: true }, 'webhook.delete', req);
|
|
1115
|
+
return ack('Webhook deleted.');
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── SERVER (the server-token's own server) ───────────────────────────────────────
|
|
1120
|
+
if (family === 'server' && seg.length === 1) {
|
|
1121
|
+
const s = getRow('server', String(DEFAULT_SERVER_ID), req.root);
|
|
1122
|
+
if (!s) return fail(POSTMARK_ERRORS.serverNotFound, 'This server was not found.');
|
|
1123
|
+
if (method === 'GET') return { status: 200, body: view(s) };
|
|
1124
|
+
if (method === 'PUT') {
|
|
1125
|
+
const patch = pickServerFields(body);
|
|
1126
|
+
if (Object.keys(patch).length === 0) return fail(POSTMARK_ERRORS.serverNoData, 'No server data received.');
|
|
1127
|
+
await writeResource('server', String(DEFAULT_SERVER_ID), patch, 'server.update', req);
|
|
1128
|
+
return { status: 200, body: view(getRow('server', String(DEFAULT_SERVER_ID), req.root)!) };
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ── SERVERS (account token) ──────────────────────────────────────────────────────
|
|
1133
|
+
if (family === 'servers') {
|
|
1134
|
+
const serverMissing = fail(POSTMARK_ERRORS.serverNotFound, 'This server was not found.');
|
|
1135
|
+
if (seg.length === 1 && method === 'GET') {
|
|
1136
|
+
let items = rows('server', req.root).map(view);
|
|
1137
|
+
if (query.name) items = items.filter((s) => String(s.Name ?? '').includes(query.name!));
|
|
1138
|
+
return { status: 200, body: { TotalCount: items.length, Servers: paginate(items, query) } };
|
|
1139
|
+
}
|
|
1140
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1141
|
+
if (typeof body.Name !== 'string' || body.Name.trim() === '') return fail(POSTMARK_ERRORS.serverNameInvalid, 'Server name is invalid or missing.');
|
|
1142
|
+
if (rows('server', req.root).some((s) => s.Name === body.Name)) return fail(POSTMARK_ERRORS.serverNameExists, 'This server name already exists.');
|
|
1143
|
+
if (typeof body.InboundDomain === 'string' && body.InboundDomain !== '' && rows('server', req.root).some((s) => s.InboundDomain === body.InboundDomain)) {
|
|
1144
|
+
return fail(POSTMARK_ERRORS.serverInboundDomainInUse, 'The specified inbound domain is already registered or in use on another server.');
|
|
1145
|
+
}
|
|
1146
|
+
const id = nextIntId('server', req.root);
|
|
1147
|
+
const s = await writeResource('server', String(id), serverRecord(id, body.Name, body), 'server.create', req);
|
|
1148
|
+
return { status: 200, body: s };
|
|
1149
|
+
}
|
|
1150
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1151
|
+
const s = getRow('server', idAt(1), req.root);
|
|
1152
|
+
return s ? { status: 200, body: view(s) } : serverMissing;
|
|
1153
|
+
}
|
|
1154
|
+
if (seg.length === 2 && method === 'PUT') {
|
|
1155
|
+
if (!getRow('server', idAt(1), req.root)) return serverMissing;
|
|
1156
|
+
const patch = pickServerFields(body);
|
|
1157
|
+
if (Object.keys(patch).length === 0) return fail(POSTMARK_ERRORS.serverNoData, 'No server data received.');
|
|
1158
|
+
await writeResource('server', idAt(1), patch, 'server.update', req);
|
|
1159
|
+
return { status: 200, body: view(getRow('server', idAt(1), req.root)!) };
|
|
1160
|
+
}
|
|
1161
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
1162
|
+
if (!getRow('server', idAt(1), req.root)) return serverMissing;
|
|
1163
|
+
await writeResource('server', idAt(1), { deleted: true }, 'server.delete', req);
|
|
1164
|
+
return ack('Server deleted.');
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── STATS ────────────────────────────────────────────────────────────────────────
|
|
1169
|
+
if (family === 'stats' && seg[1] === 'outbound' && method === 'GET') {
|
|
1170
|
+
const root = req.root;
|
|
1171
|
+
if (seg.length === 2) return { status: 200, body: outboundStatistics(root) };
|
|
1172
|
+
const leaf = seg.slice(2).join('/').toLowerCase();
|
|
1173
|
+
const opens = eventCount(root, 'Opened');
|
|
1174
|
+
const clicks = eventCount(root, 'LinkClicked');
|
|
1175
|
+
if (leaf === 'sends') return { status: 200, body: dayCounts(root, { Sent: rows('message', root).length }, () => ({ Sent: 1 })) };
|
|
1176
|
+
if (leaf === 'bounces') return { status: 200, body: dayCounts(root, { HardBounce: eventCount(root, 'Bounced') }, (m) => ({ HardBounce: messageEvents(m, 'Bounced') })) };
|
|
1177
|
+
if (leaf === 'spam') return { status: 200, body: dayCounts(root, { SpamComplaint: eventCount(root, 'SpamComplaint') }, (m) => ({ SpamComplaint: messageEvents(m, 'SpamComplaint') })) };
|
|
1178
|
+
if (leaf === 'tracked') return { status: 200, body: dayCounts(root, { Tracked: rows('message', root).filter((m) => m.TrackOpens === true).length }, (m) => ({ Tracked: m.TrackOpens === true ? 1 : 0 })) };
|
|
1179
|
+
if (leaf === 'opens') return { status: 200, body: dayCounts(root, { Opens: opens, Unique: opens }, (m) => ({ Opens: messageEvents(m, 'Opened'), Unique: messageEvents(m, 'Opened') })) };
|
|
1180
|
+
if (leaf === 'opens/platforms') return { status: 200, body: dayCounts(root, { WebMail: opens, Desktop: 0, Mobile: 0, Unknown: 0 }, (m) => ({ WebMail: messageEvents(m, 'Opened') })) };
|
|
1181
|
+
if (leaf === 'opens/emailclients') return { status: 200, body: dayCounts(root, { 'Twin Mail': opens }, (m) => ({ 'Twin Mail': messageEvents(m, 'Opened') })) };
|
|
1182
|
+
if (leaf === 'opens/readtimes') return { status: 200, body: dayCounts(root, { '7s+': opens }, (m) => ({ '7s+': messageEvents(m, 'Opened') })) };
|
|
1183
|
+
if (leaf === 'clicks') return { status: 200, body: dayCounts(root, { Clicks: clicks, Unique: clicks }, (m) => ({ Clicks: messageEvents(m, 'LinkClicked'), Unique: messageEvents(m, 'LinkClicked') })) };
|
|
1184
|
+
if (leaf === 'clicks/browserfamilies') return { status: 200, body: dayCounts(root, { 'Twin Browser': clicks }, (m) => ({ 'Twin Browser': messageEvents(m, 'LinkClicked') })) };
|
|
1185
|
+
if (leaf === 'clicks/platforms') return { status: 200, body: dayCounts(root, { Desktop: clicks, Mobile: 0, Unknown: 0 }, (m) => ({ Desktop: messageEvents(m, 'LinkClicked') })) };
|
|
1186
|
+
if (leaf === 'clicks/location') return { status: 200, body: dayCounts(root, { HTML: clicks, Text: 0 }, (m) => ({ HTML: messageEvents(m, 'LinkClicked'), Text: 0 })) };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ── INBOUND RULE TRIGGERS ────────────────────────────────────────────────────────
|
|
1190
|
+
if (family === 'triggers' && seg[1]?.toLowerCase() === 'inboundrules') {
|
|
1191
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1192
|
+
const items = rows('inbound_rule', req.root).map(view);
|
|
1193
|
+
return { status: 200, body: { TotalCount: items.length, InboundRules: paginate(items, query) } };
|
|
1194
|
+
}
|
|
1195
|
+
if (seg.length === 2 && method === 'POST') {
|
|
1196
|
+
if (typeof body.Rule !== 'string' || body.Rule.trim() === '') return fail(POSTMARK_ERRORS.inboundRuleNoData, 'No trigger data received.');
|
|
1197
|
+
if (rows('inbound_rule', req.root).some((r) => r.Rule === body.Rule)) return fail(POSTMARK_ERRORS.inboundRuleExists, 'This inbound rule already exists.');
|
|
1198
|
+
const id = nextIntId('inbound_rule', req.root);
|
|
1199
|
+
const r = await writeResource('inbound_rule', String(id), { ID: id, Rule: body.Rule }, 'inbound_rule.create', req);
|
|
1200
|
+
return { status: 200, body: r };
|
|
1201
|
+
}
|
|
1202
|
+
if (seg.length === 3 && method === 'DELETE') {
|
|
1203
|
+
if (!getRow('inbound_rule', idAt(2), req.root)) return fail(POSTMARK_ERRORS.inboundRuleNotFound, 'This inbound rule was not found.');
|
|
1204
|
+
await writeResource('inbound_rule', idAt(2), { deleted: true }, 'inbound_rule.delete', req);
|
|
1205
|
+
return ack('Inbound rule removed.');
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ── DOMAINS (account token) ──────────────────────────────────────────────────────
|
|
1210
|
+
if (family === 'domains') {
|
|
1211
|
+
const domainMissing = fail(POSTMARK_ERRORS.domainNotFound, 'This domain was not found.');
|
|
1212
|
+
if (seg.length === 1 && method === 'GET') {
|
|
1213
|
+
const items = rows('domain', req.root).map(view).map(domainSummary);
|
|
1214
|
+
return { status: 200, body: { TotalCount: items.length, Domains: paginate(items, query) } };
|
|
1215
|
+
}
|
|
1216
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1217
|
+
if (typeof body.Name !== 'string' || body.Name === '') return fail(POSTMARK_ERRORS.domainNameRequired, 'Name is a required field to create a Domain.');
|
|
1218
|
+
if (rows('domain', req.root).some((d) => d.Name === body.Name)) return fail(POSTMARK_ERRORS.domainExists, 'Domain already exists.');
|
|
1219
|
+
const id = nextIntId('domain', req.root);
|
|
1220
|
+
const d = await writeResource('domain', String(id), domainRecord(id, body.Name, typeof body.ReturnPathDomain === 'string' ? body.ReturnPathDomain : ''), 'domain.create', req);
|
|
1221
|
+
return { status: 200, body: d };
|
|
1222
|
+
}
|
|
1223
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1224
|
+
const d = getRow('domain', idAt(1), req.root);
|
|
1225
|
+
return d ? { status: 200, body: view(d) } : domainMissing;
|
|
1226
|
+
}
|
|
1227
|
+
if (seg.length === 2 && method === 'PUT') {
|
|
1228
|
+
if (!getRow('domain', idAt(1), req.root)) return domainMissing;
|
|
1229
|
+
if (typeof body.ReturnPathDomain !== 'string') return fail(POSTMARK_ERRORS.invalidEmailValue, 'A valid ReturnPathDomain must be provided.');
|
|
1230
|
+
await writeResource('domain', idAt(1), { ReturnPathDomain: body.ReturnPathDomain, ReturnPathDomainVerified: false }, 'domain.update', req);
|
|
1231
|
+
return { status: 200, body: view(getRow('domain', idAt(1), req.root)!) };
|
|
1232
|
+
}
|
|
1233
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
1234
|
+
if (!getRow('domain', idAt(1), req.root)) return domainMissing;
|
|
1235
|
+
await writeResource('domain', idAt(1), { deleted: true }, 'domain.delete', req);
|
|
1236
|
+
return ack('Domain deleted.');
|
|
1237
|
+
}
|
|
1238
|
+
if (seg.length === 3) {
|
|
1239
|
+
const d = getRow('domain', idAt(1), req.root);
|
|
1240
|
+
if (!d) return domainMissing;
|
|
1241
|
+
const verb = seg[2]!.toLowerCase();
|
|
1242
|
+
const after = async () => ({ status: 200, body: view(getRow('domain', idAt(1), req.root)!) });
|
|
1243
|
+
if (verb === 'verifydkim' && method === 'PUT') {
|
|
1244
|
+
await writeResource('domain', idAt(1), { DKIMVerified: true, WeakDKIM: false, DKIMUpdateStatus: 'Verified', DKIMPendingHost: '', DKIMPendingTextValue: '' }, 'domain.verify_dkim', req);
|
|
1245
|
+
return after();
|
|
1246
|
+
}
|
|
1247
|
+
if (verb === 'verifyreturnpath' && method === 'PUT') {
|
|
1248
|
+
// ⚠ doc-unverified RULE (§9): nothing in the SDK or the published docs states that a
|
|
1249
|
+
// ReturnPathDomain must be set before it can be verified — this is the TWIN's own
|
|
1250
|
+
// precondition, chosen because reporting `ReturnPathDomainVerified: true` for a domain
|
|
1251
|
+
// that has no return path configured would be a fake success. The code is borrowed
|
|
1252
|
+
// too, so the capability verify asserts only the 422, not the number.
|
|
1253
|
+
if (!d.ReturnPathDomain) return fail(POSTMARK_ERRORS.invalidEmailValue, 'Set a ReturnPathDomain before verifying it.');
|
|
1254
|
+
await writeResource('domain', idAt(1), { ReturnPathDomainVerified: true }, 'domain.verify_return_path', req);
|
|
1255
|
+
return after();
|
|
1256
|
+
}
|
|
1257
|
+
if (verb === 'verifyspf' && method === 'POST') {
|
|
1258
|
+
await writeResource('domain', idAt(1), { SPFVerified: true }, 'domain.verify_spf', req);
|
|
1259
|
+
return after();
|
|
1260
|
+
}
|
|
1261
|
+
if (verb === 'rotatedkim' && method === 'POST') {
|
|
1262
|
+
const selector = `twin${String(d.ID)}rot`;
|
|
1263
|
+
await writeResource('domain', idAt(1), {
|
|
1264
|
+
DKIMPendingHost: `${selector}._domainkey.${String(d.Name)}`,
|
|
1265
|
+
DKIMPendingTextValue: `k=rsa;p=TWIN${selector.toUpperCase()}`,
|
|
1266
|
+
DKIMUpdateStatus: 'Pending', DKIMVerified: false,
|
|
1267
|
+
}, 'domain.rotate_dkim', req);
|
|
1268
|
+
return after();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// ── SENDER SIGNATURES (account token) ────────────────────────────────────────────
|
|
1274
|
+
if (family === 'senders') {
|
|
1275
|
+
const sigMissing = fail(POSTMARK_ERRORS.signatureNotFound, 'This signature was not found.');
|
|
1276
|
+
if (seg.length === 1 && method === 'GET') {
|
|
1277
|
+
// NB: the array key is `SenderSignatures`, not `Senders`, despite the path.
|
|
1278
|
+
const items = rows('sender_signature', req.root).map(view).map((s) => ({
|
|
1279
|
+
Domain: s.Domain, EmailAddress: s.EmailAddress, ReplyToEmailAddress: s.ReplyToEmailAddress,
|
|
1280
|
+
Name: s.Name, Confirmed: s.Confirmed, ID: s.ID,
|
|
1281
|
+
}));
|
|
1282
|
+
return { status: 200, body: { TotalCount: items.length, SenderSignatures: paginate(items, query) } };
|
|
1283
|
+
}
|
|
1284
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1285
|
+
if (typeof body.FromEmail !== 'string' || body.FromEmail === '') return fail(POSTMARK_ERRORS.fromEmailRequired, 'FromEmail is a required field to create a Sender Signature.');
|
|
1286
|
+
if (!looksLikeEmail(body.FromEmail)) return fail(POSTMARK_ERRORS.invalidEmailValue, 'The value provided is not a valid email address.');
|
|
1287
|
+
const domain = body.FromEmail.split('@')[1]!.toLowerCase();
|
|
1288
|
+
if (PUBLIC_DOMAINS.has(domain)) return fail(POSTMARK_ERRORS.publicDomainSignature, "You can't use public domain emails or public domains.");
|
|
1289
|
+
if (rows('sender_signature', req.root).some((s) => s.EmailAddress === body.FromEmail)) {
|
|
1290
|
+
return fail(POSTMARK_ERRORS.signatureExists, 'This signature already exists, or a similar signature already exists.');
|
|
1291
|
+
}
|
|
1292
|
+
const id = nextIntId('sender_signature', req.root);
|
|
1293
|
+
const s = await writeResource('sender_signature', String(id), signatureRecord(id, body.FromEmail, body), 'sender_signature.create', req);
|
|
1294
|
+
return { status: 200, body: s };
|
|
1295
|
+
}
|
|
1296
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1297
|
+
const s = getRow('sender_signature', idAt(1), req.root);
|
|
1298
|
+
return s ? { status: 200, body: view(s) } : sigMissing;
|
|
1299
|
+
}
|
|
1300
|
+
if (seg.length === 2 && method === 'PUT') {
|
|
1301
|
+
if (!getRow('sender_signature', idAt(1), req.root)) return sigMissing;
|
|
1302
|
+
const patch: Record<string, unknown> = {};
|
|
1303
|
+
if (typeof body.Name === 'string') patch.Name = body.Name;
|
|
1304
|
+
if (typeof body.ReplyToEmail === 'string') patch.ReplyToEmailAddress = body.ReplyToEmail;
|
|
1305
|
+
if (typeof body.ReturnPathDomain === 'string') patch.ReturnPathDomain = body.ReturnPathDomain;
|
|
1306
|
+
if (typeof body.ConfirmationPersonalNote === 'string') patch.ConfirmationPersonalNote = body.ConfirmationPersonalNote;
|
|
1307
|
+
if (Object.keys(patch).length === 0) return fail(POSTMARK_ERRORS.signatureNoData, 'No update data or signature data received.');
|
|
1308
|
+
await writeResource('sender_signature', idAt(1), patch, 'sender_signature.update', req);
|
|
1309
|
+
return { status: 200, body: view(getRow('sender_signature', idAt(1), req.root)!) };
|
|
1310
|
+
}
|
|
1311
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
1312
|
+
if (!getRow('sender_signature', idAt(1), req.root)) return sigMissing;
|
|
1313
|
+
await writeResource('sender_signature', idAt(1), { deleted: true }, 'sender_signature.delete', req);
|
|
1314
|
+
return ack('Signature deleted.');
|
|
1315
|
+
}
|
|
1316
|
+
if (seg.length === 3 && method === 'POST') {
|
|
1317
|
+
const s = getRow('sender_signature', idAt(1), req.root);
|
|
1318
|
+
if (!s) return sigMissing;
|
|
1319
|
+
const verb = seg[2]!.toLowerCase();
|
|
1320
|
+
if (verb === 'resend') return ack('Confirmation email for the sender signature was re-sent.');
|
|
1321
|
+
if (verb === 'verifyspf') {
|
|
1322
|
+
await writeResource('sender_signature', idAt(1), { SPFVerified: true }, 'sender_signature.verify_spf', req);
|
|
1323
|
+
return { status: 200, body: view(getRow('sender_signature', idAt(1), req.root)!) };
|
|
1324
|
+
}
|
|
1325
|
+
if (verb === 'requestnewdkim') {
|
|
1326
|
+
await writeResource('sender_signature', idAt(1), { DKIMUpdateStatus: 'Pending', DKIMPendingHost: `twinrot._domainkey.${String(s.Domain)}` }, 'sender_signature.request_dkim', req);
|
|
1327
|
+
return ack('New DKIM has been requested for this signature.');
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ── DATA REMOVALS (account token, GDPR) ──────────────────────────────────────────
|
|
1333
|
+
if (family === 'data-removals') {
|
|
1334
|
+
if (seg.length === 1 && method === 'POST') {
|
|
1335
|
+
const by = String(body.RequestedBy ?? '');
|
|
1336
|
+
const forWhom = String(body.RequestedFor ?? '');
|
|
1337
|
+
if (!looksLikeEmail(by) || !looksLikeEmail(forWhom)) {
|
|
1338
|
+
return fail(POSTMARK_ERRORS.invalidEmailValue, "Provide a valid 'RequestedBy' and 'RequestedFor' email address.");
|
|
1339
|
+
}
|
|
1340
|
+
const id = nextIntId('data_removal', req.root);
|
|
1341
|
+
await writeResource('data_removal', String(id), {
|
|
1342
|
+
ID: id, Status: 'Pending', RequestedBy: by, RequestedFor: forWhom,
|
|
1343
|
+
NotifyWhenCompleted: body.NotifyWhenCompleted === true,
|
|
1344
|
+
}, 'data_removal.create', req);
|
|
1345
|
+
return { status: 200, body: { ID: id, Status: 'Pending' } };
|
|
1346
|
+
}
|
|
1347
|
+
if (seg.length === 2 && method === 'GET') {
|
|
1348
|
+
const d = getRow('data_removal', idAt(1), req.root);
|
|
1349
|
+
if (!d) return fail(POSTMARK_ERRORS.dataRemovalIdInvalid, 'Missing or incorrect data removal request ID.');
|
|
1350
|
+
return { status: 200, body: { ID: d.ID, Status: d.Status } };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// An unmodeled ROUTE fails like the vendor's 404 — never a fake success.
|
|
1355
|
+
// ⚠ doc-unverified: the vendor's body for a completely unrouted path is not published; the
|
|
1356
|
+
// twin keeps its own `{ErrorCode, Message}` envelope so a caller sees a Postmark-shaped error.
|
|
1357
|
+
return err(`Cannot ${method} ${path}`, 404, POSTMARK_ERRORS.invalidRequestField);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ── route helpers ───────────────────────────────────────────────────────────────────
|
|
1361
|
+
/** POST /email/withTemplate + each entry of POST /email/batchWithTemplates. */
|
|
1362
|
+
async function sendWithTemplate(p: Record<string, unknown>, req: PostmarkRequest): Promise<PostmarkResponse> {
|
|
1363
|
+
const v = validateSend(p, true);
|
|
1364
|
+
if (v) return v;
|
|
1365
|
+
if (p.TemplateId === undefined && p.TemplateAlias === undefined) {
|
|
1366
|
+
return fail(POSTMARK_ERRORS.templateNotFound, 'The request specifies neither TemplateId nor TemplateAlias.');
|
|
1367
|
+
}
|
|
1368
|
+
const t = resolveTemplate(p.TemplateId !== undefined ? p.TemplateId : p.TemplateAlias, req.root);
|
|
1369
|
+
if (!t) return fail(POSTMARK_ERRORS.templateNotFound, 'The template associated with this request was not found.');
|
|
1370
|
+
const model = asObject(p.TemplateModel);
|
|
1371
|
+
return sendOne({
|
|
1372
|
+
...p,
|
|
1373
|
+
Subject: renderTemplate(t.Subject, model),
|
|
1374
|
+
HtmlBody: t.HtmlBody == null ? undefined : renderTemplate(t.HtmlBody, model),
|
|
1375
|
+
TextBody: t.TextBody == null ? undefined : renderTemplate(t.TextBody, model),
|
|
1376
|
+
}, req);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** The list-shape of an outbound message (no body, no MessageEvents) — SDK: OutboundMessage. */
|
|
1380
|
+
function outboundSummary(m: Record<string, unknown>): Record<string, unknown> {
|
|
1381
|
+
return {
|
|
1382
|
+
MessageID: m.MessageID, Tag: m.Tag, To: m.To, Cc: m.Cc, Bcc: m.Bcc,
|
|
1383
|
+
Recipients: m.Recipients, ReceivedAt: m.ReceivedAt, From: m.From, Subject: m.Subject,
|
|
1384
|
+
Attachments: m.Attachments, Status: m.Status, TrackOpens: m.TrackOpens,
|
|
1385
|
+
TrackLinks: m.TrackLinks, Metadata: m.Metadata, MessageStream: m.MessageStream,
|
|
1386
|
+
Sandboxed: m.Sandboxed,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/** The list-shape of an inbound message (no bodies/headers) — SDK: InboundMessage. */
|
|
1390
|
+
function inboundSummary(m: Record<string, unknown>): Record<string, unknown> {
|
|
1391
|
+
const { TextBody: _t, HtmlBody: _h, StrippedTextReply: _s, Headers: _hd, BlockedReason: _b, ...rest } = m;
|
|
1392
|
+
return rest;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** The raw RFC-822-ish source Postmark returns from `/dump` and in `details.Body`. */
|
|
1396
|
+
function renderRawBody(m: Record<string, unknown>): string {
|
|
1397
|
+
const to = (m.Recipients as string[] | undefined)?.join(', ') ?? '';
|
|
1398
|
+
const body = (m.HtmlBody as string | null) ?? (m.TextBody as string | null) ?? '';
|
|
1399
|
+
return `From: ${m.From}\r\nTo: ${to}\r\nSubject: ${m.Subject}\r\nX-PM-Message-Id: ${m.MessageID}\r\n\r\n${body}`;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Fill Postmark's Triggers block, defaulting every trigger the caller omitted. */
|
|
1403
|
+
function normalizeTriggers(raw: unknown): Record<string, unknown> {
|
|
1404
|
+
const t = asObject(raw);
|
|
1405
|
+
const flag = (k: string) => ({ Enabled: asObject(t[k]).Enabled === true });
|
|
1406
|
+
return {
|
|
1407
|
+
Open: { ...flag('Open'), PostFirstOpenOnly: asObject(t.Open).PostFirstOpenOnly === true },
|
|
1408
|
+
Click: flag('Click'),
|
|
1409
|
+
Delivery: flag('Delivery'),
|
|
1410
|
+
Bounce: { ...flag('Bounce'), IncludeContent: asObject(t.Bounce).IncludeContent === true },
|
|
1411
|
+
SpamComplaint: { ...flag('SpamComplaint'), IncludeContent: asObject(t.SpamComplaint).IncludeContent === true },
|
|
1412
|
+
SubscriptionChange: flag('SubscriptionChange'),
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const SERVER_FIELDS = [
|
|
1417
|
+
'Name', 'Color', 'SmtpApiActivated', 'RawEmailEnabled', 'InboundHookUrl', 'BounceHookUrl',
|
|
1418
|
+
'OpenHookUrl', 'DeliveryHookUrl', 'ClickHookUrl', 'PostFirstOpenOnly', 'InboundSpamThreshold',
|
|
1419
|
+
'TrackOpens', 'TrackLinks', 'IncludeBounceContentInHook', 'EnableSmtpApiErrorHooks',
|
|
1420
|
+
'InboundDomain', 'DeliveryType',
|
|
1421
|
+
];
|
|
1422
|
+
function pickServerFields(body: Record<string, unknown>): Record<string, unknown> {
|
|
1423
|
+
const out: Record<string, unknown> = {};
|
|
1424
|
+
for (const k of SERVER_FIELDS) if (k in body) out[k] = body[k];
|
|
1425
|
+
return out;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Postmark refuses a sender signature on a free/public mailbox domain (ErrorCode 503). */
|
|
1429
|
+
const PUBLIC_DOMAINS = new Set(['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com', 'icloud.com', 'live.com', 'msn.com']);
|
|
1430
|
+
|
|
1431
|
+
/** The DomainDetails object Postmark returns (SDK: models/domains/Domain.d.ts). */
|
|
1432
|
+
function domainRecord(id: number, name: string, returnPath: string): Record<string, unknown> {
|
|
1433
|
+
return {
|
|
1434
|
+
ID: id, Name: name,
|
|
1435
|
+
SPFVerified: false, DKIMVerified: false, WeakDKIM: false, ReturnPathDomainVerified: false,
|
|
1436
|
+
SPFHost: name, SPFTextValue: 'v=spf1 a mx include:spf.mtasv.net ~all',
|
|
1437
|
+
DKIMHost: `20260101._domainkey.${name}`, DKIMTextValue: 'k=rsa;p=TWINDKIMPUBLICKEY',
|
|
1438
|
+
DKIMPendingHost: '', DKIMPendingTextValue: '', DKIMRevokedHost: '', DKIMRevokedTextValue: '',
|
|
1439
|
+
SafeToRemoveRevokedKeyFromDNS: false, DKIMUpdateStatus: 'Verified',
|
|
1440
|
+
ReturnPathDomain: returnPath, ReturnPathDomainCNAMEValue: 'pm.mtasv.net',
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** The list-shape of a domain (SDK: `Domain` — the 6 fields, no DNS detail block). */
|
|
1444
|
+
function domainSummary(d: Record<string, unknown>): Record<string, unknown> {
|
|
1445
|
+
return { ID: d.ID, Name: d.Name, SPFVerified: d.SPFVerified, DKIMVerified: d.DKIMVerified, WeakDKIM: d.WeakDKIM, ReturnPathDomainVerified: d.ReturnPathDomainVerified };
|
|
1446
|
+
}
|
|
1447
|
+
/** The SignatureDetails object Postmark returns (SDK: models/senders/Signature.d.ts). */
|
|
1448
|
+
function signatureRecord(id: number, fromEmail: string, body: Record<string, unknown>): Record<string, unknown> {
|
|
1449
|
+
const domain = fromEmail.split('@')[1] ?? '';
|
|
1450
|
+
return {
|
|
1451
|
+
...domainRecord(id, domain, typeof body.ReturnPathDomain === 'string' ? body.ReturnPathDomain : ''),
|
|
1452
|
+
ID: id,
|
|
1453
|
+
Domain: domain,
|
|
1454
|
+
EmailAddress: fromEmail,
|
|
1455
|
+
ReplyToEmailAddress: typeof body.ReplyToEmail === 'string' ? body.ReplyToEmail : '',
|
|
1456
|
+
Name: typeof body.Name === 'string' ? body.Name : fromEmail,
|
|
1457
|
+
Confirmed: false,
|
|
1458
|
+
ConfirmationPersonalNote: typeof body.ConfirmationPersonalNote === 'string' ? body.ConfirmationPersonalNote : '',
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* `/message-streams/:id/suppressions{,/dump,/delete}` — Postmark's suppression list is
|
|
1464
|
+
* STREAM-SCOPED, and deletion is a POST to `/delete` (not an HTTP DELETE).
|
|
1465
|
+
*/
|
|
1466
|
+
async function suppressionsRoute(
|
|
1467
|
+
seg: string[], method: string, body: Record<string, unknown>, query: Record<string, string>, req: PostmarkRequest,
|
|
1468
|
+
): Promise<PostmarkResponse> {
|
|
1469
|
+
const stream = decodeURIComponent(seg[1] ?? '');
|
|
1470
|
+
if (!getRow('message_stream', stream, req.root)) {
|
|
1471
|
+
return fail(POSTMARK_ERRORS.streamNotFound, 'The message stream for the provided ID was not found.');
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (seg.length === 4 && seg[3] === 'dump' && method === 'GET') {
|
|
1475
|
+
let items = rows('suppression', req.root).filter((s) => s.MessageStream === stream).map(view)
|
|
1476
|
+
.map((s) => ({ EmailAddress: s.EmailAddress, SuppressionReason: s.SuppressionReason, Origin: s.Origin, CreatedAt: s.CreatedAt }));
|
|
1477
|
+
if (query.suppressionReason) items = items.filter((s) => s.SuppressionReason === query.suppressionReason);
|
|
1478
|
+
if (query.origin) items = items.filter((s) => s.Origin === query.origin);
|
|
1479
|
+
if (query.emailAddress) items = items.filter((s) => String(s.EmailAddress).includes(query.emailAddress!));
|
|
1480
|
+
return { status: 200, body: { Suppressions: items } };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const entries = Array.isArray(body.Suppressions) ? body.Suppressions.map(asObject) : [];
|
|
1484
|
+
if (seg.length === 3 && method === 'POST') { // add
|
|
1485
|
+
if (entries.length === 0) return fail(POSTMARK_ERRORS.suppressionNoBody, 'A proper request body must be provided.');
|
|
1486
|
+
const out: Array<Record<string, unknown>> = [];
|
|
1487
|
+
for (const e of entries) {
|
|
1488
|
+
const email = String(e.EmailAddress ?? '');
|
|
1489
|
+
if (!looksLikeEmail(email)) { out.push({ EmailAddress: email, Status: 'Failed', Message: 'An invalid email address was provided.' }); continue; }
|
|
1490
|
+
await suppress(stream, email, 'ManualSuppression', 'Customer', req);
|
|
1491
|
+
out.push({ EmailAddress: email, Status: 'Suppressed', Message: null });
|
|
1492
|
+
}
|
|
1493
|
+
return { status: 200, body: { Suppressions: out } };
|
|
1494
|
+
}
|
|
1495
|
+
if (seg.length === 4 && seg[3] === 'delete' && method === 'POST') {
|
|
1496
|
+
if (entries.length === 0) return fail(POSTMARK_ERRORS.suppressionNoBody, 'A proper request body must be provided.');
|
|
1497
|
+
const out: Array<Record<string, unknown>> = [];
|
|
1498
|
+
for (const e of entries) {
|
|
1499
|
+
const email = String(e.EmailAddress ?? '');
|
|
1500
|
+
const sid = suppressionId(stream, email);
|
|
1501
|
+
const cur = getRow('suppression', sid, req.root);
|
|
1502
|
+
// Postmark only lets you delete a MANUAL suppression — a hard bounce or spam complaint
|
|
1503
|
+
// must be cleared through the Bounce API instead (per-item ErrorCode 1406 semantics).
|
|
1504
|
+
if (cur && cur.SuppressionReason !== 'ManualSuppression') {
|
|
1505
|
+
out.push({ EmailAddress: email, Status: 'Failed', Message: 'You do not have the required authority to change this suppression.' });
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (cur) await writeResource('suppression', sid, { deleted: true }, 'suppression.delete', req);
|
|
1509
|
+
out.push({ EmailAddress: email, Status: 'Deleted', Message: null });
|
|
1510
|
+
}
|
|
1511
|
+
return { status: 200, body: { Suppressions: out } };
|
|
1512
|
+
}
|
|
1513
|
+
return err(`Cannot ${method} /${seg.join('/')}`, 404, POSTMARK_ERRORS.invalidRequestField);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Resource types the twin serves (for the conformance + mirror surfaces).
|
|
1517
|
+
export const POSTMARK_RESOURCE_TYPES = [
|
|
1518
|
+
'message', 'bounce', 'template', 'message_stream', 'webhook', 'suppression',
|
|
1519
|
+
'server', 'domain', 'sender_signature', 'inbound_rule', 'inbound_message', 'data_removal',
|
|
1520
|
+
] as const;
|