@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/messaging",
3
- "version": "0.2.0",
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.1"
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, "&amp;")
175
+ .replace(/</g, "&lt;")
176
+ .replace(/>/g, "&gt;");
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
+ }