@poncho-ai/messaging 0.2.0 → 0.2.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/.turbo/turbo-build.log +5 -5
- package/.turbo/turbo-test.log +29 -0
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +151 -4
- package/dist/index.js +627 -14
- package/package.json +14 -3
- package/src/adapters/email/utils.ts +259 -0
- package/src/adapters/resend/index.ts +653 -0
- package/src/adapters/slack/index.ts +7 -1
- package/src/bridge.ts +43 -13
- package/src/index.ts +15 -0
- package/src/types.ts +53 -4
- package/test/adapters/email-utils.test.ts +290 -0
- package/test/adapters/resend.test.ts +108 -0
- package/test/bridge.test.ts +121 -8
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type http from "node:http";
|
|
3
|
+
import type { ToolDefinition } from "@poncho-ai/sdk";
|
|
4
|
+
import type {
|
|
5
|
+
FileAttachment,
|
|
6
|
+
IncomingMessage as PonchoIncomingMessage,
|
|
7
|
+
IncomingMessageHandler,
|
|
8
|
+
MessagingAdapter,
|
|
9
|
+
RouteRegistrar,
|
|
10
|
+
ThreadRef,
|
|
11
|
+
} from "../../types.js";
|
|
12
|
+
import {
|
|
13
|
+
buildReplyHeaders,
|
|
14
|
+
buildReplySubject,
|
|
15
|
+
extractDisplayName,
|
|
16
|
+
extractEmailAddress,
|
|
17
|
+
markdownToEmailHtml,
|
|
18
|
+
matchesSenderPattern,
|
|
19
|
+
parseReferences,
|
|
20
|
+
stripQuotedReply,
|
|
21
|
+
} from "../email/utils.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types for the dynamically-imported Resend SDK
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
interface ResendClient {
|
|
28
|
+
emails: {
|
|
29
|
+
send(opts: {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string[];
|
|
32
|
+
subject: string;
|
|
33
|
+
text: string;
|
|
34
|
+
html?: string;
|
|
35
|
+
cc?: string[];
|
|
36
|
+
bcc?: string[];
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
attachments?: Array<{ filename: string; content?: string; path?: string; contentType?: string }>;
|
|
39
|
+
}): Promise<{ data?: { id: string }; error?: unknown }>;
|
|
40
|
+
receiving: {
|
|
41
|
+
get(emailId: string): Promise<{
|
|
42
|
+
data?: {
|
|
43
|
+
html?: string;
|
|
44
|
+
text?: string;
|
|
45
|
+
headers?: Array<{ name: string; value: string }>;
|
|
46
|
+
};
|
|
47
|
+
error?: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Svix webhook verification (works with any Resend SDK version)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function verifySvixSignature(
|
|
58
|
+
rawBody: string,
|
|
59
|
+
svixId: string,
|
|
60
|
+
svixTimestamp: string,
|
|
61
|
+
svixSignature: string,
|
|
62
|
+
secret: string,
|
|
63
|
+
): void {
|
|
64
|
+
const now = Math.floor(Date.now() / 1000);
|
|
65
|
+
const ts = parseInt(svixTimestamp, 10);
|
|
66
|
+
const tolerance = 5 * 60;
|
|
67
|
+
if (isNaN(ts) || Math.abs(now - ts) > tolerance) {
|
|
68
|
+
throw new Error("Timestamp outside tolerance");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const secretBytes = Buffer.from(
|
|
72
|
+
secret.startsWith("whsec_") ? secret.slice(6) : secret,
|
|
73
|
+
"base64",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const toSign = `${svixId}.${svixTimestamp}.${rawBody}`;
|
|
77
|
+
const expected = createHmac("sha256", secretBytes)
|
|
78
|
+
.update(toSign)
|
|
79
|
+
.digest("base64");
|
|
80
|
+
|
|
81
|
+
const candidates = svixSignature.split(" ").map((sig) => {
|
|
82
|
+
const parts = sig.split(",");
|
|
83
|
+
return parts.length === 2 ? parts[1]! : parts[0]!;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!candidates.some((c) => c === expected)) {
|
|
87
|
+
throw new Error("Signature mismatch");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// LRU deduplication set
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
class LruSet {
|
|
96
|
+
private readonly max: number;
|
|
97
|
+
private readonly set = new Set<string>();
|
|
98
|
+
|
|
99
|
+
constructor(max = 1000) {
|
|
100
|
+
this.max = max;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
has(key: string): boolean {
|
|
104
|
+
return this.set.has(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
add(key: string): void {
|
|
108
|
+
if (this.set.size >= this.max) {
|
|
109
|
+
// Evict the oldest entry
|
|
110
|
+
const first = this.set.values().next().value as string;
|
|
111
|
+
this.set.delete(first);
|
|
112
|
+
}
|
|
113
|
+
this.set.add(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// FileAttachment → Resend attachment normalisation
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const DATA_URI_RE = /^data:[^;]+;base64,/;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Convert a `FileAttachment` into a Resend-compatible attachment object.
|
|
125
|
+
*
|
|
126
|
+
* `FileAttachment.data` can be raw base64, a data URI, an HTTPS URL, or a
|
|
127
|
+
* `poncho-upload://` reference. Resend accepts either `content` (base64 /
|
|
128
|
+
* Buffer) or `path` (remote URL). This function maps each format to the
|
|
129
|
+
* correct field, returning `null` for unresolvable references.
|
|
130
|
+
*/
|
|
131
|
+
function toResendAttachment(
|
|
132
|
+
f: FileAttachment,
|
|
133
|
+
): { filename: string; content?: string; path?: string; contentType?: string } | null {
|
|
134
|
+
const filename = f.filename ?? "attachment";
|
|
135
|
+
const data = f.data;
|
|
136
|
+
|
|
137
|
+
if (data.startsWith("poncho-upload://")) {
|
|
138
|
+
console.warn("[resend-adapter] skipping poncho-upload:// attachment (not resolvable from adapter):", filename);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (data.startsWith("https://") || data.startsWith("http://")) {
|
|
143
|
+
return { filename, path: data, contentType: f.mediaType };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (DATA_URI_RE.test(data)) {
|
|
147
|
+
return { filename, content: data.replace(DATA_URI_RE, ""), contentType: f.mediaType };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { filename, content: data, contentType: f.mediaType };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// ResendAdapter
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export interface ResendAdapterOptions {
|
|
158
|
+
apiKeyEnv?: string;
|
|
159
|
+
webhookSecretEnv?: string;
|
|
160
|
+
fromEnv?: string;
|
|
161
|
+
allowedSenders?: string[];
|
|
162
|
+
mode?: "auto-reply" | "tool";
|
|
163
|
+
allowedRecipients?: string[];
|
|
164
|
+
maxSendsPerRun?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const collectBody = (req: http.IncomingMessage): Promise<string> =>
|
|
168
|
+
new Promise((resolve, reject) => {
|
|
169
|
+
const chunks: Buffer[] = [];
|
|
170
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
171
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
172
|
+
req.on("error", reject);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
interface ThreadMeta {
|
|
176
|
+
subject: string;
|
|
177
|
+
senderEmail: string;
|
|
178
|
+
references: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class ResendAdapter implements MessagingAdapter {
|
|
182
|
+
readonly platform = "resend" as const;
|
|
183
|
+
readonly autoReply: boolean;
|
|
184
|
+
|
|
185
|
+
hasSentInCurrentRequest = false;
|
|
186
|
+
|
|
187
|
+
private resend: ResendClient | undefined;
|
|
188
|
+
private apiKey = "";
|
|
189
|
+
private webhookSecret = "";
|
|
190
|
+
private fromAddress = "";
|
|
191
|
+
private readonly apiKeyEnv: string;
|
|
192
|
+
private readonly webhookSecretEnv: string;
|
|
193
|
+
private readonly fromEnv: string;
|
|
194
|
+
private readonly allowedSenders: string[] | undefined;
|
|
195
|
+
private readonly allowedRecipients: string[] | undefined;
|
|
196
|
+
private readonly maxSendsPerRun: number;
|
|
197
|
+
private readonly mode: "auto-reply" | "tool";
|
|
198
|
+
private handler: IncomingMessageHandler | undefined;
|
|
199
|
+
private sendCount = 0;
|
|
200
|
+
|
|
201
|
+
/** Request-scoped thread metadata for sendReply. */
|
|
202
|
+
private readonly threadMeta = new Map<string, ThreadMeta>();
|
|
203
|
+
|
|
204
|
+
/** Deduplication set for svix-id headers. */
|
|
205
|
+
private readonly processed = new LruSet(1000);
|
|
206
|
+
|
|
207
|
+
constructor(options: ResendAdapterOptions = {}) {
|
|
208
|
+
this.apiKeyEnv = options.apiKeyEnv ?? "RESEND_API_KEY";
|
|
209
|
+
this.webhookSecretEnv = options.webhookSecretEnv ?? "RESEND_WEBHOOK_SECRET";
|
|
210
|
+
this.fromEnv = options.fromEnv ?? "RESEND_FROM";
|
|
211
|
+
this.allowedSenders = options.allowedSenders;
|
|
212
|
+
this.mode = options.mode ?? "auto-reply";
|
|
213
|
+
this.autoReply = this.mode !== "tool";
|
|
214
|
+
this.allowedRecipients = options.allowedRecipients;
|
|
215
|
+
this.maxSendsPerRun = options.maxSendsPerRun ?? 10;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
resetRequestState(): void {
|
|
219
|
+
this.hasSentInCurrentRequest = false;
|
|
220
|
+
this.sendCount = 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -----------------------------------------------------------------------
|
|
224
|
+
// MessagingAdapter implementation
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async initialize(): Promise<void> {
|
|
228
|
+
this.apiKey = process.env[this.apiKeyEnv] ?? "";
|
|
229
|
+
this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
|
|
230
|
+
this.fromAddress = process.env[this.fromEnv] ?? "";
|
|
231
|
+
|
|
232
|
+
if (!this.apiKey) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Resend messaging: ${this.apiKeyEnv} environment variable is not set`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!this.webhookSecret) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Resend messaging: ${this.webhookSecretEnv} environment variable is not set`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (!this.fromAddress) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Resend messaging: ${this.fromEnv} environment variable is not set`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const mod = await import("resend");
|
|
250
|
+
const ResendClass = mod.Resend;
|
|
251
|
+
this.resend = new ResendClass(this.apiKey) as unknown as ResendClient;
|
|
252
|
+
} catch {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"ResendAdapter requires the 'resend' package. Install it: npm install resend",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
onMessage(handler: IncomingMessageHandler): void {
|
|
260
|
+
this.handler = handler;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
registerRoutes(router: RouteRegistrar): void {
|
|
264
|
+
router("POST", "/api/messaging/resend", (req, res) =>
|
|
265
|
+
this.handleRequest(req, res),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async sendReply(
|
|
270
|
+
threadRef: ThreadRef,
|
|
271
|
+
content: string,
|
|
272
|
+
options?: { files?: FileAttachment[] },
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
if (!this.resend) throw new Error("ResendAdapter not initialised");
|
|
275
|
+
|
|
276
|
+
const meta = this.threadMeta.get(threadRef.platformThreadId);
|
|
277
|
+
this.threadMeta.delete(threadRef.platformThreadId);
|
|
278
|
+
const subject = meta
|
|
279
|
+
? buildReplySubject(meta.subject)
|
|
280
|
+
: "Re: (no subject)";
|
|
281
|
+
const headers = meta
|
|
282
|
+
? buildReplyHeaders(threadRef.messageId ?? threadRef.platformThreadId, meta.references)
|
|
283
|
+
: {};
|
|
284
|
+
|
|
285
|
+
const attachments = options?.files
|
|
286
|
+
?.map((f) => toResendAttachment(f))
|
|
287
|
+
.filter((a): a is NonNullable<typeof a> => a !== null);
|
|
288
|
+
|
|
289
|
+
console.log("[resend-adapter] sendReply →", {
|
|
290
|
+
from: this.fromAddress,
|
|
291
|
+
to: threadRef.channelId,
|
|
292
|
+
subject,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const result = await this.resend.emails.send({
|
|
296
|
+
from: this.fromAddress,
|
|
297
|
+
to: [threadRef.channelId],
|
|
298
|
+
subject,
|
|
299
|
+
text: content,
|
|
300
|
+
html: markdownToEmailHtml(content),
|
|
301
|
+
headers,
|
|
302
|
+
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (result.error) {
|
|
306
|
+
console.error("[resend-adapter] send failed:", JSON.stringify(result.error));
|
|
307
|
+
throw new Error(`Resend send failed: ${JSON.stringify(result.error)}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log("[resend-adapter] email sent:", result.data);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async indicateProcessing(
|
|
314
|
+
_threadRef: ThreadRef,
|
|
315
|
+
): Promise<() => Promise<void>> {
|
|
316
|
+
return async () => {};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
getToolDefinitions(): ToolDefinition[] {
|
|
320
|
+
if (this.mode !== "tool") return [];
|
|
321
|
+
|
|
322
|
+
const adapter = this;
|
|
323
|
+
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
name: "send_email",
|
|
327
|
+
description:
|
|
328
|
+
"Send an email via Resend. The body is written in markdown and will be converted to HTML. " +
|
|
329
|
+
"To thread as a reply, provide in_reply_to with the original message ID. Omit it for a new standalone email.",
|
|
330
|
+
inputSchema: {
|
|
331
|
+
type: "object",
|
|
332
|
+
properties: {
|
|
333
|
+
to: {
|
|
334
|
+
type: "array",
|
|
335
|
+
items: { type: "string" },
|
|
336
|
+
description: "Recipient email addresses",
|
|
337
|
+
},
|
|
338
|
+
subject: {
|
|
339
|
+
type: "string",
|
|
340
|
+
description: "Email subject line",
|
|
341
|
+
},
|
|
342
|
+
body: {
|
|
343
|
+
type: "string",
|
|
344
|
+
description: "Email body in markdown (converted to HTML)",
|
|
345
|
+
},
|
|
346
|
+
cc: {
|
|
347
|
+
type: "array",
|
|
348
|
+
items: { type: "string" },
|
|
349
|
+
description: "CC recipient email addresses",
|
|
350
|
+
},
|
|
351
|
+
bcc: {
|
|
352
|
+
type: "array",
|
|
353
|
+
items: { type: "string" },
|
|
354
|
+
description: "BCC recipient email addresses",
|
|
355
|
+
},
|
|
356
|
+
in_reply_to: {
|
|
357
|
+
type: "string",
|
|
358
|
+
description: "Message-ID to thread this email under (for replies). Omit for a new email.",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
required: ["to", "subject", "body"],
|
|
362
|
+
},
|
|
363
|
+
handler: async (input: Record<string, unknown>) => {
|
|
364
|
+
return adapter.handleSendEmailTool(input);
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async handleSendEmailTool(
|
|
371
|
+
input: Record<string, unknown>,
|
|
372
|
+
): Promise<{ success: boolean; id?: string; error?: string }> {
|
|
373
|
+
if (!this.resend) {
|
|
374
|
+
return { success: false, error: "ResendAdapter not initialised" };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (this.sendCount >= this.maxSendsPerRun) {
|
|
378
|
+
return {
|
|
379
|
+
success: false,
|
|
380
|
+
error: `Send limit reached (${this.maxSendsPerRun} per run). Cannot send more emails in this run.`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const to = input.to as string[];
|
|
385
|
+
const subject = input.subject as string;
|
|
386
|
+
const body = input.body as string;
|
|
387
|
+
const cc = input.cc as string[] | undefined;
|
|
388
|
+
const bcc = input.bcc as string[] | undefined;
|
|
389
|
+
const inReplyTo = input.in_reply_to as string | undefined;
|
|
390
|
+
|
|
391
|
+
const allRecipients = [...to, ...(cc ?? []), ...(bcc ?? [])];
|
|
392
|
+
if (this.allowedRecipients && this.allowedRecipients.length > 0) {
|
|
393
|
+
for (const addr of allRecipients) {
|
|
394
|
+
if (!matchesSenderPattern(addr, this.allowedRecipients)) {
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: `Recipient "${addr}" is not in the allowed recipients list. Allowed patterns: ${this.allowedRecipients.join(", ")}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const headers: Record<string, string> = {};
|
|
404
|
+
if (inReplyTo) {
|
|
405
|
+
headers["In-Reply-To"] = inReplyTo;
|
|
406
|
+
headers["References"] = inReplyTo;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log("[resend-adapter] send_email tool →", {
|
|
410
|
+
from: this.fromAddress,
|
|
411
|
+
to,
|
|
412
|
+
subject,
|
|
413
|
+
cc: cc ?? undefined,
|
|
414
|
+
bcc: bcc ?? undefined,
|
|
415
|
+
inReplyTo: inReplyTo ?? undefined,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const result = await this.resend.emails.send({
|
|
419
|
+
from: this.fromAddress,
|
|
420
|
+
to,
|
|
421
|
+
subject,
|
|
422
|
+
text: body,
|
|
423
|
+
html: markdownToEmailHtml(body),
|
|
424
|
+
cc: cc && cc.length > 0 ? cc : undefined,
|
|
425
|
+
bcc: bcc && bcc.length > 0 ? bcc : undefined,
|
|
426
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (result.error) {
|
|
430
|
+
console.error("[resend-adapter] send_email tool failed:", JSON.stringify(result.error));
|
|
431
|
+
return { success: false, error: JSON.stringify(result.error) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.sendCount++;
|
|
435
|
+
this.hasSentInCurrentRequest = true;
|
|
436
|
+
|
|
437
|
+
console.log("[resend-adapter] send_email tool sent:", result.data);
|
|
438
|
+
return { success: true, id: result.data?.id };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// -----------------------------------------------------------------------
|
|
442
|
+
// HTTP request handling
|
|
443
|
+
// -----------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
private async handleRequest(
|
|
446
|
+
req: http.IncomingMessage,
|
|
447
|
+
res: http.ServerResponse,
|
|
448
|
+
): Promise<void> {
|
|
449
|
+
if (!this.resend) {
|
|
450
|
+
res.writeHead(500);
|
|
451
|
+
res.end("Adapter not initialised");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const rawBody = await collectBody(req);
|
|
456
|
+
|
|
457
|
+
// -- Svix signature verification --------------------------------------
|
|
458
|
+
const svixId = req.headers["svix-id"] as string | undefined;
|
|
459
|
+
const svixTimestamp = req.headers["svix-timestamp"] as string | undefined;
|
|
460
|
+
const svixSignature = req.headers["svix-signature"] as string | undefined;
|
|
461
|
+
|
|
462
|
+
if (!svixId || !svixTimestamp || !svixSignature) {
|
|
463
|
+
console.warn("[resend-adapter] 401: missing svix headers", {
|
|
464
|
+
hasSvixId: !!svixId,
|
|
465
|
+
hasSvixTimestamp: !!svixTimestamp,
|
|
466
|
+
hasSvixSignature: !!svixSignature,
|
|
467
|
+
});
|
|
468
|
+
res.writeHead(401);
|
|
469
|
+
res.end("Missing signature headers");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, this.webhookSecret);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.warn("[resend-adapter] 401: signature verification failed", err instanceof Error ? err.message : err);
|
|
477
|
+
res.writeHead(401);
|
|
478
|
+
res.end("Invalid signature");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// -- Deduplication via svix-id ----------------------------------------
|
|
483
|
+
if (this.processed.has(svixId)) {
|
|
484
|
+
res.writeHead(200);
|
|
485
|
+
res.end();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.processed.add(svixId);
|
|
489
|
+
|
|
490
|
+
// -- Parse payload ----------------------------------------------------
|
|
491
|
+
let payload: { type?: string; data?: Record<string, unknown> };
|
|
492
|
+
try {
|
|
493
|
+
payload = JSON.parse(rawBody) as typeof payload;
|
|
494
|
+
} catch {
|
|
495
|
+
res.writeHead(400);
|
|
496
|
+
res.end("Invalid JSON");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (payload.type !== "email.received") {
|
|
501
|
+
res.writeHead(200);
|
|
502
|
+
res.end();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Acknowledge immediately
|
|
507
|
+
res.writeHead(200);
|
|
508
|
+
res.end();
|
|
509
|
+
|
|
510
|
+
const data = payload.data;
|
|
511
|
+
if (!data || !this.handler) return;
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await this.processInboundEmail(data, payload);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error("[resend-adapter] error processing inbound email", err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private async processInboundEmail(
|
|
521
|
+
data: Record<string, unknown>,
|
|
522
|
+
payload: unknown,
|
|
523
|
+
): Promise<void> {
|
|
524
|
+
if (!this.handler) return;
|
|
525
|
+
|
|
526
|
+
const fromRaw = String(data.from ?? "");
|
|
527
|
+
const senderEmail = extractEmailAddress(fromRaw);
|
|
528
|
+
const senderName = extractDisplayName(fromRaw);
|
|
529
|
+
|
|
530
|
+
// -- Sender allowlist -------------------------------------------------
|
|
531
|
+
if (!matchesSenderPattern(senderEmail, this.allowedSenders)) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const emailId = String(data.email_id ?? "");
|
|
536
|
+
const messageId = String(data.message_id ?? "");
|
|
537
|
+
const subject = String(data.subject ?? "");
|
|
538
|
+
|
|
539
|
+
// -- Fetch email body + headers via REST API ---------------------------
|
|
540
|
+
let text = "";
|
|
541
|
+
let emailHeaders: Array<{ name: string; value: string }> | Record<string, string> | undefined;
|
|
542
|
+
|
|
543
|
+
if (emailId) {
|
|
544
|
+
try {
|
|
545
|
+
const resp = await fetch(
|
|
546
|
+
`https://api.resend.com/emails/receiving/${emailId}`,
|
|
547
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } },
|
|
548
|
+
);
|
|
549
|
+
if (resp.ok) {
|
|
550
|
+
const emailData = (await resp.json()) as {
|
|
551
|
+
text?: string;
|
|
552
|
+
html?: string;
|
|
553
|
+
headers?: Array<{ name: string; value: string }> | Record<string, string>;
|
|
554
|
+
};
|
|
555
|
+
text = emailData.text ?? "";
|
|
556
|
+
emailHeaders = emailData.headers;
|
|
557
|
+
} else {
|
|
558
|
+
const body = await resp.text().catch(() => "");
|
|
559
|
+
console.error(
|
|
560
|
+
`[resend-adapter] failed to fetch email body: ${resp.status} ${resp.statusText}`,
|
|
561
|
+
`\n URL: https://api.resend.com/emails/receiving/${emailId}`,
|
|
562
|
+
`\n Key: ${this.apiKey.slice(0, 6)}...${this.apiKey.slice(-4)}`,
|
|
563
|
+
body ? `\n Response: ${body.slice(0, 200)}` : "",
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error("[resend-adapter] failed to fetch email body", err);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Strip quoted replies to avoid feeding duplicate context to the agent
|
|
572
|
+
const cleanText = stripQuotedReply(text).trim();
|
|
573
|
+
if (!cleanText) return;
|
|
574
|
+
|
|
575
|
+
// -- Reply metadata (consumed by sendReply within the same invocation) --
|
|
576
|
+
const references = parseReferences(emailHeaders);
|
|
577
|
+
this.threadMeta.set(messageId, {
|
|
578
|
+
subject,
|
|
579
|
+
senderEmail,
|
|
580
|
+
references: [...references, messageId].filter(Boolean),
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// -- Download attachments ---------------------------------------------
|
|
584
|
+
const webhookAttachments = data.attachments as Array<{ id?: string; filename?: string; content_type?: string }> | undefined;
|
|
585
|
+
const files = await this.fetchAndDownloadAttachments(emailId, webhookAttachments);
|
|
586
|
+
|
|
587
|
+
// -- Build and dispatch message ---------------------------------------
|
|
588
|
+
// Each incoming email creates its own conversation (no threading).
|
|
589
|
+
const message: PonchoIncomingMessage = {
|
|
590
|
+
text: cleanText,
|
|
591
|
+
subject: subject || undefined,
|
|
592
|
+
files: files.length > 0 ? files : undefined,
|
|
593
|
+
threadRef: {
|
|
594
|
+
channelId: senderEmail,
|
|
595
|
+
platformThreadId: messageId,
|
|
596
|
+
messageId,
|
|
597
|
+
},
|
|
598
|
+
sender: { id: senderEmail, name: senderName },
|
|
599
|
+
platform: "resend",
|
|
600
|
+
raw: payload,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
await this.handler(message);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// -----------------------------------------------------------------------
|
|
607
|
+
// Attachment helpers
|
|
608
|
+
// -----------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
private async fetchAndDownloadAttachments(
|
|
611
|
+
emailId: string,
|
|
612
|
+
webhookAttachments: Array<{ id?: string; filename?: string; content_type?: string }> | undefined,
|
|
613
|
+
): Promise<FileAttachment[]> {
|
|
614
|
+
if (!emailId || !webhookAttachments || webhookAttachments.length === 0) return [];
|
|
615
|
+
|
|
616
|
+
// Fetch attachment metadata (with download_url) from the Resend API
|
|
617
|
+
let attachments: Array<{ filename?: string; content_type?: string; download_url?: string }> = [];
|
|
618
|
+
try {
|
|
619
|
+
const resp = await fetch(
|
|
620
|
+
`https://api.resend.com/emails/receiving/${emailId}/attachments`,
|
|
621
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } },
|
|
622
|
+
);
|
|
623
|
+
if (resp.ok) {
|
|
624
|
+
const body = await resp.json();
|
|
625
|
+
attachments = (Array.isArray(body) ? body : (body as { data?: unknown[] }).data ?? []) as typeof attachments;
|
|
626
|
+
} else {
|
|
627
|
+
console.error("[resend-adapter] failed to list attachments:", resp.status, resp.statusText);
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
} catch (err) {
|
|
631
|
+
console.error("[resend-adapter] failed to list attachments", err);
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const results: FileAttachment[] = [];
|
|
636
|
+
for (const att of attachments) {
|
|
637
|
+
if (!att.download_url) continue;
|
|
638
|
+
try {
|
|
639
|
+
const resp = await fetch(att.download_url);
|
|
640
|
+
if (!resp.ok) continue;
|
|
641
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
642
|
+
results.push({
|
|
643
|
+
data: buf.toString("base64"),
|
|
644
|
+
mediaType: att.content_type ?? "application/octet-stream",
|
|
645
|
+
filename: att.filename,
|
|
646
|
+
});
|
|
647
|
+
} catch {
|
|
648
|
+
// Best-effort: skip attachments that fail to download
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return results;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
@@ -35,6 +35,8 @@ const collectBody = (req: http.IncomingMessage): Promise<string> =>
|
|
|
35
35
|
|
|
36
36
|
export class SlackAdapter implements MessagingAdapter {
|
|
37
37
|
readonly platform = "slack" as const;
|
|
38
|
+
readonly autoReply = true;
|
|
39
|
+
readonly hasSentInCurrentRequest = false;
|
|
38
40
|
|
|
39
41
|
private botToken = "";
|
|
40
42
|
private signingSecret = "";
|
|
@@ -78,7 +80,11 @@ export class SlackAdapter implements MessagingAdapter {
|
|
|
78
80
|
);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
async sendReply(
|
|
83
|
+
async sendReply(
|
|
84
|
+
threadRef: ThreadRef,
|
|
85
|
+
content: string,
|
|
86
|
+
_options?: { files?: Array<{ data: string; mediaType: string; filename?: string }> },
|
|
87
|
+
): Promise<void> {
|
|
82
88
|
const chunks = splitMessage(content);
|
|
83
89
|
for (const chunk of chunks) {
|
|
84
90
|
await postMessage(
|