@mcinteerj/openclaw-gmail 1.2.0

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.
@@ -0,0 +1,176 @@
1
+ import { spawn } from "node:child_process";
2
+ import { marked } from "marked";
3
+ import sanitizeHtml from "sanitize-html";
4
+ import { type OutboundContext, type OpenClawConfig } from "openclaw/plugin-sdk";
5
+ import { resolveGmailAccount } from "./accounts.js";
6
+ import { isGmailThreadId } from "./normalize.js";
7
+ import { fetchQuotedContext } from "./quoting.js";
8
+ import { validateThreadReply, isEmailAllowed } from "./outbound-check.js";
9
+ import type { GmailConfig } from "./config.js";
10
+
11
+ export interface GmailOutboundContext extends OutboundContext {
12
+ subject?: string;
13
+ threadId?: string;
14
+ replyToId?: string;
15
+ }
16
+
17
+ async function spawnGog(args: string[], retries = 3): Promise<void> {
18
+ for (let i = 0; i < retries; i++) {
19
+ try {
20
+ await new Promise<void>((resolve, reject) => {
21
+ const proc = spawn("gog", args, { stdio: "pipe" });
22
+ let err = "";
23
+ let out = "";
24
+ proc.stderr.on("data", (d) => err += d.toString());
25
+ proc.stdout.on("data", (d) => out += d.toString());
26
+ proc.on("error", (e) => reject(new Error(`gog failed to spawn: ${e.message}`)));
27
+ proc.on("close", (code) => {
28
+ if (code === 0) resolve();
29
+ else reject(new Error(`gog failed (code ${code}): ${err || out}`));
30
+ });
31
+
32
+ // Add a timeout
33
+ setTimeout(() => {
34
+ proc.kill();
35
+ reject(new Error("gog timed out after 30s"));
36
+ }, 30000);
37
+ });
38
+ return;
39
+ } catch (err) {
40
+ if (i === retries - 1) throw err;
41
+ await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
42
+ }
43
+ }
44
+ }
45
+
46
+ export async function sendGmailText(ctx: GmailOutboundContext) {
47
+ const { to, text, accountId, cfg, threadId, replyToId, subject: explicitSubject } = ctx;
48
+ const account = resolveGmailAccount(cfg, accountId);
49
+ const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
50
+
51
+ // Validate we have a target - prioritize threadId if it's valid
52
+ const effectiveThreadId = isGmailThreadId(String(threadId)) ? String(threadId) : undefined;
53
+ const toValue = effectiveThreadId || to || "";
54
+
55
+ if (!toValue) {
56
+ throw new Error("Gmail send requires a valid 'to' address or thread ID");
57
+ }
58
+
59
+ const args = ["gmail", "send"];
60
+
61
+ if (account.email) {
62
+ args.push("--account", account.email);
63
+ }
64
+
65
+ // Determine if quoted replies are enabled (default: true)
66
+ const accountCfg = gmailCfg?.accounts?.[accountId || "default"];
67
+ const includeQuotedReplies = accountCfg?.includeQuotedReplies
68
+ ?? gmailCfg?.defaults?.includeQuotedReplies
69
+ ?? true;
70
+
71
+ // Determine outbound restrictions
72
+ const allowOutboundTo = accountCfg?.allowOutboundTo
73
+ ?? gmailCfg?.defaults?.allowOutboundTo
74
+ ?? account.allowFrom
75
+ ?? [];
76
+ const threadReplyPolicy = accountCfg?.threadReplyPolicy
77
+ ?? gmailCfg?.defaults?.threadReplyPolicy
78
+ ?? "open"; // Default: open for backwards compatibility
79
+
80
+ // Build the body, potentially with quoted thread context
81
+ let body = text;
82
+ const subject = explicitSubject || "(no subject)";
83
+
84
+ const isThread = isGmailThreadId(toValue);
85
+
86
+ // Validate outbound recipients
87
+ if (isThread && threadReplyPolicy !== "open" && account.email) {
88
+ const validation = await validateThreadReply(
89
+ toValue,
90
+ account.email,
91
+ allowOutboundTo,
92
+ threadReplyPolicy
93
+ );
94
+
95
+ if (!validation.ok) {
96
+ const blockedList = validation.blocked?.join(", ") || "unknown";
97
+ throw new Error(
98
+ `Thread reply blocked by policy (${threadReplyPolicy}): ${validation.reason}. ` +
99
+ `Blocked recipients: ${blockedList}. ` +
100
+ `Add them to allowOutboundTo or change threadReplyPolicy to "open".`
101
+ );
102
+ }
103
+ } else if (!isThread && allowOutboundTo.length > 0) {
104
+ // Direct email: check allowOutboundTo
105
+ if (!isEmailAllowed(toValue, allowOutboundTo)) {
106
+ throw new Error(
107
+ `Direct email to ${toValue} blocked: not in allowOutboundTo list.`
108
+ );
109
+ }
110
+ }
111
+
112
+ if (!isThread) {
113
+ args.push("--to", toValue);
114
+ args.push("--subject", subject);
115
+ } else {
116
+ // Reply to thread
117
+ if (replyToId) {
118
+ args.push("--reply-to-message-id", String(replyToId));
119
+ } else {
120
+ args.push("--thread-id", toValue);
121
+ }
122
+
123
+ args.push("--subject", subject);
124
+ args.push("--reply-all");
125
+
126
+ // Fetch and append quoted thread context if enabled
127
+ if (includeQuotedReplies && account.email) {
128
+ try {
129
+ const quotedContext = await fetchQuotedContext(
130
+ toValue,
131
+ account.email,
132
+ account.email
133
+ );
134
+ if (quotedContext) {
135
+ body = `${text}\n\n${quotedContext}`;
136
+ }
137
+ } catch (err) {
138
+ // Non-fatal: proceed without quoted context
139
+ console.error(`[gmail] Failed to fetch quoted context: ${err}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Convert body to HTML and sanitize
145
+ try {
146
+ const rawHtml = await marked.parse(body);
147
+ const cleanHtml = sanitizeHtml(rawHtml, {
148
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
149
+ allowedAttributes: {
150
+ ...sanitizeHtml.defaults.allowedAttributes,
151
+ '*': ['style', 'class']
152
+ }
153
+ });
154
+ args.push("--body-html", cleanHtml);
155
+ args.push("--body", body);
156
+ } catch (err) {
157
+ console.error("Markdown parsing or sanitization failed, sending plain text", err);
158
+ args.push("--body", body);
159
+ }
160
+
161
+ await spawnGog(args);
162
+
163
+ // Archive if it was a thread (Reply = Archive)
164
+ if (isThread) {
165
+ const archiveArgs = ["gmail", "labels", "modify", toValue];
166
+ if (account.email) archiveArgs.push("--account", account.email);
167
+ archiveArgs.push("--remove", "INBOX");
168
+
169
+ // Best effort archive
170
+ spawnGog(archiveArgs).catch((err) => {
171
+ console.error(`Failed to archive thread ${toValue}: ${err.message}`);
172
+ });
173
+ }
174
+
175
+ return { id: "sent" };
176
+ }
package/src/quoting.ts ADDED
@@ -0,0 +1,230 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface ThreadMessage {
4
+ id: string;
5
+ threadId: string;
6
+ date: string;
7
+ from: string;
8
+ to?: string;
9
+ cc?: string;
10
+ subject: string;
11
+ body: string;
12
+ labels: string[];
13
+ }
14
+
15
+ export interface ThreadResponse {
16
+ id: string;
17
+ historyId: string;
18
+ messages: ThreadMessage[];
19
+ }
20
+
21
+ // Raw gog output types
22
+ interface GogThreadOutput {
23
+ downloaded: unknown;
24
+ thread: {
25
+ id: string;
26
+ historyId: string;
27
+ messages: GogRawMessage[];
28
+ };
29
+ }
30
+
31
+ interface GogRawMessage {
32
+ id: string;
33
+ threadId: string;
34
+ internalDate: string;
35
+ labelIds: string[];
36
+ payload: {
37
+ headers: { name: string; value: string }[];
38
+ parts?: { body?: { data?: string }; mimeType: string }[];
39
+ body?: { data?: string };
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Extract header value from gog message payload
45
+ */
46
+ function getHeader(msg: GogRawMessage, name: string): string | undefined {
47
+ return msg.payload.headers.find(
48
+ (h) => h.name.toLowerCase() === name.toLowerCase()
49
+ )?.value;
50
+ }
51
+
52
+ /**
53
+ * Extract plain text body from gog message
54
+ */
55
+ function extractBody(msg: GogRawMessage): string {
56
+ // Try multipart first
57
+ if (msg.payload.parts) {
58
+ const plainPart = msg.payload.parts.find((p) => p.mimeType === "text/plain");
59
+ if (plainPart?.body?.data) {
60
+ return Buffer.from(plainPart.body.data, "base64").toString("utf-8");
61
+ }
62
+ }
63
+ // Fallback to direct body
64
+ if (msg.payload.body?.data) {
65
+ return Buffer.from(msg.payload.body.data, "base64").toString("utf-8");
66
+ }
67
+ return "";
68
+ }
69
+
70
+ /**
71
+ * Convert gog raw message to our ThreadMessage format
72
+ */
73
+ function parseGogMessage(raw: GogRawMessage): ThreadMessage {
74
+ const from = getHeader(raw, "From") || "";
75
+ const date = getHeader(raw, "Date") || new Date(parseInt(raw.internalDate)).toISOString();
76
+ const subject = getHeader(raw, "Subject") || "";
77
+ const body = extractBody(raw);
78
+
79
+ return {
80
+ id: raw.id,
81
+ threadId: raw.threadId,
82
+ date,
83
+ from,
84
+ subject,
85
+ body,
86
+ labels: raw.labelIds || [],
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Fetch thread data from gog CLI
92
+ */
93
+ async function fetchThread(threadId: string, account?: string): Promise<ThreadResponse | null> {
94
+ const args = ["gmail", "thread", "get", threadId, "--full", "--json"];
95
+ if (account) {
96
+ args.push("--account", account);
97
+ }
98
+
99
+ return new Promise((resolve) => {
100
+ const proc = spawn("gog", args, { stdio: "pipe" });
101
+ let stdout = "";
102
+ let stderr = "";
103
+
104
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
105
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
106
+
107
+ proc.on("error", (e) => {
108
+ console.error(`[gmail] Failed to spawn gog for thread fetch: ${e.message}`);
109
+ resolve(null);
110
+ });
111
+
112
+ proc.on("close", (code) => {
113
+ if (code !== 0) {
114
+ console.error(`[gmail] gog thread get failed (code ${code}): ${stderr}`);
115
+ resolve(null);
116
+ return;
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(stdout) as GogThreadOutput;
120
+ // gog wraps the thread in { downloaded, thread }
121
+ const thread = parsed.thread;
122
+ if (!thread || !thread.messages) {
123
+ resolve(null);
124
+ return;
125
+ }
126
+ resolve({
127
+ id: thread.id,
128
+ historyId: thread.historyId,
129
+ messages: thread.messages.map(parseGogMessage),
130
+ });
131
+ } catch (e) {
132
+ console.error(`[gmail] Failed to parse thread JSON: ${e}`);
133
+ resolve(null);
134
+ }
135
+ });
136
+
137
+ // Timeout after 15s
138
+ setTimeout(() => {
139
+ proc.kill();
140
+ console.error(`[gmail] gog thread get timed out`);
141
+ resolve(null);
142
+ }, 15000);
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Format a date string for the quote header
148
+ */
149
+ function formatQuoteDate(dateStr: string): string {
150
+ try {
151
+ const date = new Date(dateStr);
152
+ return date.toLocaleString("en-US", {
153
+ weekday: "short",
154
+ year: "numeric",
155
+ month: "short",
156
+ day: "numeric",
157
+ hour: "numeric",
158
+ minute: "2-digit",
159
+ timeZoneName: "short",
160
+ });
161
+ } catch {
162
+ return dateStr;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Extract display name or email from "Name <email>" format
168
+ */
169
+ function extractSenderDisplay(from: string): string {
170
+ const match = from.match(/^(.*?)\s*<(.*)>$/);
171
+ if (match) {
172
+ const name = match[1].replace(/^"|"$/g, "").trim();
173
+ return name || match[2];
174
+ }
175
+ return from;
176
+ }
177
+
178
+ /**
179
+ * Include message body as-is (flat, no ">" prefix to avoid staggered indentation)
180
+ */
181
+ function quoteBody(body: string): string {
182
+ return body;
183
+ }
184
+
185
+ /**
186
+ * Build quoted context from the most recent non-self message.
187
+ * Gmail standard: only quote the last message (which itself contains older quotes).
188
+ * This avoids nested/staggered quoting.
189
+ */
190
+ export function buildQuotedThread(
191
+ messages: ThreadMessage[],
192
+ accountEmail: string
193
+ ): string {
194
+ // Filter out messages from the account itself
195
+ const otherMessages = messages.filter((msg) => {
196
+ const emailMatch = msg.from.match(/<(.*)>/);
197
+ const senderEmail = emailMatch ? emailMatch[1] : msg.from;
198
+ return senderEmail.toLowerCase() !== accountEmail.toLowerCase();
199
+ });
200
+
201
+ if (otherMessages.length === 0) {
202
+ return "";
203
+ }
204
+
205
+ // Only quote the most recent message from others (last in array = newest)
206
+ const lastMsg = otherMessages[otherMessages.length - 1];
207
+ const sender = extractSenderDisplay(lastMsg.from);
208
+ const date = formatQuoteDate(lastMsg.date);
209
+ const header = `On ${date}, ${sender} wrote:`;
210
+ const quoted = quoteBody(lastMsg.body.trim());
211
+
212
+ return `${header}\n\n${quoted}`;
213
+ }
214
+
215
+ /**
216
+ * Fetch and format quoted context for a thread reply.
217
+ * Returns the formatted quote block or empty string if unavailable.
218
+ */
219
+ export async function fetchQuotedContext(
220
+ threadId: string,
221
+ accountEmail: string,
222
+ accountArg?: string
223
+ ): Promise<string> {
224
+ const thread = await fetchThread(threadId, accountArg);
225
+ if (!thread || !thread.messages || thread.messages.length === 0) {
226
+ return "";
227
+ }
228
+
229
+ return buildQuotedThread(thread.messages, accountEmail);
230
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setGmailRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getGmailRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Gmail runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ htmlToText,
4
+ stripFooterJunk,
5
+ cleanWhitespace,
6
+ sanitizeEmailBody,
7
+ } from "./sanitize.js";
8
+
9
+ // ── htmlToText ─────────────────────────────────────────────────────────────
10
+
11
+ describe("htmlToText", () => {
12
+ it("strips <style> blocks", () => {
13
+ const html = `<style>.foo{color:red}</style><p>Hello</p>`;
14
+ expect(htmlToText(html)).toContain("Hello");
15
+ expect(htmlToText(html)).not.toContain("color");
16
+ });
17
+
18
+ it("strips <script> blocks", () => {
19
+ const html = `<script>alert("x")</script><p>Hello</p>`;
20
+ expect(htmlToText(html)).toContain("Hello");
21
+ expect(htmlToText(html)).not.toContain("alert");
22
+ });
23
+
24
+ it("strips <head> blocks", () => {
25
+ const html = `<head><meta charset="utf-8"><title>Email</title></head><body>Content</body>`;
26
+ expect(htmlToText(html)).toContain("Content");
27
+ expect(htmlToText(html)).not.toContain("charset");
28
+ });
29
+
30
+ it("removes HTML comments", () => {
31
+ const html = `<!-- tracking comment -->Hello`;
32
+ expect(htmlToText(html)).toBe("Hello");
33
+ });
34
+
35
+ it("removes 1×1 tracking pixels", () => {
36
+ const html = `<img width="1" height="1" src="https://track.example.com/open.gif" />Visible`;
37
+ expect(htmlToText(html)).toBe("Visible");
38
+ });
39
+
40
+ it("removes tracking pixels when value is followed by > (no trailing space)", () => {
41
+ // width="1"> with no space before closing bracket
42
+ expect(htmlToText(`<img width="1" height="1">Visible`)).toBe("Visible");
43
+ expect(htmlToText(`<img width='1' height='1'>Visible`)).toBe("Visible");
44
+ expect(htmlToText(`<img width=1 height=1>Visible`)).toBe("Visible");
45
+ // Single dimension should still match
46
+ expect(htmlToText(`<img width="1" src="https://t.co/px.gif">Visible`)).toBe("Visible");
47
+ expect(htmlToText(`<img height="1" src="https://t.co/px.gif">Visible`)).toBe("Visible");
48
+ });
49
+
50
+ it("removes display:none images", () => {
51
+ const html = `<img style="display:none" src="https://example.com/img.png" />Visible`;
52
+ expect(htmlToText(html)).toBe("Visible");
53
+ });
54
+
55
+ it("removes base64 data URI images", () => {
56
+ const html = `<img src="data:image/png;base64,iVBORw0KGgo=" />Visible`;
57
+ expect(htmlToText(html)).toBe("Visible");
58
+ });
59
+
60
+ it("converts <br> to newlines", () => {
61
+ const html = `Line 1<br>Line 2<br/>Line 3`;
62
+ expect(htmlToText(html)).toBe("Line 1\nLine 2\nLine 3");
63
+ });
64
+
65
+ it("converts block elements to newlines", () => {
66
+ const html = `<div>Block 1</div><div>Block 2</div>`;
67
+ const text = htmlToText(html);
68
+ expect(text).toContain("Block 1");
69
+ expect(text).toContain("Block 2");
70
+ expect(text).toMatch(/Block 1\n+Block 2/);
71
+ });
72
+
73
+ it("extracts link text with URL when different", () => {
74
+ const html = `<a href="https://example.com">Click here</a>`;
75
+ expect(htmlToText(html)).toBe("Click here (https://example.com)");
76
+ });
77
+
78
+ it("shows only link text when URL matches text", () => {
79
+ const html = `<a href="https://example.com">https://example.com</a>`;
80
+ expect(htmlToText(html)).toBe("https://example.com");
81
+ });
82
+
83
+ it("shows only link text for mailto links", () => {
84
+ const html = `<a href="mailto:test@example.com">test@example.com</a>`;
85
+ expect(htmlToText(html)).toBe("test@example.com");
86
+ });
87
+
88
+ it("strips remaining HTML tags", () => {
89
+ const html = `<span class="foo"><strong>Bold</strong> text</span>`;
90
+ expect(htmlToText(html)).toBe("Bold text");
91
+ });
92
+
93
+ it("decodes named HTML entities", () => {
94
+ expect(htmlToText("&amp; &lt; &gt; &quot; &apos;")).toBe('& < > " \'');
95
+ });
96
+
97
+ it("decodes numeric HTML entities", () => {
98
+ expect(htmlToText("&#39;")).toBe("'");
99
+ expect(htmlToText("&#x27;")).toBe("'");
100
+ });
101
+
102
+ it("decodes &nbsp; to regular space", () => {
103
+ expect(htmlToText("Hello&nbsp;World")).toBe("Hello World");
104
+ });
105
+ });
106
+
107
+ // ── stripFooterJunk ────────────────────────────────────────────────────────
108
+
109
+ describe("stripFooterJunk", () => {
110
+ it("removes 'Sent from my iPhone'", () => {
111
+ const text = "Actual message\n\nSent from my iPhone";
112
+ expect(stripFooterJunk(text).trim()).toBe("Actual message");
113
+ });
114
+
115
+ it("removes 'Sent from my Galaxy' (case-insensitive)", () => {
116
+ const text = "Body\nSent from my Galaxy S24";
117
+ expect(stripFooterJunk(text).trim()).toBe("Body");
118
+ });
119
+
120
+ it("removes 'Get Outlook for iOS'", () => {
121
+ const text = "Body\n\nGet Outlook for iOS";
122
+ expect(stripFooterJunk(text).trim()).toBe("Body");
123
+ });
124
+
125
+ it("removes unsubscribe lines", () => {
126
+ const text = "Content\n\nTo unsubscribe click here";
127
+ expect(stripFooterJunk(text).trim()).toBe("Content");
128
+ });
129
+
130
+ it("removes confidentiality disclaimers", () => {
131
+ const text =
132
+ "Content\n\nThis email is confidential and intended only for the intended recipient.";
133
+ expect(stripFooterJunk(text).trim()).toBe("Content");
134
+ });
135
+
136
+ it("chops everything after signature separator '--'", () => {
137
+ const text = "Real content\n--\nJohn Doe\nCEO, Example Corp";
138
+ expect(stripFooterJunk(text).trim()).toBe("Real content");
139
+ });
140
+
141
+ it("preserves signature block when stripSignature is false", () => {
142
+ const text = "Real content\n--\nJohn Doe\nCEO, Example Corp";
143
+ const result = stripFooterJunk(text, false);
144
+ expect(result).toContain("Real content");
145
+ expect(result).toContain("John Doe");
146
+ expect(result).toContain("CEO, Example Corp");
147
+ });
148
+
149
+ it("removes copyright footers", () => {
150
+ const text = "Content\n\n© 2024 Example Corp. All rights reserved.";
151
+ const result = stripFooterJunk(text).trim();
152
+ expect(result).not.toContain("©");
153
+ expect(result).toContain("Content");
154
+ });
155
+
156
+ it("preserves normal content", () => {
157
+ const text = "This is a normal email with no junk.";
158
+ expect(stripFooterJunk(text)).toBe(text);
159
+ });
160
+ });
161
+
162
+ // ── cleanWhitespace ────────────────────────────────────────────────────────
163
+
164
+ describe("cleanWhitespace", () => {
165
+ it("trims each line", () => {
166
+ expect(cleanWhitespace(" hello \n world ")).toBe("hello\nworld");
167
+ });
168
+
169
+ it("collapses 3+ newlines to 2", () => {
170
+ expect(cleanWhitespace("a\n\n\n\nb")).toBe("a\n\nb");
171
+ });
172
+
173
+ it("trims leading/trailing whitespace", () => {
174
+ expect(cleanWhitespace("\n\n hello \n\n")).toBe("hello");
175
+ });
176
+
177
+ it("handles empty string", () => {
178
+ expect(cleanWhitespace("")).toBe("");
179
+ });
180
+ });
181
+
182
+ // ── sanitizeEmailBody (full pipeline) ──────────────────────────────────────
183
+
184
+ describe("sanitizeEmailBody", () => {
185
+ it("converts a full HTML email to clean text", () => {
186
+ const html = `
187
+ <html>
188
+ <head><style>.x{color:red}</style></head>
189
+ <body>
190
+ <div>Hi there,</div>
191
+ <p>This is the <strong>important</strong> message.</p>
192
+ <br>
193
+ <p>Check out <a href="https://example.com">our site</a>.</p>
194
+ <img width="1" height="1" src="https://track.example.com/pixel.gif" />
195
+ <p>Sent from my iPhone</p>
196
+ </body>
197
+ </html>
198
+ `;
199
+ const result = sanitizeEmailBody(html);
200
+ expect(result).toContain("Hi there,");
201
+ expect(result).toContain("important");
202
+ expect(result).toContain("our site (https://example.com)");
203
+ expect(result).not.toContain("<");
204
+ expect(result).not.toContain("style");
205
+ expect(result).not.toContain("track.example.com");
206
+ expect(result).not.toContain("Sent from my iPhone");
207
+ });
208
+
209
+ it("handles plain text HTML (no real tags)", () => {
210
+ const html = "Just a plain string with no HTML.";
211
+ expect(sanitizeEmailBody(html)).toBe("Just a plain string with no HTML.");
212
+ });
213
+
214
+ it("handles empty string", () => {
215
+ expect(sanitizeEmailBody("")).toBe("");
216
+ });
217
+
218
+ it("strips base64 inline images from newsletters", () => {
219
+ const html = `<p>Hello</p><img src="data:image/png;base64,abc123=" /><p>Bye</p>`;
220
+ const result = sanitizeEmailBody(html);
221
+ expect(result).not.toContain("data:");
222
+ expect(result).toContain("Hello");
223
+ expect(result).toContain("Bye");
224
+ });
225
+
226
+ it("handles deeply nested HTML", () => {
227
+ const html = `
228
+ <div><div><div><span>Deep content</span></div></div></div>
229
+ `;
230
+ expect(sanitizeEmailBody(html)).toContain("Deep content");
231
+ });
232
+
233
+ it("preserves signature when stripSignature option is false", () => {
234
+ const html = `<p>Hello</p><p>--</p><p>John Doe</p>`;
235
+ const result = sanitizeEmailBody(html, { stripSignature: false });
236
+ expect(result).toContain("Hello");
237
+ expect(result).toContain("John Doe");
238
+ });
239
+ });