@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,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
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -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
|
+
};
|