@vellumai/vellum-gateway 0.6.0 → 0.6.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/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Bun binary source (pinned to SHA digest for immutable reference)
2
- FROM oven/bun:1.2.21@sha256:5a2011bf09364b9af658ac1e66f60d08092f4291aeefbff448d58b027734fdd0 AS bun
2
+ FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS bun
3
3
 
4
4
  # Build stage
5
5
  FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS builder
@@ -20,6 +20,8 @@ WORKDIR /app
20
20
 
21
21
  RUN apt-get update && apt-get upgrade -y && apt-get install -y \
22
22
  ca-certificates \
23
+ iproute2 \
24
+ procps \
23
25
  && rm -rf /var/lib/apt/lists/*
24
26
 
25
27
  # Copy bun binary from builder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,7 +23,8 @@ describe("config: hardcoded defaults", () => {
23
23
  expect(config.unmappedPolicy).toBe("reject");
24
24
  expect(config.routingEntries).toEqual([]);
25
25
  expect(config.defaultAssistantId).toBeUndefined();
26
- expect(config.logFile).toEqual({ dir: undefined, retentionDays: 30 });
26
+ expect(config.logFile.dir).toMatch(/\.vellum\/logs$/);
27
+ expect(config.logFile.retentionDays).toBe(30);
27
28
  });
28
29
 
29
30
  test("GATEWAY_PORT defaults to 7830", () => {
@@ -97,7 +97,7 @@ const {
97
97
  } = await import("../feature-flag-defaults.js");
98
98
  const { clearFeatureFlagStoreCache, readPersistedFeatureFlags } =
99
99
  await import("../feature-flag-store.js");
100
- const { clearRemoteFeatureFlagStoreCache } =
100
+ const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
101
101
  await import("../feature-flag-remote-store.js");
102
102
 
103
103
  describe("GET /v1/feature-flags handler", () => {
@@ -337,6 +337,43 @@ describe("GET /v1/feature-flags handler", () => {
337
337
  expect(emailFlag.enabled).toBe(false);
338
338
  });
339
339
 
340
+ test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
341
+ // Scenario: the LD poller (RemoteFeatureFlagSync) writes
342
+ // email-channel: false, the gateway caches it, then a subsequent
343
+ // poll writes email-channel: true. The GET handler should return
344
+ // the updated value because writeRemoteFeatureFlags() updates
345
+ // both disk and the in-memory cache.
346
+
347
+ // Step 1: First poll writes email-channel: false (simulated via
348
+ // writeRemoteFeatureFlags, which is what the poller calls internally).
349
+ writeRemoteFeatureFlags({ "email-channel": false });
350
+
351
+ const handler = createFeatureFlagsGetHandler();
352
+ const res1 = await handler(
353
+ new Request("http://gateway.test/v1/feature-flags"),
354
+ );
355
+ const body1 = await res1.json();
356
+ const emailFlag1 = body1.flags.find(
357
+ (f: { key: string }) => f.key === "email-channel",
358
+ );
359
+ expect(emailFlag1.enabled).toBe(false);
360
+
361
+ // Step 2: Second poll writes email-channel: true — the poller
362
+ // calls writeRemoteFeatureFlags which updates file + cache.
363
+ writeRemoteFeatureFlags({ "email-channel": true });
364
+
365
+ // Step 3: The GET handler should immediately reflect the update
366
+ // without needing a file-watcher round-trip.
367
+ const res2 = await handler(
368
+ new Request("http://gateway.test/v1/feature-flags"),
369
+ );
370
+ const body2 = await res2.json();
371
+ const emailFlag2 = body2.flags.find(
372
+ (f: { key: string }) => f.key === "email-channel",
373
+ );
374
+ expect(emailFlag2.enabled).toBe(true);
375
+ });
376
+
340
377
  test("registry default used when neither local nor remote is set", async () => {
341
378
  // No local override
342
379
  if (existsSync(featureFlagStorePath)) {
@@ -428,6 +428,43 @@ describe("RemoteFeatureFlagSync", () => {
428
428
  );
429
429
  });
430
430
 
431
+ test("ignores remote false for GA flags (defaultEnabled: true in registry)", async () => {
432
+ // The platform sends false for all flags it knows about (blanket-deny).
433
+ // GA flags (defaultEnabled: true in the registry) should not be disabled
434
+ // by remote overrides — only local persisted overrides can do that.
435
+ fetchMock = mock(async () =>
436
+ Response.json({
437
+ flags: {
438
+ // GA flag (defaultEnabled: true) — remote false should be dropped
439
+ "conversation-starters": false,
440
+ // Gated flag (defaultEnabled: false) — remote false is kept
441
+ "email-channel": false,
442
+ // GA flag set to true — should be kept (redundant but harmless)
443
+ browser: true,
444
+ // Unknown flag — remote false is kept (not in registry)
445
+ "unknown-flag": false,
446
+ },
447
+ }),
448
+ );
449
+
450
+ const sync = new RemoteFeatureFlagSync({
451
+ credentials: fakeCredentialCache(defaultCredentials()),
452
+ });
453
+ await sync.start();
454
+ sync.stop();
455
+
456
+ clearRemoteFeatureFlagStoreCache();
457
+ const cached = readRemoteFeatureFlags();
458
+ // conversation-starters (GA, remote false) should be absent
459
+ expect(cached["conversation-starters"]).toBeUndefined();
460
+ // email-channel (gated, remote false) should be present
461
+ expect(cached["email-channel"]).toBe(false);
462
+ // browser (GA, remote true) should be present
463
+ expect(cached.browser).toBe(true);
464
+ // unknown-flag (not in registry, remote false) should be present
465
+ expect(cached["unknown-flag"]).toBe(false);
466
+ });
467
+
431
468
  test("trims whitespace from credential values", async () => {
432
469
  fetchMock = mock(async () => Response.json({ flags: {} }));
433
470
 
@@ -450,4 +487,97 @@ describe("RemoteFeatureFlagSync", () => {
450
487
  const headers = init?.headers as Record<string, string>;
451
488
  expect(headers.Authorization).toBe("Api-Key trimmed-key");
452
489
  });
490
+
491
+ test("polls with backoff when initial fetch fails, then snaps to steady-state on success", async () => {
492
+ // Simulate: first two fetches fail (missing creds), third succeeds.
493
+ let callCount = 0;
494
+ const credsFn = async (key: string) => {
495
+ callCount++;
496
+ // First 6 calls = 2 attempts × 3 credential reads each → missing API key.
497
+ // After that, credentials are available.
498
+ if (callCount <= 6) {
499
+ if (key === "credential/vellum/assistant_api_key") return undefined;
500
+ return defaultCredentials()[key];
501
+ }
502
+ return defaultCredentials()[key];
503
+ };
504
+ const creds = { get: credsFn } as unknown as CredentialCache;
505
+
506
+ fetchMock = mock(async () =>
507
+ Response.json({ flags: { "backoff-flag": true } }),
508
+ );
509
+
510
+ const sync = new RemoteFeatureFlagSync({
511
+ credentials: creds,
512
+ initialPollIntervalMs: 50,
513
+ });
514
+ await sync.start();
515
+
516
+ // Initial fetch failed (missing creds) — no fetch calls yet
517
+ expect(fetchMock).not.toHaveBeenCalled();
518
+
519
+ // Wait for first poll (50ms) — still fails (creds still missing)
520
+ await new Promise((r) => setTimeout(r, 80));
521
+ expect(fetchMock).not.toHaveBeenCalled();
522
+
523
+ // Wait for second poll (100ms = 50ms doubled) — creds now available
524
+ await new Promise((r) => setTimeout(r, 130));
525
+ expect(fetchMock).toHaveBeenCalledTimes(1);
526
+
527
+ clearRemoteFeatureFlagStoreCache();
528
+ expect(readRemoteFeatureFlags()).toEqual({ "backoff-flag": true });
529
+
530
+ sync.stop();
531
+ });
532
+
533
+ test("snaps to steady-state interval immediately when initial fetch succeeds", async () => {
534
+ fetchMock = mock(async () => Response.json({ flags: { "ok-flag": true } }));
535
+
536
+ const sync = new RemoteFeatureFlagSync({
537
+ credentials: fakeCredentialCache(defaultCredentials()),
538
+ initialPollIntervalMs: 50,
539
+ });
540
+ await sync.start();
541
+
542
+ // Initial fetch succeeded — 1 call
543
+ expect(fetchMock).toHaveBeenCalledTimes(1);
544
+
545
+ // Wait past what would be the initial poll interval — should NOT poll
546
+ // again because the interval snapped to steady-state (5 min)
547
+ await new Promise((r) => setTimeout(r, 100));
548
+ expect(fetchMock).toHaveBeenCalledTimes(1);
549
+
550
+ sync.stop();
551
+ });
552
+
553
+ test("doubles poll interval on consecutive failures", async () => {
554
+ // Always fail — missing creds
555
+ const creds = defaultCredentials();
556
+ delete creds["credential/vellum/assistant_api_key"];
557
+
558
+ fetchMock = mock(async () => Response.json({ flags: {} }));
559
+
560
+ const sync = new RemoteFeatureFlagSync({
561
+ credentials: fakeCredentialCache(creds),
562
+ initialPollIntervalMs: 50,
563
+ });
564
+ await sync.start();
565
+
566
+ // No fetch calls (missing creds)
567
+ expect(fetchMock).not.toHaveBeenCalled();
568
+
569
+ // After 50ms: first poll fires, still fails → interval doubles to 100ms
570
+ await new Promise((r) => setTimeout(r, 80));
571
+ expect(fetchMock).not.toHaveBeenCalled();
572
+
573
+ // After another 100ms: second poll fires, still fails → interval doubles to 200ms
574
+ await new Promise((r) => setTimeout(r, 130));
575
+ expect(fetchMock).not.toHaveBeenCalled();
576
+
577
+ // After another 200ms: third poll fires
578
+ await new Promise((r) => setTimeout(r, 230));
579
+ expect(fetchMock).not.toHaveBeenCalled();
580
+
581
+ sync.stop();
582
+ });
453
583
  });
@@ -10,7 +10,7 @@ import type { ChannelId } from "./types.js";
10
10
 
11
11
  export type InboundChannelId = Extract<
12
12
  ChannelId,
13
- "telegram" | "whatsapp" | "slack"
13
+ "telegram" | "whatsapp" | "slack" | "email"
14
14
  >;
15
15
 
16
16
  interface InboundEventBase<C extends InboundChannelId> {
@@ -52,8 +52,10 @@ interface InboundEventBase<C extends InboundChannelId> {
52
52
  export type TelegramInboundEvent = InboundEventBase<"telegram">;
53
53
  export type WhatsAppInboundEvent = InboundEventBase<"whatsapp">;
54
54
  export type SlackInboundEvent = InboundEventBase<"slack">;
55
+ export type EmailInboundEvent = InboundEventBase<"email">;
55
56
 
56
57
  export type GatewayInboundEvent =
57
58
  | TelegramInboundEvent
58
59
  | WhatsAppInboundEvent
59
- | SlackInboundEvent;
60
+ | SlackInboundEvent
61
+ | EmailInboundEvent;
@@ -35,3 +35,21 @@ export function buildWhatsAppTransportMetadata(): {
35
35
  uxBrief: WHATSAPP_CHANNEL_TRANSPORT_UX_BRIEF,
36
36
  };
37
37
  }
38
+
39
+ export const EMAIL_CHANNEL_TRANSPORT_HINTS = [
40
+ "email-medium",
41
+ "defer-dashboard-only-tasks",
42
+ ] as const;
43
+
44
+ export const EMAIL_CHANNEL_TRANSPORT_UX_BRIEF =
45
+ "Email is an asynchronous medium. Responses can be longer and more detailed than chat. Use proper formatting. The user may not see the response immediately.";
46
+
47
+ export function buildEmailTransportMetadata(): {
48
+ hints: string[];
49
+ uxBrief: string;
50
+ } {
51
+ return {
52
+ hints: [...EMAIL_CHANNEL_TRANSPORT_HINTS],
53
+ uxBrief: EMAIL_CHANNEL_TRANSPORT_UX_BRIEF,
54
+ };
55
+ }
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
 
4
5
  import { getLogger, type LogFileConfig } from "./logger.js";
@@ -148,7 +149,9 @@ export function loadConfig(): GatewayConfig {
148
149
  }
149
150
 
150
151
  const logFile: LogFileConfig = {
151
- dir: undefined,
152
+ dir:
153
+ process.env.GATEWAY_LOG_DIR ??
154
+ join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "logs"),
152
155
  retentionDays: 30,
153
156
  };
154
157
 
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ContentMismatchError,
4
+ validateDownloadedContent,
5
+ } from "./download-validation.js";
6
+
7
+ /** Create a minimal valid PNG buffer that file-type can detect. */
8
+ function makePngBuffer(): Uint8Array {
9
+ return new Uint8Array([
10
+ // PNG signature
11
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
12
+ // IHDR chunk: length (13)
13
+ 0x00, 0x00, 0x00, 0x0d,
14
+ // IHDR type
15
+ 0x49, 0x48, 0x44, 0x52,
16
+ // Width: 1
17
+ 0x00, 0x00, 0x00, 0x01,
18
+ // Height: 1
19
+ 0x00, 0x00, 0x00, 0x01,
20
+ // Bit depth, color type, compression, filter, interlace
21
+ 0x08, 0x02, 0x00, 0x00, 0x00,
22
+ // CRC (placeholder)
23
+ 0x90, 0x77, 0x53, 0xde,
24
+ ]);
25
+ }
26
+
27
+ function htmlBuffer(html: string): Uint8Array {
28
+ return new TextEncoder().encode(html);
29
+ }
30
+
31
+ describe("validateDownloadedContent", () => {
32
+ test("throws ContentMismatchError when HTML is received instead of image/png", async () => {
33
+ const buffer = htmlBuffer(
34
+ "<!DOCTYPE html><html><body><h1>Access Denied</h1></body></html>",
35
+ );
36
+
37
+ await expect(
38
+ validateDownloadedContent(buffer, "image/png", "F001"),
39
+ ).rejects.toThrow(ContentMismatchError);
40
+
41
+ await expect(
42
+ validateDownloadedContent(buffer, "image/png", "F001"),
43
+ ).rejects.toThrow(
44
+ "File F001 declared as image/png but content is HTML (likely an auth/error page)",
45
+ );
46
+ });
47
+
48
+ test("throws ContentMismatchError for HTML with leading whitespace and BOM", async () => {
49
+ // UTF-8 BOM (EF BB BF) + whitespace + HTML
50
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
51
+ const html = new TextEncoder().encode(
52
+ " \n <!DOCTYPE html><html><body>Error</body></html>",
53
+ );
54
+ const buffer = new Uint8Array(bom.length + html.length);
55
+ buffer.set(bom, 0);
56
+ buffer.set(html, bom.length);
57
+
58
+ await expect(
59
+ validateDownloadedContent(buffer, "image/jpeg", "F002"),
60
+ ).rejects.toThrow(ContentMismatchError);
61
+ });
62
+
63
+ test("passes for valid PNG buffer with declared image/png", async () => {
64
+ const buffer = makePngBuffer();
65
+
66
+ // Should not throw
67
+ await validateDownloadedContent(buffer, "image/png", "F003");
68
+ });
69
+
70
+ test("passes for valid PNG buffer with declared image/jpeg (same image family)", async () => {
71
+ const buffer = makePngBuffer();
72
+
73
+ // file-type detects it as image/png, which still starts with "image/"
74
+ // so it should pass even though declared as image/jpeg
75
+ await validateDownloadedContent(buffer, "image/jpeg", "F004");
76
+ });
77
+
78
+ test("passes for plain text buffer with declared text/plain (non-binary)", async () => {
79
+ const buffer = new TextEncoder().encode("hello world");
80
+
81
+ // text/plain is not a binary MIME type, so no validation is performed
82
+ await validateDownloadedContent(
83
+ new Uint8Array(buffer),
84
+ "text/plain",
85
+ "F005",
86
+ );
87
+ });
88
+
89
+ test("passes for empty buffer with declared image/png", async () => {
90
+ const buffer = new Uint8Array(0);
91
+
92
+ // Empty buffer: looksLikeHtml returns false, fileTypeFromBuffer returns
93
+ // undefined, so we allow it through and let downstream handle it
94
+ await validateDownloadedContent(buffer, "image/png", "F006");
95
+ });
96
+ });
@@ -0,0 +1,92 @@
1
+ import { fileTypeFromBuffer } from "file-type";
2
+
3
+ export class ContentMismatchError extends Error {
4
+ override name = "ContentMismatchError";
5
+ }
6
+
7
+ /**
8
+ * Check whether a buffer looks like an HTML page by inspecting the first
9
+ * non-whitespace / non-BOM bytes for common HTML markers.
10
+ */
11
+ export function looksLikeHtml(buffer: Uint8Array): boolean {
12
+ // Skip leading whitespace and UTF-8 BOM (0xEF 0xBB 0xBF)
13
+ let offset = 0;
14
+ // Skip UTF-8 BOM if present
15
+ if (
16
+ buffer.length >= 3 &&
17
+ buffer[0] === 0xef &&
18
+ buffer[1] === 0xbb &&
19
+ buffer[2] === 0xbf
20
+ ) {
21
+ offset = 3;
22
+ }
23
+ // Skip whitespace characters (space, tab, newline, carriage return)
24
+ while (offset < buffer.length) {
25
+ const byte = buffer[offset];
26
+ if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
27
+ offset++;
28
+ } else {
29
+ break;
30
+ }
31
+ }
32
+
33
+ if (offset >= buffer.length) {
34
+ return false;
35
+ }
36
+
37
+ const remaining = buffer.subarray(offset);
38
+ const prefix = new TextDecoder("utf-8", { fatal: false }).decode(
39
+ remaining.subarray(0, Math.min(remaining.length, 15)),
40
+ );
41
+
42
+ const upper = prefix.toUpperCase();
43
+ return upper.startsWith("<!DOCTYPE") || upper.startsWith("<HTML");
44
+ }
45
+
46
+ const BINARY_MIME_PREFIXES = [
47
+ "image/",
48
+ "audio/",
49
+ "video/",
50
+ "application/pdf",
51
+ ] as const;
52
+
53
+ /**
54
+ * Validate that downloaded content actually matches its declared MIME type.
55
+ *
56
+ * This catches a common failure mode where CDNs (Slack, WhatsApp, Telegram)
57
+ * return an HTML error or auth page instead of the actual binary file.
58
+ * Base64-encoding that HTML and sending it to a provider as e.g. `image/png`
59
+ * causes the provider to reject the request.
60
+ */
61
+ export async function validateDownloadedContent(
62
+ buffer: Uint8Array,
63
+ declaredMime: string,
64
+ fileId: string,
65
+ ): Promise<void> {
66
+ const isBinary = BINARY_MIME_PREFIXES.some((prefix) =>
67
+ declaredMime.startsWith(prefix),
68
+ );
69
+
70
+ if (!isBinary) {
71
+ return;
72
+ }
73
+
74
+ // Guard: if the buffer looks like HTML, it's almost certainly an error page
75
+ if (looksLikeHtml(buffer)) {
76
+ throw new ContentMismatchError(
77
+ `File ${fileId} declared as ${declaredMime} but content is HTML (likely an auth/error page)`,
78
+ );
79
+ }
80
+
81
+ // For image types, do a deeper check with file-type detection
82
+ if (declaredMime.startsWith("image/")) {
83
+ const detected = await fileTypeFromBuffer(buffer);
84
+ if (detected && !detected.mime.startsWith("image/")) {
85
+ throw new ContentMismatchError(
86
+ `File ${fileId} declared as ${declaredMime} but detected as ${detected.mime}`,
87
+ );
88
+ }
89
+ // If detected is undefined and it doesn't look like HTML, allow it
90
+ // through — some image formats may not be in file-type's database.
91
+ }
92
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { normalizeEmailWebhook } from "./normalize.js";
3
+
4
+ describe("normalizeEmailWebhook", () => {
5
+ function makePayload(overrides?: Record<string, unknown>) {
6
+ return {
7
+ from: "alice@example.com",
8
+ to: "bot@vellum.me",
9
+ messageId: "<msg-1@example.com>",
10
+ conversationId: "conv-1",
11
+ subject: "Test Subject",
12
+ strippedText: "Hello, world!",
13
+ bodyText: "On Mon, someone wrote:\n> old\n\nHello, world!",
14
+ timestamp: "2026-04-03T01:00:00.000Z",
15
+ ...(overrides ?? {}),
16
+ };
17
+ }
18
+
19
+ it("normalizes a valid email payload", () => {
20
+ const result = normalizeEmailWebhook(makePayload());
21
+ expect(result).not.toBeNull();
22
+ expect(result!.eventId).toBe("<msg-1@example.com>");
23
+ expect(result!.recipientAddress).toBe("bot@vellum.me");
24
+ expect(result!.event.sourceChannel).toBe("email");
25
+ expect(result!.event.message.content).toBe("Hello, world!");
26
+ expect(result!.event.message.conversationExternalId).toBe("conv-1");
27
+ expect(result!.event.message.externalMessageId).toBe("<msg-1@example.com>");
28
+ expect(result!.event.actor.actorExternalId).toBe("alice@example.com");
29
+ expect(result!.event.actor.displayName).toBe("alice@example.com");
30
+ });
31
+
32
+ it("returns null when required fields are missing", () => {
33
+ // Missing 'from'
34
+ expect(
35
+ normalizeEmailWebhook({
36
+ to: "bot@vellum.me",
37
+ messageId: "m",
38
+ conversationId: "c",
39
+ }),
40
+ ).toBeNull();
41
+ // Missing 'to'
42
+ expect(
43
+ normalizeEmailWebhook({
44
+ from: "a@b.com",
45
+ messageId: "m",
46
+ conversationId: "c",
47
+ }),
48
+ ).toBeNull();
49
+ // Missing 'messageId'
50
+ expect(
51
+ normalizeEmailWebhook({
52
+ from: "a@b.com",
53
+ to: "bot@vellum.me",
54
+ conversationId: "c",
55
+ }),
56
+ ).toBeNull();
57
+ // Missing 'conversationId'
58
+ expect(
59
+ normalizeEmailWebhook({
60
+ from: "a@b.com",
61
+ to: "bot@vellum.me",
62
+ messageId: "m",
63
+ }),
64
+ ).toBeNull();
65
+ });
66
+
67
+ it("returns null for empty object", () => {
68
+ expect(normalizeEmailWebhook({})).toBeNull();
69
+ });
70
+
71
+ it("uses fromName as displayName when provided", () => {
72
+ const result = normalizeEmailWebhook(
73
+ makePayload({ fromName: "Alice Smith" }),
74
+ );
75
+ expect(result).not.toBeNull();
76
+ expect(result!.event.actor.actorExternalId).toBe("alice@example.com");
77
+ expect(result!.event.actor.displayName).toBe("Alice Smith");
78
+ });
79
+
80
+ it("falls back to email as displayName when fromName is absent", () => {
81
+ const result = normalizeEmailWebhook(makePayload());
82
+ expect(result!.event.actor.displayName).toBe("alice@example.com");
83
+ });
84
+
85
+ it("prefers strippedText over bodyText", () => {
86
+ const result = normalizeEmailWebhook(
87
+ makePayload({
88
+ strippedText: "Just the new reply",
89
+ bodyText: "Full email with quoted content",
90
+ }),
91
+ );
92
+ expect(result!.event.message.content).toBe("Just the new reply");
93
+ });
94
+
95
+ it("falls back to bodyText when strippedText is missing", () => {
96
+ const payload = makePayload();
97
+ delete (payload as Record<string, unknown>).strippedText;
98
+ const result = normalizeEmailWebhook(payload);
99
+ expect(result!.event.message.content).toBe(
100
+ "On Mon, someone wrote:\n> old\n\nHello, world!",
101
+ );
102
+ });
103
+
104
+ it("uses empty string when both strippedText and bodyText are missing", () => {
105
+ const payload = makePayload();
106
+ delete (payload as Record<string, unknown>).strippedText;
107
+ delete (payload as Record<string, unknown>).bodyText;
108
+ const result = normalizeEmailWebhook(payload);
109
+ expect(result!.event.message.content).toBe("");
110
+ });
111
+
112
+ it("uses messageId as eventId", () => {
113
+ const result = normalizeEmailWebhook(
114
+ makePayload({ messageId: "<unique@example.com>" }),
115
+ );
116
+ expect(result!.eventId).toBe("<unique@example.com>");
117
+ });
118
+
119
+ it("sets username to sender email", () => {
120
+ const result = normalizeEmailWebhook(makePayload());
121
+ expect(result!.event.actor.username).toBe("alice@example.com");
122
+ });
123
+
124
+ it("preserves raw payload in event.raw", () => {
125
+ const payload = makePayload();
126
+ const result = normalizeEmailWebhook(payload);
127
+ expect(result!.event.raw).toEqual(payload);
128
+ });
129
+ });