@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.
- package/README.md +32 -0
- package/package.json +17 -0
- package/src/index.test.ts +978 -0
- package/src/index.ts +37 -0
- package/src/mail-builder.test.ts +740 -0
- package/src/mail-builder.ts +492 -0
- package/src/mime.ts +990 -0
- package/src/pgp-sign.ts +178 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
InboundMessage,
|
|
4
|
+
MessageAttachment,
|
|
5
|
+
OutboundMessage,
|
|
6
|
+
} from "@intx/types/runtime";
|
|
7
|
+
|
|
8
|
+
import { createInboundMessage, createOutboundMessage } from "./mail-builder";
|
|
9
|
+
import type {
|
|
10
|
+
CreateInboundMessageOpts,
|
|
11
|
+
CreateOutboundMessageOpts,
|
|
12
|
+
} from "./mail-builder";
|
|
13
|
+
|
|
14
|
+
const FROM = "alice@example.com";
|
|
15
|
+
const TO = "agent@example.com";
|
|
16
|
+
|
|
17
|
+
// Test-only escape hatches. The builders' types reject obviously invalid
|
|
18
|
+
// inputs at compile time, but the defensive validation also has to surface
|
|
19
|
+
// errors when callers bypass the type system (e.g. data deserialised from
|
|
20
|
+
// unknown JSON). These helpers route around the type checker so the
|
|
21
|
+
// runtime guards can be exercised directly.
|
|
22
|
+
function callInboundUnsafe(opts: unknown): unknown {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- exercise runtime validation against type-violating input
|
|
24
|
+
return createInboundMessage(opts as CreateInboundMessageOpts);
|
|
25
|
+
}
|
|
26
|
+
function callOutboundUnsafe(opts: unknown): unknown {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- exercise runtime validation against type-violating input
|
|
28
|
+
return createOutboundMessage(opts as CreateOutboundMessageOpts);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("createInboundMessage", () => {
|
|
32
|
+
test("conversation content with default ref/flags/signatureStatus", () => {
|
|
33
|
+
const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" });
|
|
34
|
+
|
|
35
|
+
expect(msg.ref).toEqual({ uid: 1, mailbox: "INBOX" });
|
|
36
|
+
expect(msg.flags).toEqual([]);
|
|
37
|
+
expect(msg.signatureStatus).toBe("missing");
|
|
38
|
+
expect(msg.content).toBe("hi");
|
|
39
|
+
expect(msg.payload).toBeUndefined();
|
|
40
|
+
expect(msg.attachments).toBeUndefined();
|
|
41
|
+
expect(msg.headers.from).toBe(FROM);
|
|
42
|
+
expect(msg.headers.to).toEqual([TO]);
|
|
43
|
+
expect(msg.headers.interchangeType).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("auto-generated messageId derives domain from `from`", () => {
|
|
47
|
+
const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" });
|
|
48
|
+
expect(msg.headers.messageId).toMatch(/^<[^<>\s]+@example\.com>$/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("auto-generated date is a parseable ISO string", () => {
|
|
52
|
+
const msg = createInboundMessage({ from: FROM, to: TO, content: "hi" });
|
|
53
|
+
const parsed = new Date(msg.headers.date);
|
|
54
|
+
expect(Number.isNaN(parsed.getTime())).toBe(false);
|
|
55
|
+
expect(msg.headers.date).toBe(parsed.toISOString());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("structured payload sets interchangeType header automatically", () => {
|
|
59
|
+
const msg = createInboundMessage({
|
|
60
|
+
from: FROM,
|
|
61
|
+
to: TO,
|
|
62
|
+
payload: {
|
|
63
|
+
type: "offering.request",
|
|
64
|
+
body: { offeringId: "code-review", parameters: {} },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(msg.payload).toEqual({
|
|
69
|
+
type: "offering.request",
|
|
70
|
+
version: "1",
|
|
71
|
+
body: { offeringId: "code-review", parameters: {} },
|
|
72
|
+
});
|
|
73
|
+
expect(msg.headers.interchangeType).toBe("offering.request");
|
|
74
|
+
expect(msg.content).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("payload.version override is preserved", () => {
|
|
78
|
+
const msg = createInboundMessage({
|
|
79
|
+
from: FROM,
|
|
80
|
+
to: TO,
|
|
81
|
+
payload: {
|
|
82
|
+
type: "payment.required",
|
|
83
|
+
version: "2",
|
|
84
|
+
body: { amount: "0.50" },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
expect(msg.payload?.version).toBe("2");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("attachments are passed through when non-empty", () => {
|
|
91
|
+
const attachments: MessageAttachment[] = [
|
|
92
|
+
{
|
|
93
|
+
name: "report.pdf",
|
|
94
|
+
contentType: "application/pdf",
|
|
95
|
+
data: new Uint8Array([1, 2, 3]),
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const msg = createInboundMessage({
|
|
99
|
+
from: FROM,
|
|
100
|
+
to: TO,
|
|
101
|
+
content: "see attached",
|
|
102
|
+
attachments,
|
|
103
|
+
});
|
|
104
|
+
expect(msg.attachments).toEqual(attachments);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("threading headers are wired through", () => {
|
|
108
|
+
const msg = createInboundMessage({
|
|
109
|
+
from: FROM,
|
|
110
|
+
to: TO,
|
|
111
|
+
content: "reply",
|
|
112
|
+
inReplyTo: "<original@example.com>",
|
|
113
|
+
references: ["<one@example.com>", "<two@example.com>"],
|
|
114
|
+
correlationId: "corr-123",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(msg.headers.inReplyTo).toBe("<original@example.com>");
|
|
118
|
+
expect(msg.headers.references).toEqual([
|
|
119
|
+
"<one@example.com>",
|
|
120
|
+
"<two@example.com>",
|
|
121
|
+
]);
|
|
122
|
+
expect(msg.headers.interchangeCorrelationId).toBe("corr-123");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("all interchange identity headers map through", () => {
|
|
126
|
+
const msg = createInboundMessage({
|
|
127
|
+
from: FROM,
|
|
128
|
+
to: TO,
|
|
129
|
+
content: "hi",
|
|
130
|
+
tenantId: "tenant-1",
|
|
131
|
+
agentId: "agent-1",
|
|
132
|
+
sessionId: "session-1",
|
|
133
|
+
offeringId: "offering-1",
|
|
134
|
+
schemaVersion: "1",
|
|
135
|
+
traceparent: "00-trace-span-01",
|
|
136
|
+
tracestate: "vendor=foo",
|
|
137
|
+
listId: "list-1",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(msg.headers.interchangeTenantId).toBe("tenant-1");
|
|
141
|
+
expect(msg.headers.interchangeAgentId).toBe("agent-1");
|
|
142
|
+
expect(msg.headers.interchangeSessionId).toBe("session-1");
|
|
143
|
+
expect(msg.headers.interchangeOfferingId).toBe("offering-1");
|
|
144
|
+
expect(msg.headers.interchangeSchemaVersion).toBe("1");
|
|
145
|
+
expect(msg.headers.traceparent).toBe("00-trace-span-01");
|
|
146
|
+
expect(msg.headers.tracestate).toBe("vendor=foo");
|
|
147
|
+
expect(msg.headers.listId).toBe("list-1");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("ref override merges with synthetic defaults", () => {
|
|
151
|
+
const a = createInboundMessage({
|
|
152
|
+
from: FROM,
|
|
153
|
+
to: TO,
|
|
154
|
+
content: "x",
|
|
155
|
+
ref: { uid: 42 },
|
|
156
|
+
});
|
|
157
|
+
expect(a.ref).toEqual({ uid: 42, mailbox: "INBOX" });
|
|
158
|
+
|
|
159
|
+
const b = createInboundMessage({
|
|
160
|
+
from: FROM,
|
|
161
|
+
to: TO,
|
|
162
|
+
content: "x",
|
|
163
|
+
ref: { mailbox: "Trash" },
|
|
164
|
+
});
|
|
165
|
+
expect(b.ref).toEqual({ uid: 1, mailbox: "Trash" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("Date input is normalised to an ISO string", () => {
|
|
169
|
+
const fixed = new Date("2026-01-02T03:04:05.000Z");
|
|
170
|
+
const msg = createInboundMessage({
|
|
171
|
+
from: FROM,
|
|
172
|
+
to: TO,
|
|
173
|
+
content: "x",
|
|
174
|
+
date: fixed,
|
|
175
|
+
});
|
|
176
|
+
expect(msg.headers.date).toBe("2026-01-02T03:04:05.000Z");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("flags and signatureStatus overrides are respected", () => {
|
|
180
|
+
const msg = createInboundMessage({
|
|
181
|
+
from: FROM,
|
|
182
|
+
to: TO,
|
|
183
|
+
content: "x",
|
|
184
|
+
flags: ["\\Seen"],
|
|
185
|
+
signatureStatus: "valid",
|
|
186
|
+
});
|
|
187
|
+
expect(msg.flags).toEqual(["\\Seen"]);
|
|
188
|
+
expect(msg.signatureStatus).toBe("valid");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("cc accepts a string and normalises to an array", () => {
|
|
192
|
+
const msg = createInboundMessage({
|
|
193
|
+
from: FROM,
|
|
194
|
+
to: TO,
|
|
195
|
+
content: "x",
|
|
196
|
+
cc: "watcher@example.com",
|
|
197
|
+
});
|
|
198
|
+
expect(msg.headers.cc).toEqual(["watcher@example.com"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("control-frame (no content, no payload) is allowed", () => {
|
|
202
|
+
const msg = createInboundMessage({ from: FROM, to: TO });
|
|
203
|
+
expect(msg.content).toBeUndefined();
|
|
204
|
+
expect(msg.payload).toBeUndefined();
|
|
205
|
+
expect(msg.flags).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("explicit interchangeType is allowed for content-only messages", () => {
|
|
209
|
+
const msg = createInboundMessage({
|
|
210
|
+
from: FROM,
|
|
211
|
+
to: TO,
|
|
212
|
+
content: "join",
|
|
213
|
+
interchangeType: "conversation.join",
|
|
214
|
+
});
|
|
215
|
+
expect(msg.headers.interchangeType).toBe("conversation.join");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("validation", () => {
|
|
219
|
+
test("throws when both content and payload are set", () => {
|
|
220
|
+
expect(() =>
|
|
221
|
+
createInboundMessage({
|
|
222
|
+
from: FROM,
|
|
223
|
+
to: TO,
|
|
224
|
+
content: "x",
|
|
225
|
+
payload: { type: "offering.request", body: {} },
|
|
226
|
+
}),
|
|
227
|
+
).toThrow(/`content` and `payload` are mutually exclusive/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("throws when from is empty", () => {
|
|
231
|
+
expect(() =>
|
|
232
|
+
createInboundMessage({ from: "", to: TO, content: "x" }),
|
|
233
|
+
).toThrow(/`from` must be a non-empty string/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("throws when to is an empty array", () => {
|
|
237
|
+
expect(() =>
|
|
238
|
+
createInboundMessage({ from: FROM, to: [], content: "x" }),
|
|
239
|
+
).toThrow(/`to` must contain at least one recipient address/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("throws when to contains an empty string", () => {
|
|
243
|
+
expect(() =>
|
|
244
|
+
createInboundMessage({ from: FROM, to: [""], content: "x" }),
|
|
245
|
+
).toThrow(/`to\[0\]` must be a non-empty string/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("throws when payload.type is not a known InterchangeType", () => {
|
|
249
|
+
expect(() =>
|
|
250
|
+
callInboundUnsafe({
|
|
251
|
+
from: FROM,
|
|
252
|
+
to: TO,
|
|
253
|
+
payload: {
|
|
254
|
+
type: "not.a.real.type",
|
|
255
|
+
body: {},
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
).toThrow(/`payload.type` is not a valid InterchangeType/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("throws when interchangeType conflicts with payload.type", () => {
|
|
262
|
+
expect(() =>
|
|
263
|
+
createInboundMessage({
|
|
264
|
+
from: FROM,
|
|
265
|
+
to: TO,
|
|
266
|
+
payload: { type: "offering.request", body: {} },
|
|
267
|
+
interchangeType: "payment.required",
|
|
268
|
+
}),
|
|
269
|
+
).toThrow(
|
|
270
|
+
/`interchangeType` \(payment\.required\) conflicts with `payload\.type` \(offering\.request\)/,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("throws when messageId lacks angle brackets", () => {
|
|
275
|
+
expect(() =>
|
|
276
|
+
createInboundMessage({
|
|
277
|
+
from: FROM,
|
|
278
|
+
to: TO,
|
|
279
|
+
content: "x",
|
|
280
|
+
messageId: "missing-brackets@example.com",
|
|
281
|
+
}),
|
|
282
|
+
).toThrow(/`messageId` must be an RFC 2822 message identifier/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("throws when inReplyTo lacks angle brackets", () => {
|
|
286
|
+
expect(() =>
|
|
287
|
+
createInboundMessage({
|
|
288
|
+
from: FROM,
|
|
289
|
+
to: TO,
|
|
290
|
+
content: "x",
|
|
291
|
+
inReplyTo: "bare",
|
|
292
|
+
}),
|
|
293
|
+
).toThrow(/`inReplyTo` must be an RFC 2822 message identifier/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("throws when references contains a malformed entry", () => {
|
|
297
|
+
expect(() =>
|
|
298
|
+
createInboundMessage({
|
|
299
|
+
from: FROM,
|
|
300
|
+
to: TO,
|
|
301
|
+
content: "x",
|
|
302
|
+
references: ["<one@example.com>", "bad"],
|
|
303
|
+
}),
|
|
304
|
+
).toThrow(/`references\[1\]` must be an RFC 2822 message identifier/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("throws when references is an empty array", () => {
|
|
308
|
+
expect(() =>
|
|
309
|
+
createInboundMessage({
|
|
310
|
+
from: FROM,
|
|
311
|
+
to: TO,
|
|
312
|
+
content: "x",
|
|
313
|
+
references: [],
|
|
314
|
+
}),
|
|
315
|
+
).toThrow(/`references`, when provided, must contain at least one entry/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("throws when date string is not parseable", () => {
|
|
319
|
+
expect(() =>
|
|
320
|
+
createInboundMessage({
|
|
321
|
+
from: FROM,
|
|
322
|
+
to: TO,
|
|
323
|
+
content: "x",
|
|
324
|
+
date: "not-a-date",
|
|
325
|
+
}),
|
|
326
|
+
).toThrow(/`date` is not a parseable date string/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("throws when date is an Invalid Date instance", () => {
|
|
330
|
+
expect(() =>
|
|
331
|
+
createInboundMessage({
|
|
332
|
+
from: FROM,
|
|
333
|
+
to: TO,
|
|
334
|
+
content: "x",
|
|
335
|
+
date: new Date("nope"),
|
|
336
|
+
}),
|
|
337
|
+
).toThrow(/`date` is an Invalid Date/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("throws when optional string fields are empty", () => {
|
|
341
|
+
expect(() =>
|
|
342
|
+
createInboundMessage({
|
|
343
|
+
from: FROM,
|
|
344
|
+
to: TO,
|
|
345
|
+
content: "x",
|
|
346
|
+
correlationId: "",
|
|
347
|
+
}),
|
|
348
|
+
).toThrow(/`correlationId`, when provided, must be a non-empty string/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("throws when flags contains an empty string", () => {
|
|
352
|
+
expect(() =>
|
|
353
|
+
createInboundMessage({
|
|
354
|
+
from: FROM,
|
|
355
|
+
to: TO,
|
|
356
|
+
content: "x",
|
|
357
|
+
flags: ["\\Seen", ""],
|
|
358
|
+
}),
|
|
359
|
+
).toThrow(/`flags\[1\]` must be a non-empty string/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("throws when ref.mailbox is empty", () => {
|
|
363
|
+
expect(() =>
|
|
364
|
+
createInboundMessage({
|
|
365
|
+
from: FROM,
|
|
366
|
+
to: TO,
|
|
367
|
+
content: "x",
|
|
368
|
+
ref: { mailbox: "" },
|
|
369
|
+
}),
|
|
370
|
+
).toThrow(/`ref\.mailbox`.*must be a non-empty string/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("throws when from lacks an @ domain", () => {
|
|
374
|
+
expect(() =>
|
|
375
|
+
createInboundMessage({
|
|
376
|
+
from: "alice",
|
|
377
|
+
to: TO,
|
|
378
|
+
content: "x",
|
|
379
|
+
}),
|
|
380
|
+
).toThrow(/`from` must be an RFC 5322 address/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("throws when a `to` entry lacks an @ domain", () => {
|
|
384
|
+
expect(() =>
|
|
385
|
+
createInboundMessage({
|
|
386
|
+
from: FROM,
|
|
387
|
+
to: ["agent@example.com", "loose-string"],
|
|
388
|
+
content: "x",
|
|
389
|
+
}),
|
|
390
|
+
).toThrow(/`to\[1\]` must be an RFC 5322 address/);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("throws when messageId has angle brackets but no @ host", () => {
|
|
394
|
+
expect(() =>
|
|
395
|
+
createInboundMessage({
|
|
396
|
+
from: FROM,
|
|
397
|
+
to: TO,
|
|
398
|
+
content: "x",
|
|
399
|
+
messageId: "<no-at-sign>",
|
|
400
|
+
}),
|
|
401
|
+
).toThrow(/`messageId` must be an RFC 2822 message identifier/);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("throws when payload.type is a conversation type", () => {
|
|
405
|
+
expect(() =>
|
|
406
|
+
createInboundMessage({
|
|
407
|
+
from: FROM,
|
|
408
|
+
to: TO,
|
|
409
|
+
payload: { type: "conversation.message", body: {} },
|
|
410
|
+
}),
|
|
411
|
+
).toThrow(/conversation types must use `content` instead of `payload`/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("throws when payload.body is null", () => {
|
|
415
|
+
expect(() =>
|
|
416
|
+
callInboundUnsafe({
|
|
417
|
+
from: FROM,
|
|
418
|
+
to: TO,
|
|
419
|
+
payload: { type: "offering.request", body: null },
|
|
420
|
+
}),
|
|
421
|
+
).toThrow(/`payload\.body` must be a plain object/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("throws when payload.body is an array", () => {
|
|
425
|
+
expect(() =>
|
|
426
|
+
callInboundUnsafe({
|
|
427
|
+
from: FROM,
|
|
428
|
+
to: TO,
|
|
429
|
+
payload: { type: "offering.request", body: [] },
|
|
430
|
+
}),
|
|
431
|
+
).toThrow(/`payload\.body` must be a plain object/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("throws when payload.version is not a string", () => {
|
|
435
|
+
expect(() =>
|
|
436
|
+
callInboundUnsafe({
|
|
437
|
+
from: FROM,
|
|
438
|
+
to: TO,
|
|
439
|
+
payload: { type: "offering.request", body: {}, version: 7 },
|
|
440
|
+
}),
|
|
441
|
+
).toThrow(/`payload\.version`.*must be a non-empty string/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("throws when ref.uid is not an integer", () => {
|
|
445
|
+
expect(() =>
|
|
446
|
+
callInboundUnsafe({
|
|
447
|
+
from: FROM,
|
|
448
|
+
to: TO,
|
|
449
|
+
content: "x",
|
|
450
|
+
ref: { uid: "not-a-number" },
|
|
451
|
+
}),
|
|
452
|
+
).toThrow(/`ref\.uid`.*must be a positive integer/);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("throws when content is the empty string", () => {
|
|
456
|
+
expect(() =>
|
|
457
|
+
createInboundMessage({ from: FROM, to: TO, content: "" }),
|
|
458
|
+
).toThrow(/`content`, when provided, must be a non-empty string/);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("throws when signatureStatus is not a recognised value", () => {
|
|
462
|
+
expect(() =>
|
|
463
|
+
callInboundUnsafe({
|
|
464
|
+
from: FROM,
|
|
465
|
+
to: TO,
|
|
466
|
+
content: "x",
|
|
467
|
+
signatureStatus: "bogus",
|
|
468
|
+
}),
|
|
469
|
+
).toThrow(/`signatureStatus` is not a recognised SignatureStatus/);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("throws when ref.uid is zero", () => {
|
|
473
|
+
expect(() =>
|
|
474
|
+
createInboundMessage({
|
|
475
|
+
from: FROM,
|
|
476
|
+
to: TO,
|
|
477
|
+
content: "x",
|
|
478
|
+
ref: { uid: 0 },
|
|
479
|
+
}),
|
|
480
|
+
).toThrow(/`ref\.uid`.*must be a positive integer/);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("createOutboundMessage", () => {
|
|
486
|
+
test("conversation content with required fields", () => {
|
|
487
|
+
const msg = createOutboundMessage({
|
|
488
|
+
to: TO,
|
|
489
|
+
type: "conversation.message",
|
|
490
|
+
content: "hi",
|
|
491
|
+
});
|
|
492
|
+
expect(msg).toEqual({
|
|
493
|
+
to: TO,
|
|
494
|
+
type: "conversation.message",
|
|
495
|
+
content: "hi",
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("structured payload with summary and attachments", () => {
|
|
500
|
+
const attachments: MessageAttachment[] = [
|
|
501
|
+
{
|
|
502
|
+
name: "x.bin",
|
|
503
|
+
contentType: "application/octet-stream",
|
|
504
|
+
data: new Uint8Array([0]),
|
|
505
|
+
},
|
|
506
|
+
];
|
|
507
|
+
const msg = createOutboundMessage({
|
|
508
|
+
to: TO,
|
|
509
|
+
type: "offering.request",
|
|
510
|
+
payload: { offeringId: "code-review", parameters: {} },
|
|
511
|
+
summary: "Code review request",
|
|
512
|
+
attachments,
|
|
513
|
+
});
|
|
514
|
+
expect(msg.type).toBe("offering.request");
|
|
515
|
+
expect(msg.payload).toEqual({ offeringId: "code-review", parameters: {} });
|
|
516
|
+
expect(msg.summary).toBe("Code review request");
|
|
517
|
+
expect(msg.attachments).toEqual(attachments);
|
|
518
|
+
expect(msg.content).toBeUndefined();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("to/cc are passed through without normalisation", () => {
|
|
522
|
+
const single = createOutboundMessage({
|
|
523
|
+
to: "single@example.com",
|
|
524
|
+
type: "conversation.message",
|
|
525
|
+
content: "hi",
|
|
526
|
+
cc: "watcher@example.com",
|
|
527
|
+
});
|
|
528
|
+
expect(single.to).toBe("single@example.com");
|
|
529
|
+
expect(single.cc).toBe("watcher@example.com");
|
|
530
|
+
|
|
531
|
+
const many = createOutboundMessage({
|
|
532
|
+
to: ["a@example.com", "b@example.com"],
|
|
533
|
+
type: "conversation.message",
|
|
534
|
+
content: "hi",
|
|
535
|
+
cc: ["c@example.com"],
|
|
536
|
+
});
|
|
537
|
+
expect(many.to).toEqual(["a@example.com", "b@example.com"]);
|
|
538
|
+
expect(many.cc).toEqual(["c@example.com"]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("threading fields are wired through", () => {
|
|
542
|
+
const msg = createOutboundMessage({
|
|
543
|
+
to: TO,
|
|
544
|
+
type: "approval.granted",
|
|
545
|
+
payload: { decision: "approved" },
|
|
546
|
+
inReplyTo: "<request@example.com>",
|
|
547
|
+
correlationId: "corr-abc",
|
|
548
|
+
sessionId: "session-1",
|
|
549
|
+
tenantId: "tenant-1",
|
|
550
|
+
});
|
|
551
|
+
expect(msg.inReplyTo).toBe("<request@example.com>");
|
|
552
|
+
expect(msg.correlationId).toBe("corr-abc");
|
|
553
|
+
expect(msg.sessionId).toBe("session-1");
|
|
554
|
+
expect(msg.tenantId).toBe("tenant-1");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe("validation", () => {
|
|
558
|
+
test("throws when type is invalid", () => {
|
|
559
|
+
expect(() =>
|
|
560
|
+
callOutboundUnsafe({
|
|
561
|
+
to: TO,
|
|
562
|
+
type: "not.real",
|
|
563
|
+
content: "x",
|
|
564
|
+
}),
|
|
565
|
+
).toThrow(/`type` is not a valid InterchangeType/);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("throws when content and payload are both set", () => {
|
|
569
|
+
expect(() =>
|
|
570
|
+
createOutboundMessage({
|
|
571
|
+
to: TO,
|
|
572
|
+
type: "offering.request",
|
|
573
|
+
content: "x",
|
|
574
|
+
payload: { offeringId: "x" },
|
|
575
|
+
}),
|
|
576
|
+
).toThrow(/`content` and `payload` are mutually exclusive/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("throws when to is empty string", () => {
|
|
580
|
+
expect(() =>
|
|
581
|
+
createOutboundMessage({
|
|
582
|
+
to: "",
|
|
583
|
+
type: "conversation.message",
|
|
584
|
+
content: "x",
|
|
585
|
+
}),
|
|
586
|
+
).toThrow(/`to` must be a non-empty string/);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("throws when to is empty array", () => {
|
|
590
|
+
expect(() =>
|
|
591
|
+
createOutboundMessage({
|
|
592
|
+
to: [],
|
|
593
|
+
type: "conversation.message",
|
|
594
|
+
content: "x",
|
|
595
|
+
}),
|
|
596
|
+
).toThrow(/`to` must contain at least one recipient address/);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("throws when to array has empty entry", () => {
|
|
600
|
+
expect(() =>
|
|
601
|
+
createOutboundMessage({
|
|
602
|
+
to: ["valid@example.com", ""],
|
|
603
|
+
type: "conversation.message",
|
|
604
|
+
content: "x",
|
|
605
|
+
}),
|
|
606
|
+
).toThrow(/`to\[1\]` must be a non-empty string/);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("throws when cc array has empty entry", () => {
|
|
610
|
+
expect(() =>
|
|
611
|
+
createOutboundMessage({
|
|
612
|
+
to: TO,
|
|
613
|
+
type: "conversation.message",
|
|
614
|
+
content: "x",
|
|
615
|
+
cc: [""],
|
|
616
|
+
}),
|
|
617
|
+
).toThrow(/`cc\[0\]` must be a non-empty string/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("throws when inReplyTo lacks angle brackets", () => {
|
|
621
|
+
expect(() =>
|
|
622
|
+
createOutboundMessage({
|
|
623
|
+
to: TO,
|
|
624
|
+
type: "conversation.message",
|
|
625
|
+
content: "x",
|
|
626
|
+
inReplyTo: "bare-id",
|
|
627
|
+
}),
|
|
628
|
+
).toThrow(/`inReplyTo` must be an RFC 2822 message identifier/);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("throws when summary is empty", () => {
|
|
632
|
+
expect(() =>
|
|
633
|
+
createOutboundMessage({
|
|
634
|
+
to: TO,
|
|
635
|
+
type: "offering.request",
|
|
636
|
+
payload: { x: 1 },
|
|
637
|
+
summary: "",
|
|
638
|
+
}),
|
|
639
|
+
).toThrow(/`summary`, when provided, must be a non-empty string/);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("throws when type is conversation.* with payload", () => {
|
|
643
|
+
expect(() =>
|
|
644
|
+
createOutboundMessage({
|
|
645
|
+
to: TO,
|
|
646
|
+
type: "conversation.message",
|
|
647
|
+
payload: { x: 1 },
|
|
648
|
+
}),
|
|
649
|
+
).toThrow(
|
|
650
|
+
/conversation `type` conversation\.message must use `content` instead of `payload`/,
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("throws when type is non-conversation with content", () => {
|
|
655
|
+
expect(() =>
|
|
656
|
+
createOutboundMessage({
|
|
657
|
+
to: TO,
|
|
658
|
+
type: "offering.request",
|
|
659
|
+
content: "hi",
|
|
660
|
+
}),
|
|
661
|
+
).toThrow(
|
|
662
|
+
/non-conversation `type` offering\.request must use `payload` instead of `content`/,
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("throws when payload is null", () => {
|
|
667
|
+
expect(() =>
|
|
668
|
+
callOutboundUnsafe({
|
|
669
|
+
to: TO,
|
|
670
|
+
type: "offering.request",
|
|
671
|
+
payload: null,
|
|
672
|
+
}),
|
|
673
|
+
).toThrow(/`payload` must be a plain object/);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("throws when payload is an array", () => {
|
|
677
|
+
expect(() =>
|
|
678
|
+
callOutboundUnsafe({
|
|
679
|
+
to: TO,
|
|
680
|
+
type: "offering.request",
|
|
681
|
+
payload: [1, 2, 3],
|
|
682
|
+
}),
|
|
683
|
+
).toThrow(/`payload` must be a plain object/);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("throws when content is the empty string", () => {
|
|
687
|
+
expect(() =>
|
|
688
|
+
createOutboundMessage({
|
|
689
|
+
to: TO,
|
|
690
|
+
type: "conversation.message",
|
|
691
|
+
content: "",
|
|
692
|
+
}),
|
|
693
|
+
).toThrow(/`content`, when provided, must be a non-empty string/);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("throws when conversation type is missing content", () => {
|
|
697
|
+
expect(() =>
|
|
698
|
+
createOutboundMessage({
|
|
699
|
+
to: TO,
|
|
700
|
+
type: "conversation.message",
|
|
701
|
+
}),
|
|
702
|
+
).toThrow(/conversation `type` conversation\.message requires `content`/);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("throws when non-conversation type is missing payload", () => {
|
|
706
|
+
expect(() =>
|
|
707
|
+
createOutboundMessage({
|
|
708
|
+
to: TO,
|
|
709
|
+
type: "offering.request",
|
|
710
|
+
}),
|
|
711
|
+
).toThrow(/non-conversation `type` offering\.request requires `payload`/);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("throws when a to entry lacks an @ domain", () => {
|
|
715
|
+
expect(() =>
|
|
716
|
+
createOutboundMessage({
|
|
717
|
+
to: ["agent@example.com", "loose-string"],
|
|
718
|
+
type: "conversation.message",
|
|
719
|
+
content: "x",
|
|
720
|
+
}),
|
|
721
|
+
).toThrow(/`to\[1\]` must be an RFC 5322 address/);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Compile-time guards: the public return types of the builders match
|
|
727
|
+
// InboundMessage / OutboundMessage exactly. Variables are unused at runtime;
|
|
728
|
+
// referenced via the `_` prefix to satisfy lint.
|
|
729
|
+
const _inbound: InboundMessage = createInboundMessage({
|
|
730
|
+
from: FROM,
|
|
731
|
+
to: TO,
|
|
732
|
+
content: "x",
|
|
733
|
+
});
|
|
734
|
+
const _outbound: OutboundMessage = createOutboundMessage({
|
|
735
|
+
to: TO,
|
|
736
|
+
type: "conversation.message",
|
|
737
|
+
content: "x",
|
|
738
|
+
});
|
|
739
|
+
void _inbound;
|
|
740
|
+
void _outbound;
|