@intx/mime 0.1.2

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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Builders for InboundMessage and OutboundMessage shapes.
3
+ *
4
+ * Constructing these by hand requires assembling MessageRef, MessageHeaders,
5
+ * payload envelopes, signature status, and other mail-shaped fields that the
6
+ * transport normally produces after parsing wire bytes. These builders
7
+ * collapse that boilerplate behind two factories with sensible defaults.
8
+ *
9
+ * The builders use the parsed-shape MessageHeaders from
10
+ * @intx/types/runtime (where date is an ISO string), NOT the
11
+ * wire-shape MessageHeaders local to this package (where date is a Date
12
+ * object and headers are serialised to RFC 2822 bytes via assembleMessage).
13
+ *
14
+ * Consumers import the message types from @intx/types directly; the
15
+ * @intx/mime barrel does not re-export them.
16
+ */
17
+
18
+ import { type } from "arktype";
19
+ import type {
20
+ InboundMessage,
21
+ MessageAttachment,
22
+ MessageHeaders,
23
+ MessageRef,
24
+ OutboundMessage,
25
+ } from "@intx/types/runtime";
26
+ import { InterchangeType, SignatureStatus } from "@intx/types/runtime";
27
+ import { generateMessageId } from "./mime";
28
+
29
+ /**
30
+ * Default schema version for structured payloads. Matches
31
+ * docs/MESSAGE.md § Payload Structure, which specifies "version": "1" as
32
+ * the current schema version for every Interchange payload type. Audit
33
+ * this default whenever the documented schema version increments.
34
+ */
35
+ const DEFAULT_PAYLOAD_VERSION = "1";
36
+
37
+ const MESSAGE_ID_RE = /^<[^<>\s@]+@[^<>\s@]+>$/;
38
+ const ADDRESS_RE = /^[^@\s]+@[^@\s]+$/;
39
+
40
+ const CONVERSATION_TYPE_PREFIX = "conversation.";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // InboundMessage builder
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Structured payload envelope for an inbound message. `version` defaults to
48
+ * the current schema version per docs/MESSAGE.md.
49
+ */
50
+ export type InboundPayloadInput = {
51
+ type: InterchangeType;
52
+ body: Record<string, unknown>;
53
+ version?: string;
54
+ };
55
+
56
+ export type CreateInboundMessageOpts = {
57
+ from: string;
58
+ to: string | string[];
59
+
60
+ /** Plain-text body. Mutually exclusive with `payload`. */
61
+ content?: string;
62
+
63
+ /** Structured JSON envelope. Mutually exclusive with `content`. */
64
+ payload?: InboundPayloadInput;
65
+
66
+ cc?: string | string[];
67
+ subject?: string;
68
+
69
+ /**
70
+ * Defaults to `new Date().toISOString()`. Accepts Date or any string
71
+ * parseable by `new Date(...)`; stored as an ISO 8601 string.
72
+ */
73
+ date?: Date | string;
74
+
75
+ /** Defaults to `generateMessageId(from)`. Must be of the form `<id@host>`. */
76
+ messageId?: string;
77
+
78
+ inReplyTo?: string;
79
+ references?: string[];
80
+ listId?: string;
81
+
82
+ /**
83
+ * Interchange-Type header value. Auto-derived from `payload.type` when a
84
+ * payload is supplied; throws if explicitly set to a value that conflicts
85
+ * with `payload.type`.
86
+ */
87
+ interchangeType?: InterchangeType;
88
+
89
+ correlationId?: string;
90
+ tenantId?: string;
91
+ agentId?: string;
92
+ sessionId?: string;
93
+ offeringId?: string;
94
+ schemaVersion?: string;
95
+ traceparent?: string;
96
+ tracestate?: string;
97
+
98
+ attachments?: MessageAttachment[];
99
+
100
+ /** Merged with `{ uid: 1, mailbox: "INBOX" }`. */
101
+ ref?: Partial<MessageRef>;
102
+
103
+ flags?: string[];
104
+
105
+ /** Defaults to `"missing"`. */
106
+ signatureStatus?: SignatureStatus;
107
+ };
108
+
109
+ export function createInboundMessage(
110
+ opts: CreateInboundMessageOpts,
111
+ ): InboundMessage {
112
+ const fn = "createInboundMessage";
113
+
114
+ requireAddress(opts.from, "from", fn);
115
+ const to = normalizeAndValidateAddressArray(opts.to, "to", fn);
116
+
117
+ validateBodyExclusivity(opts.content, opts.payload, fn);
118
+
119
+ if (opts.payload !== undefined) {
120
+ validateInterchangeType(opts.payload.type, "payload.type", fn);
121
+ if (isConversationType(opts.payload.type)) {
122
+ throw new Error(
123
+ `${fn}: conversation types must use \`content\` instead of \`payload\`; got \`payload.type\`: ${opts.payload.type}`,
124
+ );
125
+ }
126
+ validatePayloadBody(opts.payload.body, "payload.body", fn);
127
+ if (opts.payload.version !== undefined) {
128
+ if (
129
+ typeof opts.payload.version !== "string" ||
130
+ opts.payload.version.length === 0
131
+ ) {
132
+ throw new Error(
133
+ `${fn}: \`payload.version\`, when provided, must be a non-empty string`,
134
+ );
135
+ }
136
+ }
137
+ }
138
+
139
+ if (opts.interchangeType !== undefined) {
140
+ validateInterchangeType(opts.interchangeType, "interchangeType", fn);
141
+ if (
142
+ opts.payload !== undefined &&
143
+ opts.interchangeType !== opts.payload.type
144
+ ) {
145
+ throw new Error(
146
+ `${fn}: \`interchangeType\` (${opts.interchangeType}) conflicts with \`payload.type\` (${opts.payload.type})`,
147
+ );
148
+ }
149
+ }
150
+
151
+ if (opts.messageId !== undefined) {
152
+ validateMessageId(opts.messageId, "messageId", fn);
153
+ }
154
+ if (opts.inReplyTo !== undefined) {
155
+ validateMessageId(opts.inReplyTo, "inReplyTo", fn);
156
+ }
157
+ if (opts.references !== undefined) {
158
+ if (opts.references.length === 0) {
159
+ throw new Error(
160
+ `${fn}: \`references\`, when provided, must contain at least one entry`,
161
+ );
162
+ }
163
+ opts.references.forEach((ref, i) => {
164
+ validateMessageId(ref, `references[${i}]`, fn);
165
+ });
166
+ }
167
+
168
+ const cc =
169
+ opts.cc === undefined
170
+ ? undefined
171
+ : normalizeAndValidateAddressArray(opts.cc, "cc", fn);
172
+
173
+ rejectEmptyStringIfPresent(opts.content, "content", fn);
174
+ rejectEmptyStringIfPresent(opts.subject, "subject", fn);
175
+ rejectEmptyStringIfPresent(opts.listId, "listId", fn);
176
+ rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn);
177
+ rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn);
178
+ rejectEmptyStringIfPresent(opts.agentId, "agentId", fn);
179
+ rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn);
180
+ rejectEmptyStringIfPresent(opts.offeringId, "offeringId", fn);
181
+ rejectEmptyStringIfPresent(opts.schemaVersion, "schemaVersion", fn);
182
+ rejectEmptyStringIfPresent(opts.traceparent, "traceparent", fn);
183
+ rejectEmptyStringIfPresent(opts.tracestate, "tracestate", fn);
184
+
185
+ if (opts.flags !== undefined) {
186
+ opts.flags.forEach((flag, i) => {
187
+ if (typeof flag !== "string" || flag.length === 0) {
188
+ throw new Error(`${fn}: \`flags[${i}]\` must be a non-empty string`);
189
+ }
190
+ });
191
+ }
192
+
193
+ const signatureStatus = opts.signatureStatus ?? "missing";
194
+ const validatedStatus = SignatureStatus(signatureStatus);
195
+ if (validatedStatus instanceof type.errors) {
196
+ throw new Error(
197
+ `${fn}: \`signatureStatus\` is not a recognised SignatureStatus: ${validatedStatus.summary}`,
198
+ );
199
+ }
200
+
201
+ const date = normalizeDate(opts.date, "date", fn);
202
+ const messageId = opts.messageId ?? generateMessageId(opts.from);
203
+ const derivedInterchangeType = opts.interchangeType ?? opts.payload?.type;
204
+
205
+ const headers: MessageHeaders = { from: opts.from, to, date, messageId };
206
+ if (cc !== undefined) headers.cc = cc;
207
+ if (opts.subject !== undefined) headers.subject = opts.subject;
208
+ if (opts.inReplyTo !== undefined) headers.inReplyTo = opts.inReplyTo;
209
+ if (opts.references !== undefined) headers.references = opts.references;
210
+ if (opts.listId !== undefined) headers.listId = opts.listId;
211
+ if (derivedInterchangeType !== undefined) {
212
+ headers.interchangeType = derivedInterchangeType;
213
+ }
214
+ if (opts.correlationId !== undefined) {
215
+ headers.interchangeCorrelationId = opts.correlationId;
216
+ }
217
+ if (opts.tenantId !== undefined) headers.interchangeTenantId = opts.tenantId;
218
+ if (opts.agentId !== undefined) headers.interchangeAgentId = opts.agentId;
219
+ if (opts.sessionId !== undefined) {
220
+ headers.interchangeSessionId = opts.sessionId;
221
+ }
222
+ if (opts.offeringId !== undefined) {
223
+ headers.interchangeOfferingId = opts.offeringId;
224
+ }
225
+ if (opts.schemaVersion !== undefined) {
226
+ headers.interchangeSchemaVersion = opts.schemaVersion;
227
+ }
228
+ if (opts.traceparent !== undefined) headers.traceparent = opts.traceparent;
229
+ if (opts.tracestate !== undefined) headers.tracestate = opts.tracestate;
230
+
231
+ if (opts.ref?.uid !== undefined) {
232
+ if (
233
+ typeof opts.ref.uid !== "number" ||
234
+ !Number.isInteger(opts.ref.uid) ||
235
+ !Number.isFinite(opts.ref.uid) ||
236
+ opts.ref.uid < 1
237
+ ) {
238
+ throw new Error(
239
+ `${fn}: \`ref.uid\`, when provided, must be a positive integer (IMAP UID)`,
240
+ );
241
+ }
242
+ }
243
+ const ref: MessageRef = {
244
+ uid: opts.ref?.uid ?? 1,
245
+ mailbox: opts.ref?.mailbox ?? "INBOX",
246
+ };
247
+ if (typeof ref.mailbox !== "string" || ref.mailbox.length === 0) {
248
+ throw new Error(
249
+ `${fn}: \`ref.mailbox\`, when provided, must be a non-empty string`,
250
+ );
251
+ }
252
+
253
+ const result: InboundMessage = {
254
+ ref,
255
+ headers,
256
+ flags: opts.flags ?? [],
257
+ signatureStatus,
258
+ };
259
+ if (opts.content !== undefined) result.content = opts.content;
260
+ if (opts.payload !== undefined) {
261
+ result.payload = {
262
+ type: opts.payload.type,
263
+ version: opts.payload.version ?? DEFAULT_PAYLOAD_VERSION,
264
+ body: opts.payload.body,
265
+ };
266
+ }
267
+ if (opts.attachments !== undefined && opts.attachments.length > 0) {
268
+ result.attachments = opts.attachments;
269
+ }
270
+
271
+ return result;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // OutboundMessage builder
276
+ // ---------------------------------------------------------------------------
277
+
278
+ export type CreateOutboundMessageOpts = {
279
+ to: string | string[];
280
+
281
+ /** Interchange payload type. Determines content vs payload semantics. */
282
+ type: InterchangeType;
283
+
284
+ /** Plain-text body. Mutually exclusive with `payload`. */
285
+ content?: string;
286
+
287
+ /** Structured JSON envelope body. Mutually exclusive with `content`. */
288
+ payload?: Record<string, unknown>;
289
+
290
+ cc?: string | string[];
291
+ subject?: string;
292
+
293
+ /** Human-readable summary used as the text/plain part for structured types. */
294
+ summary?: string;
295
+
296
+ inReplyTo?: string;
297
+ correlationId?: string;
298
+ sessionId?: string;
299
+ tenantId?: string;
300
+
301
+ attachments?: MessageAttachment[];
302
+ };
303
+
304
+ export function createOutboundMessage(
305
+ opts: CreateOutboundMessageOpts,
306
+ ): OutboundMessage {
307
+ const fn = "createOutboundMessage";
308
+
309
+ validateInterchangeType(opts.type, "type", fn);
310
+ // Validate addresses without mutating the source shape; the OutboundMessage
311
+ // type preserves `string | string[]` and downstream consumers handle both.
312
+ normalizeAndValidateAddressArray(opts.to, "to", fn);
313
+ if (opts.cc !== undefined) {
314
+ normalizeAndValidateAddressArray(opts.cc, "cc", fn);
315
+ }
316
+
317
+ validateBodyExclusivity(opts.content, opts.payload, fn);
318
+
319
+ if (isConversationType(opts.type)) {
320
+ if (opts.payload !== undefined) {
321
+ throw new Error(
322
+ `${fn}: conversation \`type\` ${opts.type} must use \`content\` instead of \`payload\``,
323
+ );
324
+ }
325
+ if (opts.content === undefined) {
326
+ throw new Error(
327
+ `${fn}: conversation \`type\` ${opts.type} requires \`content\``,
328
+ );
329
+ }
330
+ } else {
331
+ if (opts.content !== undefined) {
332
+ throw new Error(
333
+ `${fn}: non-conversation \`type\` ${opts.type} must use \`payload\` instead of \`content\``,
334
+ );
335
+ }
336
+ if (opts.payload === undefined) {
337
+ throw new Error(
338
+ `${fn}: non-conversation \`type\` ${opts.type} requires \`payload\``,
339
+ );
340
+ }
341
+ }
342
+ if (opts.payload !== undefined) {
343
+ validatePayloadBody(opts.payload, "payload", fn);
344
+ }
345
+
346
+ if (opts.inReplyTo !== undefined) {
347
+ validateMessageId(opts.inReplyTo, "inReplyTo", fn);
348
+ }
349
+ rejectEmptyStringIfPresent(opts.content, "content", fn);
350
+ rejectEmptyStringIfPresent(opts.subject, "subject", fn);
351
+ rejectEmptyStringIfPresent(opts.summary, "summary", fn);
352
+ rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn);
353
+ rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn);
354
+ rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn);
355
+
356
+ const result: OutboundMessage = { to: opts.to, type: opts.type };
357
+ if (opts.cc !== undefined) result.cc = opts.cc;
358
+ if (opts.subject !== undefined) result.subject = opts.subject;
359
+ if (opts.content !== undefined) result.content = opts.content;
360
+ if (opts.payload !== undefined) result.payload = opts.payload;
361
+ if (opts.summary !== undefined) result.summary = opts.summary;
362
+ if (opts.attachments !== undefined && opts.attachments.length > 0) {
363
+ result.attachments = opts.attachments;
364
+ }
365
+ if (opts.inReplyTo !== undefined) result.inReplyTo = opts.inReplyTo;
366
+ if (opts.correlationId !== undefined) {
367
+ result.correlationId = opts.correlationId;
368
+ }
369
+ if (opts.sessionId !== undefined) result.sessionId = opts.sessionId;
370
+ if (opts.tenantId !== undefined) result.tenantId = opts.tenantId;
371
+ return result;
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Validation helpers
376
+ // ---------------------------------------------------------------------------
377
+
378
+ function rejectEmptyStringIfPresent(
379
+ value: string | undefined,
380
+ field: string,
381
+ fn: string,
382
+ ): void {
383
+ if (value !== undefined && value.length === 0) {
384
+ throw new Error(
385
+ `${fn}: \`${field}\`, when provided, must be a non-empty string`,
386
+ );
387
+ }
388
+ }
389
+
390
+ function requireAddress(value: unknown, field: string, fn: string): void {
391
+ if (typeof value !== "string" || value.length === 0) {
392
+ throw new Error(`${fn}: \`${field}\` must be a non-empty string`);
393
+ }
394
+ if (!ADDRESS_RE.test(value)) {
395
+ throw new Error(
396
+ `${fn}: \`${field}\` must be an RFC 5322 address of the form \`local@domain\`; got: ${value}`,
397
+ );
398
+ }
399
+ }
400
+
401
+ function normalizeAndValidateAddressArray(
402
+ input: string | string[],
403
+ field: string,
404
+ fn: string,
405
+ ): string[] {
406
+ if (typeof input === "string") {
407
+ requireAddress(input, field, fn);
408
+ return [input];
409
+ }
410
+ if (!Array.isArray(input) || input.length === 0) {
411
+ throw new Error(
412
+ `${fn}: \`${field}\` must contain at least one recipient address`,
413
+ );
414
+ }
415
+ input.forEach((entry, i) => {
416
+ requireAddress(entry, `${field}[${i}]`, fn);
417
+ });
418
+ return input;
419
+ }
420
+
421
+ function validatePayloadBody(value: unknown, field: string, fn: string): void {
422
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
423
+ throw new Error(
424
+ `${fn}: \`${field}\` must be a plain object (got ${
425
+ value === null ? "null" : Array.isArray(value) ? "array" : typeof value
426
+ })`,
427
+ );
428
+ }
429
+ }
430
+
431
+ function isConversationType(t: InterchangeType): boolean {
432
+ return t.startsWith(CONVERSATION_TYPE_PREFIX);
433
+ }
434
+
435
+ function validateInterchangeType(
436
+ value: unknown,
437
+ field: string,
438
+ fn: string,
439
+ ): void {
440
+ const validated = InterchangeType(value);
441
+ if (validated instanceof type.errors) {
442
+ throw new Error(
443
+ `${fn}: \`${field}\` is not a valid InterchangeType: ${validated.summary}`,
444
+ );
445
+ }
446
+ }
447
+
448
+ function validateMessageId(value: string, field: string, fn: string): void {
449
+ if (!MESSAGE_ID_RE.test(value)) {
450
+ throw new Error(
451
+ `${fn}: \`${field}\` must be an RFC 2822 message identifier of the form \`<id@host>\`; got: ${value}`,
452
+ );
453
+ }
454
+ }
455
+
456
+ function normalizeDate(
457
+ input: Date | string | undefined,
458
+ field: string,
459
+ fn: string,
460
+ ): string {
461
+ if (input === undefined) return new Date().toISOString();
462
+ if (input instanceof Date) {
463
+ if (Number.isNaN(input.getTime())) {
464
+ throw new Error(`${fn}: \`${field}\` is an Invalid Date`);
465
+ }
466
+ return input.toISOString();
467
+ }
468
+ if (typeof input !== "string" || input.length === 0) {
469
+ throw new Error(
470
+ `${fn}: \`${field}\`, when provided, must be a Date or a non-empty string`,
471
+ );
472
+ }
473
+ const parsed = new Date(input);
474
+ if (Number.isNaN(parsed.getTime())) {
475
+ throw new Error(
476
+ `${fn}: \`${field}\` is not a parseable date string: ${input}`,
477
+ );
478
+ }
479
+ return parsed.toISOString();
480
+ }
481
+
482
+ function validateBodyExclusivity(
483
+ content: unknown,
484
+ payload: unknown,
485
+ fn: string,
486
+ ): void {
487
+ if (content !== undefined && payload !== undefined) {
488
+ throw new Error(
489
+ `${fn}: \`content\` and \`payload\` are mutually exclusive; provide at most one`,
490
+ );
491
+ }
492
+ }