@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,79 @@
1
+ :root {
2
+ --bg: #09090b;
3
+ --panel: #131316;
4
+ --panel-2: #1b1b20;
5
+ --border: #2a2a30;
6
+ --text: #ededf0;
7
+ --muted: #9a9aa5;
8
+ --accent: #ffe01b; /* Postmark yellow */
9
+ --accent-2: #facc15;
10
+ --ok: #22c55e;
11
+ --warn: #f59e0b;
12
+ --bad: #ef4444;
13
+ --neutral: #6b6b7b;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body, #root { height: 100%; margin: 0; }
17
+ body {
18
+ background: var(--bg);
19
+ color: var(--text);
20
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
21
+ }
22
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
23
+ .app { display: flex; flex-direction: column; height: 100%; }
24
+ .topbar {
25
+ display: flex; align-items: baseline; gap: 12px;
26
+ padding: 12px 18px; border-bottom: 1px solid var(--border); background: var(--panel);
27
+ }
28
+ .brand { font-weight: 700; color: var(--accent); font-size: 16px; }
29
+ .subtitle { color: var(--muted); font-size: 12px; }
30
+ /* The pane a reader OPENED gets the width. Three near-fixed tracks left the detail with
31
+ whatever was left over — 325px inside an 865px mirror embedded in a Room — and its own
32
+ label column then took 220px of that, so a MessageID rendered four characters per line.
33
+ Both tracks answer to the width they are actually given now. */
34
+ .body { display: grid; grid-template-columns: 200px minmax(220px, 0.8fr) minmax(0, 1.4fr); flex: 1; min-height: 0; }
35
+ .nav { border-right: 1px solid var(--border); background: var(--panel); padding: 8px; overflow: auto; }
36
+ .nav-item {
37
+ display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px;
38
+ background: transparent; color: var(--text); border: 0; border-radius: 6px; cursor: pointer;
39
+ }
40
+ .nav-item:hover { background: var(--panel-2); }
41
+ .nav-item.active { background: var(--panel-2); color: #fff; border-left: 3px solid var(--accent); }
42
+ .list { border-right: 1px solid var(--border); overflow: auto; background: var(--bg); }
43
+ .list-head { padding: 10px 14px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
44
+ .row {
45
+ display: block; width: 100%; text-align: left; padding: 10px 14px; border: 0;
46
+ border-bottom: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer;
47
+ }
48
+ .row:hover { background: var(--panel-2); }
49
+ .row.selected { background: var(--panel-2); border-left: 3px solid var(--accent-2); }
50
+ .row-title { font-weight: 600; }
51
+ .row-sub { color: var(--muted); font-size: 12px; }
52
+ .detail { overflow: auto; padding: 16px 18px; }
53
+ .detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
54
+ .message-view { margin-bottom: 16px; }
55
+ .message-meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; display: grid; gap: 2px; }
56
+ .timeline { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
57
+ .timeline .event {
58
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; text-transform: capitalize;
59
+ border: 1px solid var(--border); color: var(--muted);
60
+ }
61
+ .timeline .event.ok { color: var(--ok); border-color: var(--ok); }
62
+ .timeline .event.warn { color: var(--warn); border-color: var(--warn); }
63
+ .timeline .event.bad { color: var(--bad); border-color: var(--bad); }
64
+ .message-html { width: 100%; height: 320px; border: 1px solid var(--border); border-radius: 6px; background: #fff; }
65
+ .message-text { white-space: pre-wrap; word-break: break-word; background: var(--panel); padding: 12px; border-radius: 6px; border: 1px solid var(--border); }
66
+ .detail-lines { margin: 12px 0 0; }
67
+ .detail-line { display: grid; grid-template-columns: minmax(88px, min(220px, 38%)) minmax(0, 1fr); gap: 12px; padding: 4px 0; border-bottom: 1px solid var(--border); }
68
+ .detail-line dt { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
69
+ .detail-line dd { margin: 0; word-break: break-word; }
70
+ .empty { padding: 16px; color: var(--muted); }
71
+ .empty.error { color: var(--bad); }
72
+ .pill {
73
+ display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px;
74
+ text-transform: capitalize; border: 1px solid var(--border);
75
+ }
76
+ .pill.ok { color: var(--ok); border-color: var(--ok); }
77
+ .pill.warn { color: var(--warn); border-color: var(--warn); }
78
+ .pill.bad { color: var(--bad); border-color: var(--bad); }
79
+ .pill.neutral { color: var(--neutral); }
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import { type PostmarkRow, type FlatLine } from '../src/postmark-mirror-ui.js';
3
+ export declare function StatusPill({ value }: {
4
+ value: any;
5
+ }): React.JSX.Element;
6
+ /** The rendered body preview — html in an isolated frame, text in a <pre>. */
7
+ export declare function MessageBody({ message }: {
8
+ message: PostmarkRow;
9
+ }): React.JSX.Element;
10
+ /** The MessageEvents timeline the twin recorded for a sent message. */
11
+ export declare function DeliveryTimeline({ message }: {
12
+ message: PostmarkRow;
13
+ }): React.JSX.Element;
14
+ /** Render the flattened nested-field lines for the detail panel. */
15
+ export declare function NestedLines({ lines }: {
16
+ lines: FlatLine[];
17
+ }): React.JSX.Element;
18
+ export declare function App(): React.JSX.Element;
@@ -0,0 +1,153 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Postmark UI mirror — React/TSX client (Bun-bundled). A Postmark-dashboard-style console:
3
+ // top bar + left nav + list/detail split, with Activity (the outbound message stream) as the
4
+ // main screen, plus Bounces, Templates, Streams, Webhooks and Suppressions.
5
+ //
6
+ // It renders by consuming the twin's OWN REST API on the same origin, at POSTMARK'S REAL
7
+ // PATHS (/messages/outbound, /messages/outbound/:id/details, /bounces, /templates,
8
+ // /message-streams, /webhooks, /message-streams/outbound/suppressions/dump) — the exact
9
+ // endpoints a real integration calls. One code path serves both, so API↔UI parity holds by
10
+ // construction.
11
+ //
12
+ // The pure format helpers live in ../src/postmark-mirror-ui.ts so they can be unit-tested AND
13
+ // shared here (Bun tree-shakes the server-only exports out of this browser bundle).
14
+ import { useEffect, useMemo, useState } from 'react';
15
+ import { createRoot } from 'react-dom/client';
16
+ import { recipientsOf, messageSubject, messageStatus, messagePreview, deliveryTimeline, formatPostmarkDate, statusTone, flattenPostmarkValue, POSTMARK_MIRROR_SECTIONS, } from "../src/postmark-mirror-ui.js";
17
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
18
+ // before); served under a path prefix with a <base> tag every read and write resolves inside that
19
+ // prefix instead of escaping it.
20
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
21
+ export function StatusPill({ value }) {
22
+ const v = String(value ?? '');
23
+ return _jsx("span", { className: `pill ${statusTone(v)}`, children: v || '—' });
24
+ }
25
+ /** The rendered body preview — html in an isolated frame, text in a <pre>. */
26
+ export function MessageBody({ message }) {
27
+ const preview = messagePreview(message);
28
+ if (preview.kind === 'html') {
29
+ return _jsx("iframe", { className: "message-html", title: "message body", sandbox: "", srcDoc: preview.value });
30
+ }
31
+ if (preview.kind === 'text') {
32
+ return _jsx("pre", { className: "message-text", children: preview.value });
33
+ }
34
+ return _jsx("div", { className: "empty", children: "No body." });
35
+ }
36
+ /** The MessageEvents timeline the twin recorded for a sent message. */
37
+ export function DeliveryTimeline({ message }) {
38
+ const events = deliveryTimeline(message);
39
+ return (_jsx("div", { className: "timeline", children: events.map((ev, i) => (_jsx("span", { className: `event ${statusTone(ev)}`, children: ev }, `${ev}-${i}`))) }));
40
+ }
41
+ /** Render the flattened nested-field lines for the detail panel. */
42
+ export function NestedLines({ lines }) {
43
+ return (_jsx("dl", { className: "detail-lines", children: lines.map((ln, i) => (_jsxs("div", { className: "detail-line", children: [_jsx("dt", { className: "mono", children: ln.key }), _jsx("dd", { className: "mono", children: ln.value })] }, `${ln.key}-${i}`))) }));
44
+ }
45
+ /** The one-line summary each nav section shows for a row. */
46
+ function rowTitle(section, r) {
47
+ if (section.key === 'messages')
48
+ return messageSubject(r);
49
+ if (section.key === 'bounces')
50
+ return `${r.Type ?? 'Bounce'} · ${r.Email ?? ''}`;
51
+ if (section.key === 'templates')
52
+ return String(r.Name ?? r.TemplateId ?? '');
53
+ if (section.key === 'streams')
54
+ return String(r.Name ?? r.ID ?? '');
55
+ if (section.key === 'webhooks')
56
+ return String(r.Url ?? r.ID ?? '');
57
+ if (section.key === 'suppressions')
58
+ return String(r.EmailAddress ?? '');
59
+ return String(r[section.idKey] ?? '');
60
+ }
61
+ function rowSubtitle(section, r) {
62
+ if (section.key === 'messages')
63
+ return `to ${recipientsOf(r)} · ${messageStatus(r)}`;
64
+ if (section.key === 'bounces')
65
+ return `${r.Subject ?? ''} · ${r.Inactive ? 'inactive' : 'active'}`;
66
+ if (section.key === 'templates')
67
+ return `${r.Alias ?? '(no alias)'} · ${r.TemplateType ?? ''}`;
68
+ if (section.key === 'streams')
69
+ return `${r.MessageStreamType ?? ''} · ${r.ID ?? ''}`;
70
+ if (section.key === 'webhooks')
71
+ return String(r.MessageStream ?? '');
72
+ if (section.key === 'suppressions')
73
+ return `${r.SuppressionReason ?? ''} · ${r.Origin ?? ''}`;
74
+ return '';
75
+ }
76
+ /** The status value each section pins to its pill. */
77
+ function rowStatus(section, r) {
78
+ if (section.key === 'messages')
79
+ return messageStatus(r);
80
+ if (section.key === 'bounces')
81
+ return String(r.Type ?? '');
82
+ if (section.key === 'templates')
83
+ return r.Active ? 'Active' : 'Inactive';
84
+ if (section.key === 'streams')
85
+ return r.ArchivedAt ? 'Blocked' : 'Active';
86
+ if (section.key === 'suppressions')
87
+ return String(r.SuppressionReason ?? '');
88
+ return '';
89
+ }
90
+ function useCollection(section) {
91
+ const [rows, setRows] = useState([]);
92
+ const [loading, setLoading] = useState(true);
93
+ const [error, setError] = useState('');
94
+ useEffect(() => {
95
+ let live = true;
96
+ setLoading(true);
97
+ setError('');
98
+ fetch(`${WIRE_BASE}${section.path}`)
99
+ .then((r) => r.json())
100
+ .then((body) => {
101
+ if (!live)
102
+ return;
103
+ const data = Array.isArray(body) ? body : Array.isArray(body?.[section.collection]) ? body[section.collection] : [];
104
+ setRows(data);
105
+ setLoading(false);
106
+ })
107
+ .catch((e) => { if (live) {
108
+ setError(String(e));
109
+ setLoading(false);
110
+ } });
111
+ return () => { live = false; };
112
+ }, [section.path, section.collection]);
113
+ return { rows, loading, error };
114
+ }
115
+ /** The Activity detail pane fetches the message's FULL record — Postmark's list endpoint
116
+ * deliberately omits bodies + MessageEvents, exactly as the real API does. */
117
+ function useMessageDetails(messageId) {
118
+ const [detail, setDetail] = useState(null);
119
+ useEffect(() => {
120
+ let live = true;
121
+ setDetail(null);
122
+ if (!messageId)
123
+ return;
124
+ fetch(`${WIRE_BASE}/messages/outbound/${encodeURIComponent(messageId)}/details`)
125
+ .then((r) => r.json())
126
+ .then((body) => { if (live && body && !body.ErrorCode)
127
+ setDetail(body); })
128
+ .catch(() => { });
129
+ return () => { live = false; };
130
+ }, [messageId]);
131
+ return detail;
132
+ }
133
+ export function App() {
134
+ const [sectionKey, setSectionKey] = useState(POSTMARK_MIRROR_SECTIONS[0].key);
135
+ const [selectedId, setSelectedId] = useState(null);
136
+ const section = POSTMARK_MIRROR_SECTIONS.find((s) => s.key === sectionKey);
137
+ const { rows, loading, error } = useCollection(section);
138
+ const selected = useMemo(() => rows.find((r) => String(r[section.idKey]) === selectedId) ?? rows[0] ?? null, [rows, selectedId, section.idKey]);
139
+ const isMessages = section.key === 'messages';
140
+ const details = useMessageDetails(isMessages && selected ? String(selected.MessageID) : null);
141
+ const shown = details ?? selected;
142
+ const lines = useMemo(() => (shown ? flattenPostmarkValue(shown) : []), [shown]);
143
+ return (_jsxs("div", { className: "app", children: [_jsxs("header", { className: "topbar", children: [_jsx("span", { className: "brand", children: "Postmark twin" }), _jsx("span", { className: "subtitle", children: "local transactional email mirror \u2014 activity \u00B7 bounces \u00B7 templates \u00B7 streams" })] }), _jsxs("div", { className: "body", children: [_jsx("nav", { className: "nav", children: POSTMARK_MIRROR_SECTIONS.map((s) => (_jsx("button", { className: `nav-item ${s.key === sectionKey ? 'active' : ''}`, onClick: () => { setSectionKey(s.key); setSelectedId(null); }, children: s.label }, s.key))) }), _jsxs("section", { className: "list", children: [_jsxs("div", { className: "list-head", children: [section.label, !loading ? ` (${rows.length})` : ''] }), loading && _jsx("div", { className: "empty", children: "Loading\u2026" }), error && _jsx("div", { className: "empty error", children: error }), !loading && !error && rows.length === 0 && _jsxs("div", { className: "empty", children: ["No ", section.label.toLowerCase(), " yet."] }), rows.map((r, i) => (_jsxs("button", { className: `row ${selected && String(selected[section.idKey]) === String(r[section.idKey]) ? 'selected' : ''}`, onClick: () => setSelectedId(String(r[section.idKey])), children: [_jsx("div", { className: "row-title", children: rowTitle(section, r) }), _jsx("div", { className: "row-sub mono", children: rowSubtitle(section, r) })] }, `${String(r[section.idKey])}-${i}`)))] }), _jsx("aside", { className: "detail", children: shown ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "detail-head", children: [_jsx("span", { className: "mono", children: String(shown[section.idKey] ?? '') }), _jsx(StatusPill, { value: rowStatus(section, shown) })] }), isMessages && (_jsxs("div", { className: "message-view", children: [_jsxs("div", { className: "message-meta mono", children: [_jsxs("div", { children: ["from ", String(shown.From ?? '—')] }), _jsxs("div", { children: ["to ", recipientsOf(shown)] }), _jsxs("div", { children: [formatPostmarkDate(shown.ReceivedAt), " \u00B7 stream ", String(shown.MessageStream ?? '—')] })] }), _jsx(DeliveryTimeline, { message: shown }), _jsx(MessageBody, { message: shown })] })), _jsx(NestedLines, { lines: lines })] })) : (_jsx("div", { className: "empty", children: "Select a record." })) })] })] }));
144
+ }
145
+ // Browser-only mount. Guarded so the exported presentational components (StatusPill,
146
+ // MessageBody, DeliveryTimeline, NestedLines) can be imported and renderToStaticMarkup'd in a
147
+ // DOM-less test/SSR environment (the data-coupled UI verifies) without this top-level
148
+ // `document` access throwing. The browser bundle still mounts exactly as before.
149
+ if (typeof document !== 'undefined') {
150
+ const rootEl = document.getElementById('root');
151
+ if (rootEl)
152
+ createRoot(rootEl).render(_jsx(App, {}));
153
+ }
@@ -0,0 +1,221 @@
1
+ // Postmark UI mirror — React/TSX client (Bun-bundled). A Postmark-dashboard-style console:
2
+ // top bar + left nav + list/detail split, with Activity (the outbound message stream) as the
3
+ // main screen, plus Bounces, Templates, Streams, Webhooks and Suppressions.
4
+ //
5
+ // It renders by consuming the twin's OWN REST API on the same origin, at POSTMARK'S REAL
6
+ // PATHS (/messages/outbound, /messages/outbound/:id/details, /bounces, /templates,
7
+ // /message-streams, /webhooks, /message-streams/outbound/suppressions/dump) — the exact
8
+ // endpoints a real integration calls. One code path serves both, so API↔UI parity holds by
9
+ // construction.
10
+ //
11
+ // The pure format helpers live in ../src/postmark-mirror-ui.ts so they can be unit-tested AND
12
+ // shared here (Bun tree-shakes the server-only exports out of this browser bundle).
13
+ import React, { useEffect, useMemo, useState } from 'react';
14
+ import { createRoot } from 'react-dom/client';
15
+ import {
16
+ recipientsOf, messageSubject, messageStatus, messagePreview, deliveryTimeline,
17
+ formatPostmarkDate, statusTone, flattenPostmarkValue, POSTMARK_MIRROR_SECTIONS,
18
+ type PostmarkRow, type FlatLine, type MirrorSection,
19
+ } from '../src/postmark-mirror-ui.ts';
20
+
21
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
22
+ // before); served under a path prefix with a <base> tag every read and write resolves inside that
23
+ // prefix instead of escaping it.
24
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
25
+
26
+ export function StatusPill({ value }: { value: any }) {
27
+ const v = String(value ?? '');
28
+ return <span className={`pill ${statusTone(v)}`}>{v || '—'}</span>;
29
+ }
30
+
31
+ /** The rendered body preview — html in an isolated frame, text in a <pre>. */
32
+ export function MessageBody({ message }: { message: PostmarkRow }) {
33
+ const preview = messagePreview(message);
34
+ if (preview.kind === 'html') {
35
+ return <iframe className="message-html" title="message body" sandbox="" srcDoc={preview.value} />;
36
+ }
37
+ if (preview.kind === 'text') {
38
+ return <pre className="message-text">{preview.value}</pre>;
39
+ }
40
+ return <div className="empty">No body.</div>;
41
+ }
42
+
43
+ /** The MessageEvents timeline the twin recorded for a sent message. */
44
+ export function DeliveryTimeline({ message }: { message: PostmarkRow }) {
45
+ const events = deliveryTimeline(message);
46
+ return (
47
+ <div className="timeline">
48
+ {events.map((ev, i) => (
49
+ <span key={`${ev}-${i}`} className={`event ${statusTone(ev)}`}>{ev}</span>
50
+ ))}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /** Render the flattened nested-field lines for the detail panel. */
56
+ export function NestedLines({ lines }: { lines: FlatLine[] }) {
57
+ return (
58
+ <dl className="detail-lines">
59
+ {lines.map((ln, i) => (
60
+ <div className="detail-line" key={`${ln.key}-${i}`}>
61
+ <dt className="mono">{ln.key}</dt>
62
+ <dd className="mono">{ln.value}</dd>
63
+ </div>
64
+ ))}
65
+ </dl>
66
+ );
67
+ }
68
+
69
+ /** The one-line summary each nav section shows for a row. */
70
+ function rowTitle(section: MirrorSection, r: PostmarkRow): string {
71
+ if (section.key === 'messages') return messageSubject(r);
72
+ if (section.key === 'bounces') return `${r.Type ?? 'Bounce'} · ${r.Email ?? ''}`;
73
+ if (section.key === 'templates') return String(r.Name ?? r.TemplateId ?? '');
74
+ if (section.key === 'streams') return String(r.Name ?? r.ID ?? '');
75
+ if (section.key === 'webhooks') return String(r.Url ?? r.ID ?? '');
76
+ if (section.key === 'suppressions') return String(r.EmailAddress ?? '');
77
+ return String(r[section.idKey] ?? '');
78
+ }
79
+ function rowSubtitle(section: MirrorSection, r: PostmarkRow): string {
80
+ if (section.key === 'messages') return `to ${recipientsOf(r)} · ${messageStatus(r)}`;
81
+ if (section.key === 'bounces') return `${r.Subject ?? ''} · ${r.Inactive ? 'inactive' : 'active'}`;
82
+ if (section.key === 'templates') return `${r.Alias ?? '(no alias)'} · ${r.TemplateType ?? ''}`;
83
+ if (section.key === 'streams') return `${r.MessageStreamType ?? ''} · ${r.ID ?? ''}`;
84
+ if (section.key === 'webhooks') return String(r.MessageStream ?? '');
85
+ if (section.key === 'suppressions') return `${r.SuppressionReason ?? ''} · ${r.Origin ?? ''}`;
86
+ return '';
87
+ }
88
+ /** The status value each section pins to its pill. */
89
+ function rowStatus(section: MirrorSection, r: PostmarkRow): string {
90
+ if (section.key === 'messages') return messageStatus(r);
91
+ if (section.key === 'bounces') return String(r.Type ?? '');
92
+ if (section.key === 'templates') return r.Active ? 'Active' : 'Inactive';
93
+ if (section.key === 'streams') return r.ArchivedAt ? 'Blocked' : 'Active';
94
+ if (section.key === 'suppressions') return String(r.SuppressionReason ?? '');
95
+ return '';
96
+ }
97
+
98
+ function useCollection(section: MirrorSection): { rows: PostmarkRow[]; loading: boolean; error: string } {
99
+ const [rows, setRows] = useState<PostmarkRow[]>([]);
100
+ const [loading, setLoading] = useState(true);
101
+ const [error, setError] = useState('');
102
+ useEffect(() => {
103
+ let live = true;
104
+ setLoading(true);
105
+ setError('');
106
+ fetch(`${WIRE_BASE}${section.path}`)
107
+ .then((r) => r.json())
108
+ .then((body) => {
109
+ if (!live) return;
110
+ const data = Array.isArray(body) ? body : Array.isArray(body?.[section.collection]) ? body[section.collection] : [];
111
+ setRows(data);
112
+ setLoading(false);
113
+ })
114
+ .catch((e) => { if (live) { setError(String(e)); setLoading(false); } });
115
+ return () => { live = false; };
116
+ }, [section.path, section.collection]);
117
+ return { rows, loading, error };
118
+ }
119
+
120
+ /** The Activity detail pane fetches the message's FULL record — Postmark's list endpoint
121
+ * deliberately omits bodies + MessageEvents, exactly as the real API does. */
122
+ function useMessageDetails(messageId: string | null): PostmarkRow | null {
123
+ const [detail, setDetail] = useState<PostmarkRow | null>(null);
124
+ useEffect(() => {
125
+ let live = true;
126
+ setDetail(null);
127
+ if (!messageId) return;
128
+ fetch(`${WIRE_BASE}/messages/outbound/${encodeURIComponent(messageId)}/details`)
129
+ .then((r) => r.json())
130
+ .then((body) => { if (live && body && !body.ErrorCode) setDetail(body); })
131
+ .catch(() => { /* the detail pane degrades to the list row */ });
132
+ return () => { live = false; };
133
+ }, [messageId]);
134
+ return detail;
135
+ }
136
+
137
+ export function App() {
138
+ const [sectionKey, setSectionKey] = useState<string>(POSTMARK_MIRROR_SECTIONS[0]!.key);
139
+ const [selectedId, setSelectedId] = useState<string | null>(null);
140
+ const section = POSTMARK_MIRROR_SECTIONS.find((s) => s.key === sectionKey)!;
141
+ const { rows, loading, error } = useCollection(section);
142
+ const selected = useMemo(
143
+ () => rows.find((r) => String(r[section.idKey]) === selectedId) ?? rows[0] ?? null,
144
+ [rows, selectedId, section.idKey],
145
+ );
146
+ const isMessages = section.key === 'messages';
147
+ const details = useMessageDetails(isMessages && selected ? String(selected.MessageID) : null);
148
+ const shown = details ?? selected;
149
+ const lines = useMemo(() => (shown ? flattenPostmarkValue(shown) : []), [shown]);
150
+
151
+ return (
152
+ <div className="app">
153
+ <header className="topbar">
154
+ <span className="brand">Postmark twin</span>
155
+ <span className="subtitle">local transactional email mirror — activity · bounces · templates · streams</span>
156
+ </header>
157
+ <div className="body">
158
+ <nav className="nav">
159
+ {POSTMARK_MIRROR_SECTIONS.map((s) => (
160
+ <button
161
+ key={s.key}
162
+ className={`nav-item ${s.key === sectionKey ? 'active' : ''}`}
163
+ onClick={() => { setSectionKey(s.key); setSelectedId(null); }}
164
+ >
165
+ {s.label}
166
+ </button>
167
+ ))}
168
+ </nav>
169
+ <section className="list">
170
+ <div className="list-head">{section.label}{!loading ? ` (${rows.length})` : ''}</div>
171
+ {loading && <div className="empty">Loading…</div>}
172
+ {error && <div className="empty error">{error}</div>}
173
+ {!loading && !error && rows.length === 0 && <div className="empty">No {section.label.toLowerCase()} yet.</div>}
174
+ {rows.map((r, i) => (
175
+ <button
176
+ key={`${String(r[section.idKey])}-${i}`}
177
+ className={`row ${selected && String(selected[section.idKey]) === String(r[section.idKey]) ? 'selected' : ''}`}
178
+ onClick={() => setSelectedId(String(r[section.idKey]))}
179
+ >
180
+ <div className="row-title">{rowTitle(section, r)}</div>
181
+ <div className="row-sub mono">{rowSubtitle(section, r)}</div>
182
+ </button>
183
+ ))}
184
+ </section>
185
+ <aside className="detail">
186
+ {shown ? (
187
+ <>
188
+ <div className="detail-head">
189
+ <span className="mono">{String(shown[section.idKey] ?? '')}</span>
190
+ <StatusPill value={rowStatus(section, shown)} />
191
+ </div>
192
+ {isMessages && (
193
+ <div className="message-view">
194
+ <div className="message-meta mono">
195
+ <div>from {String(shown.From ?? '—')}</div>
196
+ <div>to {recipientsOf(shown)}</div>
197
+ <div>{formatPostmarkDate(shown.ReceivedAt)} · stream {String(shown.MessageStream ?? '—')}</div>
198
+ </div>
199
+ <DeliveryTimeline message={shown} />
200
+ <MessageBody message={shown} />
201
+ </div>
202
+ )}
203
+ <NestedLines lines={lines} />
204
+ </>
205
+ ) : (
206
+ <div className="empty">Select a record.</div>
207
+ )}
208
+ </aside>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // Browser-only mount. Guarded so the exported presentational components (StatusPill,
215
+ // MessageBody, DeliveryTimeline, NestedLines) can be imported and renderToStaticMarkup'd in a
216
+ // DOM-less test/SSR environment (the data-coupled UI verifies) without this top-level
217
+ // `document` access throwing. The browser bundle still mounts exactly as before.
218
+ if (typeof document !== 'undefined') {
219
+ const rootEl = document.getElementById('root');
220
+ if (rootEl) createRoot(rootEl).render(<App />);
221
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-postmark CLI: serve the Postmark API twin, the activity mirror UI, or run conformance.
4
+ import { hasFlag, optionValue } from '@volter/world-core/args';
5
+ import { createPostmarkTwinServer } from "./postmark-server.js";
6
+ import { createPostmarkMirrorServer } from "./postmark-mirror-ui.js";
7
+ const [cmd, ...rest] = process.argv.slice(2);
8
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
9
+ const root = optionValue(rest, '--root') || undefined;
10
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
11
+ if (cmd === 'serve') {
12
+ const s = await createPostmarkTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
13
+ process.stdout.write(`postmark twin (transactional email API)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
14
+ await keepProcessAlive();
15
+ }
16
+ else if (cmd === 'mirror') {
17
+ const s = await createPostmarkMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
18
+ process.stdout.write(`postmark mirror UI (activity) at http://127.0.0.1:${s.port}\n`);
19
+ await keepProcessAlive();
20
+ }
21
+ else if (cmd === 'conformance') {
22
+ // dev-only; lazy so the bin runs without @volter/world-tooling
23
+ const { checkPostmarkConformance, postmarkCoverage } = await import("./postmark-conformance.js");
24
+ const report = checkPostmarkConformance({ ...(root ? { root } : {}) });
25
+ process.stdout.write(`${JSON.stringify({ ...report, coverage: postmarkCoverage({ ...(root ? { root } : {}) }) }, null, 2)}\n`);
26
+ if (!report.ok)
27
+ process.exitCode = 1;
28
+ }
29
+ else {
30
+ process.stdout.write('Usage: world-postmark serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
31
+ }
@@ -0,0 +1,10 @@
1
+ export { handlePostmarkTwinRequest, POSTMARK_ERRORS, POSTMARK_RESOURCE_TYPES } from './postmark-twin.js';
2
+ export type { PostmarkRequest, PostmarkResponse } from './postmark-twin.js';
3
+ export { createPostmarkTwinFetch, createPostmarkTwinServer, type PostmarkTwinFetchOptions } from './postmark-server.js';
4
+ export { deliveryPlan, emitPostmarkWebhook, POSTMARK_RECORD_TYPES, terminalEvent, webhookHeaders, webhooksFor, } from './postmark-events.js';
5
+ export type { PostmarkEventType, PostmarkRecordType, PostmarkWebhookDelivery, WebhookContext } from './postmark-events.js';
6
+ export { mapBounce, mapMessage, mapMessageStream, mapServer, mapTemplate, mapWebhook, pullPostmarkBounces, pullPostmarkMessages, pullPostmarkMessageStreams, pullPostmarkServer, pullPostmarkTemplates, pullPostmarkWebhooks, pushPostmarkAction, syncPostmarkFromReal, } from './postmark-connector.js';
7
+ export type { PostmarkBounce, PostmarkClient, PostmarkMessageStream, PostmarkOutboundMessage, PostmarkServer, PostmarkTemplate, PostmarkWebhookConfig, } from './postmark-connector.js';
8
+ export { buildPostmarkMirrorClient, createPostmarkMirrorServer, POSTMARK_MIRROR_SECTIONS, postmarkMirrorHtml, } from './postmark-mirror-ui.js';
9
+ import { type TwinPack } from '@volter/world-core';
10
+ export declare const pack: TwinPack;
@@ -0,0 +1,54 @@
1
+ // @volter/twin-postmark — the Postmark transactional-email API twin (one vendor, one
2
+ // package), built on the shared @volter/world-core kernel. REST transport over
3
+ // api.postmarkapp.com shapes, `X-Postmark-Server-Token` / `X-Postmark-Account-Token` auth, a
4
+ // deterministic OFFLINE delivery lifecycle that writes real `MessageEvents` and mints Bounce
5
+ // records + suppressions, Postmark's real (unsigned, Basic-auth/custom-header) webhooks, and
6
+ // a React activity-mirror UI. (Conformance/capability tooling lives in @volter/world-tooling,
7
+ // a dev dependency — NOT shipped in the runtime API.)
8
+ export { handlePostmarkTwinRequest, POSTMARK_ERRORS, POSTMARK_RESOURCE_TYPES } from "./postmark-twin.js";
9
+ export { createPostmarkTwinFetch, createPostmarkTwinServer } from "./postmark-server.js";
10
+ export { deliveryPlan, emitPostmarkWebhook, POSTMARK_RECORD_TYPES, terminalEvent, webhookHeaders, webhooksFor, } from "./postmark-events.js";
11
+ export { mapBounce, mapMessage, mapMessageStream, mapServer, mapTemplate, mapWebhook, pullPostmarkBounces, pullPostmarkMessages, pullPostmarkMessageStreams, pullPostmarkServer, pullPostmarkTemplates, pullPostmarkWebhooks, pushPostmarkAction, syncPostmarkFromReal, } from "./postmark-connector.js";
12
+ export { buildPostmarkMirrorClient, createPostmarkMirrorServer, POSTMARK_MIRROR_SECTIONS, postmarkMirrorHtml, } from "./postmark-mirror-ui.js";
13
+ // Registry descriptor: the pack self-describes so tooling can discover it.
14
+ import { registerPack } from '@volter/world-core';
15
+ import { performPostmarkAction, syncPostmarkFromRemote } from "./postmark-connector.js";
16
+ export const pack = {
17
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
18
+ // state system. Moved 2026-09-08.
19
+ protocol: '2',
20
+ refresh: { every: '5m', webhook: true, onDemand: { atMost: '30s' } },
21
+ stateSystem: { perform: performPostmarkAction, refresh: syncPostmarkFromRemote },
22
+ // the round trip: send a message — Postmark's own write, and a fresh id each time
23
+ roundTrip: { method: 'POST', path: '/email', body: { From: 'round@trip.test', To: 'round@trip.test', Subject: 'round trip', TextBody: 'round trip' }, headers: { 'x-postmark-server-token': 'round-trip' } },
24
+ parityOrigin: 'http://twin',
25
+ vendor: 'postmark',
26
+ transport: 'rest',
27
+ archetype: 'crud',
28
+ bin: 'world-postmark',
29
+ resources: ['message', 'bounce', 'template', 'message_stream', 'webhook', 'suppression', 'server', 'domain', 'sender_signature', 'inbound_rule'],
30
+ specSource: 'postmark-conformance.ts (inline per-object JSON Schemas derived from the official `postmark` SDK models + developer.postmarkapp.com)',
31
+ description: 'Postmark transactional email API twin — send/template/batch sends, outbound message activity, bounces + delivery stats, templates, message streams, suppressions, webhooks, domains and sender signatures, with an activity mirror UI.',
32
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
33
+ // 2026-08-31): the official `postmark` npm client and the POSTMARK_* credential
34
+ // stem, and the one API host the SDK talks to.
35
+ adoption: {
36
+ // COMMUNITY: Postmark ships no official Python SDK; `postmarker` is the de-facto client of the
37
+ // same api.postmarkapp.com surface.
38
+ pypi: ['postmarker'],
39
+ sdks: ['postmark'], envStems: ['POSTMARK'],
40
+ },
41
+ hosts: [{ host: 'api.postmarkapp.com' }],
42
+ // No `browserRouting` — deliberately, for two independent reasons (the algolia precedent).
43
+ // 1. Postmark is a SERVER-SIDE API: the server/account tokens are secrets and there is no
44
+ // browser SDK, so forwarding it from a browser dev proxy is not a flow that exists.
45
+ // 2. There is no single stripeable API path prefix to route on. Postmark's routes sit at
46
+ // the ROOT — /email, /messages/*, /bounces, /templates, /message-streams, /webhooks,
47
+ // /stats/*, /domains, /senders — so any prefix narrow enough to be meaningful (e.g.
48
+ // '/messages/') would silently fail to forward the headline /email send, and the only
49
+ // prefix that covers everything is '/', which would capture the app's own routes too.
50
+ // Node-side zero-edit injection is unaffected: POSTMARK_TWIN_URL + the control-plane
51
+ // injector's `api.postmarkapp.com` matcher redirect the real SDK with no code change.
52
+ };
53
+ // registered at import: the kernel learns the pack's state system (protocol 2)
54
+ registerPack(pack);
@@ -0,0 +1,12 @@
1
+ import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
2
+ import { deliveryPlan, webhookHeaders, webhooksFor } from './postmark-events.js';
3
+ export declare const POSTMARK_CAPABILITIES: CapabilitySpec[];
4
+ /** The vendor's own docs-nav API groups, enumerated TOP-DOWN from developer.postmarkapp.com
5
+ * (not derived from the manifest above — that would make any bijection a tautology). */
6
+ export declare const POSTMARK_AREAS: readonly ["email", "messages", "bounces", "templates", "streams", "suppressions", "webhooks", "server", "servers", "stats", "inbound_rules", "domains", "senders", "data_removals", "auth", "honesty", "ui", "connector"];
7
+ export declare function postmarkCapabilities(): Promise<CapabilityReport>;
8
+ export declare const __postmarkInternals: {
9
+ deliveryPlan: typeof deliveryPlan;
10
+ webhookHeaders: typeof webhookHeaders;
11
+ webhooksFor: typeof webhooksFor;
12
+ };