@mcinteerj/openclaw-gmail 1.3.0 → 1.4.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.
- package/package.json +7 -4
- package/src/accounts.ts +8 -0
- package/src/api-client.ts +406 -0
- package/src/auth.ts +217 -0
- package/src/channel.ts +54 -24
- package/src/config.ts +6 -0
- package/src/gmail-client.ts +50 -0
- package/src/gog-client.ts +230 -0
- package/src/mime.ts +37 -0
- package/src/monitor.ts +52 -158
- package/src/onboarding.ts +171 -30
- package/src/outbound-check.ts +67 -107
- package/src/outbound.ts +63 -109
- package/src/quoting.ts +17 -58
package/src/outbound-check.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thread recipient validation for Gmail outbound.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Validates that thread reply recipients are permitted by allowOutboundTo.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import type { GmailClient } from "./gmail-client.js";
|
|
8
8
|
|
|
9
9
|
export interface ThreadParticipant {
|
|
10
10
|
email: string;
|
|
@@ -16,94 +16,9 @@ export interface ThreadData {
|
|
|
16
16
|
originalSender: string | null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Fetch thread data (participants and original sender) in a single call.
|
|
21
|
-
*/
|
|
22
|
-
export async function fetchThreadData(
|
|
23
|
-
threadId: string,
|
|
24
|
-
accountEmail: string
|
|
25
|
-
): Promise<ThreadData> {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const args = ["gmail", "thread", "get", threadId, "--json", "--account", accountEmail];
|
|
28
|
-
const proc = spawn("gog", args, { stdio: "pipe" });
|
|
29
|
-
let stdout = "";
|
|
30
|
-
let stderr = "";
|
|
31
|
-
let settled = false;
|
|
32
|
-
|
|
33
|
-
const timeout = setTimeout(() => {
|
|
34
|
-
if (!settled) {
|
|
35
|
-
settled = true;
|
|
36
|
-
proc.kill();
|
|
37
|
-
reject(new Error("Timeout fetching thread data"));
|
|
38
|
-
}
|
|
39
|
-
}, 10000);
|
|
40
|
-
|
|
41
|
-
const cleanup = () => {
|
|
42
|
-
clearTimeout(timeout);
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
proc.stdout.on("data", (d) => (stdout += d.toString()));
|
|
46
|
-
proc.stderr.on("data", (d) => (stderr += d.toString()));
|
|
47
|
-
|
|
48
|
-
proc.on("close", (code) => {
|
|
49
|
-
if (settled) return;
|
|
50
|
-
settled = true;
|
|
51
|
-
cleanup();
|
|
52
|
-
|
|
53
|
-
if (code !== 0) {
|
|
54
|
-
reject(new Error(`Failed to fetch thread: ${stderr}`));
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const data = JSON.parse(stdout);
|
|
60
|
-
const messages = data.thread?.messages || [];
|
|
61
|
-
const participants = new Map<string, ThreadParticipant>();
|
|
62
|
-
let originalSender: string | null = null;
|
|
63
|
-
|
|
64
|
-
for (let i = 0; i < messages.length; i++) {
|
|
65
|
-
const msg = messages[i];
|
|
66
|
-
const headers = msg.payload?.headers || [];
|
|
67
|
-
|
|
68
|
-
for (const header of headers) {
|
|
69
|
-
const name = header.name.toLowerCase();
|
|
70
|
-
if (["from", "to", "cc"].includes(name)) {
|
|
71
|
-
const addresses = parseEmailAddresses(header.value);
|
|
72
|
-
for (const addr of addresses) {
|
|
73
|
-
if (addr.email.toLowerCase() !== accountEmail.toLowerCase()) {
|
|
74
|
-
participants.set(addr.email.toLowerCase(), addr);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Capture original sender from first message's From header
|
|
78
|
-
if (i === 0 && name === "from" && !originalSender) {
|
|
79
|
-
originalSender = addr.email;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
resolve({
|
|
87
|
-
participants: Array.from(participants.values()),
|
|
88
|
-
originalSender,
|
|
89
|
-
});
|
|
90
|
-
} catch (e) {
|
|
91
|
-
reject(new Error(`Failed to parse thread: ${e}`));
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
proc.on("error", (e) => {
|
|
96
|
-
if (settled) return;
|
|
97
|
-
settled = true;
|
|
98
|
-
cleanup();
|
|
99
|
-
reject(new Error(`Failed to spawn gog: ${e.message}`));
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
19
|
/**
|
|
105
20
|
* Parse email addresses from a header value like "Name <email>, Other <email2>"
|
|
106
|
-
*
|
|
21
|
+
*
|
|
107
22
|
* Handles:
|
|
108
23
|
* - Simple: email@example.com
|
|
109
24
|
* - Named: Name <email@example.com>
|
|
@@ -113,20 +28,24 @@ export async function fetchThreadData(
|
|
|
113
28
|
export function parseEmailAddresses(value: string): ThreadParticipant[] {
|
|
114
29
|
const results: ThreadParticipant[] = [];
|
|
115
30
|
if (!value || typeof value !== "string") return results;
|
|
116
|
-
|
|
31
|
+
|
|
117
32
|
// Split by comma, but respect quoted strings
|
|
118
33
|
// This regex splits on commas that are NOT inside quotes
|
|
119
34
|
const parts = value.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
|
|
120
|
-
|
|
35
|
+
|
|
121
36
|
for (const part of parts) {
|
|
122
37
|
const trimmed = part.trim();
|
|
123
38
|
if (!trimmed) continue;
|
|
124
|
-
|
|
39
|
+
|
|
125
40
|
// Try to match "Name" <email> or Name <email>
|
|
126
|
-
|
|
127
|
-
|
|
41
|
+
// First pattern: quoted name with possible escaped quotes inside
|
|
42
|
+
// Second pattern: unquoted name
|
|
43
|
+
const match =
|
|
44
|
+
trimmed.match(/^"((?:[^"\\]|\\.)*?)"\s*<([^>]+)>$/) ||
|
|
45
|
+
trimmed.match(/^(?:([^"<]*)\s+)?<([^>]+)>$/);
|
|
46
|
+
|
|
128
47
|
if (match) {
|
|
129
|
-
const name = match[1]?.trim().replace(
|
|
48
|
+
const name = match[1]?.trim().replace(/\\"/g, '"');
|
|
130
49
|
const email = match[2].trim().toLowerCase();
|
|
131
50
|
if (email.includes("@")) {
|
|
132
51
|
results.push({ name: name || undefined, email });
|
|
@@ -136,13 +55,13 @@ export function parseEmailAddresses(value: string): ThreadParticipant[] {
|
|
|
136
55
|
results.push({ email: trimmed.toLowerCase() });
|
|
137
56
|
}
|
|
138
57
|
}
|
|
139
|
-
|
|
58
|
+
|
|
140
59
|
return results;
|
|
141
60
|
}
|
|
142
61
|
|
|
143
62
|
/**
|
|
144
63
|
* Check if an email is allowed by the allowlist.
|
|
145
|
-
*
|
|
64
|
+
*
|
|
146
65
|
* Rules:
|
|
147
66
|
* - Empty list = no restriction (returns true)
|
|
148
67
|
* - "*" in list = allow all
|
|
@@ -153,10 +72,10 @@ export function isEmailAllowed(email: string, allowList: string[]): boolean {
|
|
|
153
72
|
// Empty list = no restriction
|
|
154
73
|
if (allowList.length === 0) return true;
|
|
155
74
|
if (allowList.includes("*")) return true;
|
|
156
|
-
|
|
75
|
+
|
|
157
76
|
if (!email) return false;
|
|
158
77
|
const normalized = email.toLowerCase();
|
|
159
|
-
|
|
78
|
+
|
|
160
79
|
return allowList.some((entry) => {
|
|
161
80
|
const e = entry.toLowerCase().trim();
|
|
162
81
|
if (!e) return false;
|
|
@@ -173,9 +92,45 @@ export interface ValidationResult {
|
|
|
173
92
|
reason?: string;
|
|
174
93
|
}
|
|
175
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Extract ThreadData (participants + original sender) from thread messages.
|
|
97
|
+
* Replaces the old fetchThreadData() that spawned gog directly.
|
|
98
|
+
*/
|
|
99
|
+
function extractThreadData(
|
|
100
|
+
messages: { from: string; to?: string; cc?: string }[],
|
|
101
|
+
accountEmail: string,
|
|
102
|
+
): ThreadData {
|
|
103
|
+
const participants = new Map<string, ThreadParticipant>();
|
|
104
|
+
let originalSender: string | null = null;
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < messages.length; i++) {
|
|
107
|
+
const msg = messages[i];
|
|
108
|
+
const fields: string[] = [msg.from, msg.to, msg.cc].filter(Boolean) as string[];
|
|
109
|
+
|
|
110
|
+
for (const field of fields) {
|
|
111
|
+
const addresses = parseEmailAddresses(field);
|
|
112
|
+
for (const addr of addresses) {
|
|
113
|
+
if (addr.email.toLowerCase() !== accountEmail.toLowerCase()) {
|
|
114
|
+
participants.set(addr.email.toLowerCase(), addr);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Capture original sender from first message's From field
|
|
118
|
+
if (i === 0 && field === msg.from && !originalSender) {
|
|
119
|
+
originalSender = addr.email;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
participants: Array.from(participants.values()),
|
|
127
|
+
originalSender,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
176
131
|
/**
|
|
177
132
|
* Validate thread recipients against policy.
|
|
178
|
-
*
|
|
133
|
+
*
|
|
179
134
|
* Policies:
|
|
180
135
|
* - "open": Allow replies to anyone (default, backwards compatible)
|
|
181
136
|
* - "allowlist": All recipients must be in allowOutboundTo
|
|
@@ -185,7 +140,8 @@ export async function validateThreadReply(
|
|
|
185
140
|
threadId: string,
|
|
186
141
|
accountEmail: string,
|
|
187
142
|
allowOutboundTo: string[],
|
|
188
|
-
policy: "open" | "allowlist" | "sender-only"
|
|
143
|
+
policy: "open" | "allowlist" | "sender-only",
|
|
144
|
+
client: GmailClient,
|
|
189
145
|
): Promise<ValidationResult> {
|
|
190
146
|
if (policy === "open") {
|
|
191
147
|
return { ok: true };
|
|
@@ -193,7 +149,11 @@ export async function validateThreadReply(
|
|
|
193
149
|
|
|
194
150
|
let threadData: ThreadData;
|
|
195
151
|
try {
|
|
196
|
-
|
|
152
|
+
const thread = await client.getThread(threadId);
|
|
153
|
+
if (!thread) {
|
|
154
|
+
return { ok: false, reason: "Could not fetch thread data" };
|
|
155
|
+
}
|
|
156
|
+
threadData = extractThreadData(thread.messages, accountEmail);
|
|
197
157
|
} catch (err) {
|
|
198
158
|
console.error(`[gmail] Failed to fetch thread data for validation: ${err}`);
|
|
199
159
|
return { ok: false, reason: `Could not fetch thread data: ${err}` };
|
|
@@ -204,15 +164,15 @@ export async function validateThreadReply(
|
|
|
204
164
|
console.error(`[gmail] Could not determine original sender for thread ${threadId}`);
|
|
205
165
|
return { ok: false, reason: "Could not determine thread sender" };
|
|
206
166
|
}
|
|
207
|
-
|
|
167
|
+
|
|
208
168
|
if (!isEmailAllowed(threadData.originalSender, allowOutboundTo)) {
|
|
209
|
-
return {
|
|
210
|
-
ok: false,
|
|
211
|
-
blocked: [threadData.originalSender],
|
|
212
|
-
reason: "Thread sender not in allowOutboundTo"
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
blocked: [threadData.originalSender],
|
|
172
|
+
reason: "Thread sender not in allowOutboundTo"
|
|
213
173
|
};
|
|
214
174
|
}
|
|
215
|
-
|
|
175
|
+
|
|
216
176
|
return { ok: true };
|
|
217
177
|
}
|
|
218
178
|
|
package/src/outbound.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { marked } from "marked";
|
|
3
2
|
import sanitizeHtml from "sanitize-html";
|
|
4
3
|
import { type OutboundContext, type OpenClawConfig } from "openclaw/plugin-sdk";
|
|
@@ -7,74 +6,41 @@ import { isGmailThreadId } from "./normalize.js";
|
|
|
7
6
|
import { fetchQuotedContext, type QuotedContent } from "./quoting.js";
|
|
8
7
|
import { validateThreadReply, isEmailAllowed } from "./outbound-check.js";
|
|
9
8
|
import type { GmailConfig } from "./config.js";
|
|
9
|
+
import type { GmailClient } from "./gmail-client.js";
|
|
10
10
|
|
|
11
11
|
export interface GmailOutboundContext extends OutboundContext {
|
|
12
12
|
subject?: string;
|
|
13
13
|
threadId?: string;
|
|
14
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
|
-
}
|
|
15
|
+
client: GmailClient;
|
|
44
16
|
}
|
|
45
17
|
|
|
46
18
|
export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
47
|
-
const { to, text, accountId, cfg, threadId, replyToId, subject: explicitSubject } = ctx;
|
|
19
|
+
const { to, text, accountId, cfg, threadId, replyToId, subject: explicitSubject, client } = ctx;
|
|
48
20
|
const account = resolveGmailAccount(cfg, accountId);
|
|
49
21
|
const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
|
|
50
|
-
|
|
22
|
+
|
|
51
23
|
// Validate we have a target - prioritize threadId if it's valid
|
|
52
24
|
const effectiveThreadId = isGmailThreadId(String(threadId)) ? String(threadId) : undefined;
|
|
53
25
|
const toValue = effectiveThreadId || to || "";
|
|
54
|
-
|
|
26
|
+
|
|
55
27
|
if (!toValue) {
|
|
56
28
|
throw new Error("Gmail send requires a valid 'to' address or thread ID");
|
|
57
29
|
}
|
|
58
30
|
|
|
59
|
-
const args = ["gmail", "send"];
|
|
60
|
-
|
|
61
|
-
if (account.email) {
|
|
62
|
-
args.push("--account", account.email);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
31
|
// Determine if quoted replies are enabled (default: true)
|
|
66
32
|
const accountCfg = gmailCfg?.accounts?.[accountId || "default"];
|
|
67
|
-
const includeQuotedReplies = accountCfg?.includeQuotedReplies
|
|
68
|
-
?? gmailCfg?.defaults?.includeQuotedReplies
|
|
33
|
+
const includeQuotedReplies = accountCfg?.includeQuotedReplies
|
|
34
|
+
?? gmailCfg?.defaults?.includeQuotedReplies
|
|
69
35
|
?? true;
|
|
70
36
|
|
|
71
37
|
// Determine outbound restrictions
|
|
72
|
-
const allowOutboundTo = accountCfg?.allowOutboundTo
|
|
73
|
-
?? gmailCfg?.defaults?.allowOutboundTo
|
|
74
|
-
?? account.allowFrom
|
|
38
|
+
const allowOutboundTo = accountCfg?.allowOutboundTo
|
|
39
|
+
?? gmailCfg?.defaults?.allowOutboundTo
|
|
40
|
+
?? account.allowFrom
|
|
75
41
|
?? [];
|
|
76
|
-
const threadReplyPolicy = accountCfg?.threadReplyPolicy
|
|
77
|
-
?? gmailCfg?.defaults?.threadReplyPolicy
|
|
42
|
+
const threadReplyPolicy = accountCfg?.threadReplyPolicy
|
|
43
|
+
?? gmailCfg?.defaults?.threadReplyPolicy
|
|
78
44
|
?? "open"; // Default: open for backwards compatibility
|
|
79
45
|
|
|
80
46
|
const subject = explicitSubject || "(no subject)";
|
|
@@ -87,9 +53,10 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
|
87
53
|
toValue,
|
|
88
54
|
account.email,
|
|
89
55
|
allowOutboundTo,
|
|
90
|
-
threadReplyPolicy
|
|
56
|
+
threadReplyPolicy,
|
|
57
|
+
client,
|
|
91
58
|
);
|
|
92
|
-
|
|
59
|
+
|
|
93
60
|
if (!validation.ok) {
|
|
94
61
|
const blockedList = validation.blocked?.join(", ") || "unknown";
|
|
95
62
|
throw new Error(
|
|
@@ -107,83 +74,70 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
|
107
74
|
}
|
|
108
75
|
}
|
|
109
76
|
|
|
110
|
-
if
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
args.push("--reply-all");
|
|
123
|
-
|
|
124
|
-
// Fetch quoted thread context if enabled
|
|
125
|
-
if (includeQuotedReplies && account.email) {
|
|
126
|
-
try {
|
|
127
|
-
quotedContent = await fetchQuotedContext(
|
|
128
|
-
toValue,
|
|
129
|
-
account.email,
|
|
130
|
-
account.email
|
|
131
|
-
);
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// Non-fatal: proceed without quoted context
|
|
134
|
-
console.error(`[gmail] Failed to fetch quoted context: ${err}`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
77
|
+
// Fetch quoted thread context if enabled
|
|
78
|
+
if (isThread && includeQuotedReplies && account.email) {
|
|
79
|
+
try {
|
|
80
|
+
quotedContent = await fetchQuotedContext(
|
|
81
|
+
toValue,
|
|
82
|
+
account.email,
|
|
83
|
+
client,
|
|
84
|
+
);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Non-fatal: proceed without quoted context
|
|
87
|
+
console.error(`[gmail] Failed to fetch quoted context: ${err}`);
|
|
88
|
+
}
|
|
137
89
|
}
|
|
138
90
|
|
|
139
91
|
// Convert reply text to HTML (quotes are handled separately to avoid markdown mangling)
|
|
92
|
+
let fullHtml: string | undefined;
|
|
93
|
+
let plainBody = text;
|
|
94
|
+
|
|
140
95
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// Build Gmail-style blockquote HTML for the quoted content
|
|
151
|
-
let fullHtml = replyHtml;
|
|
152
|
-
let plainBody = text;
|
|
153
|
-
if (quotedContent) {
|
|
154
|
-
fullHtml += `<div class="gmail_quote">` +
|
|
155
|
-
`<div dir="ltr" class="gmail_attr">${sanitizeHtml(quotedContent.header, { allowedTags: [], allowedAttributes: {} })}</div>` +
|
|
156
|
-
`<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">` +
|
|
157
|
-
`${quotedContent.bodyHtml}` +
|
|
158
|
-
`</blockquote></div>`;
|
|
159
|
-
plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
|
|
96
|
+
const rawHtml = await marked.parse(text);
|
|
97
|
+
const replyHtml = sanitizeHtml(rawHtml, {
|
|
98
|
+
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
|
99
|
+
allowedAttributes: {
|
|
100
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
101
|
+
'*': ['style', 'class']
|
|
160
102
|
}
|
|
103
|
+
});
|
|
161
104
|
|
|
162
|
-
|
|
163
|
-
|
|
105
|
+
// Build Gmail-style blockquote HTML for the quoted content
|
|
106
|
+
fullHtml = replyHtml;
|
|
107
|
+
if (quotedContent) {
|
|
108
|
+
fullHtml += `<div class="gmail_quote">` +
|
|
109
|
+
`<div dir="ltr" class="gmail_attr">${sanitizeHtml(quotedContent.header, { allowedTags: [], allowedAttributes: {} })}</div>` +
|
|
110
|
+
`<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">` +
|
|
111
|
+
`${quotedContent.bodyHtml}` +
|
|
112
|
+
`</blockquote></div>`;
|
|
113
|
+
plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
|
|
114
|
+
}
|
|
164
115
|
} catch (err) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
args.push("--body", plainBody);
|
|
116
|
+
console.error("Markdown parsing or sanitization failed, sending plain text", err);
|
|
117
|
+
if (quotedContent) {
|
|
118
|
+
plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
|
|
119
|
+
}
|
|
171
120
|
}
|
|
172
121
|
|
|
173
|
-
await
|
|
122
|
+
await client.send({
|
|
123
|
+
account: account.email,
|
|
124
|
+
to: isThread ? undefined : toValue,
|
|
125
|
+
subject,
|
|
126
|
+
textBody: plainBody,
|
|
127
|
+
htmlBody: fullHtml,
|
|
128
|
+
threadId: isThread ? toValue : undefined,
|
|
129
|
+
replyToMessageId: replyToId ? String(replyToId) : undefined,
|
|
130
|
+
replyAll: isThread,
|
|
131
|
+
});
|
|
174
132
|
|
|
175
133
|
// Archive if it was a thread (Reply = Archive), unless disabled by config
|
|
176
134
|
const archiveOnReply = accountCfg?.archiveOnReply
|
|
177
135
|
?? gmailCfg?.defaults?.archiveOnReply
|
|
178
136
|
?? true;
|
|
179
137
|
if (isThread && archiveOnReply) {
|
|
180
|
-
const archiveArgs = ["gmail", "labels", "modify", toValue];
|
|
181
|
-
if (account.email) archiveArgs.push("--account", account.email);
|
|
182
|
-
archiveArgs.push("--remove", "INBOX");
|
|
183
|
-
|
|
184
138
|
// Best effort archive
|
|
185
|
-
|
|
186
|
-
|
|
139
|
+
client.modifyLabels(toValue, { remove: ["INBOX"] }).catch((err) => {
|
|
140
|
+
console.error(`Failed to archive thread ${toValue}: ${err instanceof Error ? err.message : String(err)}`);
|
|
187
141
|
});
|
|
188
142
|
}
|
|
189
143
|
|
package/src/quoting.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
import type { GmailClient } from "./gmail-client.js";
|
|
3
3
|
|
|
4
4
|
export interface QuotedContent {
|
|
5
5
|
header: string; // "On Mon, Feb 22, 2026 at 2:15 PM, John Doe wrote:"
|
|
@@ -26,8 +26,8 @@ export interface ThreadResponse {
|
|
|
26
26
|
messages: ThreadMessage[];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Raw gog output types
|
|
30
|
-
interface GogThreadOutput {
|
|
29
|
+
// Raw gog output types — exported for use by GogGmailClient
|
|
30
|
+
export interface GogThreadOutput {
|
|
31
31
|
downloaded: unknown;
|
|
32
32
|
thread: {
|
|
33
33
|
id: string;
|
|
@@ -36,7 +36,7 @@ interface GogThreadOutput {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
interface GogRawMessage {
|
|
39
|
+
export interface GogRawMessage {
|
|
40
40
|
id: string;
|
|
41
41
|
threadId: string;
|
|
42
42
|
internalDate: string;
|
|
@@ -114,59 +114,18 @@ function parseGogMessage(raw: GogRawMessage): ThreadMessage {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
*
|
|
117
|
+
* Parse raw gog thread JSON into a ThreadResponse.
|
|
118
|
+
* Extracted from fetchThread() so GogGmailClient can reuse it.
|
|
118
119
|
*/
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let stderr = "";
|
|
129
|
-
|
|
130
|
-
proc.stdout.on("data", (d) => (stdout += d.toString()));
|
|
131
|
-
proc.stderr.on("data", (d) => (stderr += d.toString()));
|
|
132
|
-
|
|
133
|
-
proc.on("error", (e) => {
|
|
134
|
-
console.error(`[gmail] Failed to spawn gog for thread fetch: ${e.message}`);
|
|
135
|
-
resolve(null);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
proc.on("close", (code) => {
|
|
139
|
-
if (code !== 0) {
|
|
140
|
-
console.error(`[gmail] gog thread get failed (code ${code}): ${stderr}`);
|
|
141
|
-
resolve(null);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
const parsed = JSON.parse(stdout) as GogThreadOutput;
|
|
146
|
-
// gog wraps the thread in { downloaded, thread }
|
|
147
|
-
const thread = parsed.thread;
|
|
148
|
-
if (!thread || !thread.messages) {
|
|
149
|
-
resolve(null);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
resolve({
|
|
153
|
-
id: thread.id,
|
|
154
|
-
historyId: thread.historyId,
|
|
155
|
-
messages: thread.messages.map(parseGogMessage),
|
|
156
|
-
});
|
|
157
|
-
} catch (e) {
|
|
158
|
-
console.error(`[gmail] Failed to parse thread JSON: ${e}`);
|
|
159
|
-
resolve(null);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Timeout after 15s
|
|
164
|
-
setTimeout(() => {
|
|
165
|
-
proc.kill();
|
|
166
|
-
console.error(`[gmail] gog thread get timed out`);
|
|
167
|
-
resolve(null);
|
|
168
|
-
}, 15000);
|
|
169
|
-
});
|
|
120
|
+
export function parseGogThreadOutput(data: unknown): ThreadResponse | null {
|
|
121
|
+
const parsed = data as GogThreadOutput;
|
|
122
|
+
const thread = parsed?.thread;
|
|
123
|
+
if (!thread || !thread.messages) return null;
|
|
124
|
+
return {
|
|
125
|
+
id: thread.id,
|
|
126
|
+
historyId: thread.historyId,
|
|
127
|
+
messages: thread.messages.map(parseGogMessage),
|
|
128
|
+
};
|
|
170
129
|
}
|
|
171
130
|
|
|
172
131
|
/**
|
|
@@ -267,9 +226,9 @@ export function buildQuotedThread(
|
|
|
267
226
|
export async function fetchQuotedContext(
|
|
268
227
|
threadId: string,
|
|
269
228
|
accountEmail: string,
|
|
270
|
-
|
|
229
|
+
client: GmailClient,
|
|
271
230
|
): Promise<QuotedContent | null> {
|
|
272
|
-
const thread = await
|
|
231
|
+
const thread = await client.getThread(threadId, { full: true });
|
|
273
232
|
if (!thread || !thread.messages || thread.messages.length === 0) {
|
|
274
233
|
return null;
|
|
275
234
|
}
|