gogcli-mcp-gmail 2.21.1 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-gmail",
3
- "version": "2.21.1",
3
+ "version": "2.22.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-gmail",
5
5
  "description": "Extended Gmail MCP server via gogcli — auth + full Gmail support (threads, labels, drafts, attachments, forward, autoreply, bulk operations)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -88,22 +88,42 @@ function trimThread(
88
88
  }
89
89
  }
90
90
 
91
- // `gog gmail attachment --inline --json` emits these fields (NO filename, NO MIME
92
- // type): base64 content when the attachment is within gog's 3 MiB inline cap,
93
- // otherwise just the on-disk path plus a `reason` explaining the size fallback.
91
+ // gog's own default for --inline-max-bytes (gmail_attachment.go:27,
92
+ // `default:"3145728"` at upstream-v0.35.0). Restated here so the wrapper can pin
93
+ // the flag on every call rather than let GOG_GMAIL_INLINE_MAX_BYTES decide.
94
+ const GOG_DEFAULT_INLINE_MAX_BYTES = 3145728;
95
+
96
+ // `gog gmail attachment --inline --json` emits base64 content when the attachment
97
+ // is within gog's inline cap (3 MiB by default, --inline-max-bytes), otherwise
98
+ // just the on-disk path plus a `reason` explaining the size fallback.
99
+ //
100
+ // `filename`/`mimeType` come from gog's own part lookup and are present whenever
101
+ // that lookup resolved the part — always in indexed mode, and by single-attachment
102
+ // fallback otherwise. They are absent when the opaque id missed against a message
103
+ // with several attachments, which is exactly the case resolveBySize exists for.
94
104
  type InlineAttachment = {
95
105
  path?: string;
96
106
  bytes?: number;
97
107
  cached?: boolean;
98
108
  contentBase64?: string;
99
109
  reason?: string;
110
+ filename?: string;
111
+ mimeType?: string;
100
112
  };
101
113
 
102
114
  // One entry from `gog gmail get --json` `.attachments[]`: the message part
103
- // metadata, which carries the TRUE filename and MIME type that the download
104
- // endpoint itself does not report. `size` is the stable key we match on —
105
- // Gmail's `attachmentId` is NOT stable across calls (see resolveBySize).
106
- type AttachmentMeta = { filename?: string; mimeType?: string; attachmentId?: string; size?: number };
115
+ // metadata, carrying the TRUE filename and MIME type. `size` is the key the
116
+ // legacy path matches on Gmail's `attachmentId` is NOT stable across calls
117
+ // (see resolveBySize); `attachmentIndex` is, which is why resolveByIndex exists.
118
+ // The two ids are mutually exclusive in gog's output: indexed mode emits the
119
+ // index INSTEAD of the id (`attachmentId,omitempty`).
120
+ type AttachmentMeta = {
121
+ filename?: string;
122
+ mimeType?: string;
123
+ attachmentId?: string;
124
+ attachmentIndex?: number;
125
+ size?: number;
126
+ };
107
127
 
108
128
  // MIME type by file extension — the download endpoint reports none, and the
109
129
  // client needs it to render an inline image or label an embedded resource. Part
@@ -209,6 +229,49 @@ async function resolveBySize(
209
229
  }
210
230
  }
211
231
 
232
+ // Resolve the part metadata for a 0-based attachment INDEX (gog >= 0.35.0). Unlike
233
+ // resolveBySize this is deterministic and needs no guessing: each part carries the
234
+ // index gog assigned it, so the lookup matches that DECLARED field rather than the
235
+ // array slot it happens to occupy. `collectAttachments` sets AttachmentIndex = i,
236
+ // so the two agree today — but they are different promises, and resolving by
237
+ // position would name the download after the wrong part on the day they diverge
238
+ // (#252).
239
+ //
240
+ // It also runs BEFORE the download rather than after, so the real filename can be
241
+ // handed to gog as --name/--out instead of the provisional `attachment` basename.
242
+ // Same one extra call as resolveBySize — it replaces it, it does not add to it.
243
+ async function resolveByIndex(
244
+ messageId: string,
245
+ index: number,
246
+ account: string | undefined,
247
+ ): Promise<AttachmentMeta | undefined> {
248
+ try {
249
+ const parsed = JSON.parse(
250
+ await run(['gmail', 'get', messageId, '--use-indexed-attachment-ids'], { account }),
251
+ ) as { attachments?: AttachmentMeta[] };
252
+ const attachments = parsed.attachments;
253
+ if (!attachments) return undefined;
254
+ // Match the DECLARED index, not the array slot. gog assigns
255
+ // AttachmentIndex = i over the collected parts (gmail_attachments.go), so
256
+ // the two agree today — but they are different promises, and resolveBySize
257
+ // above already matches on a field rather than a position. If a listing
258
+ // ever arrives filtered or reordered, position resolution does not fail, it
259
+ // names the download after the WRONG part, which is worse than an error.
260
+ const declared = attachments.find((a) => a.attachmentIndex === index);
261
+ if (declared) return declared;
262
+ // Nothing declared an index — an older gog, or a listing fetched without
263
+ // --use-indexed-attachment-ids. Position is then the only reading of the
264
+ // caller's number, and it is the one gog itself would apply.
265
+ if (attachments.every((a) => a.attachmentIndex === undefined)) return attachments[index];
266
+ // Some parts declared an index and none matched: the caller asked for an
267
+ // index that is not in this message. Guessing by position here would be the
268
+ // exact silent mis-naming this function exists to avoid.
269
+ return undefined;
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
212
275
  // A writable, ephemeral server-side output path. gog MkdirAll's the tree, and
213
276
  // /tmp is writable on both the local host and the Fly backend AND is cleared when
214
277
  // the machine stops — unlike gog's default (the gogcli config dir), which on the
@@ -223,10 +286,15 @@ function defaultOutPath(messageId: string, filename: string): string {
223
286
  // leaking the full command line (opaque attachment token included) and a raw shell
224
287
  // error such as `mkdir /home/claude: permission denied`. Keep only the stderr
225
288
  // tail, with the ids redacted.
226
- function sanitizeAttachmentError(err: unknown, messageId: string, attachmentId: string): string {
289
+ //
290
+ // `attachmentId` is undefined in indexed mode: the reference is then a small
291
+ // integer, which is neither secret nor safely substitutable — blind-replacing "0"
292
+ // would corrupt every number in the message.
293
+ function sanitizeAttachmentError(err: unknown, messageId: string, attachmentId: string | undefined): string {
227
294
  let msg = err instanceof Error ? err.message : String(err);
228
295
  msg = msg.replace(/^Command failed:.*(\n|$)/, ''); // drop the command echo line
229
- msg = msg.split(attachmentId).join('<attachment>').split(messageId).join('<message>');
296
+ if (attachmentId) msg = msg.split(attachmentId).join('<attachment>');
297
+ msg = msg.split(messageId).join('<message>');
230
298
  return msg.trim() || 'the download failed on the server';
231
299
  }
232
300
 
@@ -333,9 +401,12 @@ export function registerExtraGmailTools(server: McpServer): void {
333
401
 
334
402
  server.registerTool('gog_gmail_attachment', {
335
403
  description:
336
- 'Download a Gmail attachment and deliver its contents so you can actually read them. The real filename ' +
337
- 'and MIME type are resolved from the message part metadata, so the saved file and response are named ' +
338
- 'correctly (e.g. Guest_Copy.pdf), never a generic *.bin. deliver="auto" (default) is transport-aware: ' +
404
+ 'Download a Gmail attachment and deliver its contents so you can actually read them. Identify the ' +
405
+ 'attachment by attachmentIndex (preferred: the 0-based position from a listing fetched with ' +
406
+ 'useIndexedAttachmentIds stable, and it resolves the real name before the download) or by the legacy ' +
407
+ 'opaque attachmentId. The real filename and MIME type are resolved from the message part metadata, so ' +
408
+ 'the saved file and response are named correctly (e.g. Guest_Copy.pdf), never a generic *.bin. ' +
409
+ 'deliver="auto" (default) is transport-aware: ' +
339
410
  'images always come back as a native image block; anything else is delivered by the channel that works ' +
340
411
  'on your transport — a readable server-side file PATH on local (stdio) clients that share the filesystem, ' +
341
412
  'or a Google Drive link on the remote connector (whose backend filesystem you can\'t read, and which ' +
@@ -345,7 +416,9 @@ export function registerExtraGmailTools(server: McpServer): void {
345
416
  '{path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).',
346
417
  inputSchema: {
347
418
  messageId: z.string().describe('Gmail message ID'),
348
- attachmentId: z.string().describe('Attachment ID (from the message payload)'),
419
+ attachmentId: z.string().optional().describe('The opaque attachment ID from a listing. Legacy addressing: Gmail re-issues a DIFFERENT id for the same part on every API call, so an id copied from an older listing can be stale. Prefer attachmentIndex. Exactly one of attachmentId / attachmentIndex is required.'),
420
+ attachmentIndex: z.number().int().nonnegative().optional().describe('The attachment\'s 0-based position in its message — the `attachmentIndex` field of a listing fetched with useIndexedAttachmentIds. Stable (a message\'s MIME structure does not change), so this is the reliable way to name an attachment. Exactly one of attachmentId / attachmentIndex is required. NOTE: it is per-MESSAGE — in gog_gmail_thread_attachments the array is flattened across the whole thread, so use each row\'s messageId + attachmentIndex, never its position in that flat list.'),
421
+ inlineMaxBytes: z.number().int().nonnegative().optional().describe('Byte ceiling under which gog embeds the attachment bytes rather than only writing the file. Defaults to gog\'s own 3145728, which this server pins explicitly on every call so an ambient GOG_GMAIL_INLINE_MAX_BYTES cannot change the answer. Raise it to inline something larger, lower it to force the file/Drive path.'),
349
422
  deliver: z
350
423
  .enum(['auto', 'inline', 'drive', 'off'])
351
424
  .optional()
@@ -355,7 +428,20 @@ export function registerExtraGmailTools(server: McpServer): void {
355
428
  driveFolder: z.string().optional().describe('Destination Google Drive folder ID for the uploaded copy (drive/auto delivery on the remote connector, or oversized attachments).'),
356
429
  account: accountParam,
357
430
  },
358
- }, async ({ messageId, attachmentId, deliver = 'auto', out, name, driveFolder, account }) => {
431
+ }, async ({ messageId, attachmentId, attachmentIndex, deliver = 'auto', out, name, inlineMaxBytes, driveFolder, account }) => {
432
+ // gog reads the id and the index from the SAME positional argument and is told
433
+ // which shape it got by --use-indexed-attachment-ids, so the wrapper has to
434
+ // pick exactly one. Rejected here rather than by zod so the message can say
435
+ // which one to prefer and why.
436
+ if ((attachmentId === undefined) === (attachmentIndex === undefined)) {
437
+ return errorResult(
438
+ 'Pass exactly one of attachmentId or attachmentIndex. Prefer attachmentIndex — the 0-based ' +
439
+ '`attachmentIndex` from a listing fetched with useIndexedAttachmentIds — because Gmail\'s opaque ' +
440
+ 'attachmentId is not stable across API calls and a copied one may no longer resolve.',
441
+ );
442
+ }
443
+ const indexed = attachmentIndex !== undefined;
444
+ const attachmentRef = indexed ? String(attachmentIndex) : attachmentId as string;
359
445
  // On the remote connector, `run` forwards to the Fly backend and this store
360
446
  // is set; on local stdio it is unset. It is the one signal that tells apart
361
447
  // "the caller shares my filesystem" (stdio → deliver a path) from "the caller
@@ -371,6 +457,15 @@ export function registerExtraGmailTools(server: McpServer): void {
371
457
  let filename = name ? sanitizeFilename(name) : undefined;
372
458
  let mimeType: string | undefined = filename ? MIME_BY_EXT[extOf(filename)] : undefined;
373
459
 
460
+ // 1b. Indexed mode resolves the part metadata BEFORE the download, because
461
+ // an index identifies the part outright. The file is then written under
462
+ // its real name and the post-download size heuristic never runs.
463
+ if (indexed && !filename) {
464
+ const meta = await resolveByIndex(messageId, attachmentIndex, account);
465
+ if (meta?.filename) filename = sanitizeFilename(meta.filename);
466
+ if (meta?.mimeType) mimeType = meta.mimeType;
467
+ }
468
+
374
469
  // 2. Choose the server-side output path. A caller `out` only makes sense on
375
470
  // the local transport; on the connector it resolves on the backend the
376
471
  // caller can't read, so ignore it (with a note) and use a temp path. The
@@ -395,15 +490,32 @@ export function registerExtraGmailTools(server: McpServer): void {
395
490
  if (!mimeType && (deliver === 'auto' || deliver === 'inline')) {
396
491
  needInline = true; // need the bytes to sniff the type
397
492
  }
398
- const args = ['gmail', 'attachment', messageId, attachmentId];
493
+ const args = ['gmail', 'attachment', messageId, attachmentRef];
494
+ // PIN the id-vs-index mode on every call. GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS
495
+ // in the host env would otherwise make gog parse an opaque attachmentId as an
496
+ // integer and hard-fail ("the attachment argument must be a 0-based index",
497
+ // reproduced against a v0.35.0 build). runner.ts strips only credential-shaped
498
+ // vars, and on the remote runner the child env belongs to a backend we do not
499
+ // control — an explicit flag is the only setting authoritative on both.
500
+ args.push(indexed ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
399
501
  if (needInline) args.push('--inline');
502
+ // PINNED for the same reason as --use-indexed-attachment-ids above: gog declares
503
+ // this flag env:"GOG_GMAIL_INLINE_MAX_BYTES" (gmail_attachment.go:27), so an ambient
504
+ // value in the host — or in the remote runner's backend, whose env is not ours —
505
+ // would silently decide whether contentBase64 comes back at all. Restating gog's own
506
+ // default keeps the arg array the single authority on both transports.
507
+ args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
400
508
  args.push(`--out=${outPath}`, `--name=${filename ?? 'attachment'}`);
401
509
  const info = JSON.parse(await run(args, { account })) as InlineAttachment;
402
510
  const path = info.path ?? outPath;
403
511
 
404
- // 4. Resolve the real filename/MIME when `name` wasn't supplied, by matching
405
- // the downloaded byte count against the part metadata.
406
- if (!filename) {
512
+ // 4. Resolve the real filename/MIME when it is still unknown. gog's own
513
+ // --inline response carries the part metadata whenever its lookup hit, so
514
+ // prefer that; the size heuristic is the last resort and applies only to
515
+ // the legacy id path (an index already resolved above).
516
+ if (!filename && info.filename) filename = sanitizeFilename(info.filename);
517
+ if (!mimeType && info.mimeType) mimeType = info.mimeType;
518
+ if (!filename && !indexed) {
407
519
  const meta = await resolveBySize(messageId, info.bytes, account);
408
520
  if (meta?.filename) filename = sanitizeFilename(meta.filename);
409
521
  if (!mimeType && meta?.mimeType) mimeType = meta.mimeType;
@@ -434,7 +546,7 @@ export function registerExtraGmailTools(server: McpServer): void {
434
546
  : inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
435
547
  }
436
548
  return errorResult(
437
- `Attachment is too large to return inline (${info.reason ?? "exceeds gog's 3 MiB inline limit"}). ` +
549
+ `Attachment is too large to return inline (${info.reason ?? "exceeds gog's inline size limit, 3 MiB by default — raise inlineMaxBytes"}). ` +
438
550
  'Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.',
439
551
  );
440
552
  }
@@ -450,7 +562,7 @@ export function registerExtraGmailTools(server: McpServer): void {
450
562
  } catch (err) {
451
563
  // Never surface gog's raw error (it echoes the full command line + attachment
452
564
  // token on the backend); redact the ids and let diagnose classify the rest.
453
- return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId)));
565
+ return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId))); // opaque id only; an index needs no redaction
454
566
  }
455
567
  });
456
568
 
@@ -591,15 +703,20 @@ export function registerExtraGmailTools(server: McpServer): void {
591
703
  sanitizeContent: z.boolean().optional().describe('Strip HTML, remove URLs, omit raw payloads from JSON (largest payload-size reduction)'),
592
704
  latestN: z.number().int().positive().optional().describe('Return only the most recent N messages in the thread (wrapper-side trim; avoids overflowing context on long threads)'),
593
705
  snippetsOnly: z.boolean().optional().describe('Reduce each message to its id, labels, snippet, and key headers (From/To/Cc/Subject/Date), dropping full bodies'),
706
+ useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId`. The index is stable across calls (a message\'s MIME structure does not change) while the id is not, so this is what you want before calling gog_gmail_attachment.'),
594
707
  outDir: z.string().optional().describe('Directory to write attachments to (default: current directory)'),
595
708
  account: accountParam,
596
709
  },
597
- }, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, outDir, account }) => {
710
+ }, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, useIndexedAttachmentIds, outDir, account }) => {
598
711
  const args = ['gmail', 'thread', 'get', threadId];
599
712
  if (download) args.push('--download');
600
713
  if (full) args.push('--full');
601
714
  if (sanitizeContent) args.push('--sanitize-content');
602
715
  if (outDir) args.push(`--out-dir=${outDir}`);
716
+ // PINNED, not conditional: GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS in the host env
717
+ // swaps `attachmentId` for `attachmentIndex` in every attachments[] entry. Only
718
+ // an explicit flag makes the response shape the same on both transports.
719
+ args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
603
720
  const result = await runOrDiagnose(args, { account });
604
721
  if (latestN === undefined && !snippetsOnly) return result;
605
722
  return trimThread(result, latestN, snippetsOnly);
@@ -627,13 +744,18 @@ export function registerExtraGmailTools(server: McpServer): void {
627
744
  inputSchema: {
628
745
  threadId: z.string().describe('Gmail thread ID'),
629
746
  download: z.boolean().optional().describe('Download all attachments to the SERVER filesystem (see the note above; on the remote connector the files aren\'t reachable — fetch individually with gog_gmail_attachment instead).'),
747
+ useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` instead of an opaque `attachmentId`. Set this before calling gog_gmail_attachment: the index is stable across calls, the id is not. The index counts WITHIN each message, and this listing flattens every message\'s attachments into one array — so pair each row\'s `messageId` with its own `attachmentIndex`; a row\'s position in the flat array is NOT the index.'),
630
748
  outDir: z.string().optional().describe('Directory to write attachments to, resolved on the gog SERVER\'s filesystem (default: current directory). Not your local machine on the remote connector.'),
631
749
  account: accountParam,
632
750
  },
633
- }, async ({ threadId, download, outDir, account }) => {
751
+ }, async ({ threadId, download, useIndexedAttachmentIds, outDir, account }) => {
634
752
  const args = ['gmail', 'thread', 'attachments', threadId];
635
753
  if (download) args.push('--download');
636
754
  if (outDir) args.push(`--out-dir=${outDir}`);
755
+ // PINNED — see gog_gmail_thread_get. This listing is the one place the index is
756
+ // load-bearing: gog concatenates every message's attachments into a single
757
+ // array, so array position is NOT the per-message index the download expects.
758
+ args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
637
759
  return runOrDiagnose(args, { account });
638
760
  });
639
761
 
@@ -730,11 +852,13 @@ export function registerExtraGmailTools(server: McpServer): void {
730
852
  inputSchema: {
731
853
  draftId: z.string().describe('Draft ID'),
732
854
  download: z.boolean().optional().describe('Download draft attachments'),
855
+ useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` instead of an opaque `attachmentId` (stable across calls, unlike the id).'),
733
856
  account: accountParam,
734
857
  },
735
- }, async ({ draftId, download, account }) => {
858
+ }, async ({ draftId, download, useIndexedAttachmentIds, account }) => {
736
859
  const args = ['gmail', 'drafts', 'get', draftId];
737
860
  if (download) args.push('--download');
861
+ args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false'); // PINNED — see gog_gmail_thread_get
738
862
  return runOrDiagnose(args, { account });
739
863
  });
740
864
 
@@ -745,7 +869,7 @@ export function registerExtraGmailTools(server: McpServer): void {
745
869
  subject: z.string().describe('Subject'),
746
870
  body: z.string().describe('Body (plain text). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
747
871
  bodyHtml: z.string().optional().describe('Body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
748
- bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body, or "-" to read from stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
872
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
749
873
  replyToMessageId: z.string().optional().describe('Reply to a specific Gmail MESSAGE id — the short hex `id` field from gog_gmail_get / _search / _thread_get (e.g. 19e7593d77fd9636), NOT a thread id and NOT the RFC822 `<…@host>` Message-Id header. Anchors In-Reply-To/References to that exact message. To reply to a thread when you don\'t know the latest message, use replyToThreadId instead. If both are given, replyToMessageId wins.'),
750
874
  replyToThreadId: z.string().optional().describe('Reply to a Gmail THREAD id — passed to gog as --thread-id, which threads the draft using the thread\'s latest-message headers (In-Reply-To/References). This is what "reply to this thread" almost always means. Mutually exclusive with replyToMessageId (which wins if both are set). Thread ids and message ids are both 16-hex strings and easy to confuse — use this param, not replyToMessageId, when the id came from a thread.'),
751
875
  replyTo: z.string().optional().describe('Reply-To header address'),
@@ -753,6 +877,7 @@ export function registerExtraGmailTools(server: McpServer): void {
753
877
  replyAll: z.boolean().optional().describe('Auto-populate recipients from the original message (reply-all), inferring To/Cc from it. Requires replyToMessageId or replyToThreadId. Explicit to/cc/bcc still apply on top; omitRecipients still suppresses them.'),
754
878
  attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension. The JSON result echoes attached filenames and byte sizes — check it to confirm the files were found and embedded. On gog_gmail_drafts_update, supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them (use clearAttachments to remove all).'),
755
879
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
880
+ autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
756
881
  omitRecipients: z.boolean().optional().describe('Create the draft with no recipients even if to/cc/bcc are supplied — an accidental-send guard. Populate recipients in a later update before sending.'),
757
882
  returnFull: z.boolean().optional().describe('After writing, re-fetch and return the full stored draft (subject, body, recipients) instead of just the write acknowledgement. Costs one extra read.'),
758
883
  account: accountParam,
@@ -773,6 +898,7 @@ export function registerExtraGmailTools(server: McpServer): void {
773
898
  replyAll?: boolean;
774
899
  attach?: string[];
775
900
  from?: string;
901
+ autoFromAddressedAlias?: boolean;
776
902
  omitRecipients?: boolean;
777
903
  };
778
904
 
@@ -800,6 +926,11 @@ export function registerExtraGmailTools(server: McpServer): void {
800
926
  if (f.quote) args.push('--quote');
801
927
  if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
802
928
  if (f.from) args.push(`--from=${f.from}`);
929
+ // PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
930
+ // silently changes which address the mail goes out FROM, with nothing in the arg
931
+ // array to show for it — and the remote runner's backend env is not ours to set.
932
+ // An explicit flag is the only value authoritative on both transports.
933
+ args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
803
934
  }
804
935
 
805
936
  // Run a draft write, then — when returnFull is set — re-fetch the stored
@@ -827,7 +958,9 @@ export function registerExtraGmailTools(server: McpServer): void {
827
958
  }
828
959
  const draftId = knownDraftId ?? parsed.draftId;
829
960
  if (!draftId) return result;
830
- return runOrDiagnose(['gmail', 'drafts', 'get', draftId], { account });
961
+ // Same PIN as gog_gmail_drafts_get this re-fetch is handed to the caller
962
+ // verbatim, so its attachments[] shape must not depend on the host env.
963
+ return runOrDiagnose(['gmail', 'drafts', 'get', draftId, '--use-indexed-attachment-ids=false'], { account });
831
964
  }
832
965
 
833
966
  server.registerTool('gog_gmail_drafts_create', {
@@ -881,6 +1014,33 @@ export function registerExtraGmailTools(server: McpServer): void {
881
1014
  return runOrDiagnose(['gmail', 'drafts', 'send', draftId], { account });
882
1015
  });
883
1016
 
1017
+ server.registerTool('gog_gmail_import', {
1018
+ description:
1019
+ 'Import an existing RFC822/EML message INTO the mailbox. This is Gmail\'s import path, not a send: nothing ' +
1020
+ 'leaves the account, and the message keeps its own From/Date/Message-Id headers so it files where it ' +
1021
+ 'belongs chronologically. Use it to restore an exported message or to file a .eml under a label; to ' +
1022
+ 'actually send mail use gog_gmail_send, and to stage one use gog_gmail_drafts_create. The file is read on ' +
1023
+ 'the gog SERVER, not your machine.',
1024
+ inputSchema: {
1025
+ file: z.string().describe('Path to an RFC822/EML file that ALREADY EXISTS on the gog server. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out.'),
1026
+ labels: z.array(z.string()).optional().describe('Labels to apply to the imported message (repeatable). Each may be a label ID or a label name — names are resolved server-side. A name containing a COMMA cannot be passed here: gog declares --label as a Kong slice with no separator override, so Kong splits each value on commas and "Clients, Inc" is looked up as two labels ("Clients" and "Inc") and fails. Use that label\'s ID instead — ids never contain a comma; gog_gmail_labels_list gives you one.'),
1027
+ internalDateSource: z.enum(['dateHeader', 'receivedTime']).optional().describe('Which clock sets Gmail\'s internal date: dateHeader (gog default — the message\'s own Date header, so it sorts into the mailbox at its original time) or receivedTime (now).'),
1028
+ neverMarkSpam: z.boolean().optional().describe('Never classify the imported message as spam.'),
1029
+ processForCalendar: z.boolean().optional().describe('Process calendar invitations inside the imported message — this can ADD EVENTS to your calendar.'),
1030
+ account: accountParam,
1031
+ },
1032
+ }, async ({ file, labels, internalDateSource, neverMarkSpam, processForCalendar, account }) => {
1033
+ // Not gated: gogcli's internal/cmd/gmail_import.go has no confirmDestructive /
1034
+ // dryRunAndConfirmDestructive call site (checked at upstream v0.35.0, and a live
1035
+ // `--dry-run` against a v0.35.0 build proceeds), so no --force is appended.
1036
+ const args = ['gmail', 'import', file];
1037
+ if (labels) for (const label of labels) args.push(`--label=${label}`);
1038
+ if (internalDateSource) args.push(`--internal-date-source=${internalDateSource}`);
1039
+ if (neverMarkSpam) args.push('--never-mark-spam');
1040
+ if (processForCalendar) args.push('--process-for-calendar');
1041
+ return runOrDiagnose(args, { account });
1042
+ });
1043
+
884
1044
  server.registerTool('gog_gmail_forward', {
885
1045
  description: 'Forward an existing Gmail message to new recipients.',
886
1046
  annotations: { destructiveHint: true },
@@ -914,7 +1074,7 @@ export function registerExtraGmailTools(server: McpServer): void {
914
1074
  messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search / _messages_search (NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header).'),
915
1075
  body: z.string().optional().describe('Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
916
1076
  bodyHtml: z.string().optional().describe('Reply body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
917
- bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body, or "-" for stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
1077
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
918
1078
  to: z.array(z.string()).optional().describe('Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message.'),
919
1079
  cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
920
1080
  bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
@@ -923,6 +1083,7 @@ export function registerExtraGmailTools(server: McpServer): void {
923
1083
  noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
924
1084
  attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension.'),
925
1085
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
1086
+ autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
926
1087
  signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
927
1088
  signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
928
1089
  signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
@@ -941,6 +1102,7 @@ export function registerExtraGmailTools(server: McpServer): void {
941
1102
  noQuote?: boolean;
942
1103
  attach?: string[];
943
1104
  from?: string;
1105
+ autoFromAddressedAlias?: boolean;
944
1106
  signature?: boolean;
945
1107
  signatureFrom?: string;
946
1108
  signatureFile?: string;
@@ -962,6 +1124,7 @@ export function registerExtraGmailTools(server: McpServer): void {
962
1124
  if (f.signature) args.push('--signature');
963
1125
  if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
964
1126
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
1127
+ args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false'); // PINNED — see appendDraftFlags
965
1128
  }
966
1129
 
967
1130
  server.registerTool('gog_gmail_reply', {
@@ -1032,9 +1195,11 @@ export function registerExtraGmailTools(server: McpServer): void {
1032
1195
  includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
1033
1196
  full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
1034
1197
  bodyFormat: z.enum(['text', 'html']).optional().describe('Body format preference when includeBody is set'),
1198
+ includeAttachments: z.boolean().optional().describe('Include each message\'s attachment metadata (filename, size, mimeType, id or index). NOT a cheap add-on: like includeBody it makes gog fetch every matching message at format=full, so it costs a full per-message read — narrow the query or lower max before turning it on.'),
1199
+ useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody.'),
1035
1200
  account: accountParam,
1036
1201
  },
1037
- }, async ({ query, max, page, all, includeBody, full, bodyFormat, account }) => {
1202
+ }, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
1038
1203
  const args = ['gmail', 'messages', 'search', query];
1039
1204
  if (max !== undefined) args.push(`--max=${max}`);
1040
1205
  if (page) args.push(`--page=${page}`);
@@ -1042,6 +1207,11 @@ export function registerExtraGmailTools(server: McpServer): void {
1042
1207
  if (includeBody) args.push('--include-body');
1043
1208
  if (full) args.push('--full');
1044
1209
  if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
1210
+ // Both PINNED: GOG_GMAIL_INCLUDE_ATTACHMENTS and GOG_GMAIL_USE_INDEXED_ATTACHMENT_IDS
1211
+ // each change the result shape (and the first also the per-message API cost) with
1212
+ // nothing in the arg array to show for it. See gog_gmail_thread_get.
1213
+ args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
1214
+ args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
1045
1215
  return runOrDiagnose(args, { account });
1046
1216
  });
1047
1217
 
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
3
+
4
+ vi.mock('../../../gogcli-mcp/src/lib.js', async (orig) => {
5
+ const actual = await orig<Record<string, unknown>>();
6
+ return { ...actual, run: vi.fn() };
7
+ });
8
+ const { run } = await import('../../../gogcli-mcp/src/lib.js');
9
+ const { registerExtraGmailTools } = await import('../../src/tools/gmail-extra.js');
10
+
11
+ /**
12
+ * #252: resolveByIndex took `attachments[index]` — array POSITION — while
13
+ * resolveBySize matches on a declared field. gog assigns AttachmentIndex = i
14
+ * today, so position happens to agree; the moment it does not (a filtered or
15
+ * reordered listing) the download is silently named after the WRONG part, which
16
+ * is worse than failing.
17
+ *
18
+ * The declared `attachmentIndex` is the contract. Match it.
19
+ */
20
+ describe('resolveByIndex matches the declared attachmentIndex', () => {
21
+ let h: TestHarness;
22
+ beforeEach(async () => { vi.clearAllMocks(); h = await createTestHarness(registerExtraGmailTools); });
23
+
24
+ it('picks the part whose attachmentIndex equals the request, not its array slot', async () => {
25
+ (run as any).mockImplementation(async (args: string[]) => {
26
+ if (args[1] === 'get') {
27
+ // Declared indexes deliberately NOT in array order.
28
+ return JSON.stringify({ attachments: [
29
+ { filename: 'second.pdf', attachmentIndex: 1, size: 100, mimeType: 'application/pdf' },
30
+ { filename: 'first.pdf', attachmentIndex: 0, size: 200, mimeType: 'application/pdf' },
31
+ ] });
32
+ }
33
+ return JSON.stringify({ path: '/tmp/x', size: 200 });
34
+ });
35
+ await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
36
+ const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
37
+ // index 0 is declared by the SECOND array element (first.pdf)
38
+ expect(dl.join(' ')).toContain('first.pdf');
39
+ expect(dl.join(' ')).not.toContain('second.pdf');
40
+ });
41
+
42
+ it('still resolves when gog omits attachmentIndex, by position', async () => {
43
+ (run as any).mockImplementation(async (args: string[]) => {
44
+ if (args[1] === 'get') {
45
+ return JSON.stringify({ attachments: [
46
+ { filename: 'a.pdf', size: 100, mimeType: 'application/pdf' },
47
+ { filename: 'b.pdf', size: 200, mimeType: 'application/pdf' },
48
+ ] });
49
+ }
50
+ return JSON.stringify({ path: '/tmp/x', size: 200 });
51
+ });
52
+ await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 1 });
53
+ const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
54
+ expect(dl.join(' ')).toContain('b.pdf');
55
+ });
56
+
57
+ it('refuses to guess when indexes are declared but none matches', async () => {
58
+ // The caller asked for an index this message does not have. Falling back to
59
+ // position here would hand back a real-but-wrong attachment, which is the
60
+ // silent mis-naming this resolver exists to prevent — so it resolves to
61
+ // nothing and the download keeps its provisional name instead.
62
+ (run as any).mockImplementation(async (args: string[]) => {
63
+ if (args[1] === 'get') {
64
+ return JSON.stringify({ attachments: [
65
+ { filename: 'a.pdf', attachmentIndex: 0, size: 100, mimeType: 'application/pdf' },
66
+ { filename: 'b.pdf', attachmentIndex: 1, size: 200, mimeType: 'application/pdf' },
67
+ ] });
68
+ }
69
+ return JSON.stringify({ path: '/tmp/x', size: 200 });
70
+ });
71
+ await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 7 });
72
+ const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
73
+ expect(dl.join(' ')).not.toContain('a.pdf');
74
+ expect(dl.join(' ')).not.toContain('b.pdf');
75
+ });
76
+ });