customer-map-codex-bridge 0.5.0 → 0.5.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/mail-action.mjs CHANGED
@@ -1,363 +1,363 @@
1
- import { createHash } from 'node:crypto';
2
- import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { delimiter, isAbsolute, join, resolve } from 'node:path';
6
- import process from 'node:process';
7
-
8
- const MIN_GOG_VERSION = [0, 11, 0];
9
- const MAX_GOG_BODY_BYTES = 100_000;
10
- const MAX_PROCESS_OUTPUT_CHARS = 1_000_000;
11
- const mailActionResults = new Map();
12
- const gogVersionCache = new Map();
13
-
14
- export async function executeCustomerMapMailAction(
15
- value,
16
- { signal, timeoutMs, gogCommand: requestedGogCommand = '' },
17
- ) {
18
- let action;
19
- try {
20
- action = normalizeMailAction(value);
21
- } catch (error) {
22
- return mailActionResult(value, 'failed', { error: formatError(error) });
23
- }
24
-
25
- const cacheKey = `${action.kind}:${action.actionId}`;
26
- const cached = mailActionResults.get(cacheKey);
27
- if (cached) {
28
- if (cached.bodyHash !== action.bodyHash) {
29
- return mailActionResult(action, 'failed', { error: 'Mail action id was reused with different content.' });
30
- }
31
- return cached.result;
32
- }
33
-
34
- let gogCommand;
35
- let toolVersion;
36
- try {
37
- gogCommand = resolveGogCommand(requestedGogCommand);
38
- toolVersion = await readGogVersion(gogCommand, signal);
39
- } catch (error) {
40
- return cacheMailResult(cacheKey, action, mailActionResult(action, 'failed', {
41
- error: `gog is unavailable or unsupported: ${compactStatus(formatError(error))}`,
42
- }));
43
- }
44
-
45
- const processResult = await runProcess(gogCommand, buildGogMailArgs(action), {
46
- signal,
47
- timeoutMs: Math.max(5_000, Math.min(Number(timeoutMs) - 2_000 || 90_000, 90_000)),
48
- });
49
- return cacheMailResult(cacheKey, action, classifyGogProcessResult(action, processResult, toolVersion));
50
- }
51
-
52
- export function normalizeMailAction(value) {
53
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
- throw new Error('Invalid Customer Map mail action.');
55
- }
56
- const source = value;
57
- const kind = source.kind === 'sendEmail' || source.kind === 'saveDraft' ? source.kind : '';
58
- const actionId = cleanText(source.actionId).toLowerCase();
59
- const account = cleanText(source.account).toLowerCase();
60
- const recipient = cleanText(source.recipient).toLowerCase();
61
- const subject = cleanText(source.subject);
62
- const plainTextBody = stringValue(source.plainTextBody);
63
- const htmlBody = stringValue(source.htmlBody);
64
- const bodyHash = cleanText(source.bodyHash).toLowerCase();
65
- if (source.version !== 1 || !kind) throw new Error('Unsupported Customer Map mail action version or kind.');
66
- if (!/^[0-9a-f]{32}$/.test(actionId)) throw new Error('Invalid Customer Map mail action id.');
67
- if (!validEmail(account) || !validEmail(recipient)) throw new Error('Invalid Gmail account or recipient.');
68
- if (!subject || /[\r\n]/.test(subject)) throw new Error('Invalid email subject.');
69
- if (!plainTextBody && !htmlBody) throw new Error('Email body is empty.');
70
- if (looksLikeBodyPath(plainTextBody) || looksLikeBodyPath(htmlBody)) {
71
- throw new Error('Email body cannot be a filesystem or stdin path.');
72
- }
73
- if (Buffer.byteLength(plainTextBody, 'utf8') > MAX_GOG_BODY_BYTES || Buffer.byteLength(htmlBody, 'utf8') > MAX_GOG_BODY_BYTES) {
74
- throw new Error('Email body is too large for safe gog argument execution.');
75
- }
76
- if (!htmlBody && containsMarkdownTable(plainTextBody)) {
77
- throw new Error('Markdown table requires an HTML body before using Gmail.');
78
- }
79
- if (bodyHash !== mailBodyHash(recipient, subject, plainTextBody, htmlBody)) {
80
- throw new Error('Email body integrity check failed.');
81
- }
82
- return { version: 1, actionId, kind, account, recipient, subject, plainTextBody, htmlBody, bodyHash };
83
- }
84
-
85
- export function buildGogMailArgs(action) {
86
- const args = action.kind === 'sendEmail' ? ['gmail', 'send'] : ['gmail', 'drafts', 'create'];
87
- args.push(`--to=${action.recipient}`, `--subject=${action.subject}`);
88
- if (action.htmlBody) args.push(`--body-html=${action.htmlBody}`);
89
- if (action.plainTextBody) args.push(`--body=${action.plainTextBody}`);
90
- args.push(`--account=${action.account}`);
91
- if (action.kind === 'sendEmail') args.push('--force');
92
- args.push('--json', '--no-input');
93
- return args;
94
- }
95
-
96
- export function mailBodyHash(recipient, subject, plainTextBody, htmlBody) {
97
- return createHash('sha256')
98
- .update(recipient)
99
- .update('\n')
100
- .update(subject)
101
- .update('\n')
102
- .update(plainTextBody)
103
- .update('\n')
104
- .update(htmlBody)
105
- .digest('hex');
106
- }
107
-
108
- export function extractMailResultId(text, kind) {
109
- if (!text) return '';
110
- const candidates = [];
111
- try {
112
- candidates.push(JSON.parse(text));
113
- } catch {
114
- for (const line of String(text).split(/\r?\n/).reverse()) {
115
- try {
116
- candidates.push(JSON.parse(line));
117
- break;
118
- } catch {
119
- // Keep looking for the final JSON line.
120
- }
121
- }
122
- }
123
- for (const candidate of candidates) {
124
- const found = findMailResultId(candidate, kind);
125
- if (found) return found;
126
- }
127
- return /"(?:draftId|draft_id|messageId|message_id|id)"\s*:\s*"([^"\s]{6,})"/.exec(String(text))?.[1] || '';
128
- }
129
-
130
- export function classifyGogProcessResult(action, result, toolVersion = '') {
131
- const operation = action.kind === 'sendEmail' ? 'send' : 'draft create';
132
- const mailbox = action.kind === 'sendEmail' ? 'Gmail Sent' : 'Gmail Drafts';
133
- if (result.timedOut || result.aborted) {
134
- return mailActionResult(action, 'needsConfirmation', {
135
- error: `gog ${operation} timed out or was cancelled; check ${mailbox} before retrying.`,
136
- toolVersion,
137
- exitCode: result.exitCode,
138
- });
139
- }
140
- if (result.error) {
141
- return mailActionResult(action, 'failed', {
142
- error: `gog ${operation} could not start: ${compactStatus(result.error)}`,
143
- toolVersion,
144
- exitCode: result.exitCode,
145
- });
146
- }
147
- const messageId = extractMailResultId(result.stdout, action.kind);
148
- if (result.exitCode === 0 && messageId) {
149
- return mailActionResult(action, 'succeeded', { messageId, toolVersion, exitCode: 0 });
150
- }
151
- if (result.exitCode === 0) {
152
- return mailActionResult(action, 'needsConfirmation', {
153
- error: `gog ${operation} returned success without a verifiable Gmail id; check ${mailbox} before retrying.`,
154
- toolVersion,
155
- exitCode: 0,
156
- });
157
- }
158
- const details = compactStatus(result.stderr || result.stdout || '');
159
- return mailActionResult(action, 'failed', {
160
- error: `gog ${operation} failed with exit code ${result.exitCode ?? 'unknown'}${details ? `: ${details}` : '.'}`,
161
- toolVersion,
162
- exitCode: result.exitCode,
163
- });
164
- }
165
-
166
- export function resolveGogCommand(value = '') {
167
- const requested = cleanText(value || process.env.CUSTOMER_MAP_GOG_BIN);
168
- const fixedCandidates = process.platform === 'win32'
169
- ? []
170
- : [
171
- '/opt/homebrew/bin/gog',
172
- '/usr/local/bin/gog',
173
- '/usr/bin/gog',
174
- join(homedir(), '.local', 'bin', 'gog'),
175
- join(homedir(), 'go', 'bin', 'gog'),
176
- ];
177
- const candidates = [
178
- ...resolveCommandCandidate(requested),
179
- ...fixedCandidates,
180
- ...pathCommandCandidates(),
181
- ];
182
- const found = candidates.find(candidate => candidate && existsSync(candidate));
183
- if (!found) throw new Error('gog was not found. Install it or pass --gog /absolute/path/to/gog.');
184
- return found;
185
- }
186
-
187
- function mailActionResult(value, status, options = {}) {
188
- const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
189
- const error = options.error || '';
190
- const successReply = source.kind === 'saveDraft'
191
- ? '邮件已通过 Codex Bridge 的固定 gog 参数保存到 Gmail 草稿。'
192
- : '邮件已通过 Codex Bridge 的固定 gog 参数发送。';
193
- return {
194
- reply: status === 'succeeded' ? successReply : error,
195
- subject: '',
196
- sendText: '',
197
- tag: '',
198
- aiNote: '',
199
- continue: false,
200
- actionReceipt: {
201
- kind: source.kind === 'saveDraft' ? 'saveDraft' : 'sendEmail',
202
- status,
203
- provider: 'gmail',
204
- messageId: options.messageId || '',
205
- recipient: cleanText(source.recipient).toLowerCase(),
206
- occurredAt: status === 'succeeded' ? new Date().toISOString() : '',
207
- error,
208
- tool: 'gog',
209
- toolVersion: options.toolVersion || '',
210
- bodyMode: bodyMode(source),
211
- bodyHash: cleanText(source.bodyHash).toLowerCase(),
212
- actionId: cleanText(source.actionId).toLowerCase(),
213
- exitCode: options.exitCode ?? null,
214
- },
215
- };
216
- }
217
-
218
- async function readGogVersion(command, signal) {
219
- if (gogVersionCache.has(command)) return gogVersionCache.get(command);
220
- const result = await runProcess(command, ['--version'], { signal, timeoutMs: 10_000 });
221
- if (result.timedOut || result.aborted) throw new Error('unable to read gog version before timeout');
222
- if (result.error) throw new Error(result.error);
223
- const match = /v?(\d+)\.(\d+)\.(\d+)/.exec(result.stdout || result.stderr);
224
- if (result.exitCode !== 0 || !match) throw new Error('unable to read gog version');
225
- const version = match.slice(1, 4).map(Number);
226
- if (compareVersion(version, MIN_GOG_VERSION) < 0) {
227
- throw new Error(`gog v${version.join('.')} is older than v${MIN_GOG_VERSION.join('.')}`);
228
- }
229
- const normalized = version.join('.');
230
- gogVersionCache.set(command, normalized);
231
- return normalized;
232
- }
233
-
234
- function runProcess(executable, args, { signal, timeoutMs }) {
235
- return new Promise(resolveResult => {
236
- let stdout = '';
237
- let stderr = '';
238
- let settled = false;
239
- let child;
240
- const finish = result => {
241
- if (settled) return;
242
- settled = true;
243
- clearTimeout(timer);
244
- signal?.removeEventListener('abort', abort);
245
- resolveResult({ ...result, stdout, stderr });
246
- };
247
- const terminate = (timedOut, aborted) => {
248
- child?.kill();
249
- finish({ exitCode: null, timedOut, aborted, error: '' });
250
- };
251
- const abort = () => terminate(false, true);
252
- const timer = setTimeout(() => terminate(true, false), Math.max(1_000, timeoutMs));
253
- try {
254
- child = spawn(executable, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
255
- } catch (error) {
256
- finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) });
257
- return;
258
- }
259
- child.stdout?.on('data', chunk => { stdout = appendBounded(stdout, chunk); });
260
- child.stderr?.on('data', chunk => { stderr = appendBounded(stderr, chunk); });
261
- child.once('error', error => finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) }));
262
- child.once('close', code => finish({ exitCode: code, timedOut: false, aborted: false, error: '' }));
263
- if (signal?.aborted) abort();
264
- else signal?.addEventListener('abort', abort, { once: true });
265
- });
266
- }
267
-
268
- function cacheMailResult(cacheKey, action, result) {
269
- mailActionResults.set(cacheKey, { bodyHash: action.bodyHash, result });
270
- while (mailActionResults.size > 200) mailActionResults.delete(mailActionResults.keys().next().value);
271
- return result;
272
- }
273
-
274
- function findMailResultId(value, kind) {
275
- if (Array.isArray(value)) {
276
- for (const child of value) {
277
- const found = findMailResultId(child, kind);
278
- if (found) return found;
279
- }
280
- return '';
281
- }
282
- if (!value || typeof value !== 'object') return '';
283
- const keys = kind === 'saveDraft'
284
- ? ['draftId', 'draft_id', 'id', 'messageId', 'message_id']
285
- : ['messageId', 'message_id', 'id', 'threadId', 'thread_id'];
286
- for (const key of keys) {
287
- const candidate = cleanText(value[key]);
288
- if (candidate.length >= 6) return candidate;
289
- }
290
- for (const child of Object.values(value)) {
291
- const found = findMailResultId(child, kind);
292
- if (found) return found;
293
- }
294
- return '';
295
- }
296
-
297
- function resolveCommandCandidate(value) {
298
- if (!value) return [];
299
- if (isAbsolute(value)) return [value];
300
- if (value.includes('/') || value.includes('\\')) return [resolve(value)];
301
- return pathCommandCandidates(value);
302
- }
303
-
304
- function pathCommandCandidates(command = process.platform === 'win32' ? 'gog.exe' : 'gog') {
305
- return cleanText(process.env.PATH)
306
- .split(delimiter)
307
- .filter(Boolean)
308
- .flatMap(pathEntry => {
309
- const path = join(pathEntry, command);
310
- return process.platform === 'win32' && !/\.(?:exe|cmd)$/i.test(command) ? [path, `${path}.exe`, `${path}.cmd`] : [path];
311
- });
312
- }
313
-
314
- function bodyMode(value) {
315
- const hasHtml = Boolean(stringValue(value.htmlBody));
316
- const hasPlain = Boolean(stringValue(value.plainTextBody));
317
- if (hasHtml && hasPlain) return 'body-html+body';
318
- if (hasHtml) return 'body-html';
319
- if (hasPlain) return 'body';
320
- return '';
321
- }
322
-
323
- function compareVersion(left, right) {
324
- for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
325
- const difference = (left[index] || 0) - (right[index] || 0);
326
- if (difference) return difference > 0 ? 1 : -1;
327
- }
328
- return 0;
329
- }
330
-
331
- function appendBounded(current, chunk) {
332
- if (current.length >= MAX_PROCESS_OUTPUT_CHARS) return current;
333
- return (current + String(chunk)).slice(0, MAX_PROCESS_OUTPUT_CHARS);
334
- }
335
-
336
- function validEmail(value) {
337
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
338
- }
339
-
340
- function looksLikeBodyPath(value) {
341
- return /^(?:\/dev\/stdin|\/(?:private\/)?tmp\/\S+|[A-Za-z]:\\\S+|file:\/\/\S+)$/i.test(value.trim());
342
- }
343
-
344
- function containsMarkdownTable(value) {
345
- return /(?:^|\n)\s*\|?.+\|.+\n\s*\|?\s*:?-{3,}/.test(value);
346
- }
347
-
348
- function compactStatus(value) {
349
- const compact = String(value || '').replace(/\s+/g, ' ').trim();
350
- return compact.length <= 320 ? compact : `${compact.slice(0, 317).trimEnd()}...`;
351
- }
352
-
353
- function cleanText(value) {
354
- return typeof value === 'string' ? value.trim() : '';
355
- }
356
-
357
- function stringValue(value) {
358
- return typeof value === 'string' ? value : '';
359
- }
360
-
361
- function formatError(error) {
362
- return error instanceof Error ? error.message : String(error || 'Unknown error');
363
- }
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const MIN_GOG_VERSION = [0, 11, 0];
9
+ const MAX_GOG_BODY_BYTES = 100_000;
10
+ const MAX_PROCESS_OUTPUT_CHARS = 1_000_000;
11
+ const mailActionResults = new Map();
12
+ const gogVersionCache = new Map();
13
+
14
+ export async function executeCustomerMapMailAction(
15
+ value,
16
+ { signal, timeoutMs, gogCommand: requestedGogCommand = '' },
17
+ ) {
18
+ let action;
19
+ try {
20
+ action = normalizeMailAction(value);
21
+ } catch (error) {
22
+ return mailActionResult(value, 'failed', { error: formatError(error) });
23
+ }
24
+
25
+ const cacheKey = `${action.kind}:${action.actionId}`;
26
+ const cached = mailActionResults.get(cacheKey);
27
+ if (cached) {
28
+ if (cached.bodyHash !== action.bodyHash) {
29
+ return mailActionResult(action, 'failed', { error: 'Mail action id was reused with different content.' });
30
+ }
31
+ return cached.result;
32
+ }
33
+
34
+ let gogCommand;
35
+ let toolVersion;
36
+ try {
37
+ gogCommand = resolveGogCommand(requestedGogCommand);
38
+ toolVersion = await readGogVersion(gogCommand, signal);
39
+ } catch (error) {
40
+ return cacheMailResult(cacheKey, action, mailActionResult(action, 'failed', {
41
+ error: `gog is unavailable or unsupported: ${compactStatus(formatError(error))}`,
42
+ }));
43
+ }
44
+
45
+ const processResult = await runProcess(gogCommand, buildGogMailArgs(action), {
46
+ signal,
47
+ timeoutMs: Math.max(5_000, Math.min(Number(timeoutMs) - 2_000 || 90_000, 90_000)),
48
+ });
49
+ return cacheMailResult(cacheKey, action, classifyGogProcessResult(action, processResult, toolVersion));
50
+ }
51
+
52
+ export function normalizeMailAction(value) {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw new Error('Invalid Customer Map mail action.');
55
+ }
56
+ const source = value;
57
+ const kind = source.kind === 'sendEmail' || source.kind === 'saveDraft' ? source.kind : '';
58
+ const actionId = cleanText(source.actionId).toLowerCase();
59
+ const account = cleanText(source.account).toLowerCase();
60
+ const recipient = cleanText(source.recipient).toLowerCase();
61
+ const subject = cleanText(source.subject);
62
+ const plainTextBody = stringValue(source.plainTextBody);
63
+ const htmlBody = stringValue(source.htmlBody);
64
+ const bodyHash = cleanText(source.bodyHash).toLowerCase();
65
+ if (source.version !== 1 || !kind) throw new Error('Unsupported Customer Map mail action version or kind.');
66
+ if (!/^[0-9a-f]{32}$/.test(actionId)) throw new Error('Invalid Customer Map mail action id.');
67
+ if (!validEmail(account) || !validEmail(recipient)) throw new Error('Invalid Gmail account or recipient.');
68
+ if (!subject || /[\r\n]/.test(subject)) throw new Error('Invalid email subject.');
69
+ if (!plainTextBody && !htmlBody) throw new Error('Email body is empty.');
70
+ if (looksLikeBodyPath(plainTextBody) || looksLikeBodyPath(htmlBody)) {
71
+ throw new Error('Email body cannot be a filesystem or stdin path.');
72
+ }
73
+ if (Buffer.byteLength(plainTextBody, 'utf8') > MAX_GOG_BODY_BYTES || Buffer.byteLength(htmlBody, 'utf8') > MAX_GOG_BODY_BYTES) {
74
+ throw new Error('Email body is too large for safe gog argument execution.');
75
+ }
76
+ if (!htmlBody && containsMarkdownTable(plainTextBody)) {
77
+ throw new Error('Markdown table requires an HTML body before using Gmail.');
78
+ }
79
+ if (bodyHash !== mailBodyHash(recipient, subject, plainTextBody, htmlBody)) {
80
+ throw new Error('Email body integrity check failed.');
81
+ }
82
+ return { version: 1, actionId, kind, account, recipient, subject, plainTextBody, htmlBody, bodyHash };
83
+ }
84
+
85
+ export function buildGogMailArgs(action) {
86
+ const args = action.kind === 'sendEmail' ? ['gmail', 'send'] : ['gmail', 'drafts', 'create'];
87
+ args.push(`--to=${action.recipient}`, `--subject=${action.subject}`);
88
+ if (action.htmlBody) args.push(`--body-html=${action.htmlBody}`);
89
+ if (action.plainTextBody) args.push(`--body=${action.plainTextBody}`);
90
+ args.push(`--account=${action.account}`);
91
+ if (action.kind === 'sendEmail') args.push('--force');
92
+ args.push('--json', '--no-input');
93
+ return args;
94
+ }
95
+
96
+ export function mailBodyHash(recipient, subject, plainTextBody, htmlBody) {
97
+ return createHash('sha256')
98
+ .update(recipient)
99
+ .update('\n')
100
+ .update(subject)
101
+ .update('\n')
102
+ .update(plainTextBody)
103
+ .update('\n')
104
+ .update(htmlBody)
105
+ .digest('hex');
106
+ }
107
+
108
+ export function extractMailResultId(text, kind) {
109
+ if (!text) return '';
110
+ const candidates = [];
111
+ try {
112
+ candidates.push(JSON.parse(text));
113
+ } catch {
114
+ for (const line of String(text).split(/\r?\n/).reverse()) {
115
+ try {
116
+ candidates.push(JSON.parse(line));
117
+ break;
118
+ } catch {
119
+ // Keep looking for the final JSON line.
120
+ }
121
+ }
122
+ }
123
+ for (const candidate of candidates) {
124
+ const found = findMailResultId(candidate, kind);
125
+ if (found) return found;
126
+ }
127
+ return /"(?:draftId|draft_id|messageId|message_id|id)"\s*:\s*"([^"\s]{6,})"/.exec(String(text))?.[1] || '';
128
+ }
129
+
130
+ export function classifyGogProcessResult(action, result, toolVersion = '') {
131
+ const operation = action.kind === 'sendEmail' ? 'send' : 'draft create';
132
+ const mailbox = action.kind === 'sendEmail' ? 'Gmail Sent' : 'Gmail Drafts';
133
+ if (result.timedOut || result.aborted) {
134
+ return mailActionResult(action, 'needsConfirmation', {
135
+ error: `gog ${operation} timed out or was cancelled; check ${mailbox} before retrying.`,
136
+ toolVersion,
137
+ exitCode: result.exitCode,
138
+ });
139
+ }
140
+ if (result.error) {
141
+ return mailActionResult(action, 'failed', {
142
+ error: `gog ${operation} could not start: ${compactStatus(result.error)}`,
143
+ toolVersion,
144
+ exitCode: result.exitCode,
145
+ });
146
+ }
147
+ const messageId = extractMailResultId(result.stdout, action.kind);
148
+ if (result.exitCode === 0 && messageId) {
149
+ return mailActionResult(action, 'succeeded', { messageId, toolVersion, exitCode: 0 });
150
+ }
151
+ if (result.exitCode === 0) {
152
+ return mailActionResult(action, 'needsConfirmation', {
153
+ error: `gog ${operation} returned success without a verifiable Gmail id; check ${mailbox} before retrying.`,
154
+ toolVersion,
155
+ exitCode: 0,
156
+ });
157
+ }
158
+ const details = compactStatus(result.stderr || result.stdout || '');
159
+ return mailActionResult(action, 'failed', {
160
+ error: `gog ${operation} failed with exit code ${result.exitCode ?? 'unknown'}${details ? `: ${details}` : '.'}`,
161
+ toolVersion,
162
+ exitCode: result.exitCode,
163
+ });
164
+ }
165
+
166
+ export function resolveGogCommand(value = '') {
167
+ const requested = cleanText(value || process.env.CUSTOMER_MAP_GOG_BIN);
168
+ const fixedCandidates = process.platform === 'win32'
169
+ ? []
170
+ : [
171
+ '/opt/homebrew/bin/gog',
172
+ '/usr/local/bin/gog',
173
+ '/usr/bin/gog',
174
+ join(homedir(), '.local', 'bin', 'gog'),
175
+ join(homedir(), 'go', 'bin', 'gog'),
176
+ ];
177
+ const candidates = [
178
+ ...resolveCommandCandidate(requested),
179
+ ...fixedCandidates,
180
+ ...pathCommandCandidates(),
181
+ ];
182
+ const found = candidates.find(candidate => candidate && existsSync(candidate));
183
+ if (!found) throw new Error('gog was not found. Install it or pass --gog /absolute/path/to/gog.');
184
+ return found;
185
+ }
186
+
187
+ function mailActionResult(value, status, options = {}) {
188
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
189
+ const error = options.error || '';
190
+ const successReply = source.kind === 'saveDraft'
191
+ ? '邮件已通过 Codex Bridge 的固定 gog 参数保存到 Gmail 草稿。'
192
+ : '邮件已通过 Codex Bridge 的固定 gog 参数发送。';
193
+ return {
194
+ reply: status === 'succeeded' ? successReply : error,
195
+ subject: '',
196
+ sendText: '',
197
+ tag: '',
198
+ aiNote: '',
199
+ continue: false,
200
+ actionReceipt: {
201
+ kind: source.kind === 'saveDraft' ? 'saveDraft' : 'sendEmail',
202
+ status,
203
+ provider: 'gmail',
204
+ messageId: options.messageId || '',
205
+ recipient: cleanText(source.recipient).toLowerCase(),
206
+ occurredAt: status === 'succeeded' ? new Date().toISOString() : '',
207
+ error,
208
+ tool: 'gog',
209
+ toolVersion: options.toolVersion || '',
210
+ bodyMode: bodyMode(source),
211
+ bodyHash: cleanText(source.bodyHash).toLowerCase(),
212
+ actionId: cleanText(source.actionId).toLowerCase(),
213
+ exitCode: options.exitCode ?? null,
214
+ },
215
+ };
216
+ }
217
+
218
+ async function readGogVersion(command, signal) {
219
+ if (gogVersionCache.has(command)) return gogVersionCache.get(command);
220
+ const result = await runProcess(command, ['--version'], { signal, timeoutMs: 10_000 });
221
+ if (result.timedOut || result.aborted) throw new Error('unable to read gog version before timeout');
222
+ if (result.error) throw new Error(result.error);
223
+ const match = /v?(\d+)\.(\d+)\.(\d+)/.exec(result.stdout || result.stderr);
224
+ if (result.exitCode !== 0 || !match) throw new Error('unable to read gog version');
225
+ const version = match.slice(1, 4).map(Number);
226
+ if (compareVersion(version, MIN_GOG_VERSION) < 0) {
227
+ throw new Error(`gog v${version.join('.')} is older than v${MIN_GOG_VERSION.join('.')}`);
228
+ }
229
+ const normalized = version.join('.');
230
+ gogVersionCache.set(command, normalized);
231
+ return normalized;
232
+ }
233
+
234
+ function runProcess(executable, args, { signal, timeoutMs }) {
235
+ return new Promise(resolveResult => {
236
+ let stdout = '';
237
+ let stderr = '';
238
+ let settled = false;
239
+ let child;
240
+ const finish = result => {
241
+ if (settled) return;
242
+ settled = true;
243
+ clearTimeout(timer);
244
+ signal?.removeEventListener('abort', abort);
245
+ resolveResult({ ...result, stdout, stderr });
246
+ };
247
+ const terminate = (timedOut, aborted) => {
248
+ child?.kill();
249
+ finish({ exitCode: null, timedOut, aborted, error: '' });
250
+ };
251
+ const abort = () => terminate(false, true);
252
+ const timer = setTimeout(() => terminate(true, false), Math.max(1_000, timeoutMs));
253
+ try {
254
+ child = spawn(executable, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
255
+ } catch (error) {
256
+ finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) });
257
+ return;
258
+ }
259
+ child.stdout?.on('data', chunk => { stdout = appendBounded(stdout, chunk); });
260
+ child.stderr?.on('data', chunk => { stderr = appendBounded(stderr, chunk); });
261
+ child.once('error', error => finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) }));
262
+ child.once('close', code => finish({ exitCode: code, timedOut: false, aborted: false, error: '' }));
263
+ if (signal?.aborted) abort();
264
+ else signal?.addEventListener('abort', abort, { once: true });
265
+ });
266
+ }
267
+
268
+ function cacheMailResult(cacheKey, action, result) {
269
+ mailActionResults.set(cacheKey, { bodyHash: action.bodyHash, result });
270
+ while (mailActionResults.size > 200) mailActionResults.delete(mailActionResults.keys().next().value);
271
+ return result;
272
+ }
273
+
274
+ function findMailResultId(value, kind) {
275
+ if (Array.isArray(value)) {
276
+ for (const child of value) {
277
+ const found = findMailResultId(child, kind);
278
+ if (found) return found;
279
+ }
280
+ return '';
281
+ }
282
+ if (!value || typeof value !== 'object') return '';
283
+ const keys = kind === 'saveDraft'
284
+ ? ['draftId', 'draft_id', 'id', 'messageId', 'message_id']
285
+ : ['messageId', 'message_id', 'id', 'threadId', 'thread_id'];
286
+ for (const key of keys) {
287
+ const candidate = cleanText(value[key]);
288
+ if (candidate.length >= 6) return candidate;
289
+ }
290
+ for (const child of Object.values(value)) {
291
+ const found = findMailResultId(child, kind);
292
+ if (found) return found;
293
+ }
294
+ return '';
295
+ }
296
+
297
+ function resolveCommandCandidate(value) {
298
+ if (!value) return [];
299
+ if (isAbsolute(value)) return [value];
300
+ if (value.includes('/') || value.includes('\\')) return [resolve(value)];
301
+ return pathCommandCandidates(value);
302
+ }
303
+
304
+ function pathCommandCandidates(command = process.platform === 'win32' ? 'gog.exe' : 'gog') {
305
+ return cleanText(process.env.PATH)
306
+ .split(delimiter)
307
+ .filter(Boolean)
308
+ .flatMap(pathEntry => {
309
+ const path = join(pathEntry, command);
310
+ return process.platform === 'win32' && !/\.(?:exe|cmd)$/i.test(command) ? [path, `${path}.exe`, `${path}.cmd`] : [path];
311
+ });
312
+ }
313
+
314
+ function bodyMode(value) {
315
+ const hasHtml = Boolean(stringValue(value.htmlBody));
316
+ const hasPlain = Boolean(stringValue(value.plainTextBody));
317
+ if (hasHtml && hasPlain) return 'body-html+body';
318
+ if (hasHtml) return 'body-html';
319
+ if (hasPlain) return 'body';
320
+ return '';
321
+ }
322
+
323
+ function compareVersion(left, right) {
324
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
325
+ const difference = (left[index] || 0) - (right[index] || 0);
326
+ if (difference) return difference > 0 ? 1 : -1;
327
+ }
328
+ return 0;
329
+ }
330
+
331
+ function appendBounded(current, chunk) {
332
+ if (current.length >= MAX_PROCESS_OUTPUT_CHARS) return current;
333
+ return (current + String(chunk)).slice(0, MAX_PROCESS_OUTPUT_CHARS);
334
+ }
335
+
336
+ function validEmail(value) {
337
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
338
+ }
339
+
340
+ function looksLikeBodyPath(value) {
341
+ return /^(?:\/dev\/stdin|\/(?:private\/)?tmp\/\S+|[A-Za-z]:\\\S+|file:\/\/\S+)$/i.test(value.trim());
342
+ }
343
+
344
+ function containsMarkdownTable(value) {
345
+ return /(?:^|\n)\s*\|?.+\|.+\n\s*\|?\s*:?-{3,}/.test(value);
346
+ }
347
+
348
+ function compactStatus(value) {
349
+ const compact = String(value || '').replace(/\s+/g, ' ').trim();
350
+ return compact.length <= 320 ? compact : `${compact.slice(0, 317).trimEnd()}...`;
351
+ }
352
+
353
+ function cleanText(value) {
354
+ return typeof value === 'string' ? value.trim() : '';
355
+ }
356
+
357
+ function stringValue(value) {
358
+ return typeof value === 'string' ? value : '';
359
+ }
360
+
361
+ function formatError(error) {
362
+ return error instanceof Error ? error.message : String(error || 'Unknown error');
363
+ }