@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/src/bridge.ts
CHANGED
|
@@ -19,11 +19,13 @@ export class AgentBridge {
|
|
|
19
19
|
private readonly adapter: MessagingAdapter;
|
|
20
20
|
private readonly runner: AgentRunner;
|
|
21
21
|
private readonly waitUntil: (promise: Promise<unknown>) => void;
|
|
22
|
+
private readonly ownerIdOverride: string | undefined;
|
|
22
23
|
|
|
23
24
|
constructor(options: AgentBridgeOptions) {
|
|
24
25
|
this.adapter = options.adapter;
|
|
25
26
|
this.runner = options.runner;
|
|
26
27
|
this.waitUntil = options.waitUntil ?? ((_p: Promise<unknown>) => {});
|
|
28
|
+
this.ownerIdOverride = options.ownerId;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Wire the adapter's message handler and initialise. */
|
|
@@ -41,6 +43,8 @@ export class AgentBridge {
|
|
|
41
43
|
private async handleMessage(message: IncomingMessage): Promise<void> {
|
|
42
44
|
let cleanup: (() => Promise<void>) | undefined;
|
|
43
45
|
|
|
46
|
+
this.adapter.resetRequestState?.();
|
|
47
|
+
|
|
44
48
|
try {
|
|
45
49
|
cleanup = await this.adapter.indicateProcessing(message.threadRef);
|
|
46
50
|
|
|
@@ -49,31 +53,57 @@ export class AgentBridge {
|
|
|
49
53
|
message.threadRef,
|
|
50
54
|
);
|
|
51
55
|
|
|
56
|
+
const titleParts = [message.sender.id];
|
|
57
|
+
if (message.subject) titleParts.push(message.subject);
|
|
58
|
+
const title = titleParts.join(" — ") || `${message.platform} thread`;
|
|
59
|
+
|
|
52
60
|
const conversation = await this.runner.getOrCreateConversation(
|
|
53
61
|
conversationId,
|
|
54
62
|
{
|
|
55
63
|
platform: message.platform,
|
|
56
|
-
ownerId: message.sender.id,
|
|
57
|
-
title
|
|
64
|
+
ownerId: this.ownerIdOverride ?? message.sender.id,
|
|
65
|
+
title,
|
|
58
66
|
},
|
|
59
67
|
);
|
|
60
68
|
|
|
69
|
+
const senderLine = message.sender.name
|
|
70
|
+
? `From: ${message.sender.name} <${message.sender.id}>`
|
|
71
|
+
: `From: ${message.sender.id}`;
|
|
72
|
+
const subjectLine = message.subject ? `Subject: ${message.subject}` : "";
|
|
73
|
+
const header = [senderLine, subjectLine].filter(Boolean).join("\n");
|
|
74
|
+
const task = `${header}\n\n${message.text}`;
|
|
75
|
+
|
|
61
76
|
const result = await this.runner.run(conversationId, {
|
|
62
|
-
task
|
|
77
|
+
task,
|
|
63
78
|
messages: conversation.messages,
|
|
79
|
+
files: message.files,
|
|
80
|
+
metadata: {
|
|
81
|
+
platform: message.platform,
|
|
82
|
+
sender: message.sender,
|
|
83
|
+
threadId: message.threadRef.platformThreadId,
|
|
84
|
+
},
|
|
64
85
|
});
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
if (this.adapter.autoReply) {
|
|
88
|
+
await this.adapter.sendReply(message.threadRef, result.response, {
|
|
89
|
+
files: result.files,
|
|
90
|
+
});
|
|
91
|
+
} else if (!this.adapter.hasSentInCurrentRequest) {
|
|
92
|
+
console.warn("[agent-bridge] tool mode completed without send_email being called; no reply sent");
|
|
93
|
+
}
|
|
67
94
|
} catch (error) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
console.error("[agent-bridge] handleMessage error:", error instanceof Error ? error.message : error);
|
|
96
|
+
if (!this.adapter.hasSentInCurrentRequest) {
|
|
97
|
+
const snippet =
|
|
98
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
99
|
+
try {
|
|
100
|
+
await this.adapter.sendReply(
|
|
101
|
+
message.threadRef,
|
|
102
|
+
`Sorry, something went wrong: ${snippet}`,
|
|
103
|
+
);
|
|
104
|
+
} catch (replyError) {
|
|
105
|
+
console.error("[agent-bridge] failed to send error reply:", replyError instanceof Error ? replyError.message : replyError);
|
|
106
|
+
}
|
|
77
107
|
}
|
|
78
108
|
} finally {
|
|
79
109
|
if (cleanup) {
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
AgentBridgeOptions,
|
|
3
3
|
AgentRunner,
|
|
4
|
+
FileAttachment,
|
|
4
5
|
IncomingMessage,
|
|
5
6
|
IncomingMessageHandler,
|
|
6
7
|
MessagingAdapter,
|
|
@@ -12,3 +13,17 @@ export type {
|
|
|
12
13
|
export { AgentBridge } from "./bridge.js";
|
|
13
14
|
export { SlackAdapter } from "./adapters/slack/index.js";
|
|
14
15
|
export type { SlackAdapterOptions } from "./adapters/slack/index.js";
|
|
16
|
+
export { ResendAdapter } from "./adapters/resend/index.js";
|
|
17
|
+
export type { ResendAdapterOptions } from "./adapters/resend/index.js";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
buildReplyHeaders,
|
|
21
|
+
buildReplySubject,
|
|
22
|
+
deriveRootMessageId,
|
|
23
|
+
extractDisplayName,
|
|
24
|
+
extractEmailAddress,
|
|
25
|
+
markdownToEmailHtml,
|
|
26
|
+
matchesSenderPattern,
|
|
27
|
+
parseReferences,
|
|
28
|
+
stripQuotedReply,
|
|
29
|
+
} from "./adapters/email/utils.js";
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type http from "node:http";
|
|
2
|
-
import type { Message } from "@poncho-ai/sdk";
|
|
2
|
+
import type { Message, ToolDefinition } from "@poncho-ai/sdk";
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Thread & message primitives
|
|
@@ -12,8 +12,17 @@ export interface ThreadRef {
|
|
|
12
12
|
messageId?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface FileAttachment {
|
|
16
|
+
/** base64-encoded file data */
|
|
17
|
+
data: string;
|
|
18
|
+
mediaType: string;
|
|
19
|
+
filename?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface IncomingMessage {
|
|
16
23
|
text: string;
|
|
24
|
+
subject?: string;
|
|
25
|
+
files?: FileAttachment[];
|
|
17
26
|
threadRef: ThreadRef;
|
|
18
27
|
sender: { id: string; name?: string };
|
|
19
28
|
platform: string;
|
|
@@ -46,6 +55,15 @@ export type RouteRegistrar = (
|
|
|
46
55
|
export interface MessagingAdapter {
|
|
47
56
|
readonly platform: string;
|
|
48
57
|
|
|
58
|
+
/** When true, the bridge auto-sends the agent's response as a reply. */
|
|
59
|
+
readonly autoReply: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the adapter's tool has sent at least one message during the
|
|
63
|
+
* current request. Used by the bridge to suppress duplicate error replies.
|
|
64
|
+
*/
|
|
65
|
+
readonly hasSentInCurrentRequest: boolean;
|
|
66
|
+
|
|
49
67
|
/** Register HTTP routes on the host server for receiving platform events. */
|
|
50
68
|
registerRoutes(router: RouteRegistrar): void;
|
|
51
69
|
|
|
@@ -56,7 +74,11 @@ export interface MessagingAdapter {
|
|
|
56
74
|
onMessage(handler: IncomingMessageHandler): void;
|
|
57
75
|
|
|
58
76
|
/** Post a reply back to the originating thread. */
|
|
59
|
-
sendReply(
|
|
77
|
+
sendReply(
|
|
78
|
+
threadRef: ThreadRef,
|
|
79
|
+
content: string,
|
|
80
|
+
options?: { files?: FileAttachment[] },
|
|
81
|
+
): Promise<void>;
|
|
60
82
|
|
|
61
83
|
/**
|
|
62
84
|
* Show a processing indicator (e.g. reaction, typing).
|
|
@@ -65,6 +87,15 @@ export interface MessagingAdapter {
|
|
|
65
87
|
indicateProcessing(
|
|
66
88
|
threadRef: ThreadRef,
|
|
67
89
|
): Promise<() => Promise<void>>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Optional: return tool definitions the agent can use (e.g. send_email).
|
|
93
|
+
* Called once after initialization to register tools with the harness.
|
|
94
|
+
*/
|
|
95
|
+
getToolDefinitions?(): ToolDefinition[];
|
|
96
|
+
|
|
97
|
+
/** Reset per-request state (e.g. send counter, hasSentInCurrentRequest). */
|
|
98
|
+
resetRequestState?(): void;
|
|
68
99
|
}
|
|
69
100
|
|
|
70
101
|
// ---------------------------------------------------------------------------
|
|
@@ -79,8 +110,20 @@ export interface AgentRunner {
|
|
|
79
110
|
|
|
80
111
|
run(
|
|
81
112
|
conversationId: string,
|
|
82
|
-
input: {
|
|
83
|
-
|
|
113
|
+
input: {
|
|
114
|
+
task: string;
|
|
115
|
+
messages: Message[];
|
|
116
|
+
files?: FileAttachment[];
|
|
117
|
+
metadata?: {
|
|
118
|
+
platform: string;
|
|
119
|
+
sender: { id: string; name?: string };
|
|
120
|
+
threadId: string;
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
): Promise<{
|
|
124
|
+
response: string;
|
|
125
|
+
files?: FileAttachment[];
|
|
126
|
+
}>;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
129
|
// ---------------------------------------------------------------------------
|
|
@@ -95,4 +138,10 @@ export interface AgentBridgeOptions {
|
|
|
95
138
|
* On Vercel, pass the real `waitUntil` from `@vercel/functions`.
|
|
96
139
|
*/
|
|
97
140
|
waitUntil?: (promise: Promise<unknown>) => void;
|
|
141
|
+
/**
|
|
142
|
+
* Override the ownerId for conversations created by this bridge.
|
|
143
|
+
* Defaults to the sender's ID. Set to a fixed value (e.g. "local-owner")
|
|
144
|
+
* so messaging conversations appear in the web UI alongside regular ones.
|
|
145
|
+
*/
|
|
146
|
+
ownerId?: string;
|
|
98
147
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildReplyHeaders,
|
|
4
|
+
buildReplySubject,
|
|
5
|
+
deriveRootMessageId,
|
|
6
|
+
extractDisplayName,
|
|
7
|
+
extractEmailAddress,
|
|
8
|
+
markdownToEmailHtml,
|
|
9
|
+
matchesSenderPattern,
|
|
10
|
+
parseReferences,
|
|
11
|
+
stripQuotedReply,
|
|
12
|
+
} from "../../src/adapters/email/utils.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// extractEmailAddress
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
describe("extractEmailAddress", () => {
|
|
19
|
+
it("extracts from formatted address", () => {
|
|
20
|
+
expect(extractEmailAddress("Alice <alice@example.com>")).toBe("alice@example.com");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles bare email", () => {
|
|
24
|
+
expect(extractEmailAddress("bob@test.com")).toBe("bob@test.com");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("lowercases the result", () => {
|
|
28
|
+
expect(extractEmailAddress("Alice <Alice@Example.COM>")).toBe("alice@example.com");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles quoted display name", () => {
|
|
32
|
+
expect(extractEmailAddress('"Alice B" <alice@example.com>')).toBe("alice@example.com");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// extractDisplayName
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe("extractDisplayName", () => {
|
|
41
|
+
it("extracts name from formatted address", () => {
|
|
42
|
+
expect(extractDisplayName("Alice <alice@example.com>")).toBe("Alice");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns undefined for bare email", () => {
|
|
46
|
+
expect(extractDisplayName("bob@test.com")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("strips surrounding quotes", () => {
|
|
50
|
+
expect(extractDisplayName('"Alice B" <alice@example.com>')).toBe("Alice B");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// parseReferences
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("parseReferences", () => {
|
|
59
|
+
it("parses space-separated message IDs", () => {
|
|
60
|
+
const headers = [
|
|
61
|
+
{ name: "References", value: "<msg1@a.com> <msg2@b.com> <msg3@c.com>" },
|
|
62
|
+
];
|
|
63
|
+
expect(parseReferences(headers)).toEqual([
|
|
64
|
+
"<msg1@a.com>",
|
|
65
|
+
"<msg2@b.com>",
|
|
66
|
+
"<msg3@c.com>",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns empty array when no References header", () => {
|
|
71
|
+
expect(parseReferences([{ name: "Subject", value: "hi" }])).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns empty array for undefined headers", () => {
|
|
75
|
+
expect(parseReferences(undefined)).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("is case-insensitive for header name", () => {
|
|
79
|
+
const headers = [{ name: "references", value: "<msg@a.com>" }];
|
|
80
|
+
expect(parseReferences(headers)).toEqual(["<msg@a.com>"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles object-style headers (Record<string, string>)", () => {
|
|
84
|
+
const headers = { References: "<msg1@a.com> <msg2@b.com>" };
|
|
85
|
+
expect(parseReferences(headers)).toEqual(["<msg1@a.com>", "<msg2@b.com>"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles object-style headers case-insensitively", () => {
|
|
89
|
+
const headers = { references: "<msg@a.com>" };
|
|
90
|
+
expect(parseReferences(headers)).toEqual(["<msg@a.com>"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns empty array for object headers without References", () => {
|
|
94
|
+
const headers = { Subject: "hi", From: "alice@a.com" };
|
|
95
|
+
expect(parseReferences(headers)).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// deriveRootMessageId
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("deriveRootMessageId", () => {
|
|
104
|
+
it("returns first reference when available", () => {
|
|
105
|
+
expect(
|
|
106
|
+
deriveRootMessageId(["<root@a.com>", "<reply@a.com>"], "<current@a.com>"),
|
|
107
|
+
).toBe("<root@a.com>");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("falls back to subject+sender hash when no references", () => {
|
|
111
|
+
const result = deriveRootMessageId([], "<current@a.com>", {
|
|
112
|
+
subject: "Hello",
|
|
113
|
+
sender: "alice@example.com",
|
|
114
|
+
});
|
|
115
|
+
expect(result).toMatch(/^<fallback:[0-9a-f]+>$/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("produces stable fallback for same subject+sender", () => {
|
|
119
|
+
const a = deriveRootMessageId([], "<a@a.com>", { subject: "Hello", sender: "alice@a.com" });
|
|
120
|
+
const b = deriveRootMessageId([], "<b@b.com>", { subject: "Hello", sender: "alice@a.com" });
|
|
121
|
+
expect(a).toBe(b);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("normalises Re: prefixes in fallback", () => {
|
|
125
|
+
const a = deriveRootMessageId([], "<a@a.com>", { subject: "Hello", sender: "alice@a.com" });
|
|
126
|
+
const b = deriveRootMessageId([], "<b@b.com>", { subject: "Re: Hello", sender: "alice@a.com" });
|
|
127
|
+
expect(a).toBe(b);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("falls back to current message ID as last resort", () => {
|
|
131
|
+
expect(deriveRootMessageId([], "<current@a.com>")).toBe("<current@a.com>");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// buildReplySubject
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe("buildReplySubject", () => {
|
|
140
|
+
it("prepends Re: to a plain subject", () => {
|
|
141
|
+
expect(buildReplySubject("Q4 Revenue")).toBe("Re: Q4 Revenue");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not double-prefix", () => {
|
|
145
|
+
expect(buildReplySubject("Re: Q4 Revenue")).toBe("Re: Q4 Revenue");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("handles case-insensitive Re:", () => {
|
|
149
|
+
expect(buildReplySubject("RE: Q4 Revenue")).toBe("RE: Q4 Revenue");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// buildReplyHeaders
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe("buildReplyHeaders", () => {
|
|
158
|
+
it("builds In-Reply-To and References headers", () => {
|
|
159
|
+
const headers = buildReplyHeaders("<msg2@a.com>", ["<msg1@a.com>"]);
|
|
160
|
+
expect(headers["In-Reply-To"]).toBe("<msg2@a.com>");
|
|
161
|
+
expect(headers["References"]).toBe("<msg1@a.com> <msg2@a.com>");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not duplicate inReplyTo in references", () => {
|
|
165
|
+
const headers = buildReplyHeaders("<msg1@a.com>", ["<msg1@a.com>"]);
|
|
166
|
+
expect(headers["References"]).toBe("<msg1@a.com>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// stripQuotedReply
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
describe("stripQuotedReply", () => {
|
|
175
|
+
it("strips Gmail-style quoted replies", () => {
|
|
176
|
+
const text = "Thanks for the info.\n\nOn Mon, Jan 1, 2024, Alice wrote:\n> Previous message";
|
|
177
|
+
expect(stripQuotedReply(text)).toBe("Thanks for the info.");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("strips Outlook-style original message markers", () => {
|
|
181
|
+
const text = "Got it.\n\n-----Original Message-----\nFrom: Bob\nSent: Monday";
|
|
182
|
+
expect(stripQuotedReply(text)).toBe("Got it.");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("strips Outlook From:/Sent: header blocks", () => {
|
|
186
|
+
const text = "Sure thing.\n\nFrom: Alice <alice@example.com>\nSent: Monday, January 1\nTo: Bob";
|
|
187
|
+
expect(stripQuotedReply(text)).toBe("Sure thing.");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("strips > quoting after a blank line", () => {
|
|
191
|
+
const text = "My reply.\n\n> Previous line 1\n> Previous line 2";
|
|
192
|
+
expect(stripQuotedReply(text)).toBe("My reply.");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("preserves text with no quoted content", () => {
|
|
196
|
+
const text = "Just a plain message.";
|
|
197
|
+
expect(stripQuotedReply(text)).toBe("Just a plain message.");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("preserves > that is not a quote (no blank line before)", () => {
|
|
201
|
+
const text = "The value should be > 5\nand also > 10";
|
|
202
|
+
expect(stripQuotedReply(text)).toBe("The value should be > 5\nand also > 10");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// markdownToEmailHtml
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
describe("markdownToEmailHtml", () => {
|
|
211
|
+
it("wraps output in a styled div", () => {
|
|
212
|
+
const html = markdownToEmailHtml("Hello");
|
|
213
|
+
expect(html).toContain("<div");
|
|
214
|
+
expect(html).toContain("font-family:");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("converts bold text", () => {
|
|
218
|
+
const html = markdownToEmailHtml("This is **bold** text.");
|
|
219
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("converts inline code", () => {
|
|
223
|
+
const html = markdownToEmailHtml("Use `npm install`.");
|
|
224
|
+
expect(html).toContain("<code");
|
|
225
|
+
expect(html).toContain("npm install");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("converts fenced code blocks", () => {
|
|
229
|
+
const html = markdownToEmailHtml("```js\nconsole.log('hi');\n```");
|
|
230
|
+
expect(html).toContain("<pre");
|
|
231
|
+
expect(html).toContain("console.log");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("converts unordered lists", () => {
|
|
235
|
+
const html = markdownToEmailHtml("- Item A\n- Item B");
|
|
236
|
+
expect(html).toContain("<ul");
|
|
237
|
+
expect(html).toContain("<li>Item A</li>");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("converts ordered lists", () => {
|
|
241
|
+
const html = markdownToEmailHtml("1. First\n2. Second");
|
|
242
|
+
expect(html).toContain("<ol");
|
|
243
|
+
expect(html).toContain("<li>First</li>");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("escapes HTML entities", () => {
|
|
247
|
+
const html = markdownToEmailHtml("Use <script> & stuff");
|
|
248
|
+
expect(html).toContain("<script>");
|
|
249
|
+
expect(html).toContain("&");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// matchesSenderPattern
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe("matchesSenderPattern", () => {
|
|
258
|
+
it("returns true when no patterns (open inbox)", () => {
|
|
259
|
+
expect(matchesSenderPattern("anyone@anywhere.com", undefined)).toBe(true);
|
|
260
|
+
expect(matchesSenderPattern("anyone@anywhere.com", [])).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("matches exact email", () => {
|
|
264
|
+
expect(matchesSenderPattern("alice@example.com", ["alice@example.com"])).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("rejects non-matching email", () => {
|
|
268
|
+
expect(matchesSenderPattern("bob@other.com", ["alice@example.com"])).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("matches domain wildcard", () => {
|
|
272
|
+
expect(matchesSenderPattern("anyone@myco.com", ["*@myco.com"])).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("rejects non-matching domain", () => {
|
|
276
|
+
expect(matchesSenderPattern("anyone@other.com", ["*@myco.com"])).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("is case-insensitive", () => {
|
|
280
|
+
expect(matchesSenderPattern("Alice@EXAMPLE.com", ["alice@example.com"])).toBe(true);
|
|
281
|
+
expect(matchesSenderPattern("Alice@MYCO.COM", ["*@myco.com"])).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("matches against multiple patterns", () => {
|
|
285
|
+
const patterns = ["alice@a.com", "*@b.com"];
|
|
286
|
+
expect(matchesSenderPattern("alice@a.com", patterns)).toBe(true);
|
|
287
|
+
expect(matchesSenderPattern("bob@b.com", patterns)).toBe(true);
|
|
288
|
+
expect(matchesSenderPattern("carol@c.com", patterns)).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ResendAdapter } from "../../src/adapters/resend/index.js";
|
|
3
|
+
import type { RouteRegistrar } from "../../src/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Since the ResendAdapter dynamically imports `resend`, we test the parts that
|
|
7
|
+
* don't require a live Resend client: route registration, construction, and
|
|
8
|
+
* the allowlist/dedup logic via the exported class shape.
|
|
9
|
+
*
|
|
10
|
+
* Full integration tests require mocking the Resend SDK which is covered
|
|
11
|
+
* separately if needed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("ResendAdapter", () => {
|
|
15
|
+
it("registers a POST route at /api/messaging/resend", () => {
|
|
16
|
+
const adapter = new ResendAdapter();
|
|
17
|
+
const routes: Array<{ method: string; path: string }> = [];
|
|
18
|
+
const registrar: RouteRegistrar = (method, path) => {
|
|
19
|
+
routes.push({ method, path });
|
|
20
|
+
};
|
|
21
|
+
adapter.registerRoutes(registrar);
|
|
22
|
+
expect(routes).toEqual([{ method: "POST", path: "/api/messaging/resend" }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("has platform set to 'resend'", () => {
|
|
26
|
+
const adapter = new ResendAdapter();
|
|
27
|
+
expect(adapter.platform).toBe("resend");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("indicateProcessing returns a no-op cleanup function", async () => {
|
|
31
|
+
const adapter = new ResendAdapter();
|
|
32
|
+
const cleanup = await adapter.indicateProcessing({
|
|
33
|
+
platformThreadId: "t1",
|
|
34
|
+
channelId: "c1",
|
|
35
|
+
});
|
|
36
|
+
await expect(cleanup()).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts custom env var names", () => {
|
|
40
|
+
const adapter = new ResendAdapter({
|
|
41
|
+
apiKeyEnv: "MY_RESEND_KEY",
|
|
42
|
+
webhookSecretEnv: "MY_WEBHOOK_SECRET",
|
|
43
|
+
fromEnv: "MY_FROM",
|
|
44
|
+
});
|
|
45
|
+
// If env vars are not set, initialize should throw with the custom name
|
|
46
|
+
expect(
|
|
47
|
+
adapter.initialize(),
|
|
48
|
+
).rejects.toThrow("MY_RESEND_KEY");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws on initialize when RESEND_API_KEY is missing", async () => {
|
|
52
|
+
const adapter = new ResendAdapter();
|
|
53
|
+
await expect(adapter.initialize()).rejects.toThrow("RESEND_API_KEY");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("stores allowed senders from options", () => {
|
|
57
|
+
const adapter = new ResendAdapter({
|
|
58
|
+
allowedSenders: ["*@myco.com"],
|
|
59
|
+
});
|
|
60
|
+
expect(adapter.platform).toBe("resend");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("mode configuration", () => {
|
|
64
|
+
it("defaults to auto-reply mode", () => {
|
|
65
|
+
const adapter = new ResendAdapter();
|
|
66
|
+
expect(adapter.autoReply).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("sets autoReply to false in tool mode", () => {
|
|
70
|
+
const adapter = new ResendAdapter({ mode: "tool" });
|
|
71
|
+
expect(adapter.autoReply).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sets autoReply to true in auto-reply mode", () => {
|
|
75
|
+
const adapter = new ResendAdapter({ mode: "auto-reply" });
|
|
76
|
+
expect(adapter.autoReply).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns send_email tool definitions in tool mode", () => {
|
|
80
|
+
const adapter = new ResendAdapter({ mode: "tool" });
|
|
81
|
+
const tools = adapter.getToolDefinitions();
|
|
82
|
+
expect(tools).toHaveLength(1);
|
|
83
|
+
expect(tools[0]!.name).toBe("send_email");
|
|
84
|
+
expect(tools[0]!.inputSchema.required).toEqual(["to", "subject", "body"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns empty tool definitions in auto-reply mode", () => {
|
|
88
|
+
const adapter = new ResendAdapter({ mode: "auto-reply" });
|
|
89
|
+
const tools = adapter.getToolDefinitions();
|
|
90
|
+
expect(tools).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns empty tool definitions in default mode", () => {
|
|
94
|
+
const adapter = new ResendAdapter();
|
|
95
|
+
const tools = adapter.getToolDefinitions();
|
|
96
|
+
expect(tools).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("resetRequestState", () => {
|
|
101
|
+
it("resets hasSentInCurrentRequest flag", () => {
|
|
102
|
+
const adapter = new ResendAdapter({ mode: "tool" });
|
|
103
|
+
(adapter as unknown as { hasSentInCurrentRequest: boolean }).hasSentInCurrentRequest = true;
|
|
104
|
+
adapter.resetRequestState!();
|
|
105
|
+
expect(adapter.hasSentInCurrentRequest).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|