@vellumai/vellum-gateway 0.6.0 → 0.6.1
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +37 -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/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 +15 -3
- 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
|
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", () => {
|
|
@@ -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
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { GatewayInboundEvent } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shape of a normalized inbound email event as sent by the Vellum
|
|
5
|
+
* platform (or any upstream caller).
|
|
6
|
+
*
|
|
7
|
+
* The platform is responsible for provider-specific parsing (e.g.
|
|
8
|
+
* Mailgun multipart → JSON). By the time the payload reaches the
|
|
9
|
+
* gateway it should already be in this canonical shape.
|
|
10
|
+
*/
|
|
11
|
+
export interface VellumEmailPayload {
|
|
12
|
+
/** Sender email address (e.g. "user@vellum.me"). */
|
|
13
|
+
from: string;
|
|
14
|
+
/** Sender display name (e.g. "Alice Smith"). Optional. */
|
|
15
|
+
fromName?: string;
|
|
16
|
+
/** Recipient email address (the assistant's address). */
|
|
17
|
+
to: string;
|
|
18
|
+
/** Email subject line. */
|
|
19
|
+
subject?: string;
|
|
20
|
+
/** Plain-text body content (latest reply only, quoted text stripped). */
|
|
21
|
+
strippedText?: string;
|
|
22
|
+
/** Full plain-text body (fallback when strippedText is unavailable). */
|
|
23
|
+
bodyText?: string;
|
|
24
|
+
/** RFC 5322 Message-ID header value. */
|
|
25
|
+
messageId: string;
|
|
26
|
+
/** Message-ID of the parent message (In-Reply-To header). */
|
|
27
|
+
inReplyTo?: string;
|
|
28
|
+
/** Space-separated chain of ancestor Message-IDs (References header). */
|
|
29
|
+
references?: string;
|
|
30
|
+
/** Stable conversation/thread identifier derived by the platform. */
|
|
31
|
+
conversationId: string;
|
|
32
|
+
/** ISO 8601 timestamp of the original email. */
|
|
33
|
+
timestamp?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NormalizedEmailEvent {
|
|
37
|
+
event: GatewayInboundEvent;
|
|
38
|
+
/** Unique event/message ID for dedup. */
|
|
39
|
+
eventId: string;
|
|
40
|
+
/** Original recipient address for routing. */
|
|
41
|
+
recipientAddress: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a Vellum email webhook payload into a GatewayInboundEvent.
|
|
46
|
+
*
|
|
47
|
+
* Returns null if required fields are missing.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeEmailWebhook(
|
|
50
|
+
payload: Record<string, unknown>,
|
|
51
|
+
): NormalizedEmailEvent | null {
|
|
52
|
+
const from = payload.from as string | undefined;
|
|
53
|
+
const to = payload.to as string | undefined;
|
|
54
|
+
const messageId = payload.messageId as string | undefined;
|
|
55
|
+
const conversationId = payload.conversationId as string | undefined;
|
|
56
|
+
|
|
57
|
+
if (!from || !to || !messageId || !conversationId) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Prefer strippedText (latest reply only) over full body
|
|
62
|
+
const content =
|
|
63
|
+
(payload.strippedText as string | undefined) ??
|
|
64
|
+
(payload.bodyText as string | undefined) ??
|
|
65
|
+
"";
|
|
66
|
+
|
|
67
|
+
const fromName = payload.fromName as string | undefined;
|
|
68
|
+
|
|
69
|
+
const event: GatewayInboundEvent = {
|
|
70
|
+
version: "v1",
|
|
71
|
+
sourceChannel: "email",
|
|
72
|
+
receivedAt: new Date().toISOString(),
|
|
73
|
+
message: {
|
|
74
|
+
content,
|
|
75
|
+
conversationExternalId: conversationId,
|
|
76
|
+
externalMessageId: messageId,
|
|
77
|
+
},
|
|
78
|
+
actor: {
|
|
79
|
+
actorExternalId: from,
|
|
80
|
+
displayName: fromName || from,
|
|
81
|
+
username: from,
|
|
82
|
+
},
|
|
83
|
+
source: {
|
|
84
|
+
updateId: messageId,
|
|
85
|
+
},
|
|
86
|
+
raw: payload,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
event,
|
|
91
|
+
eventId: messageId,
|
|
92
|
+
recipientAddress: to,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, it, expect } from "bun:test";
|
|
3
|
+
import { verifyEmailWebhookSignature } from "./verify.js";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-webhook-secret-1234";
|
|
6
|
+
|
|
7
|
+
function computeSignature(body: string, secret: string): string {
|
|
8
|
+
return (
|
|
9
|
+
"sha256=" + createHmac("sha256", secret).update(body, "utf8").digest("hex")
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("verifyEmailWebhookSignature", () => {
|
|
14
|
+
const body = '{"from":"sender@example.com","to":"bot@vellum.me"}';
|
|
15
|
+
|
|
16
|
+
it("accepts a valid HMAC signature", () => {
|
|
17
|
+
const headers = new Headers({
|
|
18
|
+
"vellum-signature": computeSignature(body, SECRET),
|
|
19
|
+
});
|
|
20
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects a wrong secret", () => {
|
|
24
|
+
const headers = new Headers({
|
|
25
|
+
"vellum-signature": computeSignature(body, "wrong-secret"),
|
|
26
|
+
});
|
|
27
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects when header is missing", () => {
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects when secret is empty", () => {
|
|
36
|
+
const headers = new Headers({
|
|
37
|
+
"vellum-signature": computeSignature(body, SECRET),
|
|
38
|
+
});
|
|
39
|
+
expect(verifyEmailWebhookSignature(headers, body, "")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects when header value is empty", () => {
|
|
43
|
+
const headers = new Headers({
|
|
44
|
+
"vellum-signature": "",
|
|
45
|
+
});
|
|
46
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a signature without sha256= prefix", () => {
|
|
50
|
+
const digest = createHmac("sha256", SECRET)
|
|
51
|
+
.update(body, "utf8")
|
|
52
|
+
.digest("hex");
|
|
53
|
+
const headers = new Headers({
|
|
54
|
+
"vellum-signature": digest,
|
|
55
|
+
});
|
|
56
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects when body has been tampered with", () => {
|
|
60
|
+
const headers = new Headers({
|
|
61
|
+
"vellum-signature": computeSignature(body, SECRET),
|
|
62
|
+
});
|
|
63
|
+
expect(verifyEmailWebhookSignature(headers, "tampered body", SECRET)).toBe(
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("produces different signatures for different bodies", () => {
|
|
69
|
+
const sig1 = computeSignature("body-a", SECRET);
|
|
70
|
+
const sig2 = computeSignature("body-b", SECRET);
|
|
71
|
+
expect(sig1).not.toBe(sig2);
|
|
72
|
+
|
|
73
|
+
const headers1 = new Headers({ "vellum-signature": sig1 });
|
|
74
|
+
expect(verifyEmailWebhookSignature(headers1, "body-a", SECRET)).toBe(true);
|
|
75
|
+
expect(verifyEmailWebhookSignature(headers1, "body-b", SECRET)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects a truncated hex digest", () => {
|
|
79
|
+
const headers = new Headers({
|
|
80
|
+
"vellum-signature": "sha256=abcd1234",
|
|
81
|
+
});
|
|
82
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects non-ASCII hex values without throwing (Buffer byte length divergence)", () => {
|
|
86
|
+
// 64 non-ASCII characters whose UTF-16 .length === 64 (same as a valid
|
|
87
|
+
// hex digest) but whose Buffer byte length > 64, which would cause
|
|
88
|
+
// timingSafeEqual to throw if we only compared string lengths.
|
|
89
|
+
const nonAsciiHex = "\u00e9".repeat(64); // é is 2 bytes in UTF-8
|
|
90
|
+
const headers = new Headers({
|
|
91
|
+
"vellum-signature": `sha256=${nonAsciiHex}`,
|
|
92
|
+
});
|
|
93
|
+
// Must return false cleanly — not throw
|
|
94
|
+
expect(verifyEmailWebhookSignature(headers, body, SECRET)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify the Vellum-Signature header sent by the Vellum platform.
|
|
5
|
+
*
|
|
6
|
+
* The platform computes: HMAC-SHA256(webhookSecret, rawBody) and sends it as
|
|
7
|
+
* Vellum-Signature: sha256=<hex-digest>
|
|
8
|
+
*
|
|
9
|
+
* We must compare with a constant-time comparison to avoid timing attacks.
|
|
10
|
+
*
|
|
11
|
+
* This mirrors the WhatsApp webhook signature verification pattern
|
|
12
|
+
* (`X-Hub-Signature-256` header).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HEADER_NAME = "vellum-signature";
|
|
16
|
+
|
|
17
|
+
export function verifyEmailWebhookSignature(
|
|
18
|
+
headers: Headers,
|
|
19
|
+
rawBody: string,
|
|
20
|
+
webhookSecret: string,
|
|
21
|
+
): boolean {
|
|
22
|
+
const signatureHeader = headers.get(HEADER_NAME);
|
|
23
|
+
if (!signatureHeader || !webhookSecret) return false;
|
|
24
|
+
|
|
25
|
+
// Header format: "sha256=<hex-digest>"
|
|
26
|
+
if (!signatureHeader.startsWith("sha256=")) return false;
|
|
27
|
+
const providedHex = signatureHeader.slice(7);
|
|
28
|
+
|
|
29
|
+
const expected = createHmac("sha256", webhookSecret)
|
|
30
|
+
.update(rawBody, "utf8")
|
|
31
|
+
.digest("hex");
|
|
32
|
+
|
|
33
|
+
// Compare Buffer byte lengths — not string .length — to avoid
|
|
34
|
+
// timingSafeEqual throwing on non-ASCII input where UTF-16 code unit
|
|
35
|
+
// count matches but byte length diverges.
|
|
36
|
+
const providedBuf = Buffer.from(providedHex);
|
|
37
|
+
const expectedBuf = Buffer.from(expected);
|
|
38
|
+
if (providedBuf.length !== expectedBuf.length) return false;
|
|
39
|
+
|
|
40
|
+
return timingSafeEqual(providedBuf, expectedBuf);
|
|
41
|
+
}
|