@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,282 @@
|
|
|
1
|
+
// Postmark twin conformance — spec-true validation of the resources the twin serves against
|
|
2
|
+
// per-object JSON Schemas (required fields, types, enums), offline (no key).
|
|
3
|
+
//
|
|
4
|
+
// The schemas below are transcribed from the OFFICIAL `postmark` SDK's own TypeScript models
|
|
5
|
+
// (node_modules/postmark/dist/client/models/**/*.d.ts) — a first-party artifact naming every
|
|
6
|
+
// field and its type. Postmark's API cannot be dual-run against a live sandbox in CI, so
|
|
7
|
+
// those published shapes carry the weight here.
|
|
8
|
+
//
|
|
9
|
+
// For every resource the twin emits, validate its body via the shared specConformance
|
|
10
|
+
// harness: declared TYPES, REQUIRED fields (omission), ENUMS. Twin-internal `_`-prefixed
|
|
11
|
+
// fields are exempt; polymorphic/nested blocks (Triggers, Metadata, MessageEvents, DNS
|
|
12
|
+
// detail) are left untyped to avoid false positives.
|
|
13
|
+
import { twinResources } from '@volter/world-core';
|
|
14
|
+
import { specConformance } from '@volter/world-tooling';
|
|
15
|
+
import type { TwinResource } from '@volter/world-core';
|
|
16
|
+
|
|
17
|
+
type JsonSchema = specConformance.JsonSchema;
|
|
18
|
+
type KnownDeviation = specConformance.KnownDeviation;
|
|
19
|
+
type SpecViolation = specConformance.SpecViolation;
|
|
20
|
+
|
|
21
|
+
const SCHEMAS: Record<string, JsonSchema> = {
|
|
22
|
+
// SDK: models/messages/OutboundMessage.d.ts
|
|
23
|
+
message: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
required: ['MessageID', 'From', 'Recipients', 'ReceivedAt', 'Status', 'MessageStream'],
|
|
26
|
+
properties: {
|
|
27
|
+
MessageID: { type: 'string' },
|
|
28
|
+
From: { type: 'string' },
|
|
29
|
+
Subject: { type: 'string' },
|
|
30
|
+
To: { type: 'array' },
|
|
31
|
+
Cc: { type: 'array' },
|
|
32
|
+
Bcc: { type: 'array' },
|
|
33
|
+
Recipients: { type: 'array' },
|
|
34
|
+
Attachments: { type: 'array' },
|
|
35
|
+
ReceivedAt: { type: 'string' },
|
|
36
|
+
Tag: { type: 'string' },
|
|
37
|
+
Status: { type: 'string', enum: ['Sent', 'Queued', 'Processed'] },
|
|
38
|
+
TrackOpens: { type: 'boolean' },
|
|
39
|
+
TrackLinks: { type: 'string', enum: ['None', 'HtmlAndText', 'HtmlOnly', 'TextOnly'] },
|
|
40
|
+
MessageStream: { type: 'string' },
|
|
41
|
+
Sandboxed: { type: 'boolean' },
|
|
42
|
+
MessageEvents: { type: 'array' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
// SDK: models/bounces/Bounce.d.ts (+ BounceType / BounceTypeCode enums)
|
|
46
|
+
bounce: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
required: ['RecordType', 'ID', 'Type', 'TypeCode', 'Name', 'Email', 'BouncedAt', 'MessageStream'],
|
|
49
|
+
properties: {
|
|
50
|
+
RecordType: { type: 'string', enum: ['Bounce'] },
|
|
51
|
+
ID: { type: 'number' },
|
|
52
|
+
Type: { type: 'string', enum: ['HardBounce', 'Transient', 'SoftBounce', 'SpamComplaint', 'SpamNotification', 'Unsubscribe', 'AutoResponder', 'DnsError', 'BadEmailAddress', 'Blocked', 'ManuallyDeactivated', 'Unknown'] },
|
|
53
|
+
TypeCode: { type: 'number' },
|
|
54
|
+
Name: { type: 'string' },
|
|
55
|
+
Tag: { type: 'string' },
|
|
56
|
+
MessageID: { type: 'string' },
|
|
57
|
+
ServerID: { type: 'number' },
|
|
58
|
+
Description: { type: 'string' },
|
|
59
|
+
Details: { type: 'string' },
|
|
60
|
+
Email: { type: 'string' },
|
|
61
|
+
From: { type: 'string' },
|
|
62
|
+
BouncedAt: { type: 'string' },
|
|
63
|
+
DumpAvailable: { type: 'boolean' },
|
|
64
|
+
Inactive: { type: 'boolean' },
|
|
65
|
+
CanActivate: { type: 'boolean' },
|
|
66
|
+
Subject: { type: 'string' },
|
|
67
|
+
MessageStream: { type: 'string' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
// SDK: models/templates/Template.d.ts (+ TemplateTypes enum)
|
|
71
|
+
template: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
required: ['TemplateId', 'Name', 'TemplateType', 'Active'],
|
|
74
|
+
properties: {
|
|
75
|
+
TemplateId: { type: 'number' },
|
|
76
|
+
Name: { type: 'string' },
|
|
77
|
+
TemplateType: { type: 'string', enum: ['Standard', 'Layout'] },
|
|
78
|
+
Subject: { type: 'string' },
|
|
79
|
+
Active: { type: 'boolean' },
|
|
80
|
+
AssociatedServerId: { type: 'number' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// SDK: models/streams/MessageStream.d.ts
|
|
84
|
+
message_stream: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
required: ['ID', 'ServerID', 'Name', 'MessageStreamType', 'CreatedAt'],
|
|
87
|
+
properties: {
|
|
88
|
+
ID: { type: 'string' },
|
|
89
|
+
ServerID: { type: 'number' },
|
|
90
|
+
Name: { type: 'string' },
|
|
91
|
+
Description: { type: 'string' },
|
|
92
|
+
MessageStreamType: { type: 'string', enum: ['Transactional', 'Broadcast', 'Inbound'] },
|
|
93
|
+
CreatedAt: { type: 'string' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
// SDK: models/webhooks/Webhook.d.ts
|
|
97
|
+
webhook: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
required: ['ID', 'Url', 'MessageStream'],
|
|
100
|
+
properties: {
|
|
101
|
+
ID: { type: 'number' },
|
|
102
|
+
Url: { type: 'string' },
|
|
103
|
+
MessageStream: { type: 'string' },
|
|
104
|
+
HttpHeaders: { type: 'array' },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
// SDK: models/suppressions/Suppression.d.ts (+ SuppressionReason / SuppressionOrigin enums)
|
|
108
|
+
suppression: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
required: ['EmailAddress', 'SuppressionReason', 'Origin', 'CreatedAt'],
|
|
111
|
+
properties: {
|
|
112
|
+
EmailAddress: { type: 'string' },
|
|
113
|
+
SuppressionReason: { type: 'string', enum: ['HardBounce', 'SpamComplaint', 'ManualSuppression'] },
|
|
114
|
+
Origin: { type: 'string', enum: ['Recipient', 'Customer', 'Admin'] },
|
|
115
|
+
CreatedAt: { type: 'string' },
|
|
116
|
+
MessageStream: { type: 'string' },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
// SDK: models/server/Server.d.ts (+ ServerDeliveryTypes enum)
|
|
120
|
+
server: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
required: ['ID', 'Name', 'DeliveryType'],
|
|
123
|
+
properties: {
|
|
124
|
+
ID: { type: 'number' },
|
|
125
|
+
Name: { type: 'string' },
|
|
126
|
+
ApiTokens: { type: 'array' },
|
|
127
|
+
ServerLink: { type: 'string' },
|
|
128
|
+
Color: { type: 'string' },
|
|
129
|
+
SmtpApiActivated: { type: 'boolean' },
|
|
130
|
+
RawEmailEnabled: { type: 'boolean' },
|
|
131
|
+
DeliveryType: { type: 'string', enum: ['Live', 'Sandbox'] },
|
|
132
|
+
InboundAddress: { type: 'string' },
|
|
133
|
+
InboundHash: { type: 'string' },
|
|
134
|
+
InboundSpamThreshold: { type: 'number' },
|
|
135
|
+
TrackOpens: { type: 'boolean' },
|
|
136
|
+
TrackLinks: { type: 'string', enum: ['None', 'HtmlAndText', 'HtmlOnly', 'TextOnly'] },
|
|
137
|
+
PostFirstOpenOnly: { type: 'boolean' },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
// SDK: models/domains/Domain.d.ts (DomainDetails)
|
|
141
|
+
domain: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
required: ['ID', 'Name', 'SPFVerified', 'DKIMVerified', 'ReturnPathDomainVerified'],
|
|
144
|
+
properties: {
|
|
145
|
+
ID: { type: 'number' },
|
|
146
|
+
Name: { type: 'string' },
|
|
147
|
+
SPFVerified: { type: 'boolean' },
|
|
148
|
+
DKIMVerified: { type: 'boolean' },
|
|
149
|
+
WeakDKIM: { type: 'boolean' },
|
|
150
|
+
ReturnPathDomainVerified: { type: 'boolean' },
|
|
151
|
+
SPFHost: { type: 'string' },
|
|
152
|
+
SPFTextValue: { type: 'string' },
|
|
153
|
+
DKIMHost: { type: 'string' },
|
|
154
|
+
DKIMTextValue: { type: 'string' },
|
|
155
|
+
DKIMUpdateStatus: { type: 'string' },
|
|
156
|
+
ReturnPathDomain: { type: 'string' },
|
|
157
|
+
ReturnPathDomainCNAMEValue: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
// SDK: models/senders/Signature.d.ts (SignatureDetails extends DomainDetails)
|
|
161
|
+
sender_signature: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
required: ['ID', 'Domain', 'EmailAddress', 'Name', 'Confirmed'],
|
|
164
|
+
properties: {
|
|
165
|
+
ID: { type: 'number' },
|
|
166
|
+
Domain: { type: 'string' },
|
|
167
|
+
EmailAddress: { type: 'string' },
|
|
168
|
+
ReplyToEmailAddress: { type: 'string' },
|
|
169
|
+
Name: { type: 'string' },
|
|
170
|
+
Confirmed: { type: 'boolean' },
|
|
171
|
+
ConfirmationPersonalNote: { type: 'string' },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
// SDK: models/triggers/InboundRule.d.ts
|
|
175
|
+
inbound_rule: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
required: ['ID', 'Rule'],
|
|
178
|
+
properties: { ID: { type: 'number' }, Rule: { type: 'string' } },
|
|
179
|
+
},
|
|
180
|
+
// SDK: models/messages/InboundMessage.d.ts
|
|
181
|
+
inbound_message: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
required: ['MessageID', 'From', 'To', 'Date', 'Status', 'MessageStream'],
|
|
184
|
+
properties: {
|
|
185
|
+
MessageID: { type: 'string' },
|
|
186
|
+
From: { type: 'string' },
|
|
187
|
+
FromName: { type: 'string' },
|
|
188
|
+
To: { type: 'string' },
|
|
189
|
+
Cc: { type: 'string' },
|
|
190
|
+
Bcc: { type: 'string' },
|
|
191
|
+
Subject: { type: 'string' },
|
|
192
|
+
Date: { type: 'string' },
|
|
193
|
+
MailboxHash: { type: 'string' },
|
|
194
|
+
Tag: { type: 'string' },
|
|
195
|
+
Status: { type: 'string', enum: ['queued', 'sent', 'processed', 'blocked', 'failed', 'scheduled', 'Queued', 'Sent', 'Processed', 'Blocked', 'Failed', 'Scheduled'] },
|
|
196
|
+
Attachments: { type: 'array' },
|
|
197
|
+
MessageStream: { type: 'string' },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
// SDK: models/data_removal/DataRemoval.d.ts
|
|
201
|
+
data_removal: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
required: ['ID', 'Status'],
|
|
204
|
+
properties: {
|
|
205
|
+
ID: { type: 'number' },
|
|
206
|
+
Status: { type: 'string', enum: ['Pending', 'Done'] },
|
|
207
|
+
RequestedBy: { type: 'string' },
|
|
208
|
+
RequestedFor: { type: 'string' },
|
|
209
|
+
NotifyWhenCompleted: { type: 'boolean' },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// twin resource type → schema key (1:1 here).
|
|
215
|
+
const TYPE_TO_OBJECT: Record<string, string> = Object.fromEntries(Object.keys(SCHEMAS).map((k) => [k, k]));
|
|
216
|
+
|
|
217
|
+
export type PostmarkViolation = SpecViolation & { object: string; id: string };
|
|
218
|
+
export type PostmarkConformanceReport = {
|
|
219
|
+
ok: boolean;
|
|
220
|
+
resourcesChecked: number;
|
|
221
|
+
fieldsChecked: number;
|
|
222
|
+
violations: PostmarkViolation[];
|
|
223
|
+
knownIgnored: number;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const isTwinExtra = (k: string): boolean => k.startsWith('_');
|
|
227
|
+
|
|
228
|
+
/** The emitted body: drop kernel meta + twin-internal fields. */
|
|
229
|
+
function emitted(r: TwinResource): Record<string, unknown> {
|
|
230
|
+
const out: Record<string, unknown> = {};
|
|
231
|
+
for (const [k, v] of Object.entries(r as unknown as Record<string, unknown>)) {
|
|
232
|
+
if (k === 'type' || k === 'id' || k === 'updatedAt' || k.startsWith('_')) continue;
|
|
233
|
+
out[k] = v;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function loadPostmarkSchemas(): Record<string, JsonSchema> {
|
|
239
|
+
return SCHEMAS;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate every Postmark resource the twin serves (in `root`) against its schema. A wrong
|
|
244
|
+
* type, an undeclared missing-required field, or an enum violation fails the report.
|
|
245
|
+
* Soft-deleted rows are skipped — a tombstone is not a served resource.
|
|
246
|
+
*/
|
|
247
|
+
export function checkPostmarkConformance(opts: { root?: string; known?: KnownDeviation[] } = {}): PostmarkConformanceReport {
|
|
248
|
+
const resources = twinResources('postmark', opts.root)
|
|
249
|
+
.filter((r) => !r.type.startsWith('_') && (r as unknown as Record<string, unknown>).deleted !== true);
|
|
250
|
+
const violations: PostmarkViolation[] = [];
|
|
251
|
+
let fieldsChecked = 0;
|
|
252
|
+
let knownIgnored = 0;
|
|
253
|
+
for (const r of resources) {
|
|
254
|
+
const objectName = TYPE_TO_OBJECT[r.type];
|
|
255
|
+
if (!objectName) continue;
|
|
256
|
+
const schema = SCHEMAS[objectName];
|
|
257
|
+
if (!schema) continue;
|
|
258
|
+
const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [], flagExtra: false });
|
|
259
|
+
fieldsChecked += rep.fieldsChecked;
|
|
260
|
+
knownIgnored += rep.knownIgnored;
|
|
261
|
+
for (const v of rep.violations) violations.push({ ...v, object: objectName, id: r.id });
|
|
262
|
+
}
|
|
263
|
+
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type PostmarkCoverageReport = specConformance.SpecCoverageReport & { object: string };
|
|
267
|
+
|
|
268
|
+
/** Per-object coverage: of the fields each Postmark object declares, which does the twin emit? */
|
|
269
|
+
export function postmarkCoverage(opts: { root?: string } = {}): PostmarkCoverageReport[] {
|
|
270
|
+
const byType = new Map<string, TwinResource>();
|
|
271
|
+
for (const r of twinResources('postmark', opts.root)) {
|
|
272
|
+
if (!r.type.startsWith('_') && !byType.has(r.type) && (r as unknown as Record<string, unknown>).deleted !== true) byType.set(r.type, r);
|
|
273
|
+
}
|
|
274
|
+
const out: PostmarkCoverageReport[] = [];
|
|
275
|
+
for (const [type, r] of byType) {
|
|
276
|
+
const objectName = TYPE_TO_OBJECT[type];
|
|
277
|
+
const schema = objectName ? SCHEMAS[objectName] : undefined;
|
|
278
|
+
if (!objectName || !schema) continue;
|
|
279
|
+
out.push({ object: objectName, ...specConformance.specCoverage(schema, emitted(r), { exemptKey: isTwinExtra }) });
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
19
|
+
|
|
20
|
+
const SERVICE = 'postmark';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The injected real-Postmark boundary — the subset of `postmark`'s `ServerClient` this
|
|
24
|
+
* connector uses. A real `new ServerClient(token)` is structurally assignable; tests pass a
|
|
25
|
+
* fake. Only the methods the connector calls are declared, so the surface stays honest.
|
|
26
|
+
* Unlike Resend's `{data, error}` envelope, the Postmark SDK RESOLVES with the payload and
|
|
27
|
+
* REJECTS with a typed `PostmarkError` — this interface mirrors that.
|
|
28
|
+
*/
|
|
29
|
+
export interface PostmarkClient {
|
|
30
|
+
getOutboundMessages(filter?: Record<string, unknown>): Promise<{ TotalCount: number | string; Messages: PostmarkOutboundMessage[] }>;
|
|
31
|
+
getBounces(filter?: Record<string, unknown>): Promise<{ TotalCount: number; Bounces: PostmarkBounce[] }>;
|
|
32
|
+
getTemplates(filter?: Record<string, unknown>): Promise<{ TotalCount: number; Templates: PostmarkTemplate[] }>;
|
|
33
|
+
getMessageStreams(filter?: Record<string, unknown>): Promise<{ TotalCount: number; MessageStreams: PostmarkMessageStream[] }>;
|
|
34
|
+
getWebhooks(filter?: Record<string, unknown>): Promise<{ Webhooks: PostmarkWebhookConfig[] }>;
|
|
35
|
+
getServer(): Promise<PostmarkServer>;
|
|
36
|
+
sendEmail(message: Record<string, unknown>): Promise<{ MessageID: string; ErrorCode: number; Message: string }>;
|
|
37
|
+
createTemplate(options: Record<string, unknown>): Promise<{ TemplateId: number }>;
|
|
38
|
+
createWebhook(options: Record<string, unknown>): Promise<{ ID: number }>;
|
|
39
|
+
createMessageStream(options: Record<string, unknown>): Promise<{ ID: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PostmarkRecipient = { Email?: string; Name?: string };
|
|
43
|
+
export type PostmarkOutboundMessage = {
|
|
44
|
+
MessageID: string; Tag?: string; To?: PostmarkRecipient[]; Cc?: PostmarkRecipient[]; Bcc?: PostmarkRecipient[];
|
|
45
|
+
Recipients?: string[]; ReceivedAt?: string; From?: string; Subject?: string; Attachments?: string[];
|
|
46
|
+
Status?: string; TrackOpens?: boolean; TrackLinks?: string; Metadata?: Record<string, string>;
|
|
47
|
+
MessageStream?: string; Sandboxed?: boolean;
|
|
48
|
+
};
|
|
49
|
+
export type PostmarkBounce = {
|
|
50
|
+
ID: number; Type?: string; TypeCode?: number; Name?: string; Tag?: string; MessageID?: string;
|
|
51
|
+
ServerID?: number; Description?: string; Details?: string; Email?: string; From?: string;
|
|
52
|
+
BouncedAt?: string; DumpAvailable?: boolean; Inactive?: boolean; CanActivate?: boolean;
|
|
53
|
+
Subject?: string; MessageStream?: string;
|
|
54
|
+
};
|
|
55
|
+
export type PostmarkTemplate = {
|
|
56
|
+
TemplateId: number; Name?: string; Alias?: string | null; TemplateType?: string;
|
|
57
|
+
Active?: boolean; LayoutTemplate?: string | null; Subject?: string; HtmlBody?: string | null; TextBody?: string | null;
|
|
58
|
+
};
|
|
59
|
+
export type PostmarkMessageStream = {
|
|
60
|
+
ID: string; ServerID?: number; Name?: string; Description?: string; MessageStreamType?: string;
|
|
61
|
+
CreatedAt?: string; UpdatedAt?: string | null; ArchivedAt?: string | null;
|
|
62
|
+
SubscriptionManagementConfiguration?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
export type PostmarkWebhookConfig = {
|
|
65
|
+
ID: number; Url?: string; MessageStream?: string; HttpAuth?: Record<string, unknown> | null;
|
|
66
|
+
HttpHeaders?: Array<Record<string, unknown>>; Triggers?: Record<string, unknown>;
|
|
67
|
+
};
|
|
68
|
+
export type PostmarkServer = {
|
|
69
|
+
ID: number; Name?: string; Color?: string; DeliveryType?: string; TrackOpens?: boolean;
|
|
70
|
+
TrackLinks?: string; InboundAddress?: string; ServerLink?: string; SmtpApiActivated?: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function listOf<T>(value: T[] | undefined): T[] {
|
|
74
|
+
return Array.isArray(value) ? value : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── PULL: pure shape mappers (no client, no I/O — trivially unit-testable) ──────────
|
|
78
|
+
export function mapMessage(m: PostmarkOutboundMessage): SyncResource {
|
|
79
|
+
return {
|
|
80
|
+
type: 'message',
|
|
81
|
+
id: String(m.MessageID),
|
|
82
|
+
fields: {
|
|
83
|
+
MessageID: String(m.MessageID), Tag: m.Tag ?? '', To: listOf(m.To), Cc: listOf(m.Cc), Bcc: listOf(m.Bcc),
|
|
84
|
+
Recipients: listOf(m.Recipients), ReceivedAt: m.ReceivedAt ?? null, From: m.From ?? null,
|
|
85
|
+
Subject: m.Subject ?? '', Attachments: listOf(m.Attachments), Status: m.Status ?? 'Sent',
|
|
86
|
+
TrackOpens: m.TrackOpens === true, TrackLinks: m.TrackLinks ?? 'None', Metadata: m.Metadata ?? {},
|
|
87
|
+
MessageStream: m.MessageStream ?? 'outbound', Sandboxed: m.Sandboxed === true,
|
|
88
|
+
HtmlBody: null, TextBody: null, MessageEvents: [],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function mapBounce(b: PostmarkBounce): SyncResource {
|
|
93
|
+
return {
|
|
94
|
+
type: 'bounce',
|
|
95
|
+
id: String(b.ID),
|
|
96
|
+
fields: {
|
|
97
|
+
RecordType: 'Bounce', ID: b.ID, Type: b.Type ?? 'HardBounce', TypeCode: b.TypeCode ?? 1,
|
|
98
|
+
Name: b.Name ?? 'Hard bounce', Tag: b.Tag ?? '', MessageID: b.MessageID ?? null,
|
|
99
|
+
ServerID: b.ServerID ?? 1, Description: b.Description ?? '', Details: b.Details ?? '',
|
|
100
|
+
Email: b.Email ?? null, From: b.From ?? null, BouncedAt: b.BouncedAt ?? null,
|
|
101
|
+
DumpAvailable: b.DumpAvailable === true, Inactive: b.Inactive === true,
|
|
102
|
+
CanActivate: b.CanActivate === true, Subject: b.Subject ?? '', MessageStream: b.MessageStream ?? 'outbound',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function mapTemplate(t: PostmarkTemplate): SyncResource {
|
|
107
|
+
return {
|
|
108
|
+
type: 'template',
|
|
109
|
+
id: String(t.TemplateId),
|
|
110
|
+
fields: {
|
|
111
|
+
TemplateId: t.TemplateId, Name: t.Name ?? null, Alias: t.Alias ?? null,
|
|
112
|
+
TemplateType: t.TemplateType ?? 'Standard', Active: t.Active !== false,
|
|
113
|
+
LayoutTemplate: t.LayoutTemplate ?? null, Subject: t.Subject ?? '',
|
|
114
|
+
HtmlBody: t.HtmlBody ?? null, TextBody: t.TextBody ?? null, AssociatedServerId: 1,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function mapMessageStream(s: PostmarkMessageStream): SyncResource {
|
|
119
|
+
return {
|
|
120
|
+
type: 'message_stream',
|
|
121
|
+
id: String(s.ID),
|
|
122
|
+
fields: {
|
|
123
|
+
ID: String(s.ID), ServerID: s.ServerID ?? 1, Name: s.Name ?? String(s.ID),
|
|
124
|
+
Description: s.Description ?? '', MessageStreamType: s.MessageStreamType ?? 'Transactional',
|
|
125
|
+
CreatedAt: s.CreatedAt ?? null, UpdatedAt: s.UpdatedAt ?? null, ArchivedAt: s.ArchivedAt ?? null,
|
|
126
|
+
SubscriptionManagementConfiguration: s.SubscriptionManagementConfiguration ?? { UnsubscribeHandlingType: 'None' },
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function mapWebhook(w: PostmarkWebhookConfig): SyncResource {
|
|
131
|
+
return {
|
|
132
|
+
type: 'webhook',
|
|
133
|
+
id: String(w.ID),
|
|
134
|
+
fields: {
|
|
135
|
+
ID: w.ID, Url: w.Url ?? null, MessageStream: w.MessageStream ?? 'outbound',
|
|
136
|
+
HttpAuth: w.HttpAuth ?? null, HttpHeaders: listOf(w.HttpHeaders), Triggers: w.Triggers ?? {},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function mapServer(s: PostmarkServer): SyncResource {
|
|
141
|
+
return {
|
|
142
|
+
type: 'server',
|
|
143
|
+
id: String(s.ID),
|
|
144
|
+
fields: {
|
|
145
|
+
ID: s.ID, Name: s.Name ?? null, Color: s.Color ?? 'blue', DeliveryType: s.DeliveryType ?? 'Live',
|
|
146
|
+
TrackOpens: s.TrackOpens === true, TrackLinks: s.TrackLinks ?? 'None',
|
|
147
|
+
InboundAddress: s.InboundAddress ?? '', ServerLink: s.ServerLink ?? '',
|
|
148
|
+
SmtpApiActivated: s.SmtpApiActivated !== false,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── PULL: the network-touching entry points ────────────────────────────────────────
|
|
154
|
+
export async function pullPostmarkMessages(client: PostmarkClient): Promise<SyncResource[]> {
|
|
155
|
+
return listOf((await client.getOutboundMessages({ count: 500, offset: 0 })).Messages).map(mapMessage);
|
|
156
|
+
}
|
|
157
|
+
export async function pullPostmarkBounces(client: PostmarkClient): Promise<SyncResource[]> {
|
|
158
|
+
return listOf((await client.getBounces({ count: 500, offset: 0 })).Bounces).map(mapBounce);
|
|
159
|
+
}
|
|
160
|
+
export async function pullPostmarkTemplates(client: PostmarkClient): Promise<SyncResource[]> {
|
|
161
|
+
return listOf((await client.getTemplates({ count: 500, offset: 0 })).Templates).map(mapTemplate);
|
|
162
|
+
}
|
|
163
|
+
export async function pullPostmarkMessageStreams(client: PostmarkClient): Promise<SyncResource[]> {
|
|
164
|
+
return listOf((await client.getMessageStreams()).MessageStreams).map(mapMessageStream);
|
|
165
|
+
}
|
|
166
|
+
export async function pullPostmarkWebhooks(client: PostmarkClient): Promise<SyncResource[]> {
|
|
167
|
+
return listOf((await client.getWebhooks()).Webhooks).map(mapWebhook);
|
|
168
|
+
}
|
|
169
|
+
export async function pullPostmarkServer(client: PostmarkClient): Promise<SyncResource[]> {
|
|
170
|
+
return [mapServer(await client.getServer())];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* D7 entry point: pull from real Postmark and fold everything into the twin (mirror seeding).
|
|
175
|
+
* Callers never have to know the per-domain `pull*` mechanics.
|
|
176
|
+
*/
|
|
177
|
+
export async function syncPostmarkFromReal(
|
|
178
|
+
client: PostmarkClient,
|
|
179
|
+
opts: { root?: string; occurredAt: string },
|
|
180
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
181
|
+
const resources: SyncResource[] = [
|
|
182
|
+
...(await pullPostmarkMessageStreams(client)),
|
|
183
|
+
...(await pullPostmarkServer(client)),
|
|
184
|
+
...(await pullPostmarkTemplates(client)),
|
|
185
|
+
...(await pullPostmarkMessages(client)),
|
|
186
|
+
...(await pullPostmarkBounces(client)),
|
|
187
|
+
...(await pullPostmarkWebhooks(client)),
|
|
188
|
+
];
|
|
189
|
+
// protocol 2: the observation lands on the head through the kernel's fold — one batch, one instant
|
|
190
|
+
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}` });
|
|
191
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── PUSH ────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// The twin operations this connector knows how to push to real Postmark. Anything not here
|
|
196
|
+
// FAILS LOUDLY rather than being silently dropped.
|
|
197
|
+
const PUSHABLE = new Set(['message.send', 'template.create', 'webhook.create', 'message_stream.create']);
|
|
198
|
+
|
|
199
|
+
function assertPushable(op: string): void {
|
|
200
|
+
if (!PUSHABLE.has(op)) {
|
|
201
|
+
throw new Error(`postmark push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Recipients are stored structurally in the twin; Postmark's API takes a comma-joined string. */
|
|
206
|
+
function joinRecipients(v: unknown): string {
|
|
207
|
+
if (Array.isArray(v)) return v.map((r) => String((r as PostmarkRecipient)?.Email ?? r)).filter(Boolean).join(', ');
|
|
208
|
+
return typeof v === 'string' ? v : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Push ONE pending action to REAL Postmark via the injected client. Returns the real external
|
|
213
|
+
* id (the Postmark object id from the response). WRITES TO THE REAL ACCOUNT (sends real email).
|
|
214
|
+
*/
|
|
215
|
+
export async function pushPostmarkAction(
|
|
216
|
+
client: PostmarkClient,
|
|
217
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
218
|
+
): Promise<{ externalId: string }> {
|
|
219
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
220
|
+
assertPushable(op);
|
|
221
|
+
const f = action.fields ?? {};
|
|
222
|
+
let externalId = action.subject.id;
|
|
223
|
+
if (op === 'message.send') {
|
|
224
|
+
const res = await client.sendEmail({
|
|
225
|
+
From: String(f.From ?? ''),
|
|
226
|
+
To: joinRecipients(f.To) || joinRecipients(f.Recipients),
|
|
227
|
+
...(joinRecipients(f.Cc) ? { Cc: joinRecipients(f.Cc) } : {}),
|
|
228
|
+
...(joinRecipients(f.Bcc) ? { Bcc: joinRecipients(f.Bcc) } : {}),
|
|
229
|
+
Subject: String(f.Subject ?? ''),
|
|
230
|
+
...(f.HtmlBody != null ? { HtmlBody: String(f.HtmlBody) } : {}),
|
|
231
|
+
...(f.TextBody != null ? { TextBody: String(f.TextBody) } : {}),
|
|
232
|
+
...(f.Tag ? { Tag: String(f.Tag) } : {}),
|
|
233
|
+
...(f.MessageStream ? { MessageStream: String(f.MessageStream) } : {}),
|
|
234
|
+
...(f.TrackOpens === true ? { TrackOpens: true } : {}),
|
|
235
|
+
...(f.TrackLinks && f.TrackLinks !== 'None' ? { TrackLinks: String(f.TrackLinks) } : {}),
|
|
236
|
+
});
|
|
237
|
+
externalId = String(res.MessageID);
|
|
238
|
+
} else if (op === 'template.create') {
|
|
239
|
+
externalId = String((await client.createTemplate({
|
|
240
|
+
Name: String(f.Name ?? ''),
|
|
241
|
+
...(f.Alias != null ? { Alias: String(f.Alias) } : {}),
|
|
242
|
+
Subject: String(f.Subject ?? ''),
|
|
243
|
+
...(f.HtmlBody != null ? { HtmlBody: String(f.HtmlBody) } : {}),
|
|
244
|
+
...(f.TextBody != null ? { TextBody: String(f.TextBody) } : {}),
|
|
245
|
+
TemplateType: String(f.TemplateType ?? 'Standard'),
|
|
246
|
+
})).TemplateId);
|
|
247
|
+
} else if (op === 'webhook.create') {
|
|
248
|
+
externalId = String((await client.createWebhook({
|
|
249
|
+
Url: String(f.Url ?? ''),
|
|
250
|
+
MessageStream: String(f.MessageStream ?? 'outbound'),
|
|
251
|
+
...(f.HttpAuth ? { HttpAuth: f.HttpAuth } : {}),
|
|
252
|
+
...(Array.isArray(f.HttpHeaders) && f.HttpHeaders.length ? { HttpHeaders: f.HttpHeaders } : {}),
|
|
253
|
+
Triggers: (f.Triggers as Record<string, unknown>) ?? {},
|
|
254
|
+
})).ID);
|
|
255
|
+
} else if (op === 'message_stream.create') {
|
|
256
|
+
externalId = String((await client.createMessageStream({
|
|
257
|
+
ID: String(f.ID ?? action.subject.id),
|
|
258
|
+
Name: String(f.Name ?? ''),
|
|
259
|
+
MessageStreamType: String(f.MessageStreamType ?? 'Transactional'),
|
|
260
|
+
...(f.Description ? { Description: String(f.Description) } : {}),
|
|
261
|
+
})).ID);
|
|
262
|
+
}
|
|
263
|
+
return { externalId: externalId || action.subject.id };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
267
|
+
/** A PostmarkClient over the kernel's executor. At a REAL boundary the kernel's executor sets the sealed
|
|
268
|
+
* credential over these headers (executor.ts); at the twin's own wire any credential is one. */
|
|
269
|
+
export function postmarkClientOver(execute: RemoteExecute): PostmarkClient {
|
|
270
|
+
const get = async (path: string): Promise<any> => {
|
|
271
|
+
const res = await execute({ method: 'GET', path, headers: { accept: 'application/json', authorization: 'Bearer twin', 'x-postmark-server-token': 'twin' } });
|
|
272
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`postmark GET ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
273
|
+
return JSON.parse(res.body || '{}');
|
|
274
|
+
};
|
|
275
|
+
const post = async (path: string, body: Record<string, unknown>): Promise<any> => {
|
|
276
|
+
const res = await execute({ method: 'POST', path, headers: { accept: 'application/json', 'content-type': 'application/json', 'x-postmark-server-token': 'twin' }, body: JSON.stringify(body) });
|
|
277
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`postmark POST ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
278
|
+
return JSON.parse(res.body || '{}');
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
getServer: () => get('/server'),
|
|
282
|
+
getMessageStreams: () => get('/message-streams'),
|
|
283
|
+
getOutboundMessages: () => get('/messages/outbound'),
|
|
284
|
+
getBounces: () => get('/bounces'),
|
|
285
|
+
getTemplates: () => get('/templates'),
|
|
286
|
+
getWebhooks: () => get('/webhooks'),
|
|
287
|
+
sendEmail: (message: Record<string, unknown>) => post('/email', message),
|
|
288
|
+
createTemplate: (options: Record<string, unknown>) => post('/templates', options),
|
|
289
|
+
createWebhook: (options: Record<string, unknown>) => post('/webhooks', options),
|
|
290
|
+
createMessageStream: (options: Record<string, unknown>) => post('/message-streams', options),
|
|
291
|
+
} as unknown as PostmarkClient;
|
|
292
|
+
}
|
|
293
|
+
/** The refresh adapter: pull the account's state through the executor and fold it into the root. */
|
|
294
|
+
export async function syncPostmarkFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<ReturnType<typeof syncPostmarkFromReal>> {
|
|
295
|
+
return syncPostmarkFromReal(postmarkClientOver(execute), {
|
|
296
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
297
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
298
|
+
} as never);
|
|
299
|
+
}
|
|
300
|
+
/** The perform adapter: a message send crosses; everything else is the twin's own record. */
|
|
301
|
+
export async function performPostmarkAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
302
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
303
|
+
try {
|
|
304
|
+
return await pushPostmarkAction(postmarkClientOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
307
|
+
if (/unsupported operation|not pushable|no push/i.test(message)) {
|
|
308
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Postmark to write` } };
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|