@volter/twin-postmark 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/client/postmark-mirror.css +79 -0
- package/client/postmark-mirror.tsx +221 -0
- package/dist/client/postmark-mirror.bundle.js +321 -0
- package/dist/client/postmark-mirror.css +79 -0
- package/dist/client/postmark-mirror.d.ts +18 -0
- package/dist/client/postmark-mirror.js +153 -0
- package/dist/client/postmark-mirror.tsx +221 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +54 -0
- package/dist/src/postmark-capabilities.d.ts +12 -0
- package/dist/src/postmark-capabilities.js +1502 -0
- package/dist/src/postmark-conformance.d.ts +33 -0
- package/dist/src/postmark-conformance.js +265 -0
- package/dist/src/postmark-connector.d.ts +167 -0
- package/dist/src/postmark-connector.js +251 -0
- package/dist/src/postmark-events.d.ts +85 -0
- package/dist/src/postmark-events.js +169 -0
- package/dist/src/postmark-mirror-ui.d.ts +58 -0
- package/dist/src/postmark-mirror-ui.js +207 -0
- package/dist/src/postmark-perform-harness.d.ts +9 -0
- package/dist/src/postmark-perform-harness.js +24 -0
- package/dist/src/postmark-server.d.ts +14 -0
- package/dist/src/postmark-server.js +29 -0
- package/dist/src/postmark-twin.d.ts +82 -0
- package/dist/src/postmark-twin.js +1575 -0
- package/dist/test-fixtures/postmark-swagger-operations.json +846 -0
- package/package.json +76 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +89 -0
- package/src/postmark-capabilities.ts +1737 -0
- package/src/postmark-conformance.ts +282 -0
- package/src/postmark-connector.ts +312 -0
- package/src/postmark-events.ts +189 -0
- package/src/postmark-mirror-ui.ts +213 -0
- package/src/postmark-perform-harness.ts +21 -0
- package/src/postmark-server.ts +37 -0
- package/src/postmark-twin.ts +1520 -0
- package/test-fixtures/postmark-swagger-operations.json +846 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Postmark WEBHOOK DELIVERY + the deterministic offline delivery plan.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ FIDELITY NOTE — READ THIS BEFORE "ADDING SIGNATURES". Postmark webhooks are NOT signed.
|
|
4
|
+
// There is no `svix-signature`, no HMAC, no shared secret anywhere in Postmark's webhook
|
|
5
|
+
// design (this is a real difference from Resend/Clerk/Svix-backed vendors, and a twin that
|
|
6
|
+
// invented a signature scheme would be teaching consumers to verify something the vendor
|
|
7
|
+
// never sends). Postmark authenticates a webhook delivery the two ways its Webhooks API
|
|
8
|
+
// actually models — both of which this module reproduces:
|
|
9
|
+
// · `HttpAuth: { Username, Password }` → an HTTP Basic `Authorization` header, and
|
|
10
|
+
// · `HttpHeaders: [{ Name, Value }]` → arbitrary caller-chosen headers (the usual place
|
|
11
|
+
// a shared secret goes).
|
|
12
|
+
// Both are stored on the webhook resource by the Webhooks API (POST /webhooks), so the
|
|
13
|
+
// registry here is the twin's OWN projected state — not a parallel in-memory store.
|
|
14
|
+
//
|
|
15
|
+
// PAYLOAD. Each delivery is a plain JSON POST whose body carries a `RecordType` discriminator
|
|
16
|
+
// — `Delivery` | `Open` | `Click` | `Bounce` | `SpamComplaint` | `SubscriptionChange` |
|
|
17
|
+
// `Inbound` — matching the vendor's documented webhook payloads. A webhook only receives a
|
|
18
|
+
// record type whose `Triggers.<Type>.Enabled` is true, and only for its own MessageStream.
|
|
19
|
+
//
|
|
20
|
+
// DELIVERY PLAN. A sent email progresses through a DETERMINISTIC, OFFLINE outcome the twin
|
|
21
|
+
// drives — no SMTP, no network. `deliveryPlan()` is pure so the twin + its tests are
|
|
22
|
+
// repeatable.
|
|
23
|
+
import { projectResources } from '@volter/world-core';
|
|
24
|
+
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
25
|
+
|
|
26
|
+
const SERVICE = 'postmark';
|
|
27
|
+
|
|
28
|
+
/** Postmark's webhook `RecordType` discriminators (the outbound delivery-lifecycle family). */
|
|
29
|
+
export const POSTMARK_RECORD_TYPES = ['Delivery', 'Open', 'Click', 'Bounce', 'SpamComplaint', 'SubscriptionChange'] as const;
|
|
30
|
+
export type PostmarkRecordType = (typeof POSTMARK_RECORD_TYPES)[number];
|
|
31
|
+
|
|
32
|
+
/** RecordType → the `Triggers` key on the webhook resource that gates it. */
|
|
33
|
+
const TRIGGER_FOR: Record<PostmarkRecordType, string> = {
|
|
34
|
+
Delivery: 'Delivery', Open: 'Open', Click: 'Click',
|
|
35
|
+
Bounce: 'Bounce', SpamComplaint: 'SpamComplaint', SubscriptionChange: 'SubscriptionChange',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** An injected webhook deliverer — a fake in tests, a real HTTP POST live. */
|
|
39
|
+
export type PostmarkWebhookDelivery = (url: string, body: string, headers: Record<string, string>) => Promise<void> | void;
|
|
40
|
+
|
|
41
|
+
/** What the twin needs to route a webhook: the root to read from + the injected deliverer. */
|
|
42
|
+
export type WebhookContext = { root?: string; deliver?: PostmarkWebhookDelivery };
|
|
43
|
+
|
|
44
|
+
function asObject(v: unknown): Record<string, unknown> {
|
|
45
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The webhooks registered on a stream whose trigger for `recordType` is enabled — read from
|
|
50
|
+
* the SAME projection the Webhooks API serves, so a webhook created through `POST /webhooks`
|
|
51
|
+
* is the one that fires (no parallel registry to drift).
|
|
52
|
+
*/
|
|
53
|
+
export function webhooksFor(stream: string, recordType: PostmarkRecordType, root?: string): Array<Record<string, unknown>> {
|
|
54
|
+
return projectResources(SERVICE, root)
|
|
55
|
+
.filter((r) => r.type === 'webhook' && (r as Record<string, unknown>).deleted !== true)
|
|
56
|
+
.filter((w) => w.MessageStream === stream)
|
|
57
|
+
.filter((w) => asObject(asObject(w.Triggers)[TRIGGER_FOR[recordType]]).Enabled === true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The headers Postmark sends with a webhook delivery: JSON content type, the webhook's own
|
|
62
|
+
* custom `HttpHeaders`, and an HTTP Basic `Authorization` header when `HttpAuth` is set.
|
|
63
|
+
* Pure — exported so a verify() can assert the auth/header wiring without any I/O.
|
|
64
|
+
*/
|
|
65
|
+
export function webhookHeaders(webhook: Record<string, unknown>): Record<string, string> {
|
|
66
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
67
|
+
for (const raw of Array.isArray(webhook.HttpHeaders) ? webhook.HttpHeaders : []) {
|
|
68
|
+
const h = asObject(raw);
|
|
69
|
+
if (typeof h.Name === 'string' && h.Name !== '') headers[h.Name] = String(h.Value ?? '');
|
|
70
|
+
}
|
|
71
|
+
const auth = asObject(webhook.HttpAuth);
|
|
72
|
+
if (typeof auth.Username === 'string' && auth.Username !== '') {
|
|
73
|
+
headers.Authorization = `Basic ${Buffer.from(`${auth.Username}:${String(auth.Password ?? '')}`).toString('base64')}`;
|
|
74
|
+
}
|
|
75
|
+
return headers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fire-and-forget HTTP delivery — the live path, like the real vendor's. */
|
|
79
|
+
const httpDelivery: PostmarkWebhookDelivery = async (url, body, headers) => {
|
|
80
|
+
if (worldEgressRefusal(url) !== null) return; // the World's egress rule: refused like an unreachable endpoint
|
|
81
|
+
try {
|
|
82
|
+
await fetch(url, { method: 'POST', headers, body });
|
|
83
|
+
} catch {
|
|
84
|
+
/* fire-and-forget, like the real vendor */
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* POST one Postmark webhook payload to every webhook on `stream` whose trigger for this
|
|
90
|
+
* `RecordType` is enabled. Returns what was delivered (for assertions); a no-op when no
|
|
91
|
+
* webhook subscribes. Offline whenever `ctx.deliver` is injected.
|
|
92
|
+
*/
|
|
93
|
+
export async function emitPostmarkWebhook(
|
|
94
|
+
stream: string,
|
|
95
|
+
recordType: PostmarkRecordType,
|
|
96
|
+
payload: Record<string, unknown>,
|
|
97
|
+
ctx: WebhookContext = {},
|
|
98
|
+
): Promise<Array<{ url: string; body: string; headers: Record<string, string> }>> {
|
|
99
|
+
const targets = webhooksFor(stream, recordType, ctx.root);
|
|
100
|
+
if (targets.length === 0) return [];
|
|
101
|
+
const deliver = ctx.deliver ?? httpDelivery;
|
|
102
|
+
const body = JSON.stringify({ RecordType: recordType, ...payload });
|
|
103
|
+
const out: Array<{ url: string; body: string; headers: Record<string, string> }> = [];
|
|
104
|
+
for (const w of targets) {
|
|
105
|
+
const headers = webhookHeaders(w);
|
|
106
|
+
await deliver(String(w.Url), body, headers);
|
|
107
|
+
out.push({ url: String(w.Url), body, headers });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Deliver ONE arriving inbound message to the server's `InboundHookUrl`.
|
|
114
|
+
*
|
|
115
|
+
* This is a different door from `emitPostmarkWebhook` on purpose, because the vendor's is:
|
|
116
|
+
* Postmark's Webhooks API (`POST /webhooks`, `Triggers`) covers the OUTBOUND delivery
|
|
117
|
+
* lifecycle, while an inbound message is posted to the URL configured on the SERVER
|
|
118
|
+
* (`InboundHookUrl`, settable via `PUT /server`). The payload is the inbound message
|
|
119
|
+
* document itself — the same object `GET /messages/inbound/:id/details` serves, with NO
|
|
120
|
+
* `RecordType` discriminator, which is exactly what the vendor sends.
|
|
121
|
+
*
|
|
122
|
+
* AUTH. Postmark's inbound hook has no header configuration at all: the only credential it
|
|
123
|
+
* can carry is HTTP Basic userinfo embedded in the configured URL. That userinfo is split
|
|
124
|
+
* out into an `Authorization: Basic` header here rather than left on the URL, because that
|
|
125
|
+
* is both what the vendor puts on the wire and what `fetch` refuses to send otherwise.
|
|
126
|
+
*/
|
|
127
|
+
export async function emitPostmarkInboundHook(
|
|
128
|
+
message: Record<string, unknown>,
|
|
129
|
+
ctx: WebhookContext = {},
|
|
130
|
+
): Promise<Array<{ url: string; body: string; headers: Record<string, string> }>> {
|
|
131
|
+
const server = projectResources(SERVICE, ctx.root).find((r) => r.type === 'server' && (r as Record<string, unknown>).deleted !== true);
|
|
132
|
+
const configured = typeof server?.InboundHookUrl === 'string' ? server.InboundHookUrl : '';
|
|
133
|
+
if (configured === '') return [];
|
|
134
|
+
let parsed: URL;
|
|
135
|
+
try {
|
|
136
|
+
parsed = new URL(configured);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
141
|
+
if (parsed.username !== '') {
|
|
142
|
+
headers.Authorization = `Basic ${Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')}`;
|
|
143
|
+
parsed.username = '';
|
|
144
|
+
parsed.password = '';
|
|
145
|
+
}
|
|
146
|
+
const url = parsed.toString();
|
|
147
|
+
const body = JSON.stringify(message);
|
|
148
|
+
await (ctx.deliver ?? httpDelivery)(url, body, headers);
|
|
149
|
+
return [{ url, body, headers }];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── deterministic offline delivery plan ─────────────────────────────────────────────
|
|
153
|
+
export type PostmarkEventType = 'Delivered' | 'Opened' | 'LinkClicked' | 'Bounced' | 'SpamComplaint';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The ordered `MessageEvents` the twin drives a sent message through, OFFLINE.
|
|
157
|
+
*
|
|
158
|
+
* The recipient prefixes below are a TWIN-LOCAL convention (Postmark's own bounce testing
|
|
159
|
+
* needs real mail flow, which a local twin has none of), deliberately documented rather than
|
|
160
|
+
* dressed up as vendor behavior — they are the seam that lets a test drive the bounce and
|
|
161
|
+
* spam-complaint paths deterministically:
|
|
162
|
+
* · a recipient whose local part starts with `bounce` → a HARD BOUNCE (no delivery);
|
|
163
|
+
* · one starting with `spam` or `complaint` → delivered, then a SPAM COMPLAINT;
|
|
164
|
+
* · anything else → delivered.
|
|
165
|
+
* Opens and clicks are emitted ONLY when the message asked for that tracking — `TrackOpens`
|
|
166
|
+
* and `TrackLinks` respectively — exactly as the vendor does, so a message sent with tracking
|
|
167
|
+
* off records neither.
|
|
168
|
+
*
|
|
169
|
+
* `status` is the outbound message's `Status`. It stays `Sent` for every submitted message:
|
|
170
|
+
* on real Postmark a bounce does NOT rewrite the message's Status, it surfaces through
|
|
171
|
+
* `MessageEvents` and the Bounce API. Modeled that way here on purpose.
|
|
172
|
+
*/
|
|
173
|
+
export function deliveryPlan(input: { recipient: string; trackOpens: boolean; trackLinks: string }): { events: PostmarkEventType[]; status: string } {
|
|
174
|
+
const local = String(input.recipient ?? '').split('@')[0]?.toLowerCase() ?? '';
|
|
175
|
+
if (local.startsWith('bounce')) return { events: ['Bounced'], status: 'Sent' };
|
|
176
|
+
if (local.startsWith('spam') || local.startsWith('complaint')) {
|
|
177
|
+
return { events: ['Delivered', 'SpamComplaint'], status: 'Sent' };
|
|
178
|
+
}
|
|
179
|
+
const events: PostmarkEventType[] = ['Delivered'];
|
|
180
|
+
if (input.trackOpens) events.push('Opened');
|
|
181
|
+
if (input.trackLinks && input.trackLinks !== 'None') events.push('LinkClicked');
|
|
182
|
+
return { events, status: 'Sent' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The terminal event a message lands on, given its plan (the outbound activity headline). */
|
|
186
|
+
export function terminalEvent(input: { recipient: string; trackOpens: boolean; trackLinks: string }): PostmarkEventType {
|
|
187
|
+
const { events } = deliveryPlan(input);
|
|
188
|
+
return events[events.length - 1]!;
|
|
189
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
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.ts';
|
|
22
|
+
|
|
23
|
+
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)
|
|
24
|
+
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)
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Pure, dependency-free render/format helpers (no `@volter/world-core`, no `Bun`, no handler).
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export type PostmarkRow = Record<string, any>;
|
|
31
|
+
|
|
32
|
+
/** The recipients of an outbound message, as a readable list. */
|
|
33
|
+
export function recipientsOf(m: PostmarkRow): string {
|
|
34
|
+
if (Array.isArray(m?.Recipients)) return m.Recipients.map(String).join(', ');
|
|
35
|
+
if (Array.isArray(m?.To)) return m.To.map((r: PostmarkRow) => String(r?.Email ?? '')).filter(Boolean).join(', ');
|
|
36
|
+
if (typeof m?.To === 'string') return m.To;
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A subject line for a message row, or a placeholder. */
|
|
41
|
+
export function messageSubject(m: PostmarkRow): string {
|
|
42
|
+
return m?.Subject ? String(m.Subject) : '(no subject)';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The ordered `MessageEvents` types the twin recorded for a message. */
|
|
46
|
+
export function deliveryTimeline(m: PostmarkRow): string[] {
|
|
47
|
+
const events = Array.isArray(m?.MessageEvents) ? m.MessageEvents : [];
|
|
48
|
+
return ['Sent', ...events.map((e: PostmarkRow) => String(e?.Type ?? ''))].filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The headline delivery status of a message: its LAST recorded MessageEvent, falling back to
|
|
53
|
+
* the vendor's `Status` field. This is what the Activity list and the status pill show.
|
|
54
|
+
*/
|
|
55
|
+
export function messageStatus(m: PostmarkRow): string {
|
|
56
|
+
const timeline = deliveryTimeline(m);
|
|
57
|
+
const last = timeline[timeline.length - 1];
|
|
58
|
+
return last && last !== 'Sent' ? last : String(m?.Status ?? 'Sent');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The rendered preview of a message body — prefers HtmlBody, falls back to TextBody. */
|
|
62
|
+
export function messagePreview(m: PostmarkRow): { kind: 'html' | 'text' | 'none'; value: string } {
|
|
63
|
+
if (m?.HtmlBody) return { kind: 'html', value: String(m.HtmlBody) };
|
|
64
|
+
if (m?.TextBody) return { kind: 'text', value: String(m.TextBody) };
|
|
65
|
+
return { kind: 'none', value: '' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Format an ISO timestamp as a short date, or an em dash. */
|
|
69
|
+
export function formatPostmarkDate(iso: unknown): string {
|
|
70
|
+
if (typeof iso !== 'string' || !iso) return '—';
|
|
71
|
+
const t = Date.parse(iso);
|
|
72
|
+
if (!Number.isFinite(t)) return '—';
|
|
73
|
+
try {
|
|
74
|
+
return new Date(t).toISOString().slice(0, 16).replace('T', ' ');
|
|
75
|
+
} catch {
|
|
76
|
+
return '—';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A status → CSS tone class for the status pill. The returned values are STATIC STRING
|
|
82
|
+
* LITERALS (never template-built) so they survive the bundler's minification and a UI
|
|
83
|
+
* verify can assert on the class the component actually emits.
|
|
84
|
+
*/
|
|
85
|
+
export function statusTone(value: string): string {
|
|
86
|
+
const v = String(value ?? '').toLowerCase();
|
|
87
|
+
if (['delivered', 'opened', 'linkclicked', 'sent', 'processed', 'verified', 'active', 'true'].includes(v)) return 'ok';
|
|
88
|
+
if (['queued', 'pending', 'scheduled', 'unconfirmed', 'false'].includes(v)) return 'warn';
|
|
89
|
+
if (['bounced', 'hardbounce', 'spamcomplaint', 'blocked', 'failed', 'suppressed', 'transient'].includes(v)) return 'bad';
|
|
90
|
+
return 'neutral';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flatten a (possibly nested) value into label/value lines for the detail panel. */
|
|
94
|
+
export type FlatLine = { key: string; value: string; depth: number };
|
|
95
|
+
export function flattenPostmarkValue(value: unknown, prefix = '', depth = 0, out: FlatLine[] = []): FlatLine[] {
|
|
96
|
+
if (value === null || value === undefined) {
|
|
97
|
+
out.push({ key: prefix || '(value)', value: '—', depth });
|
|
98
|
+
} else if (Array.isArray(value)) {
|
|
99
|
+
if (value.length === 0) out.push({ key: prefix, value: '[]', depth });
|
|
100
|
+
else value.forEach((v, i) => flattenPostmarkValue(v, `${prefix}[${i}]`, depth, out));
|
|
101
|
+
} else if (typeof value === 'object') {
|
|
102
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
103
|
+
flattenPostmarkValue(v, prefix ? `${prefix}.${k}` : k, depth, out);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
out.push({ key: prefix || '(value)', value: String(value), depth });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The nav sections the mirror renders, each bound to one REAL Postmark API path + its
|
|
112
|
+
* collection key. Shared by the client (to fetch) and by tests (to assert the binding). */
|
|
113
|
+
export type MirrorSection = { key: string; label: string; path: string; collection: string; idKey: string };
|
|
114
|
+
export const POSTMARK_MIRROR_SECTIONS: MirrorSection[] = [
|
|
115
|
+
{ key: 'messages', label: 'Activity', path: '/messages/outbound', collection: 'Messages', idKey: 'MessageID' },
|
|
116
|
+
{ key: 'bounces', label: 'Bounces', path: '/bounces', collection: 'Bounces', idKey: 'ID' },
|
|
117
|
+
{ key: 'templates', label: 'Templates', path: '/templates', collection: 'Templates', idKey: 'TemplateId' },
|
|
118
|
+
{ key: 'streams', label: 'Streams', path: '/message-streams', collection: 'MessageStreams', idKey: 'ID' },
|
|
119
|
+
{ key: 'webhooks', label: 'Webhooks', path: '/webhooks', collection: 'Webhooks', idKey: 'ID' },
|
|
120
|
+
{ key: 'suppressions', label: 'Suppressions', path: '/message-streams/outbound/suppressions/dump', collection: 'Suppressions', idKey: 'EmailAddress' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Server-only (Bun) below — NOT imported by the browser client.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const APP_SHELL = `<!doctype html><html lang="en"><head><meta charset="utf-8" />
|
|
128
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
129
|
+
<base href="/"><title>Postmark twin — activity mirror</title>
|
|
130
|
+
<link rel="stylesheet" href="assets/styles.css" /></head>
|
|
131
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
132
|
+
|
|
133
|
+
let clientBundle: Promise<string> | null = null;
|
|
134
|
+
/** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module
|
|
135
|
+
* scope so a pack on its own does exactly ONE `Bun.build`. */
|
|
136
|
+
export function buildPostmarkMirrorClient(): Promise<string> {
|
|
137
|
+
if (!clientBundle) {
|
|
138
|
+
clientBundle = bundleClient(CLIENT_ENTRY())
|
|
139
|
+
.catch((error) => { clientBundle = null; throw error; });
|
|
140
|
+
}
|
|
141
|
+
return clientBundle;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The mirror's first-party credential, added to a request the adapter is about to serve.
|
|
146
|
+
*
|
|
147
|
+
* A pure REQUEST REWRITE — the mirror never calls the handler and never reaches around the wire;
|
|
148
|
+
* it only fills in the token header a browser cannot carry (Postmark authenticates every route
|
|
149
|
+
* with `X-Postmark-Server-Token`, or `X-Postmark-Account-Token` on its account-scoped families).
|
|
150
|
+
* The console is signed in to ITS root, so any non-empty token does — deliberately NOT the
|
|
151
|
+
* vendor's public test token, which is /email-only and changes the send reply text. Headers
|
|
152
|
+
* already present are left alone, so a caller that brings its own token keeps it.
|
|
153
|
+
*/
|
|
154
|
+
const MIRROR_TOKEN = 'postmark-twin-mirror-token';
|
|
155
|
+
async function withMirrorCredentials(request: Request): Promise<Request> {
|
|
156
|
+
const headers = new Headers(request.headers);
|
|
157
|
+
if (!headers.has('x-postmark-server-token')) headers.set('x-postmark-server-token', MIRROR_TOKEN);
|
|
158
|
+
if (!headers.has('x-postmark-account-token')) headers.set('x-postmark-account-token', MIRROR_TOKEN);
|
|
159
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
160
|
+
return new Request(request.url, {
|
|
161
|
+
method: request.method,
|
|
162
|
+
headers,
|
|
163
|
+
...(hasBody ? { body: await request.text() } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Serve the Postmark activity mirror (React app) over the twin's OWN REST API. */
|
|
168
|
+
export async function createPostmarkMirrorServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|
|
169
|
+
const twin = createPostmarkTwinFetch(options);
|
|
170
|
+
const server = await serveHttp({
|
|
171
|
+
// LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
|
|
172
|
+
// wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
|
|
173
|
+
// on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
|
|
174
|
+
// specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
|
|
175
|
+
// verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
|
|
176
|
+
// mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
|
|
177
|
+
// on loopback, so the verify's fetches deterministically reach THIS server.
|
|
178
|
+
hostname: '127.0.0.1',
|
|
179
|
+
port: options.port ?? 0,
|
|
180
|
+
idleTimeout: 60,
|
|
181
|
+
async fetch(request) {
|
|
182
|
+
const url = new URL(request.url);
|
|
183
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
184
|
+
try { return new Response(await buildPostmarkMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
|
|
185
|
+
catch (error) { return new Response(String(error), { status: 500 }); }
|
|
186
|
+
}
|
|
187
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
188
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
189
|
+
}
|
|
190
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
191
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
192
|
+
}
|
|
193
|
+
// Everything else → the twin's OWN FETCH ADAPTER (composition, R2): the same closure
|
|
194
|
+
// `createPostmarkTwinServer` serves, so the mirror port and the API port cannot drift — the
|
|
195
|
+
// uniform `GET /twin` door, the world clock, `readOnly`, Postmark's token auth and the
|
|
196
|
+
// `connection: close` Bun-socket workaround all come from it rather than from a hand-rolled
|
|
197
|
+
// copy that has to be kept in step. The client fetches the vendor's real paths, so the
|
|
198
|
+
// mirror and an SDK consumer are served by literally the same code (archetype A).
|
|
199
|
+
return twin(await withMirrorCredentials(request));
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The app-shell HTML (pure, for tests). The mirror itself is the React client. */
|
|
206
|
+
export function postmarkMirrorHtml(): string {
|
|
207
|
+
return APP_SHELL;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
211
|
+
export function postmarkMirrorStyles(): Promise<string> {
|
|
212
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
213
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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, type PostmarkClient } from './postmark-connector.ts';
|
|
4
|
+
|
|
5
|
+
export async function performPending(client: PostmarkClient, opts: { root?: string; occurredAt?: string } = {}): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
|
|
6
|
+
const confirmed: string[] = [];
|
|
7
|
+
const externalIds: Record<string, string> = {};
|
|
8
|
+
for (const entry of deployableEntries('postmark', opts.root)) {
|
|
9
|
+
let externalId: string;
|
|
10
|
+
try {
|
|
11
|
+
({ externalId } = await pushPostmarkAction(client, { operation: entry.operation ?? `${entry.subject.type}.update`, subject: entry.subject, fields: entry.fields ?? {} }));
|
|
12
|
+
} catch { continue; } // the twin's own record: nothing to write
|
|
13
|
+
confirmAction({
|
|
14
|
+
service: 'postmark', actionId: entry.id, subject: entry.subject, fields: entry.fields ?? {},
|
|
15
|
+
occurredAt: opts.occurredAt ?? worldNow(), vendorSubjectId: externalId, receipt: { status: 'deployed' },
|
|
16
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
17
|
+
});
|
|
18
|
+
confirmed.push(entry.id); externalIds[entry.id] = externalId;
|
|
19
|
+
}
|
|
20
|
+
return { pushed: confirmed.length, confirmed, externalIds };
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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.ts';
|
|
11
|
+
import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
|
|
12
|
+
|
|
13
|
+
/** Options every Postmark-twin HTTP surface needs, independent of who owns the socket. */
|
|
14
|
+
export interface PostmarkTwinFetchOptions {
|
|
15
|
+
root?: string;
|
|
16
|
+
readOnly?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createPostmarkTwinFetch(options: PostmarkTwinFetchOptions): (request: Request) => Promise<Response> {
|
|
20
|
+
return createTwinFetchFromHandler(handlePostmarkTwinRequest, {
|
|
21
|
+
...options,
|
|
22
|
+
manifest: statefulTwinManifest({ vendor: 'postmark', twinOf: 'the Postmark transactional-email API', stores: 'servers, sent messages and their opens/deliveries (the mirror shows the inbox)' }),
|
|
23
|
+
// The official SDK's Node transport reuses sockets. Bun 1.2 can reject a later request on
|
|
24
|
+
// that socket with its own HTTP 400 before fetch() runs; closing makes each modeled request
|
|
25
|
+
// reach the handler. (Inert under workerd, which owns its own connection handling.)
|
|
26
|
+
responseHeaders: { connection: 'close' },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function createPostmarkTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|
|
31
|
+
const server = await serveHttp({
|
|
32
|
+
port: options.port ?? 0,
|
|
33
|
+
idleTimeout: 60,
|
|
34
|
+
fetch: createPostmarkTwinFetch(options),
|
|
35
|
+
});
|
|
36
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
37
|
+
}
|