@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/messaging",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Messaging platform adapters for Poncho agents (Slack, Telegram, etc.)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,9 +20,18 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@poncho-ai/sdk": "1.0.
|
|
23
|
+
"@poncho-ai/sdk": "1.0.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"resend": ">=4.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"resend": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
24
32
|
},
|
|
25
33
|
"devDependencies": {
|
|
34
|
+
"resend": "^4.0.0",
|
|
26
35
|
"tsup": "^8.0.0",
|
|
27
36
|
"vitest": "^1.4.0"
|
|
28
37
|
},
|
|
@@ -30,7 +39,9 @@
|
|
|
30
39
|
"ai",
|
|
31
40
|
"agent",
|
|
32
41
|
"messaging",
|
|
33
|
-
"slack"
|
|
42
|
+
"slack",
|
|
43
|
+
"email",
|
|
44
|
+
"resend"
|
|
34
45
|
],
|
|
35
46
|
"license": "MIT",
|
|
36
47
|
"scripts": {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Email address parsing
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const ADDR_RE = /<([^>]+)>/;
|
|
8
|
+
|
|
9
|
+
/** Extract the bare email address from a formatted string like `"Name <addr>"`. */
|
|
10
|
+
export function extractEmailAddress(formatted: string): string {
|
|
11
|
+
const match = ADDR_RE.exec(formatted);
|
|
12
|
+
return (match ? match[1] : formatted).trim().toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Extract the display name from `"Name <addr>"`, or return `undefined`. */
|
|
16
|
+
export function extractDisplayName(formatted: string): string | undefined {
|
|
17
|
+
const idx = formatted.indexOf("<");
|
|
18
|
+
if (idx <= 0) return undefined;
|
|
19
|
+
const name = formatted.slice(0, idx).trim().replace(/^["']|["']$/g, "");
|
|
20
|
+
return name || undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// RFC 2822 References / threading
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const MSG_ID_RE = /<[^>]+>/g;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a `References` header value into an ordered array of message IDs.
|
|
31
|
+
* Handles both space-separated and newline-folded formats.
|
|
32
|
+
*/
|
|
33
|
+
export function parseReferences(
|
|
34
|
+
headers: Array<{ name: string; value: string }> | Record<string, string> | undefined,
|
|
35
|
+
): string[] {
|
|
36
|
+
if (!headers) return [];
|
|
37
|
+
|
|
38
|
+
let refValue: string | undefined;
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(headers)) {
|
|
41
|
+
const entry = headers.find((h) => h.name.toLowerCase() === "references");
|
|
42
|
+
refValue = entry?.value;
|
|
43
|
+
} else if (typeof headers === "object") {
|
|
44
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === "references");
|
|
45
|
+
refValue = key ? headers[key] : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!refValue) return [];
|
|
49
|
+
const matches = refValue.match(MSG_ID_RE);
|
|
50
|
+
return matches ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Derive a stable root message ID for a conversation.
|
|
55
|
+
*
|
|
56
|
+
* 1. First entry in the `References` chain (the original message).
|
|
57
|
+
* 2. Fallback: hash of normalised subject + sender (for clients that strip References).
|
|
58
|
+
* 3. Last resort: the current message's own ID.
|
|
59
|
+
*/
|
|
60
|
+
export function deriveRootMessageId(
|
|
61
|
+
references: string[],
|
|
62
|
+
currentMessageId: string,
|
|
63
|
+
fallback?: { subject: string; sender: string },
|
|
64
|
+
): string {
|
|
65
|
+
if (references.length > 0) return references[0]!;
|
|
66
|
+
if (fallback) {
|
|
67
|
+
const normalised = normaliseSubject(fallback.subject) + "\0" + fallback.sender.toLowerCase();
|
|
68
|
+
const hash = createHash("sha256").update(normalised).digest("hex").slice(0, 16);
|
|
69
|
+
return `<fallback:${hash}>`;
|
|
70
|
+
}
|
|
71
|
+
return currentMessageId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Strip `Re:`, `Fwd:`, and similar prefixes for normalisation. */
|
|
75
|
+
function normaliseSubject(subject: string): string {
|
|
76
|
+
return subject.replace(/^(?:re|fwd?|aw|sv|vs)\s*:\s*/gi, "").trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Reply construction
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Prepend `Re:` if the subject doesn't already have it. */
|
|
84
|
+
export function buildReplySubject(subject: string): string {
|
|
85
|
+
if (/^re\s*:/i.test(subject)) return subject;
|
|
86
|
+
return `Re: ${subject}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build `In-Reply-To` and `References` headers for an outbound reply.
|
|
91
|
+
*/
|
|
92
|
+
export function buildReplyHeaders(
|
|
93
|
+
inReplyTo: string,
|
|
94
|
+
existingReferences: string[],
|
|
95
|
+
): Record<string, string> {
|
|
96
|
+
const refs = [...existingReferences];
|
|
97
|
+
if (!refs.includes(inReplyTo)) refs.push(inReplyTo);
|
|
98
|
+
return {
|
|
99
|
+
"In-Reply-To": inReplyTo,
|
|
100
|
+
"References": refs.join(" "),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Quoted reply stripping
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Strip quoted reply content from an email body (plain text).
|
|
110
|
+
*
|
|
111
|
+
* Handles common patterns from Gmail, Apple Mail, Outlook, and Thunderbird.
|
|
112
|
+
* Best-effort heuristic — the full original text should be preserved elsewhere
|
|
113
|
+
* (e.g. `IncomingMessage.raw`) for debugging.
|
|
114
|
+
*/
|
|
115
|
+
export function stripQuotedReply(text: string): string {
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
let cutIndex = lines.length;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = lines[i]!.trim();
|
|
121
|
+
|
|
122
|
+
// Gmail / Apple Mail: "On <date>, <name> wrote:"
|
|
123
|
+
if (/^on\s+.+wrote:\s*$/i.test(line)) {
|
|
124
|
+
cutIndex = i;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Outlook: "-----Original Message-----"
|
|
129
|
+
if (/^-{2,}\s*original message\s*-{2,}$/i.test(line)) {
|
|
130
|
+
cutIndex = i;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Outlook: block starting with "From:" after a blank line
|
|
135
|
+
if (
|
|
136
|
+
/^from:\s/i.test(line) &&
|
|
137
|
+
i > 0 &&
|
|
138
|
+
lines[i - 1]!.trim() === ""
|
|
139
|
+
) {
|
|
140
|
+
// Verify next lines look like an Outlook header block
|
|
141
|
+
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
142
|
+
if (/^(sent|to|cc|subject|date):\s/i.test(nextLine)) {
|
|
143
|
+
cutIndex = i;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Standard "> " quoting: cut at first block of quoted lines
|
|
149
|
+
if (line.startsWith(">")) {
|
|
150
|
+
// Only cut if the previous line is blank or a "wrote:" line
|
|
151
|
+
if (i === 0 || lines[i - 1]!.trim() === "" || /wrote:\s*$/i.test(lines[i - 1]!)) {
|
|
152
|
+
cutIndex = i;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return lines.slice(0, cutIndex).join("\n").trimEnd();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Markdown → email-safe HTML
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Convert a markdown-ish agent response to simple email-safe HTML.
|
|
167
|
+
*
|
|
168
|
+
* This is intentionally lightweight — no external dependency. It handles the
|
|
169
|
+
* most common patterns agents produce: paragraphs, bold, italic, inline code,
|
|
170
|
+
* code blocks, unordered/ordered lists, and headings.
|
|
171
|
+
*/
|
|
172
|
+
export function markdownToEmailHtml(text: string): string {
|
|
173
|
+
const escaped = text
|
|
174
|
+
.replace(/&/g, "&")
|
|
175
|
+
.replace(/</g, "<")
|
|
176
|
+
.replace(/>/g, ">");
|
|
177
|
+
|
|
178
|
+
let html = escaped;
|
|
179
|
+
|
|
180
|
+
// Fenced code blocks
|
|
181
|
+
html = html.replace(
|
|
182
|
+
/```(?:\w*)\n([\s\S]*?)```/g,
|
|
183
|
+
(_m, code: string) =>
|
|
184
|
+
`<pre style="background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto;font-family:monospace;font-size:13px;">${code.trimEnd()}</pre>`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Inline code
|
|
188
|
+
html = html.replace(
|
|
189
|
+
/`([^`]+)`/g,
|
|
190
|
+
'<code style="background:#f5f5f5;padding:2px 4px;border-radius:3px;font-family:monospace;font-size:13px;">$1</code>',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Bold
|
|
194
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
195
|
+
|
|
196
|
+
// Italic
|
|
197
|
+
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
|
198
|
+
|
|
199
|
+
// Headings (### before ## before #)
|
|
200
|
+
html = html.replace(/^### (.+)$/gm, '<h3 style="margin:16px 0 8px;">$1</h3>');
|
|
201
|
+
html = html.replace(/^## (.+)$/gm, '<h2 style="margin:16px 0 8px;">$1</h2>');
|
|
202
|
+
html = html.replace(/^# (.+)$/gm, '<h1 style="margin:16px 0 8px;">$1</h1>');
|
|
203
|
+
|
|
204
|
+
// Unordered lists
|
|
205
|
+
html = html.replace(
|
|
206
|
+
/(?:^[*-] .+(?:\n|$))+/gm,
|
|
207
|
+
(block) => {
|
|
208
|
+
const items = block.trim().split("\n").map((l) => `<li>${l.replace(/^[*-] /, "")}</li>`);
|
|
209
|
+
return `<ul style="margin:8px 0;padding-left:24px;">${items.join("")}</ul>`;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Ordered lists
|
|
214
|
+
html = html.replace(
|
|
215
|
+
/(?:^\d+\. .+(?:\n|$))+/gm,
|
|
216
|
+
(block) => {
|
|
217
|
+
const items = block.trim().split("\n").map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`);
|
|
218
|
+
return `<ol style="margin:8px 0;padding-left:24px;">${items.join("")}</ol>`;
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Paragraphs: double newlines become paragraph breaks
|
|
223
|
+
html = html
|
|
224
|
+
.split(/\n{2,}/)
|
|
225
|
+
.map((p) => {
|
|
226
|
+
const trimmed = p.trim();
|
|
227
|
+
if (!trimmed) return "";
|
|
228
|
+
// Don't wrap block-level elements
|
|
229
|
+
if (/^<(?:h[1-6]|ul|ol|pre|blockquote)/i.test(trimmed)) return trimmed;
|
|
230
|
+
return `<p style="margin:8px 0;">${trimmed.replace(/\n/g, "<br>")}</p>`;
|
|
231
|
+
})
|
|
232
|
+
.join("\n");
|
|
233
|
+
|
|
234
|
+
return `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.6;color:#1a1a1a;">${html}</div>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Sender allowlist matching
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check whether a sender email matches any pattern in an allowlist.
|
|
243
|
+
* Patterns can be exact addresses or domain wildcards like `*@example.com`.
|
|
244
|
+
* Returns `true` if the list is empty/undefined (no restriction).
|
|
245
|
+
*/
|
|
246
|
+
export function matchesSenderPattern(
|
|
247
|
+
sender: string,
|
|
248
|
+
patterns: string[] | undefined,
|
|
249
|
+
): boolean {
|
|
250
|
+
if (!patterns || patterns.length === 0) return true;
|
|
251
|
+
const addr = sender.toLowerCase();
|
|
252
|
+
return patterns.some((pattern) => {
|
|
253
|
+
const p = pattern.toLowerCase();
|
|
254
|
+
if (p.startsWith("*@")) {
|
|
255
|
+
return addr.endsWith(p.slice(1));
|
|
256
|
+
}
|
|
257
|
+
return addr === p;
|
|
258
|
+
});
|
|
259
|
+
}
|