@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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/client/postmark-mirror.css +79 -0
  4. package/client/postmark-mirror.tsx +221 -0
  5. package/dist/client/postmark-mirror.bundle.js +321 -0
  6. package/dist/client/postmark-mirror.css +79 -0
  7. package/dist/client/postmark-mirror.d.ts +18 -0
  8. package/dist/client/postmark-mirror.js +153 -0
  9. package/dist/client/postmark-mirror.tsx +221 -0
  10. package/dist/src/cli.d.ts +2 -0
  11. package/dist/src/cli.js +31 -0
  12. package/dist/src/index.d.ts +10 -0
  13. package/dist/src/index.js +54 -0
  14. package/dist/src/postmark-capabilities.d.ts +12 -0
  15. package/dist/src/postmark-capabilities.js +1502 -0
  16. package/dist/src/postmark-conformance.d.ts +33 -0
  17. package/dist/src/postmark-conformance.js +265 -0
  18. package/dist/src/postmark-connector.d.ts +167 -0
  19. package/dist/src/postmark-connector.js +251 -0
  20. package/dist/src/postmark-events.d.ts +85 -0
  21. package/dist/src/postmark-events.js +169 -0
  22. package/dist/src/postmark-mirror-ui.d.ts +58 -0
  23. package/dist/src/postmark-mirror-ui.js +207 -0
  24. package/dist/src/postmark-perform-harness.d.ts +9 -0
  25. package/dist/src/postmark-perform-harness.js +24 -0
  26. package/dist/src/postmark-server.d.ts +14 -0
  27. package/dist/src/postmark-server.js +29 -0
  28. package/dist/src/postmark-twin.d.ts +82 -0
  29. package/dist/src/postmark-twin.js +1575 -0
  30. package/dist/test-fixtures/postmark-swagger-operations.json +846 -0
  31. package/package.json +76 -0
  32. package/src/cli.ts +29 -0
  33. package/src/index.ts +89 -0
  34. package/src/postmark-capabilities.ts +1737 -0
  35. package/src/postmark-conformance.ts +282 -0
  36. package/src/postmark-connector.ts +312 -0
  37. package/src/postmark-events.ts +189 -0
  38. package/src/postmark-mirror-ui.ts +213 -0
  39. package/src/postmark-perform-harness.ts +21 -0
  40. package/src/postmark-server.ts +37 -0
  41. package/src/postmark-twin.ts +1520 -0
  42. 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;