@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.5.9",
3
+ "version": "0.5.12",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- for (const entry of profiles) {
522
- const profileName = typeof entry === 'string' ? entry : entry.name;
523
- console.log(` Exporting whatsapp:${profileName}...`);
524
- const authState = await exportWhatsAppAuthState(profileName);
521
+ if (profiles.length > 0) {
522
+ await initDatabase();
523
+ }
525
524
 
526
- if (!authState) {
527
- console.log(' No auth state found, skipping');
528
- continue;
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
- // Send to remote gateway
532
- const response = await fetch(`${url}/import/whatsapp/${encodeURIComponent(profileName)}`, {
533
- method: 'POST',
534
- headers: {
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
- console.log(' Transferred successfully');
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
 
@@ -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
- let body = options.body;
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
- let body = options.body;
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, 'reply to email');
269
- const result = await client.reply({
270
- threadId: options.threadId,
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
 
@@ -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 type { WhatsAppAdapter } from './adapters/whatsapp';
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 = adapters.get('whatsapp') as WhatsAppAdapter | undefined;
354
+ async function handleWhatsAppPair(profile: string): Promise<Response> {
355
+ const whatsappAdapter = getOrCreateWhatsAppAdapter();
338
356
 
339
- if (!whatsappAdapter) {
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: 'not_configured',
342
- message: 'WhatsApp is not configured. Add a profile first.',
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 = adapters.get('whatsapp') as WhatsAppAdapter | undefined;
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. Reload gateway to reconnect.' });
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(config: GatewayConfig, serviceAdapters: Map<ServiceName, ServiceAdapter>): Server<unknown> {
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,
@@ -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, GmailReplyOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
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 send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
269
- const { to, cc, bcc, subject, body, isHtml, attachments } = options;
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
- const encodedMessage = Buffer.from(rawMessage).toString('base64url');
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: { raw: encodedMessage },
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({
@@ -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
  }
@@ -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}`);