@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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
3
|
+
import type { GatewayConfig } from "../../config.js";
|
|
4
|
+
import type { CredentialCache } from "../../credential-cache.js";
|
|
5
|
+
import { credentialKey } from "../../credential-key.js";
|
|
6
|
+
|
|
7
|
+
// --- Mocks ----------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const handleInboundMock = mock(
|
|
10
|
+
(_config: GatewayConfig, _normalized: unknown, _options?: unknown) =>
|
|
11
|
+
Promise.resolve({ forwarded: true, rejected: false }),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const resetConversationMock = mock(() => Promise.resolve());
|
|
15
|
+
|
|
16
|
+
mock.module("../../handlers/handle-inbound.js", () => ({
|
|
17
|
+
handleInbound: handleInboundMock,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module("../../runtime/client.js", () => ({
|
|
21
|
+
resetConversation: resetConversationMock,
|
|
22
|
+
uploadAttachment: mock(() => Promise.resolve({ id: "att-1" })),
|
|
23
|
+
AttachmentValidationError: class extends Error {},
|
|
24
|
+
CircuitBreakerOpenError: class extends Error {},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Import after mocks are registered
|
|
28
|
+
const { createEmailWebhookHandler } = await import("./email-webhook.js");
|
|
29
|
+
|
|
30
|
+
// --- Helpers ---------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const TEST_WEBHOOK_SECRET = "test-webhook-secret-1234";
|
|
33
|
+
|
|
34
|
+
function computeSignature(body: string, secret: string): string {
|
|
35
|
+
return (
|
|
36
|
+
"sha256=" + createHmac("sha256", secret).update(body, "utf8").digest("hex")
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const baseConfig: GatewayConfig = {
|
|
41
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
42
|
+
defaultAssistantId: "ast-default",
|
|
43
|
+
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
44
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
45
|
+
maxAttachmentBytes: {
|
|
46
|
+
telegram: 50 * 1024 * 1024,
|
|
47
|
+
slack: 100 * 1024 * 1024,
|
|
48
|
+
whatsapp: 16 * 1024 * 1024,
|
|
49
|
+
default: 50 * 1024 * 1024,
|
|
50
|
+
},
|
|
51
|
+
maxAttachmentConcurrency: 3,
|
|
52
|
+
maxWebhookPayloadBytes: 1024 * 1024,
|
|
53
|
+
port: 7830,
|
|
54
|
+
routingEntries: [],
|
|
55
|
+
runtimeInitialBackoffMs: 500,
|
|
56
|
+
runtimeMaxRetries: 2,
|
|
57
|
+
runtimeProxyEnabled: false,
|
|
58
|
+
runtimeProxyRequireAuth: true,
|
|
59
|
+
runtimeTimeoutMs: 30000,
|
|
60
|
+
shutdownDrainMs: 5000,
|
|
61
|
+
unmappedPolicy: "default",
|
|
62
|
+
trustProxy: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function makeEmailPayload(overrides?: {
|
|
66
|
+
from?: string;
|
|
67
|
+
fromName?: string;
|
|
68
|
+
to?: string;
|
|
69
|
+
subject?: string;
|
|
70
|
+
strippedText?: string;
|
|
71
|
+
bodyText?: string;
|
|
72
|
+
messageId?: string;
|
|
73
|
+
inReplyTo?: string;
|
|
74
|
+
references?: string;
|
|
75
|
+
conversationId?: string;
|
|
76
|
+
timestamp?: string;
|
|
77
|
+
}) {
|
|
78
|
+
return JSON.stringify({
|
|
79
|
+
from: overrides?.from ?? "sender@example.com",
|
|
80
|
+
fromName: overrides?.fromName,
|
|
81
|
+
to: overrides?.to ?? "assistant@vellum.me",
|
|
82
|
+
subject: overrides?.subject ?? "Hello",
|
|
83
|
+
strippedText: overrides?.strippedText ?? "Hi, how are you?",
|
|
84
|
+
bodyText:
|
|
85
|
+
overrides?.bodyText ??
|
|
86
|
+
"On Mon, someone wrote:\n> old\n\nHi, how are you?",
|
|
87
|
+
messageId: overrides?.messageId ?? "<msg-456@example.com>",
|
|
88
|
+
inReplyTo: overrides?.inReplyTo,
|
|
89
|
+
references: overrides?.references,
|
|
90
|
+
conversationId: overrides?.conversationId ?? "conv-abc",
|
|
91
|
+
timestamp: overrides?.timestamp ?? "2026-04-03T01:00:00.000Z",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function postRequest(body: string, secret?: string): Request {
|
|
96
|
+
const sigSecret = secret ?? TEST_WEBHOOK_SECRET;
|
|
97
|
+
return new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"vellum-signature": computeSignature(body, sigSecret),
|
|
102
|
+
},
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeCaches(secret?: string) {
|
|
108
|
+
const credentials = {
|
|
109
|
+
get: async (key: string) => {
|
|
110
|
+
if (key === credentialKey("email", "webhook_secret"))
|
|
111
|
+
return secret ?? TEST_WEBHOOK_SECRET;
|
|
112
|
+
return undefined;
|
|
113
|
+
},
|
|
114
|
+
invalidate: () => {},
|
|
115
|
+
} as unknown as CredentialCache;
|
|
116
|
+
return { credentials };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Tests ----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe("email-webhook", () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
handleInboundMock.mockClear();
|
|
124
|
+
handleInboundMock.mockResolvedValue({ forwarded: true, rejected: false });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects non-POST requests with 405", async () => {
|
|
128
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
129
|
+
const req = new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
130
|
+
method: "GET",
|
|
131
|
+
});
|
|
132
|
+
const res = await handler(req);
|
|
133
|
+
expect(res.status).toBe(405);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("forwards a valid email event to runtime", async () => {
|
|
137
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
138
|
+
const body = makeEmailPayload();
|
|
139
|
+
const res = await handler(postRequest(body));
|
|
140
|
+
expect(res.status).toBe(200);
|
|
141
|
+
|
|
142
|
+
const json = (await res.json()) as { ok: boolean };
|
|
143
|
+
expect(json.ok).toBe(true);
|
|
144
|
+
expect(handleInboundMock).toHaveBeenCalledTimes(1);
|
|
145
|
+
|
|
146
|
+
// Verify the normalized event structure
|
|
147
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
148
|
+
const event = callArgs[1] as {
|
|
149
|
+
sourceChannel: string;
|
|
150
|
+
message: {
|
|
151
|
+
content: string;
|
|
152
|
+
conversationExternalId: string;
|
|
153
|
+
externalMessageId: string;
|
|
154
|
+
};
|
|
155
|
+
actor: { actorExternalId: string; displayName: string };
|
|
156
|
+
};
|
|
157
|
+
expect(event.sourceChannel).toBe("email");
|
|
158
|
+
expect(event.message.content).toBe("Hi, how are you?");
|
|
159
|
+
expect(event.message.conversationExternalId).toBe("conv-abc");
|
|
160
|
+
expect(event.message.externalMessageId).toBe("<msg-456@example.com>");
|
|
161
|
+
expect(event.actor.actorExternalId).toBe("sender@example.com");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("acknowledges payloads missing required fields", async () => {
|
|
165
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
166
|
+
const body = JSON.stringify({ someOtherEvent: true });
|
|
167
|
+
const res = await handler(postRequest(body));
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
expect(handleInboundMock).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("deduplicates events by message ID", async () => {
|
|
173
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
174
|
+
const body = makeEmailPayload({ messageId: "<dedup-test@example.com>" });
|
|
175
|
+
|
|
176
|
+
const res1 = await handler(postRequest(body));
|
|
177
|
+
expect(res1.status).toBe(200);
|
|
178
|
+
expect(handleInboundMock).toHaveBeenCalledTimes(1);
|
|
179
|
+
|
|
180
|
+
const res2 = await handler(postRequest(body));
|
|
181
|
+
expect(res2.status).toBe(200);
|
|
182
|
+
// Second call should be deduped
|
|
183
|
+
expect(handleInboundMock).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects payloads exceeding size limit", async () => {
|
|
187
|
+
const config = { ...baseConfig, maxWebhookPayloadBytes: 10 };
|
|
188
|
+
const { handler } = createEmailWebhookHandler(config, makeCaches());
|
|
189
|
+
const body = makeEmailPayload();
|
|
190
|
+
const res = await handler(postRequest(body));
|
|
191
|
+
expect(res.status).toBe(413);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("rejects invalid JSON with 400", async () => {
|
|
195
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
196
|
+
const body = "not json";
|
|
197
|
+
const req = new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: {
|
|
200
|
+
"content-type": "application/json",
|
|
201
|
+
"vellum-signature": computeSignature(body, TEST_WEBHOOK_SECRET),
|
|
202
|
+
},
|
|
203
|
+
body,
|
|
204
|
+
});
|
|
205
|
+
const res = await handler(req);
|
|
206
|
+
expect(res.status).toBe(400);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses fromName as displayName when provided", async () => {
|
|
210
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
211
|
+
const body = makeEmailPayload({
|
|
212
|
+
from: "alice@example.com",
|
|
213
|
+
fromName: "Alice Smith",
|
|
214
|
+
messageId: "<display-name@example.com>",
|
|
215
|
+
});
|
|
216
|
+
const res = await handler(postRequest(body));
|
|
217
|
+
expect(res.status).toBe(200);
|
|
218
|
+
|
|
219
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
220
|
+
const event = callArgs[1] as {
|
|
221
|
+
actor: { actorExternalId: string; displayName: string };
|
|
222
|
+
};
|
|
223
|
+
expect(event.actor.actorExternalId).toBe("alice@example.com");
|
|
224
|
+
expect(event.actor.displayName).toBe("Alice Smith");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("falls back to email as displayName when fromName is absent", async () => {
|
|
228
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
229
|
+
const body = makeEmailPayload({
|
|
230
|
+
from: "bob@example.com",
|
|
231
|
+
messageId: "<no-name@example.com>",
|
|
232
|
+
});
|
|
233
|
+
const res = await handler(postRequest(body));
|
|
234
|
+
expect(res.status).toBe(200);
|
|
235
|
+
|
|
236
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
237
|
+
const event = callArgs[1] as {
|
|
238
|
+
actor: { displayName: string };
|
|
239
|
+
};
|
|
240
|
+
expect(event.actor.displayName).toBe("bob@example.com");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("prefers strippedText over bodyText", async () => {
|
|
244
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
245
|
+
const body = makeEmailPayload({
|
|
246
|
+
strippedText: "New reply here",
|
|
247
|
+
bodyText: "On Monday, someone wrote:\n> old content\n\nNew reply here",
|
|
248
|
+
messageId: "<stripped@example.com>",
|
|
249
|
+
});
|
|
250
|
+
const res = await handler(postRequest(body));
|
|
251
|
+
expect(res.status).toBe(200);
|
|
252
|
+
|
|
253
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
254
|
+
const event = callArgs[1] as { message: { content: string } };
|
|
255
|
+
expect(event.message.content).toBe("New reply here");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("falls back to bodyText when strippedText is absent", async () => {
|
|
259
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
260
|
+
const body = JSON.stringify({
|
|
261
|
+
from: "test@example.com",
|
|
262
|
+
to: "bot@vellum.me",
|
|
263
|
+
messageId: "<fallback@example.com>",
|
|
264
|
+
conversationId: "conv-fallback",
|
|
265
|
+
bodyText: "Full body content here",
|
|
266
|
+
});
|
|
267
|
+
const res = await handler(postRequest(body));
|
|
268
|
+
expect(res.status).toBe(200);
|
|
269
|
+
|
|
270
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
271
|
+
const event = callArgs[1] as { message: { content: string } };
|
|
272
|
+
expect(event.message.content).toBe("Full body content here");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns 409 when webhook secret is not configured", async () => {
|
|
276
|
+
const emptyCaches = {
|
|
277
|
+
credentials: {
|
|
278
|
+
get: async () => undefined,
|
|
279
|
+
invalidate: () => {},
|
|
280
|
+
} as unknown as CredentialCache,
|
|
281
|
+
};
|
|
282
|
+
const { handler } = createEmailWebhookHandler(baseConfig, emptyCaches);
|
|
283
|
+
const body = makeEmailPayload({ messageId: "<no-secret@example.com>" });
|
|
284
|
+
const res = await handler(postRequest(body));
|
|
285
|
+
expect(res.status).toBe(409);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("rejects requests with wrong webhook secret (HMAC mismatch)", async () => {
|
|
289
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
290
|
+
const body = makeEmailPayload({ messageId: "<wrong-secret@example.com>" });
|
|
291
|
+
// Sign with a different secret than what the cache returns
|
|
292
|
+
const req = new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
"content-type": "application/json",
|
|
296
|
+
"vellum-signature": computeSignature(body, "wrong-secret"),
|
|
297
|
+
},
|
|
298
|
+
body,
|
|
299
|
+
});
|
|
300
|
+
const res = await handler(req);
|
|
301
|
+
expect(res.status).toBe(403);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("rejects requests with missing signature header", async () => {
|
|
305
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
306
|
+
const body = makeEmailPayload({
|
|
307
|
+
messageId: "<missing-header@example.com>",
|
|
308
|
+
});
|
|
309
|
+
const req = new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "content-type": "application/json" },
|
|
312
|
+
body,
|
|
313
|
+
});
|
|
314
|
+
const res = await handler(req);
|
|
315
|
+
expect(res.status).toBe(403);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("passes email subject and threading headers in sourceMetadata", async () => {
|
|
319
|
+
const { handler } = createEmailWebhookHandler(baseConfig, makeCaches());
|
|
320
|
+
const body = makeEmailPayload({
|
|
321
|
+
subject: "Re: Project Update",
|
|
322
|
+
inReplyTo: "<parent@example.com>",
|
|
323
|
+
references: "<root@example.com> <parent@example.com>",
|
|
324
|
+
messageId: "<metadata@example.com>",
|
|
325
|
+
});
|
|
326
|
+
const res = await handler(postRequest(body));
|
|
327
|
+
expect(res.status).toBe(200);
|
|
328
|
+
|
|
329
|
+
const callArgs = handleInboundMock.mock.calls[0];
|
|
330
|
+
const options = callArgs[2] as {
|
|
331
|
+
sourceMetadata: {
|
|
332
|
+
emailSubject: string;
|
|
333
|
+
emailRecipient: string;
|
|
334
|
+
emailInReplyTo: string;
|
|
335
|
+
emailReferences: string;
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
expect(options.sourceMetadata.emailSubject).toBe("Re: Project Update");
|
|
339
|
+
expect(options.sourceMetadata.emailRecipient).toBe("assistant@vellum.me");
|
|
340
|
+
expect(options.sourceMetadata.emailInReplyTo).toBe("<parent@example.com>");
|
|
341
|
+
expect(options.sourceMetadata.emailReferences).toBe(
|
|
342
|
+
"<root@example.com> <parent@example.com>",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("uses messageId as dedup key for event ID", async () => {
|
|
347
|
+
const { handler, dedupCache } = createEmailWebhookHandler(
|
|
348
|
+
baseConfig,
|
|
349
|
+
makeCaches(),
|
|
350
|
+
);
|
|
351
|
+
const body = makeEmailPayload({
|
|
352
|
+
messageId: "<unique-dedup-id@example.com>",
|
|
353
|
+
});
|
|
354
|
+
await handler(postRequest(body));
|
|
355
|
+
|
|
356
|
+
// The dedup cache should have reserved this message ID
|
|
357
|
+
const status = dedupCache.reserve("<unique-dedup-id@example.com>");
|
|
358
|
+
// Should return false because it's already reserved/marked
|
|
359
|
+
expect(status).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns 403 (not 409) when cache miss resolves on force-refresh but signature is invalid", async () => {
|
|
363
|
+
// Simulate: initial cache miss, force-refresh returns real secret,
|
|
364
|
+
// but signature doesn't match. Should be 403 (not 409 "not configured").
|
|
365
|
+
const caches = {
|
|
366
|
+
credentials: {
|
|
367
|
+
get: async (_key: string, opts?: { force?: boolean }) => {
|
|
368
|
+
// First call: cache miss
|
|
369
|
+
if (!opts?.force) return undefined;
|
|
370
|
+
// Force-refresh: return real secret
|
|
371
|
+
return TEST_WEBHOOK_SECRET;
|
|
372
|
+
},
|
|
373
|
+
invalidate: () => {},
|
|
374
|
+
} as unknown as CredentialCache,
|
|
375
|
+
};
|
|
376
|
+
const { handler } = createEmailWebhookHandler(baseConfig, caches);
|
|
377
|
+
const body = makeEmailPayload({
|
|
378
|
+
messageId: "<stale-var-fix@example.com>",
|
|
379
|
+
});
|
|
380
|
+
// Sign with wrong secret
|
|
381
|
+
const req = new Request("http://localhost:7830/webhooks/email/inbound", {
|
|
382
|
+
method: "POST",
|
|
383
|
+
headers: {
|
|
384
|
+
"content-type": "application/json",
|
|
385
|
+
"vellum-signature": computeSignature(body, "wrong-secret"),
|
|
386
|
+
},
|
|
387
|
+
body,
|
|
388
|
+
});
|
|
389
|
+
const res = await handler(req);
|
|
390
|
+
// This was the stale variable bug — it used to return 409 here
|
|
391
|
+
expect(res.status).toBe(403);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { buildEmailTransportMetadata } from "../../channels/transport-hints.js";
|
|
2
|
+
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
3
|
+
import type { GatewayConfig } from "../../config.js";
|
|
4
|
+
import type { CredentialCache } from "../../credential-cache.js";
|
|
5
|
+
import { credentialKey } from "../../credential-key.js";
|
|
6
|
+
import { StringDedupCache } from "../../dedup-cache.js";
|
|
7
|
+
import { normalizeEmailWebhook } from "../../email/normalize.js";
|
|
8
|
+
import { verifyEmailWebhookSignature } from "../../email/verify.js";
|
|
9
|
+
import { handleInbound } from "../../handlers/handle-inbound.js";
|
|
10
|
+
import { getLogger } from "../../logger.js";
|
|
11
|
+
import {
|
|
12
|
+
resolveAssistant,
|
|
13
|
+
isRejection,
|
|
14
|
+
} from "../../routing/resolve-assistant.js";
|
|
15
|
+
import {
|
|
16
|
+
handleCircuitBreakerError,
|
|
17
|
+
processInboundResult,
|
|
18
|
+
} from "../../webhook-pipeline.js";
|
|
19
|
+
|
|
20
|
+
const log = getLogger("email-webhook");
|
|
21
|
+
|
|
22
|
+
export function createEmailWebhookHandler(
|
|
23
|
+
config: GatewayConfig,
|
|
24
|
+
caches?: { credentials?: CredentialCache; configFile?: ConfigFileCache },
|
|
25
|
+
) {
|
|
26
|
+
// 24-hour TTL — Message-IDs are globally unique per RFC 5322
|
|
27
|
+
const dedupCache = new StringDedupCache(24 * 60 * 60_000);
|
|
28
|
+
|
|
29
|
+
const handler = async (req: Request): Promise<Response> => {
|
|
30
|
+
const traceId = req.headers.get("x-trace-id") ?? undefined;
|
|
31
|
+
const tlog = traceId ? log.child({ traceId }) : log;
|
|
32
|
+
|
|
33
|
+
if (req.method !== "POST") {
|
|
34
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Payload size guard
|
|
38
|
+
const contentLength = req.headers.get("content-length");
|
|
39
|
+
if (
|
|
40
|
+
contentLength &&
|
|
41
|
+
Number(contentLength) > config.maxWebhookPayloadBytes
|
|
42
|
+
) {
|
|
43
|
+
tlog.warn({ contentLength }, "Email webhook payload too large");
|
|
44
|
+
return Response.json({ error: "Payload too large" }, { status: 413 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let rawBody: string;
|
|
48
|
+
try {
|
|
49
|
+
rawBody = await req.text();
|
|
50
|
+
} catch {
|
|
51
|
+
return Response.json({ error: "Failed to read body" }, { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Buffer.byteLength(rawBody) > config.maxWebhookPayloadBytes) {
|
|
55
|
+
tlog.warn(
|
|
56
|
+
{ bodyLength: Buffer.byteLength(rawBody) },
|
|
57
|
+
"Email webhook payload too large",
|
|
58
|
+
);
|
|
59
|
+
return Response.json({ error: "Payload too large" }, { status: 413 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve webhook secret from credential cache
|
|
63
|
+
const webhookSecret = caches?.credentials
|
|
64
|
+
? await caches.credentials.get(credentialKey("email", "webhook_secret"))
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
// If the initial cache read returned undefined but a credential cache is available,
|
|
68
|
+
// attempt one forced refresh before fail-closing — the credential may have been
|
|
69
|
+
// written after the TTL cache was last populated.
|
|
70
|
+
let effectiveSecret = webhookSecret;
|
|
71
|
+
if (!effectiveSecret && caches?.credentials) {
|
|
72
|
+
effectiveSecret = await caches.credentials.get(
|
|
73
|
+
credentialKey("email", "webhook_secret"),
|
|
74
|
+
{ force: true },
|
|
75
|
+
);
|
|
76
|
+
if (effectiveSecret) {
|
|
77
|
+
tlog.info(
|
|
78
|
+
"Email webhook secret resolved after forced credential refresh",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Signature validation is required — reject when no secret is configured
|
|
84
|
+
// rather than silently accepting unauthenticated payloads (fail-closed).
|
|
85
|
+
if (!effectiveSecret) {
|
|
86
|
+
tlog.warn("Email webhook secret is not configured — rejecting request");
|
|
87
|
+
return Response.json(
|
|
88
|
+
{ error: "Webhook secret not configured" },
|
|
89
|
+
{ status: 409 },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let signatureValid = verifyEmailWebhookSignature(
|
|
94
|
+
req.headers,
|
|
95
|
+
rawBody,
|
|
96
|
+
effectiveSecret,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// One-shot force retry: if verification failed and caches are available,
|
|
100
|
+
// force-refresh the webhook secret and retry once.
|
|
101
|
+
if (!signatureValid && caches?.credentials) {
|
|
102
|
+
const freshSecret = await caches.credentials.get(
|
|
103
|
+
credentialKey("email", "webhook_secret"),
|
|
104
|
+
{ force: true },
|
|
105
|
+
);
|
|
106
|
+
if (freshSecret) {
|
|
107
|
+
signatureValid = verifyEmailWebhookSignature(
|
|
108
|
+
req.headers,
|
|
109
|
+
rawBody,
|
|
110
|
+
freshSecret,
|
|
111
|
+
);
|
|
112
|
+
if (signatureValid) {
|
|
113
|
+
tlog.info(
|
|
114
|
+
"Email webhook signature verified after forced credential refresh",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!signatureValid) {
|
|
121
|
+
tlog.warn("Email webhook signature verification failed");
|
|
122
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let payload: Record<string, unknown>;
|
|
126
|
+
try {
|
|
127
|
+
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
128
|
+
} catch {
|
|
129
|
+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Normalize the webhook payload
|
|
133
|
+
const normalized = normalizeEmailWebhook(payload);
|
|
134
|
+
if (!normalized) {
|
|
135
|
+
// Missing required fields — log and acknowledge
|
|
136
|
+
tlog.debug("Email webhook missing required fields, acknowledging");
|
|
137
|
+
return Response.json({ ok: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { event, eventId, recipientAddress } = normalized;
|
|
141
|
+
|
|
142
|
+
// Dedup by event ID
|
|
143
|
+
if (!dedupCache.reserve(eventId)) {
|
|
144
|
+
tlog.info({ eventId }, "Duplicate email event ID, ignoring");
|
|
145
|
+
return Response.json({ ok: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
tlog.info(
|
|
149
|
+
{
|
|
150
|
+
source: "email",
|
|
151
|
+
eventId,
|
|
152
|
+
from: event.actor.actorExternalId,
|
|
153
|
+
to: recipientAddress,
|
|
154
|
+
messageId: event.message.externalMessageId,
|
|
155
|
+
},
|
|
156
|
+
"Email webhook received",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Resolve routing using the recipient address as both conversation
|
|
160
|
+
// and actor ID — the standard routing chain will check explicit
|
|
161
|
+
// routes first, then fall back to the default assistant.
|
|
162
|
+
const routing = resolveAssistant(
|
|
163
|
+
config,
|
|
164
|
+
event.message.conversationExternalId,
|
|
165
|
+
event.actor.actorExternalId,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (isRejection(routing)) {
|
|
169
|
+
tlog.warn(
|
|
170
|
+
{
|
|
171
|
+
from: event.actor.actorExternalId,
|
|
172
|
+
to: recipientAddress,
|
|
173
|
+
reason: routing.reason,
|
|
174
|
+
},
|
|
175
|
+
"Routing rejected inbound email",
|
|
176
|
+
);
|
|
177
|
+
// No way to reply to the sender for rejected emails — just log
|
|
178
|
+
dedupCache.mark(eventId);
|
|
179
|
+
return Response.json({ ok: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Forward to runtime
|
|
183
|
+
try {
|
|
184
|
+
const result = await handleInbound(config, event, {
|
|
185
|
+
transportMetadata: buildEmailTransportMetadata(),
|
|
186
|
+
replyCallbackUrl: undefined, // Email replies go through the outbound send path (PR 4)
|
|
187
|
+
traceId,
|
|
188
|
+
routingOverride: routing,
|
|
189
|
+
sourceMetadata: {
|
|
190
|
+
emailSubject: (payload.subject as string | undefined) ?? undefined,
|
|
191
|
+
emailRecipient: recipientAddress,
|
|
192
|
+
...(payload.inReplyTo ? { emailInReplyTo: payload.inReplyTo } : {}),
|
|
193
|
+
...(payload.references
|
|
194
|
+
? { emailReferences: payload.references }
|
|
195
|
+
: {}),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const processed = processInboundResult(
|
|
200
|
+
result,
|
|
201
|
+
dedupCache,
|
|
202
|
+
eventId,
|
|
203
|
+
() => {
|
|
204
|
+
// No real-time reply mechanism for email — rejection is logged only
|
|
205
|
+
tlog.warn(
|
|
206
|
+
{ from: event.actor.actorExternalId, to: recipientAddress },
|
|
207
|
+
"Email routing rejected after forwarding attempt",
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
tlog,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (!processed.ok) {
|
|
214
|
+
return Response.json({ error: "Internal error" }, { status: 500 });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
dedupCache.mark(eventId);
|
|
218
|
+
|
|
219
|
+
if (!result.rejected) {
|
|
220
|
+
tlog.info(
|
|
221
|
+
{ status: "forwarded", eventId },
|
|
222
|
+
"Email message forwarded to runtime",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const cbResponse = handleCircuitBreakerError(
|
|
227
|
+
err,
|
|
228
|
+
dedupCache,
|
|
229
|
+
eventId,
|
|
230
|
+
tlog,
|
|
231
|
+
);
|
|
232
|
+
if (cbResponse) return cbResponse;
|
|
233
|
+
|
|
234
|
+
tlog.error({ err, eventId }, "Failed to process inbound email");
|
|
235
|
+
dedupCache.unreserve(eventId);
|
|
236
|
+
return Response.json({ error: "Internal error" }, { status: 500 });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return Response.json({ ok: true });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return { handler, dedupCache };
|
|
243
|
+
}
|