@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 +3 -1
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/feature-flags-route.test.ts +38 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +130 -0
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/transport-hints.ts +18 -0
- package/src/config.ts +4 -1
- package/src/download-validation.test.ts +96 -0
- package/src/download-validation.ts +92 -0
- package/src/email/normalize.test.ts +129 -0
- package/src/email/normalize.ts +94 -0
- package/src/email/verify.test.ts +96 -0
- package/src/email/verify.ts +41 -0
- package/src/feature-flag-registry.json +17 -1
- package/src/feature-flag-remote-store.ts +19 -0
- package/src/feature-flag-watcher.ts +38 -12
- package/src/http/routes/email-webhook.test.ts +393 -0
- package/src/http/routes/email-webhook.ts +243 -0
- package/src/http/routes/log-export.test.ts +530 -0
- package/src/http/routes/log-export.ts +494 -0
- package/src/http/routes/telegram-webhook.ts +21 -1
- package/src/http/routes/whatsapp-webhook.ts +28 -1
- package/src/index.ts +37 -1
- package/src/logger.ts +21 -6
- package/src/remote-feature-flag-sync.ts +91 -21
- package/src/schema.ts +149 -0
- package/src/slack/download.test.ts +81 -10
- package/src/slack/download.ts +23 -1
- package/src/slack/socket-mode.ts +10 -0
- package/src/telegram/download.ts +3 -0
- package/src/types.ts +1 -0
- package/src/whatsapp/download.ts +3 -0
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
|
+
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
|
@@ -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).
|
|
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:
|
|
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
|
+
});
|