@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,207 @@
1
+ // Postmark MIRROR UI — a Postmark-dashboard-like Activity view served as a React/TSX app
2
+ // (Bun-bundled, the repo convention).
3
+ //
4
+ // TRANSPORT: archetype A, API PASSTHROUGH (ADDING_A_TWIN "Does this vendor get a mirror?").
5
+ // Every non-asset request falls straight through to the pack's OWN FETCH ADAPTER, and the
6
+ // browser client fetches POSTMARK'S OWN REAL API PATHS — `/messages/outbound`,
7
+ // `/messages/outbound/:id/details`, `/bounces`, `/templates`, `/message-streams`,
8
+ // `/webhooks`, `/message-streams/outbound/suppressions/dump`. There is exactly ONE code path
9
+ // serving the screen and the API, so API↔UI parity cannot drift: it is not "kept in sync",
10
+ // it is the same handler. (Resend needed a `/_twin/emails` side-read only because Resend has
11
+ // no list-emails endpoint; Postmark's Messages API does, so no side projection exists here.)
12
+ //
13
+ // The pure render/format helpers below are framework-agnostic so they can be unit-tested AND
14
+ // imported by the browser client — Bun tree-shakes the server-only exports out of the bundle.
15
+ //
16
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
17
+ // own fetch adapter as its API backend and reads every byte of state back over the wire.
18
+ import { readFile } from 'node:fs/promises';
19
+ import { bundleClient, fileResponse } from '@volter/world-core';
20
+ import { serveHttp } from '@volter/world-core';
21
+ import { createPostmarkTwinFetch } from "./postmark-server.js";
22
+ const CLIENT_ENTRY = () => new URL('../client/postmark-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
23
+ const CLIENT_CSS = () => new URL('../client/postmark-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
24
+ /** The recipients of an outbound message, as a readable list. */
25
+ export function recipientsOf(m) {
26
+ if (Array.isArray(m?.Recipients))
27
+ return m.Recipients.map(String).join(', ');
28
+ if (Array.isArray(m?.To))
29
+ return m.To.map((r) => String(r?.Email ?? '')).filter(Boolean).join(', ');
30
+ if (typeof m?.To === 'string')
31
+ return m.To;
32
+ return '';
33
+ }
34
+ /** A subject line for a message row, or a placeholder. */
35
+ export function messageSubject(m) {
36
+ return m?.Subject ? String(m.Subject) : '(no subject)';
37
+ }
38
+ /** The ordered `MessageEvents` types the twin recorded for a message. */
39
+ export function deliveryTimeline(m) {
40
+ const events = Array.isArray(m?.MessageEvents) ? m.MessageEvents : [];
41
+ return ['Sent', ...events.map((e) => String(e?.Type ?? ''))].filter(Boolean);
42
+ }
43
+ /**
44
+ * The headline delivery status of a message: its LAST recorded MessageEvent, falling back to
45
+ * the vendor's `Status` field. This is what the Activity list and the status pill show.
46
+ */
47
+ export function messageStatus(m) {
48
+ const timeline = deliveryTimeline(m);
49
+ const last = timeline[timeline.length - 1];
50
+ return last && last !== 'Sent' ? last : String(m?.Status ?? 'Sent');
51
+ }
52
+ /** The rendered preview of a message body — prefers HtmlBody, falls back to TextBody. */
53
+ export function messagePreview(m) {
54
+ if (m?.HtmlBody)
55
+ return { kind: 'html', value: String(m.HtmlBody) };
56
+ if (m?.TextBody)
57
+ return { kind: 'text', value: String(m.TextBody) };
58
+ return { kind: 'none', value: '' };
59
+ }
60
+ /** Format an ISO timestamp as a short date, or an em dash. */
61
+ export function formatPostmarkDate(iso) {
62
+ if (typeof iso !== 'string' || !iso)
63
+ return '—';
64
+ const t = Date.parse(iso);
65
+ if (!Number.isFinite(t))
66
+ return '—';
67
+ try {
68
+ return new Date(t).toISOString().slice(0, 16).replace('T', ' ');
69
+ }
70
+ catch {
71
+ return '—';
72
+ }
73
+ }
74
+ /**
75
+ * A status → CSS tone class for the status pill. The returned values are STATIC STRING
76
+ * LITERALS (never template-built) so they survive the bundler's minification and a UI
77
+ * verify can assert on the class the component actually emits.
78
+ */
79
+ export function statusTone(value) {
80
+ const v = String(value ?? '').toLowerCase();
81
+ if (['delivered', 'opened', 'linkclicked', 'sent', 'processed', 'verified', 'active', 'true'].includes(v))
82
+ return 'ok';
83
+ if (['queued', 'pending', 'scheduled', 'unconfirmed', 'false'].includes(v))
84
+ return 'warn';
85
+ if (['bounced', 'hardbounce', 'spamcomplaint', 'blocked', 'failed', 'suppressed', 'transient'].includes(v))
86
+ return 'bad';
87
+ return 'neutral';
88
+ }
89
+ export function flattenPostmarkValue(value, prefix = '', depth = 0, out = []) {
90
+ if (value === null || value === undefined) {
91
+ out.push({ key: prefix || '(value)', value: '—', depth });
92
+ }
93
+ else if (Array.isArray(value)) {
94
+ if (value.length === 0)
95
+ out.push({ key: prefix, value: '[]', depth });
96
+ else
97
+ value.forEach((v, i) => flattenPostmarkValue(v, `${prefix}[${i}]`, depth, out));
98
+ }
99
+ else if (typeof value === 'object') {
100
+ for (const [k, v] of Object.entries(value)) {
101
+ flattenPostmarkValue(v, prefix ? `${prefix}.${k}` : k, depth, out);
102
+ }
103
+ }
104
+ else {
105
+ out.push({ key: prefix || '(value)', value: String(value), depth });
106
+ }
107
+ return out;
108
+ }
109
+ export const POSTMARK_MIRROR_SECTIONS = [
110
+ { key: 'messages', label: 'Activity', path: '/messages/outbound', collection: 'Messages', idKey: 'MessageID' },
111
+ { key: 'bounces', label: 'Bounces', path: '/bounces', collection: 'Bounces', idKey: 'ID' },
112
+ { key: 'templates', label: 'Templates', path: '/templates', collection: 'Templates', idKey: 'TemplateId' },
113
+ { key: 'streams', label: 'Streams', path: '/message-streams', collection: 'MessageStreams', idKey: 'ID' },
114
+ { key: 'webhooks', label: 'Webhooks', path: '/webhooks', collection: 'Webhooks', idKey: 'ID' },
115
+ { key: 'suppressions', label: 'Suppressions', path: '/message-streams/outbound/suppressions/dump', collection: 'Suppressions', idKey: 'EmailAddress' },
116
+ ];
117
+ // ---------------------------------------------------------------------------
118
+ // Server-only (Bun) below — NOT imported by the browser client.
119
+ // ---------------------------------------------------------------------------
120
+ const APP_SHELL = `<!doctype html><html lang="en"><head><meta charset="utf-8" />
121
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
122
+ <base href="/"><title>Postmark twin — activity mirror</title>
123
+ <link rel="stylesheet" href="assets/styles.css" /></head>
124
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
125
+ let clientBundle = null;
126
+ /** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module
127
+ * scope so a pack on its own does exactly ONE `Bun.build`. */
128
+ export function buildPostmarkMirrorClient() {
129
+ if (!clientBundle) {
130
+ clientBundle = bundleClient(CLIENT_ENTRY())
131
+ .catch((error) => { clientBundle = null; throw error; });
132
+ }
133
+ return clientBundle;
134
+ }
135
+ /**
136
+ * The mirror's first-party credential, added to a request the adapter is about to serve.
137
+ *
138
+ * A pure REQUEST REWRITE — the mirror never calls the handler and never reaches around the wire;
139
+ * it only fills in the token header a browser cannot carry (Postmark authenticates every route
140
+ * with `X-Postmark-Server-Token`, or `X-Postmark-Account-Token` on its account-scoped families).
141
+ * The console is signed in to ITS root, so any non-empty token does — deliberately NOT the
142
+ * vendor's public test token, which is /email-only and changes the send reply text. Headers
143
+ * already present are left alone, so a caller that brings its own token keeps it.
144
+ */
145
+ const MIRROR_TOKEN = 'postmark-twin-mirror-token';
146
+ async function withMirrorCredentials(request) {
147
+ const headers = new Headers(request.headers);
148
+ if (!headers.has('x-postmark-server-token'))
149
+ headers.set('x-postmark-server-token', MIRROR_TOKEN);
150
+ if (!headers.has('x-postmark-account-token'))
151
+ headers.set('x-postmark-account-token', MIRROR_TOKEN);
152
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
153
+ return new Request(request.url, {
154
+ method: request.method,
155
+ headers,
156
+ ...(hasBody ? { body: await request.text() } : {}),
157
+ });
158
+ }
159
+ /** Serve the Postmark activity mirror (React app) over the twin's OWN REST API. */
160
+ export async function createPostmarkMirrorServer(options) {
161
+ const twin = createPostmarkTwinFetch(options);
162
+ const server = await serveHttp({
163
+ // LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
164
+ // wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
165
+ // on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
166
+ // specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
167
+ // verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
168
+ // mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
169
+ // on loopback, so the verify's fetches deterministically reach THIS server.
170
+ hostname: '127.0.0.1',
171
+ port: options.port ?? 0,
172
+ idleTimeout: 60,
173
+ async fetch(request) {
174
+ const url = new URL(request.url);
175
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
176
+ try {
177
+ return new Response(await buildPostmarkMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
178
+ }
179
+ catch (error) {
180
+ return new Response(String(error), { status: 500 });
181
+ }
182
+ }
183
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
184
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
185
+ }
186
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
187
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
188
+ }
189
+ // Everything else → the twin's OWN FETCH ADAPTER (composition, R2): the same closure
190
+ // `createPostmarkTwinServer` serves, so the mirror port and the API port cannot drift — the
191
+ // uniform `GET /twin` door, the world clock, `readOnly`, Postmark's token auth and the
192
+ // `connection: close` Bun-socket workaround all come from it rather than from a hand-rolled
193
+ // copy that has to be kept in step. The client fetches the vendor's real paths, so the
194
+ // mirror and an SDK consumer are served by literally the same code (archetype A).
195
+ return twin(await withMirrorCredentials(request));
196
+ },
197
+ });
198
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
199
+ }
200
+ /** The app-shell HTML (pure, for tests). The mirror itself is the React client. */
201
+ export function postmarkMirrorHtml() {
202
+ return APP_SHELL;
203
+ }
204
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
205
+ export function postmarkMirrorStyles() {
206
+ return readFile(CLIENT_CSS(), 'utf8');
207
+ }
@@ -0,0 +1,9 @@
1
+ import { type PostmarkClient } from './postmark-connector.js';
2
+ export declare function performPending(client: PostmarkClient, opts?: {
3
+ root?: string;
4
+ occurredAt?: string;
5
+ }): Promise<{
6
+ pushed: number;
7
+ confirmed: string[];
8
+ externalIds: Record<string, string>;
9
+ }>;
@@ -0,0 +1,24 @@
1
+ // A MINIATURE OF THE HEAD, for this pack's own claims and suites (protocol 2). Not on the serve path.
2
+ import { confirmAction, deployableEntries, worldNow } from '@volter/world-core';
3
+ import { pushPostmarkAction } from "./postmark-connector.js";
4
+ export async function performPending(client, opts = {}) {
5
+ const confirmed = [];
6
+ const externalIds = {};
7
+ for (const entry of deployableEntries('postmark', opts.root)) {
8
+ let externalId;
9
+ try {
10
+ ({ externalId } = await pushPostmarkAction(client, { operation: entry.operation ?? `${entry.subject.type}.update`, subject: entry.subject, fields: entry.fields ?? {} }));
11
+ }
12
+ catch {
13
+ continue;
14
+ } // the twin's own record: nothing to write
15
+ confirmAction({
16
+ service: 'postmark', actionId: entry.id, subject: entry.subject, fields: entry.fields ?? {},
17
+ occurredAt: opts.occurredAt ?? worldNow(), vendorSubjectId: externalId, receipt: { status: 'deployed' },
18
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
19
+ });
20
+ confirmed.push(entry.id);
21
+ externalIds[entry.id] = externalId;
22
+ }
23
+ return { pushed: confirmed.length, confirmed, externalIds };
24
+ }
@@ -0,0 +1,14 @@
1
+ /** Options every Postmark-twin HTTP surface needs, independent of who owns the socket. */
2
+ export interface PostmarkTwinFetchOptions {
3
+ root?: string;
4
+ readOnly?: boolean;
5
+ }
6
+ export declare function createPostmarkTwinFetch(options: PostmarkTwinFetchOptions): (request: Request) => Promise<Response>;
7
+ export declare function createPostmarkTwinServer(options: {
8
+ root?: string;
9
+ port?: number;
10
+ readOnly?: boolean;
11
+ }): Promise<{
12
+ port: number;
13
+ stop: () => void;
14
+ }>;
@@ -0,0 +1,29 @@
1
+ // Postmark twin HTTP server — serve the full Postmark API twin handler over HTTP so the real
2
+ // `postmark` SDK (pointed at this base URL) works unmodified. JSON bodies pass through to the
3
+ // handler. Writable by default; pass readOnly to reject writes (D3).
4
+ //
5
+ // FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
6
+ // kernel's ONE adaptation (`createTwinFetchFromHandler`); this file contributes only VALUES —
7
+ // the manifest and the `connection: close` response header. The server is one line of Bun.serve
8
+ // around that same closure.
9
+ import { serveHttp } from '@volter/world-core';
10
+ import { handlePostmarkTwinRequest } from "./postmark-twin.js";
11
+ import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
12
+ export function createPostmarkTwinFetch(options) {
13
+ return createTwinFetchFromHandler(handlePostmarkTwinRequest, {
14
+ ...options,
15
+ manifest: statefulTwinManifest({ vendor: 'postmark', twinOf: 'the Postmark transactional-email API', stores: 'servers, sent messages and their opens/deliveries (the mirror shows the inbox)' }),
16
+ // The official SDK's Node transport reuses sockets. Bun 1.2 can reject a later request on
17
+ // that socket with its own HTTP 400 before fetch() runs; closing makes each modeled request
18
+ // reach the handler. (Inert under workerd, which owns its own connection handling.)
19
+ responseHeaders: { connection: 'close' },
20
+ });
21
+ }
22
+ export async function createPostmarkTwinServer(options) {
23
+ const server = await serveHttp({
24
+ port: options.port ?? 0,
25
+ idleTimeout: 60,
26
+ fetch: createPostmarkTwinFetch(options),
27
+ });
28
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
29
+ }
@@ -0,0 +1,82 @@
1
+ import { type PostmarkWebhookDelivery } from './postmark-events.js';
2
+ export type PostmarkRequest = {
3
+ method: string;
4
+ path: string;
5
+ body?: string;
6
+ occurredAt?: string;
7
+ root?: string;
8
+ readOnly?: boolean;
9
+ /** Injected webhook deliverer (fake in tests, HTTP live). Keeps verify() offline. */
10
+ deliver?: PostmarkWebhookDelivery;
11
+ /**
12
+ * Request headers (case-insensitive). When PRESENT (the HTTP server always supplies them),
13
+ * the twin enforces Postmark's token auth: a missing/empty `X-Postmark-Server-Token` (or
14
+ * `X-Postmark-Account-Token` on account routes) → 401 `{ErrorCode:10}`. When the whole
15
+ * object is ABSENT, auth is not exercised — the in-process verify()/mirror path is a
16
+ * trusted local call, mirroring the resend pack's convention.
17
+ */
18
+ headers?: Record<string, string>;
19
+ };
20
+ export type PostmarkResponse = {
21
+ status: number;
22
+ body: unknown;
23
+ headers?: Record<string, string>;
24
+ };
25
+ /**
26
+ * The Postmark `ErrorCode` values this twin emits, each with the HTTP status the vendor
27
+ * pairs it with. Every number + status here is transcribed from developer.postmarkapp.com's
28
+ * published API-error-codes table (see the GROUNDING note above). Note how few of these are
29
+ * 404: Postmark answers "not found" for a KNOWN route with 422 + a per-domain code, and
30
+ * reserves 404 for a route that does not exist at all.
31
+ */
32
+ export declare const POSTMARK_ERRORS: {
33
+ readonly ok: 0;
34
+ readonly badToken: 10;
35
+ readonly bulkNotApproved: 14;
36
+ readonly invalidEmailRequest: 300;
37
+ readonly invalidJson: 402;
38
+ readonly invalidRequestField: 403;
39
+ readonly inactiveRecipient: 406;
40
+ readonly tooManyBatchMessages: 410;
41
+ readonly signatureNotFound: 501;
42
+ readonly signatureNoData: 502;
43
+ readonly publicDomainSignature: 503;
44
+ readonly signatureExists: 504;
45
+ readonly domainNotFound: 510;
46
+ readonly domainExists: 512;
47
+ readonly domainNameRequired: 514;
48
+ readonly fromEmailRequired: 520;
49
+ readonly invalidEmailValue: 522;
50
+ readonly serverInboundDomainInUse: 602;
51
+ readonly serverNameExists: 603;
52
+ readonly serverNameInvalid: 608;
53
+ readonly serverNoData: 609;
54
+ readonly serverNotFound: 1453;
55
+ readonly messageNotFound: 701;
56
+ readonly inboundRuleNoData: 809;
57
+ readonly inboundRuleExists: 810;
58
+ readonly inboundRuleNotFound: 812;
59
+ readonly bounceNotFound: 1001;
60
+ readonly bounceCannotActivate: 1003;
61
+ readonly templateNotFound: 1101;
62
+ readonly templateNoData: 1109;
63
+ readonly streamTypeInvalid: 1221;
64
+ readonly streamNameRequired: 1223;
65
+ readonly streamNotFound: 1226;
66
+ readonly streamIdInvalid: 1227;
67
+ readonly streamCannotArchiveDefault: 1229;
68
+ readonly streamIdExists: 1230;
69
+ readonly streamCannotArchive: 1241;
70
+ readonly streamCannotUnarchive: 1232;
71
+ readonly streamIdReservedPrefix: 1233;
72
+ readonly sendStreamNotFound: 1235;
73
+ readonly dataRemovalIdInvalid: 1301;
74
+ readonly webhookArchivedStream: 1350;
75
+ readonly webhookNotFound: 1352;
76
+ readonly webhookUrlRequired: 1354;
77
+ readonly suppressionAuthority: 1406;
78
+ readonly suppressionInvalidEmail: 1408;
79
+ readonly suppressionNoBody: 1409;
80
+ };
81
+ export declare function handlePostmarkTwinRequest(req: PostmarkRequest): Promise<PostmarkResponse>;
82
+ export declare const POSTMARK_RESOURCE_TYPES: readonly ["message", "bounce", "template", "message_stream", "webhook", "suppression", "server", "domain", "sender_signature", "inbound_rule", "inbound_message", "data_removal"];