@plosson/agentio 0.5.10 → 0.5.13

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.10",
3
+ "version": "0.5.13",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  }
@@ -1,10 +1,17 @@
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
 
8
+ // RFC 2047 encode a header value if it contains non-ASCII characters
9
+ function encodeHeaderValue(value: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ if (/^[\x00-\x7F]*$/.test(value)) return value;
12
+ return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`;
13
+ }
14
+
8
15
  // Common MIME types by extension
9
16
  const MIME_TYPES: Record<string, string> = {
10
17
  '.txt': 'text/plain',
@@ -265,14 +272,57 @@ export class GmailClient implements ServiceClient {
265
272
  return this.list({ query, limit });
266
273
  }
267
274
 
268
- async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
269
- const { to, cc, bcc, subject, body, isHtml, attachments } = options;
275
+ private async resolveReplyContext(threadId: string): Promise<{
276
+ to: string[];
277
+ subject: string;
278
+ extraHeaders: string[];
279
+ }> {
280
+ const thread = await this.gmail.users.threads.get({
281
+ userId: 'me',
282
+ id: threadId,
283
+ });
284
+
285
+ const messages = thread.data.messages || [];
286
+ if (messages.length === 0) {
287
+ throw new CliError('NOT_FOUND', `Thread not found: ${threadId}`);
288
+ }
289
+
290
+ const lastMessage = messages[messages.length - 1];
291
+ const headers = this.parseHeaders(lastMessage.payload?.headers);
292
+
293
+ const to = [headers['reply-to'] || headers['from'] || ''];
294
+ const subject = headers['subject']?.startsWith('Re:')
295
+ ? headers['subject']
296
+ : `Re: ${headers['subject'] || '(no subject)'}`;
297
+ const messageId = headers['message-id'] || '';
298
+
299
+ const extraHeaders: string[] = [];
300
+ if (messageId) {
301
+ extraHeaders.push(`In-Reply-To: ${messageId}`);
302
+ extraHeaders.push(`References: ${messageId}`);
303
+ }
304
+
305
+ return { to, subject, extraHeaders };
306
+ }
307
+
308
+ private async buildEncodedMessage(options: GmailSendOptions): Promise<string> {
309
+ const { cc, bcc, body, isHtml, attachments, replyTo } = options;
270
310
  const userEmail = await this.getUserEmail();
271
311
 
312
+ let to = options.to;
313
+ let subject = options.subject;
314
+ let extraHeaders: string[] | undefined;
315
+
316
+ if (replyTo) {
317
+ const replyContext = await this.resolveReplyContext(replyTo);
318
+ if (!to.length) to = replyContext.to;
319
+ if (!subject) subject = replyContext.subject;
320
+ extraHeaders = replyContext.extraHeaders;
321
+ }
322
+
272
323
  let rawMessage: string;
273
324
 
274
325
  if (attachments && attachments.length > 0) {
275
- // Build multipart MIME message with attachments
276
326
  rawMessage = await this.buildMultipartMessage({
277
327
  from: userEmail,
278
328
  to,
@@ -282,27 +332,35 @@ export class GmailClient implements ServiceClient {
282
332
  body,
283
333
  isHtml,
284
334
  attachments,
335
+ extraHeaders,
285
336
  });
286
337
  } else {
287
- // Simple message without attachments
288
338
  rawMessage = [
289
339
  `From: ${userEmail}`,
290
340
  `To: ${to.join(', ')}`,
291
341
  cc?.length ? `Cc: ${cc.join(', ')}` : null,
292
342
  bcc?.length ? `Bcc: ${bcc.join(', ')}` : null,
293
- `Subject: ${subject}`,
343
+ `Subject: ${encodeHeaderValue(subject)}`,
344
+ ...(extraHeaders || []),
294
345
  `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
295
346
  '',
296
347
  body,
297
348
  ].filter((line): line is string => line !== null).join('\r\n');
298
349
  }
299
350
 
300
- const encodedMessage = Buffer.from(rawMessage).toString('base64url');
351
+ return Buffer.from(rawMessage).toString('base64url');
352
+ }
353
+
354
+ async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
355
+ const encodedMessage = await this.buildEncodedMessage(options);
301
356
 
302
357
  try {
303
358
  const response = await this.gmail.users.messages.send({
304
359
  userId: 'me',
305
- requestBody: { raw: encodedMessage },
360
+ requestBody: {
361
+ raw: encodedMessage,
362
+ ...(options.replyTo ? { threadId: options.replyTo } : {}),
363
+ },
306
364
  });
307
365
 
308
366
  return {
@@ -316,6 +374,30 @@ export class GmailClient implements ServiceClient {
316
374
  }
317
375
  }
318
376
 
377
+ async draft(options: GmailSendOptions): Promise<{ id: string; messageId: string }> {
378
+ const encodedMessage = await this.buildEncodedMessage(options);
379
+
380
+ try {
381
+ const response = await this.gmail.users.drafts.create({
382
+ userId: 'me',
383
+ requestBody: {
384
+ message: {
385
+ raw: encodedMessage,
386
+ ...(options.replyTo ? { threadId: options.replyTo } : {}),
387
+ },
388
+ },
389
+ });
390
+
391
+ return {
392
+ id: response.data.id!,
393
+ messageId: response.data.message?.id || '',
394
+ };
395
+ } catch (error) {
396
+ const message = error instanceof Error ? error.message : String(error);
397
+ throw new CliError('API_ERROR', `Failed to create draft: ${message}`);
398
+ }
399
+ }
400
+
319
401
  private async buildMultipartMessage(options: {
320
402
  from: string;
321
403
  to: string[];
@@ -325,8 +407,9 @@ export class GmailClient implements ServiceClient {
325
407
  body: string;
326
408
  isHtml?: boolean;
327
409
  attachments: GmailAttachment[];
410
+ extraHeaders?: string[];
328
411
  }): Promise<string> {
329
- const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
412
+ const { from, to, cc, bcc, subject, body, isHtml, attachments, extraHeaders } = options;
330
413
 
331
414
  // Separate inline and regular attachments
332
415
  const inlineAttachments = attachments.filter(a => a.contentId);
@@ -346,7 +429,12 @@ export class GmailClient implements ServiceClient {
346
429
  lines.push(`To: ${to.join(', ')}`);
347
430
  if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
348
431
  if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
349
- lines.push(`Subject: ${subject}`);
432
+ lines.push(`Subject: ${encodeHeaderValue(subject)}`);
433
+ if (extraHeaders) {
434
+ for (const header of extraHeaders) {
435
+ lines.push(header);
436
+ }
437
+ }
350
438
  lines.push('MIME-Version: 1.0');
351
439
 
352
440
  if (hasRegular && hasInline) {
@@ -464,64 +552,6 @@ export class GmailClient implements ServiceClient {
464
552
  }
465
553
  }
466
554
 
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
555
  async archive(messageId: string): Promise<void> {
526
556
  try {
527
557
  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}`);