@plosson/agentio 0.5.9 → 0.5.12
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 +1 -1
- package/src/commands/gateway.ts +32 -24
- package/src/commands/gmail.ts +93 -85
- package/src/gateway/adapters/whatsapp.ts +4 -0
- package/src/gateway/api.ts +58 -14
- package/src/gateway/daemon.ts +1 -1
- package/src/services/gmail/client.ts +89 -66
- package/src/types/gmail.ts +1 -6
- package/src/utils/output.ts +7 -0
package/package.json
CHANGED
package/src/commands/gateway.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { password } from '@inquirer/prompts';
|
|
|
7
7
|
import { handleError, CliError } from '../utils/errors';
|
|
8
8
|
import { loadConfig, saveConfig } from '../config/config-manager';
|
|
9
9
|
import { startGateway, getGatewayConfig, LOG_FILE } from '../gateway/daemon';
|
|
10
|
-
import { exportWhatsAppAuthState } from '../gateway/store';
|
|
10
|
+
import { initDatabase, closeDatabase, exportWhatsAppAuthState } from '../gateway/store';
|
|
11
11
|
import { isInteractive } from '../utils/interactive';
|
|
12
12
|
import type { Config } from '../types/config';
|
|
13
13
|
|
|
@@ -518,32 +518,40 @@ export function registerGatewayCommands(program: Command): void {
|
|
|
518
518
|
if (options.service === 'all' || options.service === 'whatsapp') {
|
|
519
519
|
const profiles = config.profiles.whatsapp || [];
|
|
520
520
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const authState = await exportWhatsAppAuthState(profileName);
|
|
521
|
+
if (profiles.length > 0) {
|
|
522
|
+
await initDatabase();
|
|
523
|
+
}
|
|
525
524
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
525
|
+
try {
|
|
526
|
+
for (const entry of profiles) {
|
|
527
|
+
const profileName = typeof entry === 'string' ? entry : entry.name;
|
|
528
|
+
console.log(` Exporting whatsapp:${profileName}...`);
|
|
529
|
+
const authState = await exportWhatsAppAuthState(profileName);
|
|
530
530
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
'Content-Type': 'application/json',
|
|
536
|
-
'X-API-Key': key,
|
|
537
|
-
},
|
|
538
|
-
body: JSON.stringify(authState),
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
if (!response.ok) {
|
|
542
|
-
const error = await response.text();
|
|
543
|
-
throw new CliError('API_ERROR', `Failed to import to remote: ${error}`);
|
|
544
|
-
}
|
|
531
|
+
if (!authState) {
|
|
532
|
+
console.log(' No auth state found, skipping');
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
545
535
|
|
|
546
|
-
|
|
536
|
+
// Send to remote gateway
|
|
537
|
+
const response = await fetch(`${url}/import/whatsapp/${encodeURIComponent(profileName)}`, {
|
|
538
|
+
method: 'POST',
|
|
539
|
+
headers: {
|
|
540
|
+
'Content-Type': 'application/json',
|
|
541
|
+
'X-API-Key': key,
|
|
542
|
+
},
|
|
543
|
+
body: JSON.stringify(authState),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
if (!response.ok) {
|
|
547
|
+
const error = await response.text();
|
|
548
|
+
throw new CliError('API_ERROR', `Failed to import to remote: ${error}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
console.log(' Transferred successfully');
|
|
552
|
+
}
|
|
553
|
+
} finally {
|
|
554
|
+
closeDatabase();
|
|
547
555
|
}
|
|
548
556
|
}
|
|
549
557
|
|
package/src/commands/gmail.ts
CHANGED
|
@@ -7,11 +7,94 @@ import { setProfile, getProfile } from '../config/config-manager';
|
|
|
7
7
|
import { createProfileCommands } from '../utils/profile-commands';
|
|
8
8
|
import { performOAuthFlow } from '../auth/oauth';
|
|
9
9
|
import { GmailClient } from '../services/gmail/client';
|
|
10
|
-
import { printMessageList, printMessage, printSendResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
|
|
10
|
+
import { printMessageList, printMessage, printSendResult, printDraftResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
|
|
11
11
|
import { CliError, handleError } from '../utils/errors';
|
|
12
12
|
import { readStdin } from '../utils/stdin';
|
|
13
13
|
import { enforceWriteAccess } from '../utils/read-only';
|
|
14
|
-
import type { GmailAttachment } from '../types/gmail';
|
|
14
|
+
import type { GmailAttachment, GmailSendOptions } from '../types/gmail';
|
|
15
|
+
|
|
16
|
+
function addComposeOptions(cmd: Command): Command {
|
|
17
|
+
return cmd
|
|
18
|
+
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
19
|
+
.option('--to <email>', 'Recipient (repeatable, required unless --reply-to)', (val: string, acc: string[]) => [...acc, val], [])
|
|
20
|
+
.option('--cc <email>', 'CC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
|
|
21
|
+
.option('--bcc <email>', 'BCC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
|
|
22
|
+
.option('--subject <subject>', 'Email subject (required unless --reply-to)')
|
|
23
|
+
.option('--body <body>', 'Email body (or pipe via stdin)')
|
|
24
|
+
.option('--html', 'Treat body as HTML')
|
|
25
|
+
.option('--reply-to <thread-id>', 'Thread ID to reply to (derives to/subject from thread)')
|
|
26
|
+
.option('--attachment <path>', 'File to attach (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
|
|
27
|
+
.option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val: string, acc: string[]) => [...acc, val], []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function parseBody(body: string | undefined): Promise<string> {
|
|
31
|
+
if (!body) {
|
|
32
|
+
body = await readStdin() ?? undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!body) {
|
|
35
|
+
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
|
|
36
|
+
}
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseAttachments(paths: string[]): GmailAttachment[] {
|
|
41
|
+
return paths.map((path: string) => ({
|
|
42
|
+
path,
|
|
43
|
+
filename: basename(path),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseInlineAttachments(specs: string[]): GmailAttachment[] {
|
|
48
|
+
return specs.map((spec: string) => {
|
|
49
|
+
const colonIndex = spec.indexOf(':');
|
|
50
|
+
if (colonIndex === -1) {
|
|
51
|
+
throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
|
|
52
|
+
}
|
|
53
|
+
const contentId = spec.substring(0, colonIndex);
|
|
54
|
+
const path = spec.substring(colonIndex + 1);
|
|
55
|
+
return {
|
|
56
|
+
path,
|
|
57
|
+
filename: basename(path),
|
|
58
|
+
contentId,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function parseSendOptions(options: Record<string, unknown>): Promise<GmailSendOptions> {
|
|
64
|
+
const replyTo = options.replyTo as string | undefined;
|
|
65
|
+
const to = options.to as string[];
|
|
66
|
+
const subject = options.subject as string | undefined;
|
|
67
|
+
|
|
68
|
+
if (!replyTo) {
|
|
69
|
+
if (!to.length) {
|
|
70
|
+
throw new CliError('INVALID_PARAMS', '--to is required (unless using --reply-to)');
|
|
71
|
+
}
|
|
72
|
+
if (!subject) {
|
|
73
|
+
throw new CliError('INVALID_PARAMS', '--subject is required (unless using --reply-to)');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const body = await parseBody(options.body as string | undefined);
|
|
78
|
+
|
|
79
|
+
const regularAttachments = parseAttachments(options.attachment as string[]);
|
|
80
|
+
const inlineAttachments = parseInlineAttachments(options.inline as string[]);
|
|
81
|
+
|
|
82
|
+
const attachments: GmailAttachment[] | undefined =
|
|
83
|
+
regularAttachments.length || inlineAttachments.length
|
|
84
|
+
? [...regularAttachments, ...inlineAttachments]
|
|
85
|
+
: undefined;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
to,
|
|
89
|
+
cc: (options.cc as string[]).length ? options.cc as string[] : undefined,
|
|
90
|
+
bcc: (options.bcc as string[]).length ? options.bcc as string[] : undefined,
|
|
91
|
+
subject: subject || '',
|
|
92
|
+
body,
|
|
93
|
+
isHtml: options.html as boolean | undefined,
|
|
94
|
+
attachments,
|
|
95
|
+
replyTo,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
15
98
|
|
|
16
99
|
function escapeHtml(text: string): string {
|
|
17
100
|
return text
|
|
@@ -176,102 +259,27 @@ Query Syntax Examples:
|
|
|
176
259
|
}
|
|
177
260
|
});
|
|
178
261
|
|
|
179
|
-
gmail
|
|
180
|
-
.command('send')
|
|
181
|
-
.description('Send an email')
|
|
182
|
-
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
183
|
-
.requiredOption('--to <email>', 'Recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
184
|
-
.option('--cc <email>', 'CC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
185
|
-
.option('--bcc <email>', 'BCC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
186
|
-
.requiredOption('--subject <subject>', 'Email subject')
|
|
187
|
-
.option('--body <body>', 'Email body (or pipe via stdin)')
|
|
188
|
-
.option('--html', 'Treat body as HTML')
|
|
189
|
-
.option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
190
|
-
.option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val, acc: string[]) => [...acc, val], [])
|
|
262
|
+
addComposeOptions(gmail.command('send').description('Send an email'))
|
|
191
263
|
.action(async (options) => {
|
|
192
264
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// Check for stdin if no body provided
|
|
196
|
-
if (!body) {
|
|
197
|
-
body = await readStdin();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!body) {
|
|
201
|
-
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Process regular attachments
|
|
205
|
-
const regularAttachments: GmailAttachment[] = options.attachment.map((path: string) => ({
|
|
206
|
-
path,
|
|
207
|
-
filename: basename(path),
|
|
208
|
-
}));
|
|
209
|
-
|
|
210
|
-
// Process inline images (format: contentId:filepath)
|
|
211
|
-
const inlineAttachments: GmailAttachment[] = options.inline.map((spec: string) => {
|
|
212
|
-
const colonIndex = spec.indexOf(':');
|
|
213
|
-
if (colonIndex === -1) {
|
|
214
|
-
throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
|
|
215
|
-
}
|
|
216
|
-
const contentId = spec.substring(0, colonIndex);
|
|
217
|
-
const path = spec.substring(colonIndex + 1);
|
|
218
|
-
return {
|
|
219
|
-
path,
|
|
220
|
-
filename: basename(path),
|
|
221
|
-
contentId,
|
|
222
|
-
};
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// Combine attachments
|
|
226
|
-
const attachments: GmailAttachment[] | undefined =
|
|
227
|
-
regularAttachments.length || inlineAttachments.length
|
|
228
|
-
? [...regularAttachments, ...inlineAttachments]
|
|
229
|
-
: undefined;
|
|
230
|
-
|
|
265
|
+
const sendOptions = await parseSendOptions(options);
|
|
231
266
|
const { client, profile } = await getGmailClient(options.profile);
|
|
232
267
|
await enforceWriteAccess('gmail', profile, 'send email');
|
|
233
|
-
const result = await client.send(
|
|
234
|
-
to: options.to,
|
|
235
|
-
cc: options.cc.length ? options.cc : undefined,
|
|
236
|
-
bcc: options.bcc.length ? options.bcc : undefined,
|
|
237
|
-
subject: options.subject,
|
|
238
|
-
body,
|
|
239
|
-
isHtml: options.html,
|
|
240
|
-
attachments,
|
|
241
|
-
});
|
|
268
|
+
const result = await client.send(sendOptions);
|
|
242
269
|
printSendResult(result);
|
|
243
270
|
} catch (error) {
|
|
244
271
|
handleError(error);
|
|
245
272
|
}
|
|
246
273
|
});
|
|
247
274
|
|
|
248
|
-
gmail
|
|
249
|
-
.command('reply')
|
|
250
|
-
.description('Reply to a thread')
|
|
251
|
-
.option('--profile <name>', 'Profile name (optional if only one profile exists)')
|
|
252
|
-
.requiredOption('--thread-id <id>', 'Thread ID')
|
|
253
|
-
.option('--body <body>', 'Reply body (or pipe via stdin)')
|
|
254
|
-
.option('--html', 'Treat body as HTML')
|
|
275
|
+
addComposeOptions(gmail.command('draft').description('Create an email draft'))
|
|
255
276
|
.action(async (options) => {
|
|
256
277
|
try {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (!body) {
|
|
260
|
-
body = await readStdin();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (!body) {
|
|
264
|
-
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
|
|
265
|
-
}
|
|
266
|
-
|
|
278
|
+
const sendOptions = await parseSendOptions(options);
|
|
267
279
|
const { client, profile } = await getGmailClient(options.profile);
|
|
268
|
-
await enforceWriteAccess('gmail', profile, '
|
|
269
|
-
const result = await client.
|
|
270
|
-
|
|
271
|
-
body,
|
|
272
|
-
isHtml: options.html,
|
|
273
|
-
});
|
|
274
|
-
printSendResult(result);
|
|
280
|
+
await enforceWriteAccess('gmail', profile, 'create draft');
|
|
281
|
+
const result = await client.draft(sendOptions);
|
|
282
|
+
printDraftResult(result);
|
|
275
283
|
} catch (error) {
|
|
276
284
|
handleError(error);
|
|
277
285
|
}
|
|
@@ -75,6 +75,10 @@ export class WhatsAppAdapter extends BaseAdapter {
|
|
|
75
75
|
|
|
76
76
|
private profiles: Map<string, ProfileConnection> = new Map();
|
|
77
77
|
|
|
78
|
+
hasProfile(profile: string): boolean {
|
|
79
|
+
return this.profiles.has(profile);
|
|
80
|
+
}
|
|
81
|
+
|
|
78
82
|
async connect(profile: string, credentials: unknown): Promise<void> {
|
|
79
83
|
const creds = credentials as WhatsAppCredentials;
|
|
80
84
|
|
package/src/gateway/api.ts
CHANGED
|
@@ -52,13 +52,31 @@ import {
|
|
|
52
52
|
importWhatsAppAuthState,
|
|
53
53
|
type WhatsAppAuthExport,
|
|
54
54
|
} from './store';
|
|
55
|
-
import type { ServiceAdapter } from './adapters/types';
|
|
56
|
-
import
|
|
55
|
+
import type { ServiceAdapter, AdapterInboundMessage } from './adapters/types';
|
|
56
|
+
import { WhatsAppAdapter } from './adapters/whatsapp';
|
|
57
57
|
|
|
58
58
|
let server: Server<unknown> | null = null;
|
|
59
59
|
let apiKey: string = '';
|
|
60
60
|
let startTime: number = 0;
|
|
61
61
|
let adapters: Map<ServiceName, ServiceAdapter> = new Map();
|
|
62
|
+
let onAdapterMessage: ((service: ServiceName, profile: string, message: AdapterInboundMessage) => void) | null = null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get or lazily create the WhatsApp adapter
|
|
66
|
+
*/
|
|
67
|
+
function getOrCreateWhatsAppAdapter(): WhatsAppAdapter {
|
|
68
|
+
let adapter = adapters.get('whatsapp') as WhatsAppAdapter | undefined;
|
|
69
|
+
if (!adapter) {
|
|
70
|
+
adapter = new WhatsAppAdapter();
|
|
71
|
+
adapter.onMessage = (profile, message) => {
|
|
72
|
+
if (onAdapterMessage) {
|
|
73
|
+
onAdapterMessage('whatsapp', profile, message);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
adapters.set('whatsapp', adapter);
|
|
77
|
+
}
|
|
78
|
+
return adapter;
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
/**
|
|
64
82
|
* JSON error response helper
|
|
@@ -333,19 +351,39 @@ function handleMedia(id: string): Response {
|
|
|
333
351
|
/**
|
|
334
352
|
* Handle WhatsApp pairing request
|
|
335
353
|
*/
|
|
336
|
-
function handleWhatsAppPair(profile: string): Response {
|
|
337
|
-
const whatsappAdapter =
|
|
354
|
+
async function handleWhatsAppPair(profile: string): Promise<Response> {
|
|
355
|
+
const whatsappAdapter = getOrCreateWhatsAppAdapter();
|
|
338
356
|
|
|
339
|
-
|
|
357
|
+
// If the profile isn't known to the adapter yet, start connecting it
|
|
358
|
+
const state = whatsappAdapter.getWhatsAppState(profile);
|
|
359
|
+
if (!state.connected && !whatsappAdapter.hasProfile(profile)) {
|
|
360
|
+
try {
|
|
361
|
+
await whatsappAdapter.connect(profile, { paired: false });
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const message = error instanceof Error ? error.message : 'Failed to start pairing';
|
|
364
|
+
const response: WhatsAppPairResponse = {
|
|
365
|
+
status: 'connecting',
|
|
366
|
+
message,
|
|
367
|
+
};
|
|
368
|
+
return jsonResponse(response);
|
|
369
|
+
}
|
|
370
|
+
// Re-check state after connect
|
|
371
|
+
const newState = whatsappAdapter.getWhatsAppState(profile);
|
|
372
|
+
if (newState.qrCode) {
|
|
373
|
+
const response: WhatsAppPairResponse = {
|
|
374
|
+
status: 'waiting_qr',
|
|
375
|
+
qrCode: newState.qrCode,
|
|
376
|
+
message: 'Scan QR code with WhatsApp on your phone',
|
|
377
|
+
};
|
|
378
|
+
return jsonResponse(response);
|
|
379
|
+
}
|
|
340
380
|
const response: WhatsAppPairResponse = {
|
|
341
|
-
status: '
|
|
342
|
-
message:
|
|
381
|
+
status: 'connecting',
|
|
382
|
+
message: newState.error || 'Connecting to WhatsApp...',
|
|
343
383
|
};
|
|
344
384
|
return jsonResponse(response);
|
|
345
385
|
}
|
|
346
386
|
|
|
347
|
-
const state = whatsappAdapter.getWhatsAppState(profile);
|
|
348
|
-
|
|
349
387
|
if (state.connected) {
|
|
350
388
|
const response: WhatsAppPairResponse = {
|
|
351
389
|
status: 'connected',
|
|
@@ -390,13 +428,14 @@ async function handleWhatsAppImport(profile: string, request: Request): Promise<
|
|
|
390
428
|
});
|
|
391
429
|
|
|
392
430
|
// Disconnect and reconnect WhatsApp adapter to use new credentials
|
|
393
|
-
const whatsappAdapter =
|
|
394
|
-
if (whatsappAdapter) {
|
|
431
|
+
const whatsappAdapter = getOrCreateWhatsAppAdapter();
|
|
432
|
+
if (whatsappAdapter.hasProfile(profile)) {
|
|
395
433
|
await whatsappAdapter.disconnect(profile);
|
|
396
|
-
// Note: reconnection will happen on next daemon cycle or manual reload
|
|
397
434
|
}
|
|
435
|
+
// Reconnect with imported auth state
|
|
436
|
+
await whatsappAdapter.connect(profile, { paired: true });
|
|
398
437
|
|
|
399
|
-
return jsonResponse({ success: true, message: 'Auth state imported
|
|
438
|
+
return jsonResponse({ success: true, message: 'Auth state imported and reconnecting.' });
|
|
400
439
|
} catch (error) {
|
|
401
440
|
const message = error instanceof Error ? error.message : 'Import failed';
|
|
402
441
|
return jsonError(message, 500);
|
|
@@ -753,12 +792,17 @@ async function handleRequest(request: Request): Promise<Response> {
|
|
|
753
792
|
/**
|
|
754
793
|
* Start the API server
|
|
755
794
|
*/
|
|
756
|
-
export function startApiServer(
|
|
795
|
+
export function startApiServer(
|
|
796
|
+
config: GatewayConfig,
|
|
797
|
+
serviceAdapters: Map<ServiceName, ServiceAdapter>,
|
|
798
|
+
messageHandler?: (service: ServiceName, profile: string, message: AdapterInboundMessage) => void,
|
|
799
|
+
): Server<unknown> {
|
|
757
800
|
const port = config?.server?.port ?? DEFAULT_GATEWAY_CONFIG.server.port;
|
|
758
801
|
const host = config?.server?.host ?? DEFAULT_GATEWAY_CONFIG.server.host;
|
|
759
802
|
apiKey = config?.apiKey ?? '';
|
|
760
803
|
startTime = Date.now();
|
|
761
804
|
adapters = serviceAdapters;
|
|
805
|
+
onAdapterMessage = messageHandler ?? null;
|
|
762
806
|
|
|
763
807
|
server = Bun.serve({
|
|
764
808
|
port,
|
package/src/gateway/daemon.ts
CHANGED
|
@@ -274,7 +274,7 @@ export async function startGateway(): Promise<void> {
|
|
|
274
274
|
await initializeAdapters();
|
|
275
275
|
|
|
276
276
|
// Start API server
|
|
277
|
-
startApiServer(gatewayConfig, adapters);
|
|
277
|
+
startApiServer(gatewayConfig, adapters, handleInboundMessage);
|
|
278
278
|
|
|
279
279
|
// Start outbox processor (every 2 seconds)
|
|
280
280
|
outboxInterval = setInterval(processOutbox, 2000);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { gmail, type gmail_v1 } from '@googleapis/gmail';
|
|
2
2
|
import type { OAuth2Client } from 'google-auth-library';
|
|
3
3
|
import { basename } from 'path';
|
|
4
|
-
import type { GmailMessage, GmailListOptions, GmailSendOptions,
|
|
4
|
+
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
|
|
5
5
|
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
6
6
|
import { CliError } from '../../utils/errors';
|
|
7
7
|
|
|
@@ -265,14 +265,57 @@ export class GmailClient implements ServiceClient {
|
|
|
265
265
|
return this.list({ query, limit });
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
async
|
|
269
|
-
|
|
268
|
+
private async resolveReplyContext(threadId: string): Promise<{
|
|
269
|
+
to: string[];
|
|
270
|
+
subject: string;
|
|
271
|
+
extraHeaders: string[];
|
|
272
|
+
}> {
|
|
273
|
+
const thread = await this.gmail.users.threads.get({
|
|
274
|
+
userId: 'me',
|
|
275
|
+
id: threadId,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const messages = thread.data.messages || [];
|
|
279
|
+
if (messages.length === 0) {
|
|
280
|
+
throw new CliError('NOT_FOUND', `Thread not found: ${threadId}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const lastMessage = messages[messages.length - 1];
|
|
284
|
+
const headers = this.parseHeaders(lastMessage.payload?.headers);
|
|
285
|
+
|
|
286
|
+
const to = [headers['reply-to'] || headers['from'] || ''];
|
|
287
|
+
const subject = headers['subject']?.startsWith('Re:')
|
|
288
|
+
? headers['subject']
|
|
289
|
+
: `Re: ${headers['subject'] || '(no subject)'}`;
|
|
290
|
+
const messageId = headers['message-id'] || '';
|
|
291
|
+
|
|
292
|
+
const extraHeaders: string[] = [];
|
|
293
|
+
if (messageId) {
|
|
294
|
+
extraHeaders.push(`In-Reply-To: ${messageId}`);
|
|
295
|
+
extraHeaders.push(`References: ${messageId}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { to, subject, extraHeaders };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async buildEncodedMessage(options: GmailSendOptions): Promise<string> {
|
|
302
|
+
const { cc, bcc, body, isHtml, attachments, replyTo } = options;
|
|
270
303
|
const userEmail = await this.getUserEmail();
|
|
271
304
|
|
|
305
|
+
let to = options.to;
|
|
306
|
+
let subject = options.subject;
|
|
307
|
+
let extraHeaders: string[] | undefined;
|
|
308
|
+
|
|
309
|
+
if (replyTo) {
|
|
310
|
+
const replyContext = await this.resolveReplyContext(replyTo);
|
|
311
|
+
if (!to.length) to = replyContext.to;
|
|
312
|
+
if (!subject) subject = replyContext.subject;
|
|
313
|
+
extraHeaders = replyContext.extraHeaders;
|
|
314
|
+
}
|
|
315
|
+
|
|
272
316
|
let rawMessage: string;
|
|
273
317
|
|
|
274
318
|
if (attachments && attachments.length > 0) {
|
|
275
|
-
// Build multipart MIME message with attachments
|
|
276
319
|
rawMessage = await this.buildMultipartMessage({
|
|
277
320
|
from: userEmail,
|
|
278
321
|
to,
|
|
@@ -282,27 +325,35 @@ export class GmailClient implements ServiceClient {
|
|
|
282
325
|
body,
|
|
283
326
|
isHtml,
|
|
284
327
|
attachments,
|
|
328
|
+
extraHeaders,
|
|
285
329
|
});
|
|
286
330
|
} else {
|
|
287
|
-
// Simple message without attachments
|
|
288
331
|
rawMessage = [
|
|
289
332
|
`From: ${userEmail}`,
|
|
290
333
|
`To: ${to.join(', ')}`,
|
|
291
334
|
cc?.length ? `Cc: ${cc.join(', ')}` : null,
|
|
292
335
|
bcc?.length ? `Bcc: ${bcc.join(', ')}` : null,
|
|
293
336
|
`Subject: ${subject}`,
|
|
337
|
+
...(extraHeaders || []),
|
|
294
338
|
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
|
|
295
339
|
'',
|
|
296
340
|
body,
|
|
297
341
|
].filter((line): line is string => line !== null).join('\r\n');
|
|
298
342
|
}
|
|
299
343
|
|
|
300
|
-
|
|
344
|
+
return Buffer.from(rawMessage).toString('base64url');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
|
|
348
|
+
const encodedMessage = await this.buildEncodedMessage(options);
|
|
301
349
|
|
|
302
350
|
try {
|
|
303
351
|
const response = await this.gmail.users.messages.send({
|
|
304
352
|
userId: 'me',
|
|
305
|
-
requestBody: {
|
|
353
|
+
requestBody: {
|
|
354
|
+
raw: encodedMessage,
|
|
355
|
+
...(options.replyTo ? { threadId: options.replyTo } : {}),
|
|
356
|
+
},
|
|
306
357
|
});
|
|
307
358
|
|
|
308
359
|
return {
|
|
@@ -316,6 +367,30 @@ export class GmailClient implements ServiceClient {
|
|
|
316
367
|
}
|
|
317
368
|
}
|
|
318
369
|
|
|
370
|
+
async draft(options: GmailSendOptions): Promise<{ id: string; messageId: string }> {
|
|
371
|
+
const encodedMessage = await this.buildEncodedMessage(options);
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const response = await this.gmail.users.drafts.create({
|
|
375
|
+
userId: 'me',
|
|
376
|
+
requestBody: {
|
|
377
|
+
message: {
|
|
378
|
+
raw: encodedMessage,
|
|
379
|
+
...(options.replyTo ? { threadId: options.replyTo } : {}),
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
id: response.data.id!,
|
|
386
|
+
messageId: response.data.message?.id || '',
|
|
387
|
+
};
|
|
388
|
+
} catch (error) {
|
|
389
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
390
|
+
throw new CliError('API_ERROR', `Failed to create draft: ${message}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
319
394
|
private async buildMultipartMessage(options: {
|
|
320
395
|
from: string;
|
|
321
396
|
to: string[];
|
|
@@ -325,8 +400,9 @@ export class GmailClient implements ServiceClient {
|
|
|
325
400
|
body: string;
|
|
326
401
|
isHtml?: boolean;
|
|
327
402
|
attachments: GmailAttachment[];
|
|
403
|
+
extraHeaders?: string[];
|
|
328
404
|
}): Promise<string> {
|
|
329
|
-
const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
|
|
405
|
+
const { from, to, cc, bcc, subject, body, isHtml, attachments, extraHeaders } = options;
|
|
330
406
|
|
|
331
407
|
// Separate inline and regular attachments
|
|
332
408
|
const inlineAttachments = attachments.filter(a => a.contentId);
|
|
@@ -347,6 +423,11 @@ export class GmailClient implements ServiceClient {
|
|
|
347
423
|
if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
|
|
348
424
|
if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
|
|
349
425
|
lines.push(`Subject: ${subject}`);
|
|
426
|
+
if (extraHeaders) {
|
|
427
|
+
for (const header of extraHeaders) {
|
|
428
|
+
lines.push(header);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
350
431
|
lines.push('MIME-Version: 1.0');
|
|
351
432
|
|
|
352
433
|
if (hasRegular && hasInline) {
|
|
@@ -464,64 +545,6 @@ export class GmailClient implements ServiceClient {
|
|
|
464
545
|
}
|
|
465
546
|
}
|
|
466
547
|
|
|
467
|
-
async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
|
|
468
|
-
const { threadId, body, isHtml } = options;
|
|
469
|
-
|
|
470
|
-
// Get the thread to find the last message
|
|
471
|
-
try {
|
|
472
|
-
const thread = await this.gmail.users.threads.get({
|
|
473
|
-
userId: 'me',
|
|
474
|
-
id: threadId,
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
const messages = thread.data.messages || [];
|
|
478
|
-
if (messages.length === 0) {
|
|
479
|
-
throw new CliError('NOT_FOUND', `Thread not found: ${threadId}`);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const lastMessage = messages[messages.length - 1];
|
|
483
|
-
const headers = this.parseHeaders(lastMessage.payload?.headers);
|
|
484
|
-
|
|
485
|
-
const userEmail = await this.getUserEmail();
|
|
486
|
-
const replyTo = headers['reply-to'] || headers['from'] || '';
|
|
487
|
-
const subject = headers['subject']?.startsWith('Re:')
|
|
488
|
-
? headers['subject']
|
|
489
|
-
: `Re: ${headers['subject'] || '(no subject)'}`;
|
|
490
|
-
const messageId = headers['message-id'] || '';
|
|
491
|
-
|
|
492
|
-
const rawHeaders = [
|
|
493
|
-
`From: ${userEmail}`,
|
|
494
|
-
`To: ${replyTo}`,
|
|
495
|
-
`Subject: ${subject}`,
|
|
496
|
-
messageId ? `In-Reply-To: ${messageId}` : '',
|
|
497
|
-
messageId ? `References: ${messageId}` : '',
|
|
498
|
-
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
|
|
499
|
-
'',
|
|
500
|
-
body,
|
|
501
|
-
].filter(Boolean).join('\r\n');
|
|
502
|
-
|
|
503
|
-
const encodedMessage = Buffer.from(rawHeaders).toString('base64url');
|
|
504
|
-
|
|
505
|
-
const response = await this.gmail.users.messages.send({
|
|
506
|
-
userId: 'me',
|
|
507
|
-
requestBody: {
|
|
508
|
-
raw: encodedMessage,
|
|
509
|
-
threadId,
|
|
510
|
-
},
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
id: response.data.id!,
|
|
515
|
-
threadId: response.data.threadId!,
|
|
516
|
-
labelIds: response.data.labelIds || ['SENT'],
|
|
517
|
-
};
|
|
518
|
-
} catch (error) {
|
|
519
|
-
if (error instanceof CliError) throw error;
|
|
520
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
521
|
-
throw new CliError('API_ERROR', `Failed to send reply: ${message}`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
548
|
async archive(messageId: string): Promise<void> {
|
|
526
549
|
try {
|
|
527
550
|
await this.gmail.users.messages.modify({
|
package/src/types/gmail.ts
CHANGED
|
@@ -40,10 +40,5 @@ export interface GmailSendOptions {
|
|
|
40
40
|
body: string;
|
|
41
41
|
isHtml?: boolean;
|
|
42
42
|
attachments?: GmailAttachment[];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
export interface GmailReplyOptions {
|
|
46
|
-
threadId: string;
|
|
47
|
-
body: string;
|
|
48
|
-
isHtml?: boolean;
|
|
43
|
+
replyTo?: string; // Thread ID to reply to
|
|
49
44
|
}
|
package/src/utils/output.ts
CHANGED
|
@@ -86,6 +86,13 @@ export function printSendResult(result: { id: string; threadId: string }): void
|
|
|
86
86
|
console.log(`Thread: ${result.threadId}`);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Format draft creation result
|
|
90
|
+
export function printDraftResult(result: { id: string; messageId: string }): void {
|
|
91
|
+
console.log('Draft created');
|
|
92
|
+
console.log(`Draft ID: ${result.id}`);
|
|
93
|
+
console.log(`Message ID: ${result.messageId}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
// Format archive confirmation
|
|
90
97
|
export function printArchived(messageId: string): void {
|
|
91
98
|
console.log(`Archived: ${messageId}`);
|