@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
package/dist/index.js
CHANGED
|
@@ -4,11 +4,13 @@ var AgentBridge = class {
|
|
|
4
4
|
adapter;
|
|
5
5
|
runner;
|
|
6
6
|
waitUntil;
|
|
7
|
+
ownerIdOverride;
|
|
7
8
|
constructor(options) {
|
|
8
9
|
this.adapter = options.adapter;
|
|
9
10
|
this.runner = options.runner;
|
|
10
11
|
this.waitUntil = options.waitUntil ?? ((_p) => {
|
|
11
12
|
});
|
|
13
|
+
this.ownerIdOverride = options.ownerId;
|
|
12
14
|
}
|
|
13
15
|
/** Wire the adapter's message handler and initialise. */
|
|
14
16
|
async start() {
|
|
@@ -21,33 +23,59 @@ var AgentBridge = class {
|
|
|
21
23
|
}
|
|
22
24
|
async handleMessage(message) {
|
|
23
25
|
let cleanup;
|
|
26
|
+
this.adapter.resetRequestState?.();
|
|
24
27
|
try {
|
|
25
28
|
cleanup = await this.adapter.indicateProcessing(message.threadRef);
|
|
26
29
|
const conversationId = conversationIdFromThread(
|
|
27
30
|
message.platform,
|
|
28
31
|
message.threadRef
|
|
29
32
|
);
|
|
33
|
+
const titleParts = [message.sender.id];
|
|
34
|
+
if (message.subject) titleParts.push(message.subject);
|
|
35
|
+
const title = titleParts.join(" \u2014 ") || `${message.platform} thread`;
|
|
30
36
|
const conversation = await this.runner.getOrCreateConversation(
|
|
31
37
|
conversationId,
|
|
32
38
|
{
|
|
33
39
|
platform: message.platform,
|
|
34
|
-
ownerId: message.sender.id,
|
|
35
|
-
title
|
|
40
|
+
ownerId: this.ownerIdOverride ?? message.sender.id,
|
|
41
|
+
title
|
|
36
42
|
}
|
|
37
43
|
);
|
|
44
|
+
const senderLine = message.sender.name ? `From: ${message.sender.name} <${message.sender.id}>` : `From: ${message.sender.id}`;
|
|
45
|
+
const subjectLine = message.subject ? `Subject: ${message.subject}` : "";
|
|
46
|
+
const header = [senderLine, subjectLine].filter(Boolean).join("\n");
|
|
47
|
+
const task = `${header}
|
|
48
|
+
|
|
49
|
+
${message.text}`;
|
|
38
50
|
const result = await this.runner.run(conversationId, {
|
|
39
|
-
task
|
|
40
|
-
messages: conversation.messages
|
|
51
|
+
task,
|
|
52
|
+
messages: conversation.messages,
|
|
53
|
+
files: message.files,
|
|
54
|
+
metadata: {
|
|
55
|
+
platform: message.platform,
|
|
56
|
+
sender: message.sender,
|
|
57
|
+
threadId: message.threadRef.platformThreadId
|
|
58
|
+
}
|
|
41
59
|
});
|
|
42
|
-
|
|
60
|
+
if (this.adapter.autoReply) {
|
|
61
|
+
await this.adapter.sendReply(message.threadRef, result.response, {
|
|
62
|
+
files: result.files
|
|
63
|
+
});
|
|
64
|
+
} else if (!this.adapter.hasSentInCurrentRequest) {
|
|
65
|
+
console.warn("[agent-bridge] tool mode completed without send_email being called; no reply sent");
|
|
66
|
+
}
|
|
43
67
|
} catch (error) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
console.error("[agent-bridge] handleMessage error:", error instanceof Error ? error.message : error);
|
|
69
|
+
if (!this.adapter.hasSentInCurrentRequest) {
|
|
70
|
+
const snippet = error instanceof Error ? error.message : "Unknown error";
|
|
71
|
+
try {
|
|
72
|
+
await this.adapter.sendReply(
|
|
73
|
+
message.threadRef,
|
|
74
|
+
`Sorry, something went wrong: ${snippet}`
|
|
75
|
+
);
|
|
76
|
+
} catch (replyError) {
|
|
77
|
+
console.error("[agent-bridge] failed to send error reply:", replyError instanceof Error ? replyError.message : replyError);
|
|
78
|
+
}
|
|
51
79
|
}
|
|
52
80
|
} finally {
|
|
53
81
|
if (cleanup) {
|
|
@@ -154,6 +182,8 @@ var collectBody = (req) => new Promise((resolve, reject) => {
|
|
|
154
182
|
});
|
|
155
183
|
var SlackAdapter = class {
|
|
156
184
|
platform = "slack";
|
|
185
|
+
autoReply = true;
|
|
186
|
+
hasSentInCurrentRequest = false;
|
|
157
187
|
botToken = "";
|
|
158
188
|
signingSecret = "";
|
|
159
189
|
botTokenEnv;
|
|
@@ -190,7 +220,7 @@ var SlackAdapter = class {
|
|
|
190
220
|
(req, res) => this.handleRequest(req, res)
|
|
191
221
|
);
|
|
192
222
|
}
|
|
193
|
-
async sendReply(threadRef, content) {
|
|
223
|
+
async sendReply(threadRef, content, _options) {
|
|
194
224
|
const chunks = splitMessage(content);
|
|
195
225
|
for (const chunk of chunks) {
|
|
196
226
|
await postMessage(
|
|
@@ -284,7 +314,590 @@ var SlackAdapter = class {
|
|
|
284
314
|
res.end();
|
|
285
315
|
}
|
|
286
316
|
};
|
|
317
|
+
|
|
318
|
+
// src/adapters/resend/index.ts
|
|
319
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
320
|
+
|
|
321
|
+
// src/adapters/email/utils.ts
|
|
322
|
+
import { createHash } from "crypto";
|
|
323
|
+
var ADDR_RE = /<([^>]+)>/;
|
|
324
|
+
function extractEmailAddress(formatted) {
|
|
325
|
+
const match = ADDR_RE.exec(formatted);
|
|
326
|
+
return (match ? match[1] : formatted).trim().toLowerCase();
|
|
327
|
+
}
|
|
328
|
+
function extractDisplayName(formatted) {
|
|
329
|
+
const idx = formatted.indexOf("<");
|
|
330
|
+
if (idx <= 0) return void 0;
|
|
331
|
+
const name = formatted.slice(0, idx).trim().replace(/^["']|["']$/g, "");
|
|
332
|
+
return name || void 0;
|
|
333
|
+
}
|
|
334
|
+
var MSG_ID_RE = /<[^>]+>/g;
|
|
335
|
+
function parseReferences(headers) {
|
|
336
|
+
if (!headers) return [];
|
|
337
|
+
let refValue;
|
|
338
|
+
if (Array.isArray(headers)) {
|
|
339
|
+
const entry = headers.find((h) => h.name.toLowerCase() === "references");
|
|
340
|
+
refValue = entry?.value;
|
|
341
|
+
} else if (typeof headers === "object") {
|
|
342
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === "references");
|
|
343
|
+
refValue = key ? headers[key] : void 0;
|
|
344
|
+
}
|
|
345
|
+
if (!refValue) return [];
|
|
346
|
+
const matches = refValue.match(MSG_ID_RE);
|
|
347
|
+
return matches ?? [];
|
|
348
|
+
}
|
|
349
|
+
function deriveRootMessageId(references, currentMessageId, fallback) {
|
|
350
|
+
if (references.length > 0) return references[0];
|
|
351
|
+
if (fallback) {
|
|
352
|
+
const normalised = normaliseSubject(fallback.subject) + "\0" + fallback.sender.toLowerCase();
|
|
353
|
+
const hash = createHash("sha256").update(normalised).digest("hex").slice(0, 16);
|
|
354
|
+
return `<fallback:${hash}>`;
|
|
355
|
+
}
|
|
356
|
+
return currentMessageId;
|
|
357
|
+
}
|
|
358
|
+
function normaliseSubject(subject) {
|
|
359
|
+
return subject.replace(/^(?:re|fwd?|aw|sv|vs)\s*:\s*/gi, "").trim();
|
|
360
|
+
}
|
|
361
|
+
function buildReplySubject(subject) {
|
|
362
|
+
if (/^re\s*:/i.test(subject)) return subject;
|
|
363
|
+
return `Re: ${subject}`;
|
|
364
|
+
}
|
|
365
|
+
function buildReplyHeaders(inReplyTo, existingReferences) {
|
|
366
|
+
const refs = [...existingReferences];
|
|
367
|
+
if (!refs.includes(inReplyTo)) refs.push(inReplyTo);
|
|
368
|
+
return {
|
|
369
|
+
"In-Reply-To": inReplyTo,
|
|
370
|
+
"References": refs.join(" ")
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function stripQuotedReply(text) {
|
|
374
|
+
const lines = text.split("\n");
|
|
375
|
+
let cutIndex = lines.length;
|
|
376
|
+
for (let i = 0; i < lines.length; i++) {
|
|
377
|
+
const line = lines[i].trim();
|
|
378
|
+
if (/^on\s+.+wrote:\s*$/i.test(line)) {
|
|
379
|
+
cutIndex = i;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
if (/^-{2,}\s*original message\s*-{2,}$/i.test(line)) {
|
|
383
|
+
cutIndex = i;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
if (/^from:\s/i.test(line) && i > 0 && lines[i - 1].trim() === "") {
|
|
387
|
+
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
388
|
+
if (/^(sent|to|cc|subject|date):\s/i.test(nextLine)) {
|
|
389
|
+
cutIndex = i;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (line.startsWith(">")) {
|
|
394
|
+
if (i === 0 || lines[i - 1].trim() === "" || /wrote:\s*$/i.test(lines[i - 1])) {
|
|
395
|
+
cutIndex = i;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return lines.slice(0, cutIndex).join("\n").trimEnd();
|
|
401
|
+
}
|
|
402
|
+
function markdownToEmailHtml(text) {
|
|
403
|
+
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
404
|
+
let html = escaped;
|
|
405
|
+
html = html.replace(
|
|
406
|
+
/```(?:\w*)\n([\s\S]*?)```/g,
|
|
407
|
+
(_m, code) => `<pre style="background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto;font-family:monospace;font-size:13px;">${code.trimEnd()}</pre>`
|
|
408
|
+
);
|
|
409
|
+
html = html.replace(
|
|
410
|
+
/`([^`]+)`/g,
|
|
411
|
+
'<code style="background:#f5f5f5;padding:2px 4px;border-radius:3px;font-family:monospace;font-size:13px;">$1</code>'
|
|
412
|
+
);
|
|
413
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
414
|
+
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
|
415
|
+
html = html.replace(/^### (.+)$/gm, '<h3 style="margin:16px 0 8px;">$1</h3>');
|
|
416
|
+
html = html.replace(/^## (.+)$/gm, '<h2 style="margin:16px 0 8px;">$1</h2>');
|
|
417
|
+
html = html.replace(/^# (.+)$/gm, '<h1 style="margin:16px 0 8px;">$1</h1>');
|
|
418
|
+
html = html.replace(
|
|
419
|
+
/(?:^[*-] .+(?:\n|$))+/gm,
|
|
420
|
+
(block) => {
|
|
421
|
+
const items = block.trim().split("\n").map((l) => `<li>${l.replace(/^[*-] /, "")}</li>`);
|
|
422
|
+
return `<ul style="margin:8px 0;padding-left:24px;">${items.join("")}</ul>`;
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
html = html.replace(
|
|
426
|
+
/(?:^\d+\. .+(?:\n|$))+/gm,
|
|
427
|
+
(block) => {
|
|
428
|
+
const items = block.trim().split("\n").map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`);
|
|
429
|
+
return `<ol style="margin:8px 0;padding-left:24px;">${items.join("")}</ol>`;
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
html = html.split(/\n{2,}/).map((p) => {
|
|
433
|
+
const trimmed = p.trim();
|
|
434
|
+
if (!trimmed) return "";
|
|
435
|
+
if (/^<(?:h[1-6]|ul|ol|pre|blockquote)/i.test(trimmed)) return trimmed;
|
|
436
|
+
return `<p style="margin:8px 0;">${trimmed.replace(/\n/g, "<br>")}</p>`;
|
|
437
|
+
}).join("\n");
|
|
438
|
+
return `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.6;color:#1a1a1a;">${html}</div>`;
|
|
439
|
+
}
|
|
440
|
+
function matchesSenderPattern(sender, patterns) {
|
|
441
|
+
if (!patterns || patterns.length === 0) return true;
|
|
442
|
+
const addr = sender.toLowerCase();
|
|
443
|
+
return patterns.some((pattern) => {
|
|
444
|
+
const p = pattern.toLowerCase();
|
|
445
|
+
if (p.startsWith("*@")) {
|
|
446
|
+
return addr.endsWith(p.slice(1));
|
|
447
|
+
}
|
|
448
|
+
return addr === p;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/adapters/resend/index.ts
|
|
453
|
+
function verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, secret) {
|
|
454
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
455
|
+
const ts = parseInt(svixTimestamp, 10);
|
|
456
|
+
const tolerance = 5 * 60;
|
|
457
|
+
if (isNaN(ts) || Math.abs(now - ts) > tolerance) {
|
|
458
|
+
throw new Error("Timestamp outside tolerance");
|
|
459
|
+
}
|
|
460
|
+
const secretBytes = Buffer.from(
|
|
461
|
+
secret.startsWith("whsec_") ? secret.slice(6) : secret,
|
|
462
|
+
"base64"
|
|
463
|
+
);
|
|
464
|
+
const toSign = `${svixId}.${svixTimestamp}.${rawBody}`;
|
|
465
|
+
const expected = createHmac2("sha256", secretBytes).update(toSign).digest("base64");
|
|
466
|
+
const candidates = svixSignature.split(" ").map((sig) => {
|
|
467
|
+
const parts = sig.split(",");
|
|
468
|
+
return parts.length === 2 ? parts[1] : parts[0];
|
|
469
|
+
});
|
|
470
|
+
if (!candidates.some((c) => c === expected)) {
|
|
471
|
+
throw new Error("Signature mismatch");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
var LruSet = class {
|
|
475
|
+
max;
|
|
476
|
+
set = /* @__PURE__ */ new Set();
|
|
477
|
+
constructor(max = 1e3) {
|
|
478
|
+
this.max = max;
|
|
479
|
+
}
|
|
480
|
+
has(key) {
|
|
481
|
+
return this.set.has(key);
|
|
482
|
+
}
|
|
483
|
+
add(key) {
|
|
484
|
+
if (this.set.size >= this.max) {
|
|
485
|
+
const first = this.set.values().next().value;
|
|
486
|
+
this.set.delete(first);
|
|
487
|
+
}
|
|
488
|
+
this.set.add(key);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
var DATA_URI_RE = /^data:[^;]+;base64,/;
|
|
492
|
+
function toResendAttachment(f) {
|
|
493
|
+
const filename = f.filename ?? "attachment";
|
|
494
|
+
const data = f.data;
|
|
495
|
+
if (data.startsWith("poncho-upload://")) {
|
|
496
|
+
console.warn("[resend-adapter] skipping poncho-upload:// attachment (not resolvable from adapter):", filename);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
if (data.startsWith("https://") || data.startsWith("http://")) {
|
|
500
|
+
return { filename, path: data, contentType: f.mediaType };
|
|
501
|
+
}
|
|
502
|
+
if (DATA_URI_RE.test(data)) {
|
|
503
|
+
return { filename, content: data.replace(DATA_URI_RE, ""), contentType: f.mediaType };
|
|
504
|
+
}
|
|
505
|
+
return { filename, content: data, contentType: f.mediaType };
|
|
506
|
+
}
|
|
507
|
+
var collectBody2 = (req) => new Promise((resolve, reject) => {
|
|
508
|
+
const chunks = [];
|
|
509
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
510
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
511
|
+
req.on("error", reject);
|
|
512
|
+
});
|
|
513
|
+
var ResendAdapter = class {
|
|
514
|
+
platform = "resend";
|
|
515
|
+
autoReply;
|
|
516
|
+
hasSentInCurrentRequest = false;
|
|
517
|
+
resend;
|
|
518
|
+
apiKey = "";
|
|
519
|
+
webhookSecret = "";
|
|
520
|
+
fromAddress = "";
|
|
521
|
+
apiKeyEnv;
|
|
522
|
+
webhookSecretEnv;
|
|
523
|
+
fromEnv;
|
|
524
|
+
allowedSenders;
|
|
525
|
+
allowedRecipients;
|
|
526
|
+
maxSendsPerRun;
|
|
527
|
+
mode;
|
|
528
|
+
handler;
|
|
529
|
+
sendCount = 0;
|
|
530
|
+
/** Request-scoped thread metadata for sendReply. */
|
|
531
|
+
threadMeta = /* @__PURE__ */ new Map();
|
|
532
|
+
/** Deduplication set for svix-id headers. */
|
|
533
|
+
processed = new LruSet(1e3);
|
|
534
|
+
constructor(options = {}) {
|
|
535
|
+
this.apiKeyEnv = options.apiKeyEnv ?? "RESEND_API_KEY";
|
|
536
|
+
this.webhookSecretEnv = options.webhookSecretEnv ?? "RESEND_WEBHOOK_SECRET";
|
|
537
|
+
this.fromEnv = options.fromEnv ?? "RESEND_FROM";
|
|
538
|
+
this.allowedSenders = options.allowedSenders;
|
|
539
|
+
this.mode = options.mode ?? "auto-reply";
|
|
540
|
+
this.autoReply = this.mode !== "tool";
|
|
541
|
+
this.allowedRecipients = options.allowedRecipients;
|
|
542
|
+
this.maxSendsPerRun = options.maxSendsPerRun ?? 10;
|
|
543
|
+
}
|
|
544
|
+
resetRequestState() {
|
|
545
|
+
this.hasSentInCurrentRequest = false;
|
|
546
|
+
this.sendCount = 0;
|
|
547
|
+
}
|
|
548
|
+
// -----------------------------------------------------------------------
|
|
549
|
+
// MessagingAdapter implementation
|
|
550
|
+
// -----------------------------------------------------------------------
|
|
551
|
+
async initialize() {
|
|
552
|
+
this.apiKey = process.env[this.apiKeyEnv] ?? "";
|
|
553
|
+
this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
|
|
554
|
+
this.fromAddress = process.env[this.fromEnv] ?? "";
|
|
555
|
+
if (!this.apiKey) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Resend messaging: ${this.apiKeyEnv} environment variable is not set`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (!this.webhookSecret) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Resend messaging: ${this.webhookSecretEnv} environment variable is not set`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (!this.fromAddress) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`Resend messaging: ${this.fromEnv} environment variable is not set`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const mod = await import("resend");
|
|
572
|
+
const ResendClass = mod.Resend;
|
|
573
|
+
this.resend = new ResendClass(this.apiKey);
|
|
574
|
+
} catch {
|
|
575
|
+
throw new Error(
|
|
576
|
+
"ResendAdapter requires the 'resend' package. Install it: npm install resend"
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
onMessage(handler) {
|
|
581
|
+
this.handler = handler;
|
|
582
|
+
}
|
|
583
|
+
registerRoutes(router) {
|
|
584
|
+
router(
|
|
585
|
+
"POST",
|
|
586
|
+
"/api/messaging/resend",
|
|
587
|
+
(req, res) => this.handleRequest(req, res)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
async sendReply(threadRef, content, options) {
|
|
591
|
+
if (!this.resend) throw new Error("ResendAdapter not initialised");
|
|
592
|
+
const meta = this.threadMeta.get(threadRef.platformThreadId);
|
|
593
|
+
this.threadMeta.delete(threadRef.platformThreadId);
|
|
594
|
+
const subject = meta ? buildReplySubject(meta.subject) : "Re: (no subject)";
|
|
595
|
+
const headers = meta ? buildReplyHeaders(threadRef.messageId ?? threadRef.platformThreadId, meta.references) : {};
|
|
596
|
+
const attachments = options?.files?.map((f) => toResendAttachment(f)).filter((a) => a !== null);
|
|
597
|
+
console.log("[resend-adapter] sendReply \u2192", {
|
|
598
|
+
from: this.fromAddress,
|
|
599
|
+
to: threadRef.channelId,
|
|
600
|
+
subject
|
|
601
|
+
});
|
|
602
|
+
const result = await this.resend.emails.send({
|
|
603
|
+
from: this.fromAddress,
|
|
604
|
+
to: [threadRef.channelId],
|
|
605
|
+
subject,
|
|
606
|
+
text: content,
|
|
607
|
+
html: markdownToEmailHtml(content),
|
|
608
|
+
headers,
|
|
609
|
+
attachments: attachments && attachments.length > 0 ? attachments : void 0
|
|
610
|
+
});
|
|
611
|
+
if (result.error) {
|
|
612
|
+
console.error("[resend-adapter] send failed:", JSON.stringify(result.error));
|
|
613
|
+
throw new Error(`Resend send failed: ${JSON.stringify(result.error)}`);
|
|
614
|
+
}
|
|
615
|
+
console.log("[resend-adapter] email sent:", result.data);
|
|
616
|
+
}
|
|
617
|
+
async indicateProcessing(_threadRef) {
|
|
618
|
+
return async () => {
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
getToolDefinitions() {
|
|
622
|
+
if (this.mode !== "tool") return [];
|
|
623
|
+
const adapter = this;
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
name: "send_email",
|
|
627
|
+
description: "Send an email via Resend. The body is written in markdown and will be converted to HTML. To thread as a reply, provide in_reply_to with the original message ID. Omit it for a new standalone email.",
|
|
628
|
+
inputSchema: {
|
|
629
|
+
type: "object",
|
|
630
|
+
properties: {
|
|
631
|
+
to: {
|
|
632
|
+
type: "array",
|
|
633
|
+
items: { type: "string" },
|
|
634
|
+
description: "Recipient email addresses"
|
|
635
|
+
},
|
|
636
|
+
subject: {
|
|
637
|
+
type: "string",
|
|
638
|
+
description: "Email subject line"
|
|
639
|
+
},
|
|
640
|
+
body: {
|
|
641
|
+
type: "string",
|
|
642
|
+
description: "Email body in markdown (converted to HTML)"
|
|
643
|
+
},
|
|
644
|
+
cc: {
|
|
645
|
+
type: "array",
|
|
646
|
+
items: { type: "string" },
|
|
647
|
+
description: "CC recipient email addresses"
|
|
648
|
+
},
|
|
649
|
+
bcc: {
|
|
650
|
+
type: "array",
|
|
651
|
+
items: { type: "string" },
|
|
652
|
+
description: "BCC recipient email addresses"
|
|
653
|
+
},
|
|
654
|
+
in_reply_to: {
|
|
655
|
+
type: "string",
|
|
656
|
+
description: "Message-ID to thread this email under (for replies). Omit for a new email."
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
required: ["to", "subject", "body"]
|
|
660
|
+
},
|
|
661
|
+
handler: async (input) => {
|
|
662
|
+
return adapter.handleSendEmailTool(input);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
];
|
|
666
|
+
}
|
|
667
|
+
async handleSendEmailTool(input) {
|
|
668
|
+
if (!this.resend) {
|
|
669
|
+
return { success: false, error: "ResendAdapter not initialised" };
|
|
670
|
+
}
|
|
671
|
+
if (this.sendCount >= this.maxSendsPerRun) {
|
|
672
|
+
return {
|
|
673
|
+
success: false,
|
|
674
|
+
error: `Send limit reached (${this.maxSendsPerRun} per run). Cannot send more emails in this run.`
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
const to = input.to;
|
|
678
|
+
const subject = input.subject;
|
|
679
|
+
const body = input.body;
|
|
680
|
+
const cc = input.cc;
|
|
681
|
+
const bcc = input.bcc;
|
|
682
|
+
const inReplyTo = input.in_reply_to;
|
|
683
|
+
const allRecipients = [...to, ...cc ?? [], ...bcc ?? []];
|
|
684
|
+
if (this.allowedRecipients && this.allowedRecipients.length > 0) {
|
|
685
|
+
for (const addr of allRecipients) {
|
|
686
|
+
if (!matchesSenderPattern(addr, this.allowedRecipients)) {
|
|
687
|
+
return {
|
|
688
|
+
success: false,
|
|
689
|
+
error: `Recipient "${addr}" is not in the allowed recipients list. Allowed patterns: ${this.allowedRecipients.join(", ")}`
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const headers = {};
|
|
695
|
+
if (inReplyTo) {
|
|
696
|
+
headers["In-Reply-To"] = inReplyTo;
|
|
697
|
+
headers["References"] = inReplyTo;
|
|
698
|
+
}
|
|
699
|
+
console.log("[resend-adapter] send_email tool \u2192", {
|
|
700
|
+
from: this.fromAddress,
|
|
701
|
+
to,
|
|
702
|
+
subject,
|
|
703
|
+
cc: cc ?? void 0,
|
|
704
|
+
bcc: bcc ?? void 0,
|
|
705
|
+
inReplyTo: inReplyTo ?? void 0
|
|
706
|
+
});
|
|
707
|
+
const result = await this.resend.emails.send({
|
|
708
|
+
from: this.fromAddress,
|
|
709
|
+
to,
|
|
710
|
+
subject,
|
|
711
|
+
text: body,
|
|
712
|
+
html: markdownToEmailHtml(body),
|
|
713
|
+
cc: cc && cc.length > 0 ? cc : void 0,
|
|
714
|
+
bcc: bcc && bcc.length > 0 ? bcc : void 0,
|
|
715
|
+
headers: Object.keys(headers).length > 0 ? headers : void 0
|
|
716
|
+
});
|
|
717
|
+
if (result.error) {
|
|
718
|
+
console.error("[resend-adapter] send_email tool failed:", JSON.stringify(result.error));
|
|
719
|
+
return { success: false, error: JSON.stringify(result.error) };
|
|
720
|
+
}
|
|
721
|
+
this.sendCount++;
|
|
722
|
+
this.hasSentInCurrentRequest = true;
|
|
723
|
+
console.log("[resend-adapter] send_email tool sent:", result.data);
|
|
724
|
+
return { success: true, id: result.data?.id };
|
|
725
|
+
}
|
|
726
|
+
// -----------------------------------------------------------------------
|
|
727
|
+
// HTTP request handling
|
|
728
|
+
// -----------------------------------------------------------------------
|
|
729
|
+
async handleRequest(req, res) {
|
|
730
|
+
if (!this.resend) {
|
|
731
|
+
res.writeHead(500);
|
|
732
|
+
res.end("Adapter not initialised");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const rawBody = await collectBody2(req);
|
|
736
|
+
const svixId = req.headers["svix-id"];
|
|
737
|
+
const svixTimestamp = req.headers["svix-timestamp"];
|
|
738
|
+
const svixSignature = req.headers["svix-signature"];
|
|
739
|
+
if (!svixId || !svixTimestamp || !svixSignature) {
|
|
740
|
+
console.warn("[resend-adapter] 401: missing svix headers", {
|
|
741
|
+
hasSvixId: !!svixId,
|
|
742
|
+
hasSvixTimestamp: !!svixTimestamp,
|
|
743
|
+
hasSvixSignature: !!svixSignature
|
|
744
|
+
});
|
|
745
|
+
res.writeHead(401);
|
|
746
|
+
res.end("Missing signature headers");
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, this.webhookSecret);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
console.warn("[resend-adapter] 401: signature verification failed", err instanceof Error ? err.message : err);
|
|
753
|
+
res.writeHead(401);
|
|
754
|
+
res.end("Invalid signature");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (this.processed.has(svixId)) {
|
|
758
|
+
res.writeHead(200);
|
|
759
|
+
res.end();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
this.processed.add(svixId);
|
|
763
|
+
let payload;
|
|
764
|
+
try {
|
|
765
|
+
payload = JSON.parse(rawBody);
|
|
766
|
+
} catch {
|
|
767
|
+
res.writeHead(400);
|
|
768
|
+
res.end("Invalid JSON");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (payload.type !== "email.received") {
|
|
772
|
+
res.writeHead(200);
|
|
773
|
+
res.end();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
res.writeHead(200);
|
|
777
|
+
res.end();
|
|
778
|
+
const data = payload.data;
|
|
779
|
+
if (!data || !this.handler) return;
|
|
780
|
+
try {
|
|
781
|
+
await this.processInboundEmail(data, payload);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.error("[resend-adapter] error processing inbound email", err);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async processInboundEmail(data, payload) {
|
|
787
|
+
if (!this.handler) return;
|
|
788
|
+
const fromRaw = String(data.from ?? "");
|
|
789
|
+
const senderEmail = extractEmailAddress(fromRaw);
|
|
790
|
+
const senderName = extractDisplayName(fromRaw);
|
|
791
|
+
if (!matchesSenderPattern(senderEmail, this.allowedSenders)) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const emailId = String(data.email_id ?? "");
|
|
795
|
+
const messageId = String(data.message_id ?? "");
|
|
796
|
+
const subject = String(data.subject ?? "");
|
|
797
|
+
let text = "";
|
|
798
|
+
let emailHeaders;
|
|
799
|
+
if (emailId) {
|
|
800
|
+
try {
|
|
801
|
+
const resp = await fetch(
|
|
802
|
+
`https://api.resend.com/emails/receiving/${emailId}`,
|
|
803
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } }
|
|
804
|
+
);
|
|
805
|
+
if (resp.ok) {
|
|
806
|
+
const emailData = await resp.json();
|
|
807
|
+
text = emailData.text ?? "";
|
|
808
|
+
emailHeaders = emailData.headers;
|
|
809
|
+
} else {
|
|
810
|
+
const body = await resp.text().catch(() => "");
|
|
811
|
+
console.error(
|
|
812
|
+
`[resend-adapter] failed to fetch email body: ${resp.status} ${resp.statusText}`,
|
|
813
|
+
`
|
|
814
|
+
URL: https://api.resend.com/emails/receiving/${emailId}`,
|
|
815
|
+
`
|
|
816
|
+
Key: ${this.apiKey.slice(0, 6)}...${this.apiKey.slice(-4)}`,
|
|
817
|
+
body ? `
|
|
818
|
+
Response: ${body.slice(0, 200)}` : ""
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.error("[resend-adapter] failed to fetch email body", err);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const cleanText = stripQuotedReply(text).trim();
|
|
826
|
+
if (!cleanText) return;
|
|
827
|
+
const references = parseReferences(emailHeaders);
|
|
828
|
+
this.threadMeta.set(messageId, {
|
|
829
|
+
subject,
|
|
830
|
+
senderEmail,
|
|
831
|
+
references: [...references, messageId].filter(Boolean)
|
|
832
|
+
});
|
|
833
|
+
const webhookAttachments = data.attachments;
|
|
834
|
+
const files = await this.fetchAndDownloadAttachments(emailId, webhookAttachments);
|
|
835
|
+
const message = {
|
|
836
|
+
text: cleanText,
|
|
837
|
+
subject: subject || void 0,
|
|
838
|
+
files: files.length > 0 ? files : void 0,
|
|
839
|
+
threadRef: {
|
|
840
|
+
channelId: senderEmail,
|
|
841
|
+
platformThreadId: messageId,
|
|
842
|
+
messageId
|
|
843
|
+
},
|
|
844
|
+
sender: { id: senderEmail, name: senderName },
|
|
845
|
+
platform: "resend",
|
|
846
|
+
raw: payload
|
|
847
|
+
};
|
|
848
|
+
await this.handler(message);
|
|
849
|
+
}
|
|
850
|
+
// -----------------------------------------------------------------------
|
|
851
|
+
// Attachment helpers
|
|
852
|
+
// -----------------------------------------------------------------------
|
|
853
|
+
async fetchAndDownloadAttachments(emailId, webhookAttachments) {
|
|
854
|
+
if (!emailId || !webhookAttachments || webhookAttachments.length === 0) return [];
|
|
855
|
+
let attachments = [];
|
|
856
|
+
try {
|
|
857
|
+
const resp = await fetch(
|
|
858
|
+
`https://api.resend.com/emails/receiving/${emailId}/attachments`,
|
|
859
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } }
|
|
860
|
+
);
|
|
861
|
+
if (resp.ok) {
|
|
862
|
+
const body = await resp.json();
|
|
863
|
+
attachments = Array.isArray(body) ? body : body.data ?? [];
|
|
864
|
+
} else {
|
|
865
|
+
console.error("[resend-adapter] failed to list attachments:", resp.status, resp.statusText);
|
|
866
|
+
return [];
|
|
867
|
+
}
|
|
868
|
+
} catch (err) {
|
|
869
|
+
console.error("[resend-adapter] failed to list attachments", err);
|
|
870
|
+
return [];
|
|
871
|
+
}
|
|
872
|
+
const results = [];
|
|
873
|
+
for (const att of attachments) {
|
|
874
|
+
if (!att.download_url) continue;
|
|
875
|
+
try {
|
|
876
|
+
const resp = await fetch(att.download_url);
|
|
877
|
+
if (!resp.ok) continue;
|
|
878
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
879
|
+
results.push({
|
|
880
|
+
data: buf.toString("base64"),
|
|
881
|
+
mediaType: att.content_type ?? "application/octet-stream",
|
|
882
|
+
filename: att.filename
|
|
883
|
+
});
|
|
884
|
+
} catch {
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return results;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
287
890
|
export {
|
|
288
891
|
AgentBridge,
|
|
289
|
-
|
|
892
|
+
ResendAdapter,
|
|
893
|
+
SlackAdapter,
|
|
894
|
+
buildReplyHeaders,
|
|
895
|
+
buildReplySubject,
|
|
896
|
+
deriveRootMessageId,
|
|
897
|
+
extractDisplayName,
|
|
898
|
+
extractEmailAddress,
|
|
899
|
+
markdownToEmailHtml,
|
|
900
|
+
matchesSenderPattern,
|
|
901
|
+
parseReferences,
|
|
902
|
+
stripQuotedReply
|
|
290
903
|
};
|