@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,251 @@
1
+ // Postmark CONNECTOR — the live-vendor pull/push path that gives the Postmark twin the full
2
+ // "git for SaaS" lifecycle over an INJECTED client (the auth boundary).
3
+ //
4
+ // PULL (real → twin): fetch real Postmark objects via the injected client (outbound
5
+ // messages, bounces, templates, message streams, webhooks, and the
6
+ // server itself), map them to SyncResource[], and fold them into the
7
+ // tree through the kernel's observe (its own diff, so a re-pull of identical
8
+ // state is a no-op).
9
+ // PUSH (twin → real): for every PENDING local action, call the injected client and
10
+ // confirmAction on success. The headline push is sending local
11
+ // messages to real Postmark; templates/webhooks/streams follow.
12
+ //
13
+ // The vendor I/O is an INJECTED client interface (`PostmarkClient`): a fake in tests, a real
14
+ // `new postmark.ServerClient(token)` in prod. The pack imports NO SDK and holds NO key. The
15
+ // shape below is a structural SUBSET of the real `ServerClient` (method names taken from the
16
+ // SDK's own ServerClient.d.ts), so a real client satisfies it without adaptation.
17
+ import { observeResources } from '@volter/world-core';
18
+ const SERVICE = 'postmark';
19
+ function listOf(value) {
20
+ return Array.isArray(value) ? value : [];
21
+ }
22
+ // ── PULL: pure shape mappers (no client, no I/O — trivially unit-testable) ──────────
23
+ export function mapMessage(m) {
24
+ return {
25
+ type: 'message',
26
+ id: String(m.MessageID),
27
+ fields: {
28
+ MessageID: String(m.MessageID), Tag: m.Tag ?? '', To: listOf(m.To), Cc: listOf(m.Cc), Bcc: listOf(m.Bcc),
29
+ Recipients: listOf(m.Recipients), ReceivedAt: m.ReceivedAt ?? null, From: m.From ?? null,
30
+ Subject: m.Subject ?? '', Attachments: listOf(m.Attachments), Status: m.Status ?? 'Sent',
31
+ TrackOpens: m.TrackOpens === true, TrackLinks: m.TrackLinks ?? 'None', Metadata: m.Metadata ?? {},
32
+ MessageStream: m.MessageStream ?? 'outbound', Sandboxed: m.Sandboxed === true,
33
+ HtmlBody: null, TextBody: null, MessageEvents: [],
34
+ },
35
+ };
36
+ }
37
+ export function mapBounce(b) {
38
+ return {
39
+ type: 'bounce',
40
+ id: String(b.ID),
41
+ fields: {
42
+ RecordType: 'Bounce', ID: b.ID, Type: b.Type ?? 'HardBounce', TypeCode: b.TypeCode ?? 1,
43
+ Name: b.Name ?? 'Hard bounce', Tag: b.Tag ?? '', MessageID: b.MessageID ?? null,
44
+ ServerID: b.ServerID ?? 1, Description: b.Description ?? '', Details: b.Details ?? '',
45
+ Email: b.Email ?? null, From: b.From ?? null, BouncedAt: b.BouncedAt ?? null,
46
+ DumpAvailable: b.DumpAvailable === true, Inactive: b.Inactive === true,
47
+ CanActivate: b.CanActivate === true, Subject: b.Subject ?? '', MessageStream: b.MessageStream ?? 'outbound',
48
+ },
49
+ };
50
+ }
51
+ export function mapTemplate(t) {
52
+ return {
53
+ type: 'template',
54
+ id: String(t.TemplateId),
55
+ fields: {
56
+ TemplateId: t.TemplateId, Name: t.Name ?? null, Alias: t.Alias ?? null,
57
+ TemplateType: t.TemplateType ?? 'Standard', Active: t.Active !== false,
58
+ LayoutTemplate: t.LayoutTemplate ?? null, Subject: t.Subject ?? '',
59
+ HtmlBody: t.HtmlBody ?? null, TextBody: t.TextBody ?? null, AssociatedServerId: 1,
60
+ },
61
+ };
62
+ }
63
+ export function mapMessageStream(s) {
64
+ return {
65
+ type: 'message_stream',
66
+ id: String(s.ID),
67
+ fields: {
68
+ ID: String(s.ID), ServerID: s.ServerID ?? 1, Name: s.Name ?? String(s.ID),
69
+ Description: s.Description ?? '', MessageStreamType: s.MessageStreamType ?? 'Transactional',
70
+ CreatedAt: s.CreatedAt ?? null, UpdatedAt: s.UpdatedAt ?? null, ArchivedAt: s.ArchivedAt ?? null,
71
+ SubscriptionManagementConfiguration: s.SubscriptionManagementConfiguration ?? { UnsubscribeHandlingType: 'None' },
72
+ },
73
+ };
74
+ }
75
+ export function mapWebhook(w) {
76
+ return {
77
+ type: 'webhook',
78
+ id: String(w.ID),
79
+ fields: {
80
+ ID: w.ID, Url: w.Url ?? null, MessageStream: w.MessageStream ?? 'outbound',
81
+ HttpAuth: w.HttpAuth ?? null, HttpHeaders: listOf(w.HttpHeaders), Triggers: w.Triggers ?? {},
82
+ },
83
+ };
84
+ }
85
+ export function mapServer(s) {
86
+ return {
87
+ type: 'server',
88
+ id: String(s.ID),
89
+ fields: {
90
+ ID: s.ID, Name: s.Name ?? null, Color: s.Color ?? 'blue', DeliveryType: s.DeliveryType ?? 'Live',
91
+ TrackOpens: s.TrackOpens === true, TrackLinks: s.TrackLinks ?? 'None',
92
+ InboundAddress: s.InboundAddress ?? '', ServerLink: s.ServerLink ?? '',
93
+ SmtpApiActivated: s.SmtpApiActivated !== false,
94
+ },
95
+ };
96
+ }
97
+ // ── PULL: the network-touching entry points ────────────────────────────────────────
98
+ export async function pullPostmarkMessages(client) {
99
+ return listOf((await client.getOutboundMessages({ count: 500, offset: 0 })).Messages).map(mapMessage);
100
+ }
101
+ export async function pullPostmarkBounces(client) {
102
+ return listOf((await client.getBounces({ count: 500, offset: 0 })).Bounces).map(mapBounce);
103
+ }
104
+ export async function pullPostmarkTemplates(client) {
105
+ return listOf((await client.getTemplates({ count: 500, offset: 0 })).Templates).map(mapTemplate);
106
+ }
107
+ export async function pullPostmarkMessageStreams(client) {
108
+ return listOf((await client.getMessageStreams()).MessageStreams).map(mapMessageStream);
109
+ }
110
+ export async function pullPostmarkWebhooks(client) {
111
+ return listOf((await client.getWebhooks()).Webhooks).map(mapWebhook);
112
+ }
113
+ export async function pullPostmarkServer(client) {
114
+ return [mapServer(await client.getServer())];
115
+ }
116
+ /**
117
+ * D7 entry point: pull from real Postmark and fold everything into the twin (mirror seeding).
118
+ * Callers never have to know the per-domain `pull*` mechanics.
119
+ */
120
+ export async function syncPostmarkFromReal(client, opts) {
121
+ const resources = [
122
+ ...(await pullPostmarkMessageStreams(client)),
123
+ ...(await pullPostmarkServer(client)),
124
+ ...(await pullPostmarkTemplates(client)),
125
+ ...(await pullPostmarkMessages(client)),
126
+ ...(await pullPostmarkBounces(client)),
127
+ ...(await pullPostmarkWebhooks(client)),
128
+ ];
129
+ // protocol 2: the observation lands on the head through the kernel's fold — one batch, one instant
130
+ const result = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), { ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}` });
131
+ return { observed: result.observed, deltasAppended: result.appended };
132
+ }
133
+ // ── PUSH ────────────────────────────────────────────────────────────────────────────
134
+ // The twin operations this connector knows how to push to real Postmark. Anything not here
135
+ // FAILS LOUDLY rather than being silently dropped.
136
+ const PUSHABLE = new Set(['message.send', 'template.create', 'webhook.create', 'message_stream.create']);
137
+ function assertPushable(op) {
138
+ if (!PUSHABLE.has(op)) {
139
+ throw new Error(`postmark push: unsupported operation '${op}' — refusing to silently drop a local write`);
140
+ }
141
+ }
142
+ /** Recipients are stored structurally in the twin; Postmark's API takes a comma-joined string. */
143
+ function joinRecipients(v) {
144
+ if (Array.isArray(v))
145
+ return v.map((r) => String(r?.Email ?? r)).filter(Boolean).join(', ');
146
+ return typeof v === 'string' ? v : '';
147
+ }
148
+ /**
149
+ * Push ONE pending action to REAL Postmark via the injected client. Returns the real external
150
+ * id (the Postmark object id from the response). WRITES TO THE REAL ACCOUNT (sends real email).
151
+ */
152
+ export async function pushPostmarkAction(client, action) {
153
+ const op = action.operation ?? `${action.subject.type}.update`;
154
+ assertPushable(op);
155
+ const f = action.fields ?? {};
156
+ let externalId = action.subject.id;
157
+ if (op === 'message.send') {
158
+ const res = await client.sendEmail({
159
+ From: String(f.From ?? ''),
160
+ To: joinRecipients(f.To) || joinRecipients(f.Recipients),
161
+ ...(joinRecipients(f.Cc) ? { Cc: joinRecipients(f.Cc) } : {}),
162
+ ...(joinRecipients(f.Bcc) ? { Bcc: joinRecipients(f.Bcc) } : {}),
163
+ Subject: String(f.Subject ?? ''),
164
+ ...(f.HtmlBody != null ? { HtmlBody: String(f.HtmlBody) } : {}),
165
+ ...(f.TextBody != null ? { TextBody: String(f.TextBody) } : {}),
166
+ ...(f.Tag ? { Tag: String(f.Tag) } : {}),
167
+ ...(f.MessageStream ? { MessageStream: String(f.MessageStream) } : {}),
168
+ ...(f.TrackOpens === true ? { TrackOpens: true } : {}),
169
+ ...(f.TrackLinks && f.TrackLinks !== 'None' ? { TrackLinks: String(f.TrackLinks) } : {}),
170
+ });
171
+ externalId = String(res.MessageID);
172
+ }
173
+ else if (op === 'template.create') {
174
+ externalId = String((await client.createTemplate({
175
+ Name: String(f.Name ?? ''),
176
+ ...(f.Alias != null ? { Alias: String(f.Alias) } : {}),
177
+ Subject: String(f.Subject ?? ''),
178
+ ...(f.HtmlBody != null ? { HtmlBody: String(f.HtmlBody) } : {}),
179
+ ...(f.TextBody != null ? { TextBody: String(f.TextBody) } : {}),
180
+ TemplateType: String(f.TemplateType ?? 'Standard'),
181
+ })).TemplateId);
182
+ }
183
+ else if (op === 'webhook.create') {
184
+ externalId = String((await client.createWebhook({
185
+ Url: String(f.Url ?? ''),
186
+ MessageStream: String(f.MessageStream ?? 'outbound'),
187
+ ...(f.HttpAuth ? { HttpAuth: f.HttpAuth } : {}),
188
+ ...(Array.isArray(f.HttpHeaders) && f.HttpHeaders.length ? { HttpHeaders: f.HttpHeaders } : {}),
189
+ Triggers: f.Triggers ?? {},
190
+ })).ID);
191
+ }
192
+ else if (op === 'message_stream.create') {
193
+ externalId = String((await client.createMessageStream({
194
+ ID: String(f.ID ?? action.subject.id),
195
+ Name: String(f.Name ?? ''),
196
+ MessageStreamType: String(f.MessageStreamType ?? 'Transactional'),
197
+ ...(f.Description ? { Description: String(f.Description) } : {}),
198
+ })).ID);
199
+ }
200
+ return { externalId: externalId || action.subject.id };
201
+ }
202
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
203
+ /** A PostmarkClient over the kernel's executor. At a REAL boundary the kernel's executor sets the sealed
204
+ * credential over these headers (executor.ts); at the twin's own wire any credential is one. */
205
+ export function postmarkClientOver(execute) {
206
+ const get = async (path) => {
207
+ const res = await execute({ method: 'GET', path, headers: { accept: 'application/json', authorization: 'Bearer twin', 'x-postmark-server-token': 'twin' } });
208
+ if (res.status < 200 || res.status >= 300)
209
+ throw new Error(`postmark GET ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
210
+ return JSON.parse(res.body || '{}');
211
+ };
212
+ const post = async (path, body) => {
213
+ const res = await execute({ method: 'POST', path, headers: { accept: 'application/json', 'content-type': 'application/json', 'x-postmark-server-token': 'twin' }, body: JSON.stringify(body) });
214
+ if (res.status < 200 || res.status >= 300)
215
+ throw new Error(`postmark POST ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
216
+ return JSON.parse(res.body || '{}');
217
+ };
218
+ return {
219
+ getServer: () => get('/server'),
220
+ getMessageStreams: () => get('/message-streams'),
221
+ getOutboundMessages: () => get('/messages/outbound'),
222
+ getBounces: () => get('/bounces'),
223
+ getTemplates: () => get('/templates'),
224
+ getWebhooks: () => get('/webhooks'),
225
+ sendEmail: (message) => post('/email', message),
226
+ createTemplate: (options) => post('/templates', options),
227
+ createWebhook: (options) => post('/webhooks', options),
228
+ createMessageStream: (options) => post('/message-streams', options),
229
+ };
230
+ }
231
+ /** The refresh adapter: pull the account's state through the executor and fold it into the root. */
232
+ export async function syncPostmarkFromRemote(execute, opts = {}) {
233
+ return syncPostmarkFromReal(postmarkClientOver(execute), {
234
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
235
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
236
+ });
237
+ }
238
+ /** The perform adapter: a message send crosses; everything else is the twin's own record. */
239
+ export async function performPostmarkAction(execute, action, _ctx) {
240
+ const op = action.operation ?? `${action.subject.type}.update`;
241
+ try {
242
+ return await pushPostmarkAction(postmarkClientOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} });
243
+ }
244
+ catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ if (/unsupported operation|not pushable|no push/i.test(message)) {
247
+ return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Postmark to write` } };
248
+ }
249
+ throw error;
250
+ }
251
+ }
@@ -0,0 +1,85 @@
1
+ /** Postmark's webhook `RecordType` discriminators (the outbound delivery-lifecycle family). */
2
+ export declare const POSTMARK_RECORD_TYPES: readonly ["Delivery", "Open", "Click", "Bounce", "SpamComplaint", "SubscriptionChange"];
3
+ export type PostmarkRecordType = (typeof POSTMARK_RECORD_TYPES)[number];
4
+ /** An injected webhook deliverer — a fake in tests, a real HTTP POST live. */
5
+ export type PostmarkWebhookDelivery = (url: string, body: string, headers: Record<string, string>) => Promise<void> | void;
6
+ /** What the twin needs to route a webhook: the root to read from + the injected deliverer. */
7
+ export type WebhookContext = {
8
+ root?: string;
9
+ deliver?: PostmarkWebhookDelivery;
10
+ };
11
+ /**
12
+ * The webhooks registered on a stream whose trigger for `recordType` is enabled — read from
13
+ * the SAME projection the Webhooks API serves, so a webhook created through `POST /webhooks`
14
+ * is the one that fires (no parallel registry to drift).
15
+ */
16
+ export declare function webhooksFor(stream: string, recordType: PostmarkRecordType, root?: string): Array<Record<string, unknown>>;
17
+ /**
18
+ * The headers Postmark sends with a webhook delivery: JSON content type, the webhook's own
19
+ * custom `HttpHeaders`, and an HTTP Basic `Authorization` header when `HttpAuth` is set.
20
+ * Pure — exported so a verify() can assert the auth/header wiring without any I/O.
21
+ */
22
+ export declare function webhookHeaders(webhook: Record<string, unknown>): Record<string, string>;
23
+ /**
24
+ * POST one Postmark webhook payload to every webhook on `stream` whose trigger for this
25
+ * `RecordType` is enabled. Returns what was delivered (for assertions); a no-op when no
26
+ * webhook subscribes. Offline whenever `ctx.deliver` is injected.
27
+ */
28
+ export declare function emitPostmarkWebhook(stream: string, recordType: PostmarkRecordType, payload: Record<string, unknown>, ctx?: WebhookContext): Promise<Array<{
29
+ url: string;
30
+ body: string;
31
+ headers: Record<string, string>;
32
+ }>>;
33
+ /**
34
+ * Deliver ONE arriving inbound message to the server's `InboundHookUrl`.
35
+ *
36
+ * This is a different door from `emitPostmarkWebhook` on purpose, because the vendor's is:
37
+ * Postmark's Webhooks API (`POST /webhooks`, `Triggers`) covers the OUTBOUND delivery
38
+ * lifecycle, while an inbound message is posted to the URL configured on the SERVER
39
+ * (`InboundHookUrl`, settable via `PUT /server`). The payload is the inbound message
40
+ * document itself — the same object `GET /messages/inbound/:id/details` serves, with NO
41
+ * `RecordType` discriminator, which is exactly what the vendor sends.
42
+ *
43
+ * AUTH. Postmark's inbound hook has no header configuration at all: the only credential it
44
+ * can carry is HTTP Basic userinfo embedded in the configured URL. That userinfo is split
45
+ * out into an `Authorization: Basic` header here rather than left on the URL, because that
46
+ * is both what the vendor puts on the wire and what `fetch` refuses to send otherwise.
47
+ */
48
+ export declare function emitPostmarkInboundHook(message: Record<string, unknown>, ctx?: WebhookContext): Promise<Array<{
49
+ url: string;
50
+ body: string;
51
+ headers: Record<string, string>;
52
+ }>>;
53
+ export type PostmarkEventType = 'Delivered' | 'Opened' | 'LinkClicked' | 'Bounced' | 'SpamComplaint';
54
+ /**
55
+ * The ordered `MessageEvents` the twin drives a sent message through, OFFLINE.
56
+ *
57
+ * The recipient prefixes below are a TWIN-LOCAL convention (Postmark's own bounce testing
58
+ * needs real mail flow, which a local twin has none of), deliberately documented rather than
59
+ * dressed up as vendor behavior — they are the seam that lets a test drive the bounce and
60
+ * spam-complaint paths deterministically:
61
+ * · a recipient whose local part starts with `bounce` → a HARD BOUNCE (no delivery);
62
+ * · one starting with `spam` or `complaint` → delivered, then a SPAM COMPLAINT;
63
+ * · anything else → delivered.
64
+ * Opens and clicks are emitted ONLY when the message asked for that tracking — `TrackOpens`
65
+ * and `TrackLinks` respectively — exactly as the vendor does, so a message sent with tracking
66
+ * off records neither.
67
+ *
68
+ * `status` is the outbound message's `Status`. It stays `Sent` for every submitted message:
69
+ * on real Postmark a bounce does NOT rewrite the message's Status, it surfaces through
70
+ * `MessageEvents` and the Bounce API. Modeled that way here on purpose.
71
+ */
72
+ export declare function deliveryPlan(input: {
73
+ recipient: string;
74
+ trackOpens: boolean;
75
+ trackLinks: string;
76
+ }): {
77
+ events: PostmarkEventType[];
78
+ status: string;
79
+ };
80
+ /** The terminal event a message lands on, given its plan (the outbound activity headline). */
81
+ export declare function terminalEvent(input: {
82
+ recipient: string;
83
+ trackOpens: boolean;
84
+ trackLinks: string;
85
+ }): PostmarkEventType;
@@ -0,0 +1,169 @@
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
+ const SERVICE = 'postmark';
26
+ /** Postmark's webhook `RecordType` discriminators (the outbound delivery-lifecycle family). */
27
+ export const POSTMARK_RECORD_TYPES = ['Delivery', 'Open', 'Click', 'Bounce', 'SpamComplaint', 'SubscriptionChange'];
28
+ /** RecordType → the `Triggers` key on the webhook resource that gates it. */
29
+ const TRIGGER_FOR = {
30
+ Delivery: 'Delivery', Open: 'Open', Click: 'Click',
31
+ Bounce: 'Bounce', SpamComplaint: 'SpamComplaint', SubscriptionChange: 'SubscriptionChange',
32
+ };
33
+ function asObject(v) {
34
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
35
+ }
36
+ /**
37
+ * The webhooks registered on a stream whose trigger for `recordType` is enabled — read from
38
+ * the SAME projection the Webhooks API serves, so a webhook created through `POST /webhooks`
39
+ * is the one that fires (no parallel registry to drift).
40
+ */
41
+ export function webhooksFor(stream, recordType, root) {
42
+ return projectResources(SERVICE, root)
43
+ .filter((r) => r.type === 'webhook' && r.deleted !== true)
44
+ .filter((w) => w.MessageStream === stream)
45
+ .filter((w) => asObject(asObject(w.Triggers)[TRIGGER_FOR[recordType]]).Enabled === true);
46
+ }
47
+ /**
48
+ * The headers Postmark sends with a webhook delivery: JSON content type, the webhook's own
49
+ * custom `HttpHeaders`, and an HTTP Basic `Authorization` header when `HttpAuth` is set.
50
+ * Pure — exported so a verify() can assert the auth/header wiring without any I/O.
51
+ */
52
+ export function webhookHeaders(webhook) {
53
+ const headers = { 'content-type': 'application/json' };
54
+ for (const raw of Array.isArray(webhook.HttpHeaders) ? webhook.HttpHeaders : []) {
55
+ const h = asObject(raw);
56
+ if (typeof h.Name === 'string' && h.Name !== '')
57
+ headers[h.Name] = String(h.Value ?? '');
58
+ }
59
+ const auth = asObject(webhook.HttpAuth);
60
+ if (typeof auth.Username === 'string' && auth.Username !== '') {
61
+ headers.Authorization = `Basic ${Buffer.from(`${auth.Username}:${String(auth.Password ?? '')}`).toString('base64')}`;
62
+ }
63
+ return headers;
64
+ }
65
+ /** Fire-and-forget HTTP delivery — the live path, like the real vendor's. */
66
+ const httpDelivery = async (url, body, headers) => {
67
+ if (worldEgressRefusal(url) !== null)
68
+ return; // the World's egress rule: refused like an unreachable endpoint
69
+ try {
70
+ await fetch(url, { method: 'POST', headers, body });
71
+ }
72
+ catch {
73
+ /* fire-and-forget, like the real vendor */
74
+ }
75
+ };
76
+ /**
77
+ * POST one Postmark webhook payload to every webhook on `stream` whose trigger for this
78
+ * `RecordType` is enabled. Returns what was delivered (for assertions); a no-op when no
79
+ * webhook subscribes. Offline whenever `ctx.deliver` is injected.
80
+ */
81
+ export async function emitPostmarkWebhook(stream, recordType, payload, ctx = {}) {
82
+ const targets = webhooksFor(stream, recordType, ctx.root);
83
+ if (targets.length === 0)
84
+ return [];
85
+ const deliver = ctx.deliver ?? httpDelivery;
86
+ const body = JSON.stringify({ RecordType: recordType, ...payload });
87
+ const out = [];
88
+ for (const w of targets) {
89
+ const headers = webhookHeaders(w);
90
+ await deliver(String(w.Url), body, headers);
91
+ out.push({ url: String(w.Url), body, headers });
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Deliver ONE arriving inbound message to the server's `InboundHookUrl`.
97
+ *
98
+ * This is a different door from `emitPostmarkWebhook` on purpose, because the vendor's is:
99
+ * Postmark's Webhooks API (`POST /webhooks`, `Triggers`) covers the OUTBOUND delivery
100
+ * lifecycle, while an inbound message is posted to the URL configured on the SERVER
101
+ * (`InboundHookUrl`, settable via `PUT /server`). The payload is the inbound message
102
+ * document itself — the same object `GET /messages/inbound/:id/details` serves, with NO
103
+ * `RecordType` discriminator, which is exactly what the vendor sends.
104
+ *
105
+ * AUTH. Postmark's inbound hook has no header configuration at all: the only credential it
106
+ * can carry is HTTP Basic userinfo embedded in the configured URL. That userinfo is split
107
+ * out into an `Authorization: Basic` header here rather than left on the URL, because that
108
+ * is both what the vendor puts on the wire and what `fetch` refuses to send otherwise.
109
+ */
110
+ export async function emitPostmarkInboundHook(message, ctx = {}) {
111
+ const server = projectResources(SERVICE, ctx.root).find((r) => r.type === 'server' && r.deleted !== true);
112
+ const configured = typeof server?.InboundHookUrl === 'string' ? server.InboundHookUrl : '';
113
+ if (configured === '')
114
+ return [];
115
+ let parsed;
116
+ try {
117
+ parsed = new URL(configured);
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ const headers = { 'content-type': 'application/json' };
123
+ if (parsed.username !== '') {
124
+ headers.Authorization = `Basic ${Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')}`;
125
+ parsed.username = '';
126
+ parsed.password = '';
127
+ }
128
+ const url = parsed.toString();
129
+ const body = JSON.stringify(message);
130
+ await (ctx.deliver ?? httpDelivery)(url, body, headers);
131
+ return [{ url, body, headers }];
132
+ }
133
+ /**
134
+ * The ordered `MessageEvents` the twin drives a sent message through, OFFLINE.
135
+ *
136
+ * The recipient prefixes below are a TWIN-LOCAL convention (Postmark's own bounce testing
137
+ * needs real mail flow, which a local twin has none of), deliberately documented rather than
138
+ * dressed up as vendor behavior — they are the seam that lets a test drive the bounce and
139
+ * spam-complaint paths deterministically:
140
+ * · a recipient whose local part starts with `bounce` → a HARD BOUNCE (no delivery);
141
+ * · one starting with `spam` or `complaint` → delivered, then a SPAM COMPLAINT;
142
+ * · anything else → delivered.
143
+ * Opens and clicks are emitted ONLY when the message asked for that tracking — `TrackOpens`
144
+ * and `TrackLinks` respectively — exactly as the vendor does, so a message sent with tracking
145
+ * off records neither.
146
+ *
147
+ * `status` is the outbound message's `Status`. It stays `Sent` for every submitted message:
148
+ * on real Postmark a bounce does NOT rewrite the message's Status, it surfaces through
149
+ * `MessageEvents` and the Bounce API. Modeled that way here on purpose.
150
+ */
151
+ export function deliveryPlan(input) {
152
+ const local = String(input.recipient ?? '').split('@')[0]?.toLowerCase() ?? '';
153
+ if (local.startsWith('bounce'))
154
+ return { events: ['Bounced'], status: 'Sent' };
155
+ if (local.startsWith('spam') || local.startsWith('complaint')) {
156
+ return { events: ['Delivered', 'SpamComplaint'], status: 'Sent' };
157
+ }
158
+ const events = ['Delivered'];
159
+ if (input.trackOpens)
160
+ events.push('Opened');
161
+ if (input.trackLinks && input.trackLinks !== 'None')
162
+ events.push('LinkClicked');
163
+ return { events, status: 'Sent' };
164
+ }
165
+ /** The terminal event a message lands on, given its plan (the outbound activity headline). */
166
+ export function terminalEvent(input) {
167
+ const { events } = deliveryPlan(input);
168
+ return events[events.length - 1];
169
+ }
@@ -0,0 +1,58 @@
1
+ export type PostmarkRow = Record<string, any>;
2
+ /** The recipients of an outbound message, as a readable list. */
3
+ export declare function recipientsOf(m: PostmarkRow): string;
4
+ /** A subject line for a message row, or a placeholder. */
5
+ export declare function messageSubject(m: PostmarkRow): string;
6
+ /** The ordered `MessageEvents` types the twin recorded for a message. */
7
+ export declare function deliveryTimeline(m: PostmarkRow): string[];
8
+ /**
9
+ * The headline delivery status of a message: its LAST recorded MessageEvent, falling back to
10
+ * the vendor's `Status` field. This is what the Activity list and the status pill show.
11
+ */
12
+ export declare function messageStatus(m: PostmarkRow): string;
13
+ /** The rendered preview of a message body — prefers HtmlBody, falls back to TextBody. */
14
+ export declare function messagePreview(m: PostmarkRow): {
15
+ kind: 'html' | 'text' | 'none';
16
+ value: string;
17
+ };
18
+ /** Format an ISO timestamp as a short date, or an em dash. */
19
+ export declare function formatPostmarkDate(iso: unknown): string;
20
+ /**
21
+ * A status → CSS tone class for the status pill. The returned values are STATIC STRING
22
+ * LITERALS (never template-built) so they survive the bundler's minification and a UI
23
+ * verify can assert on the class the component actually emits.
24
+ */
25
+ export declare function statusTone(value: string): string;
26
+ /** Flatten a (possibly nested) value into label/value lines for the detail panel. */
27
+ export type FlatLine = {
28
+ key: string;
29
+ value: string;
30
+ depth: number;
31
+ };
32
+ export declare function flattenPostmarkValue(value: unknown, prefix?: string, depth?: number, out?: FlatLine[]): FlatLine[];
33
+ /** The nav sections the mirror renders, each bound to one REAL Postmark API path + its
34
+ * collection key. Shared by the client (to fetch) and by tests (to assert the binding). */
35
+ export type MirrorSection = {
36
+ key: string;
37
+ label: string;
38
+ path: string;
39
+ collection: string;
40
+ idKey: string;
41
+ };
42
+ export declare const POSTMARK_MIRROR_SECTIONS: MirrorSection[];
43
+ /** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module
44
+ * scope so a pack on its own does exactly ONE `Bun.build`. */
45
+ export declare function buildPostmarkMirrorClient(): Promise<string>;
46
+ /** Serve the Postmark activity mirror (React app) over the twin's OWN REST API. */
47
+ export declare function createPostmarkMirrorServer(options: {
48
+ root?: string;
49
+ port?: number;
50
+ readOnly?: boolean;
51
+ }): Promise<{
52
+ port: number;
53
+ stop: () => void;
54
+ }>;
55
+ /** The app-shell HTML (pure, for tests). The mirror itself is the React client. */
56
+ export declare function postmarkMirrorHtml(): string;
57
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
58
+ export declare function postmarkMirrorStyles(): Promise<string>;