gogcli-mcp 4.3.0 → 4.4.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.
@@ -5,8 +5,9 @@ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
5
5
  import type { GogArg } from '../runner.js';
6
6
  import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
7
7
  import type { InlineAttachmentInput } from '../attachments.js';
8
- import { attachmentNames, bodyPreview, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
8
+ import { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
9
9
  import { pos } from '../argv.js';
10
+ import { hasCommandWord } from '../dispatch-confirmation.js';
10
11
  import { confinePath, confinePaths } from '../file-roots.js';
11
12
 
12
13
  // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
@@ -98,8 +99,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
98
99
  // The send-side reply/reply-all schema, distinct from the shared replySchema
99
100
  // above. gog_gmail_drafts_reply / _reply_all (gogcli-mcp-gmail) reuse
100
101
  // replySchema verbatim and must never gain a send-confirmation input — draft
101
- // tools stage mail, while send tools request confirmation through MCP.
102
- const sendReplySchema = z.object(replySchema);
102
+ // tools stage mail, while send tools request confirmation (through MCP, or the
103
+ // opt-in confirmToken fallback, which is why only THIS schema carries it).
104
+ const sendReplySchema = z.object({ ...replySchema, confirmToken: confirmTokenParam });
103
105
 
104
106
  // gog's own `reply`/`reply-all` response never echoes the resolved
105
107
  // recipients when replying to a single message (the common case: gog only
@@ -148,6 +150,62 @@ export function computeReplyRecipients(
148
150
  return emails;
149
151
  }
150
152
 
153
+ /** `message.threadId` out of `gog gmail get --format=metadata --json`, if present. */
154
+ export function metadataThreadId(raw: string): string | undefined {
155
+ try {
156
+ const threadId = (JSON.parse(raw) as { message?: { threadId?: unknown } } | null)?.message?.threadId;
157
+ return typeof threadId === 'string' ? threadId : undefined;
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ // The token fallback's preview and bound payload for a reply. To/Cc/Bcc are the
164
+ // same approximation computeReplyRecipients makes (gog composes the final list),
165
+ // split by field so the user can see who is on Bcc.
166
+ function replyTokenSubject(
167
+ kind: 'reply' | 'reply-all',
168
+ messageId: string,
169
+ account: string | undefined,
170
+ headers: Record<string, string>,
171
+ threadId: string | undefined,
172
+ flags: ReplyFlags,
173
+ ) {
174
+ const removed = new Set(extractEmails(...(flags.remove ?? [])));
175
+ const keep = (emails: string[]) => emails.filter((e) => !removed.has(e));
176
+ const to = keep(extractEmails(headers.from, ...(kind === 'reply-all' ? [headers.to] : []), ...(flags.to ?? [])));
177
+ const cc = keep(extractEmails(...(kind === 'reply-all' ? [headers.cc] : []), ...(flags.cc ?? [])));
178
+ const bcc = keep(extractEmails(...(flags.bcc ?? [])));
179
+ const attachments = attachmentDetails(flags.attach, flags.attachInline);
180
+ const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined);
181
+ const from = flags.from ?? (flags.autoFromAddressedAlias ? 'the send-as alias the original was addressed to' : senderPreview(account));
182
+ const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p): p is string => Boolean(p)), undefined);
183
+ return {
184
+ target: messageId,
185
+ payload: {
186
+ kind, to, cc, bcc, subject, from,
187
+ body: flags.body, bodyHtml: flags.bodyHtml, localFiles,
188
+ signature: Boolean(flags.signature), signatureFrom: flags.signatureFrom,
189
+ attachments, quote: !flags.noQuote,
190
+ threadId, inReplyTo: headers.message_id, references: headers.references,
191
+ },
192
+ preview: {
193
+ from, to, cc, bcc, subject,
194
+ body: flags.body,
195
+ ...(flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {}),
196
+ ...(flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {}),
197
+ ...(flags.signature || flags.signatureFrom || flags.signatureFile
198
+ ? { signature: flags.signatureFile ?? flags.signatureFrom ?? 'the Gmail signature of the sending address' }
199
+ : {}),
200
+ quotesOriginal: !flags.noQuote,
201
+ attachments: attachmentPreview(attachments),
202
+ replyingToMessageId: messageId,
203
+ threadId,
204
+ inReplyTo: headers.message_id,
205
+ },
206
+ };
207
+ }
208
+
151
209
  // Shared by gog_gmail_reply and gog_gmail_reply_all: fetch the target
152
210
  // message's headers (always — the prompt needs them and so does the audit
153
211
  // log on the accepted path), then confirm and send.
@@ -161,12 +219,14 @@ async function sendReply(
161
219
  account: string | undefined,
162
220
  flags: ReplyFlags,
163
221
  ctx: ServerContext,
222
+ confirmToken?: string,
164
223
  ) {
165
224
  const metaResult = await runOrDiagnose(['gmail', 'get', pos(messageId), '--format=metadata'], { account });
166
225
  if (metaResult.isError) return metaResult;
167
- const headers = parseMetadataHeaders(resultText(metaResult));
226
+ const metaText = resultText(metaResult);
227
+ const headers = parseMetadataHeaders(metaText);
168
228
  const recipients = computeReplyRecipients(kind, headers, flags);
169
- const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
229
+ const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
170
230
  messageId,
171
231
  recipients,
172
232
  recipientCount: recipients.length,
@@ -177,6 +237,13 @@ async function sendReply(
177
237
  bodyHtmlFile: flags.bodyHtmlFile,
178
238
  attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
179
239
  attachments: attachmentNames(flags.attach, flags.attachInline),
240
+ }, {
241
+ tool: toolName,
242
+ account,
243
+ confirmToken,
244
+ // The metadata read above IS the phase-2 re-read: it runs on every call,
245
+ // so the original's Message-ID/References are fresh here.
246
+ subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags),
180
247
  });
181
248
  if (confirmation) return confirmation;
182
249
  const args: GogArg[] = ['gmail', kind, pos(messageId)];
@@ -205,12 +272,11 @@ export function vetGmailRun(subcommand: string, args: readonly string[]): string
205
272
  return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
206
273
  }
207
274
  if (subcommand === 'settings') {
208
- // kong lets flags precede the command word, and a global flag can take its
209
- // value as the next token (`settings --color never filters ...`), so the
210
- // word is not necessarily args[0]. Refuse it wherever it appears; a
211
- // legitimate settings call carrying one of these words as a value is rare
212
- // and has a dedicated tool anyway.
213
- const blocked = args.find((a) => GMAIL_RUN_BLOCKED_SETTINGS.has(a.toLowerCase()));
275
+ // A global flag can take its value as the next token (`settings --color
276
+ // never filters ...`), so the word is not necessarily args[0]. Refuse it
277
+ // wherever it appears; a legitimate settings call carrying one of these
278
+ // words as a value is rare and has a dedicated tool anyway.
279
+ const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
214
280
  if (blocked) {
215
281
  return `gog gmail settings ${blocked} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
216
282
  }
@@ -291,7 +357,8 @@ export function registerGmailTools(server: McpServer): void {
291
357
  + 'and byte sizes — check it to confirm the files were embedded. '
292
358
  + 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
293
359
  + 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
294
- + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
360
+ + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
361
+ + CONFIRM_FALLBACK_DESCRIPTION,
295
362
  annotations: { destructiveHint: true },
296
363
  inputSchema: z.object({
297
364
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -305,8 +372,9 @@ export function registerGmailTools(server: McpServer): void {
305
372
  attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
306
373
  attachInline: attachInlineParam,
307
374
  account: accountParam,
375
+ confirmToken: confirmTokenParam,
308
376
  }),
309
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
377
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
310
378
  confinePaths(attach, 'attach');
311
379
  // Built (and validated — inlineAttachmentArgs throws on bad base64 or an
312
380
  // oversize file) BEFORE the confirmation request, on both paths: a prompt that
@@ -334,7 +402,7 @@ export function registerGmailTools(server: McpServer): void {
334
402
  args.push(...inline);
335
403
 
336
404
  const recipients = extractEmails(to, cc, bcc);
337
- const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
405
+ const confirmation = await requireGmailDispatchConfirmation(ctx, 'gmail.send', {
338
406
  to, cc, bcc, recipients, recipientCount: recipients.length, subject,
339
407
  bodyLength: body.length,
340
408
  bodyPreview: bodyPreview(body),
@@ -342,6 +410,27 @@ export function registerGmailTools(server: McpServer): void {
342
410
  quoting: Boolean(quote),
343
411
  attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
344
412
  attachments: attachmentNames(attach, attachInline),
413
+ }, {
414
+ tool: 'gog_gmail_send',
415
+ account,
416
+ confirmToken,
417
+ // Nothing is stored between the phases: the payload IS the arguments,
418
+ // so phase 2 must repeat them and any difference is a changed send.
419
+ subject: () => {
420
+ const attachments = attachmentDetails(attach, attachInline);
421
+ const from = senderPreview(account);
422
+ return {
423
+ target: replyToMessageId ?? threadId ?? '',
424
+ payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
425
+ preview: {
426
+ from, to, cc, bcc, subject, body,
427
+ attachments: attachmentPreview(attachments),
428
+ threadId,
429
+ inReplyTo: replyToMessageId,
430
+ quotesOriginal: Boolean(quote),
431
+ },
432
+ };
433
+ },
345
434
  });
346
435
  if (confirmation) return confirmation;
347
436
  const result = await runOrDiagnose(args, { account });
@@ -376,13 +465,14 @@ export function registerGmailTools(server: McpServer): void {
376
465
  + 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
377
466
  + 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
378
467
  + 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
379
- + 'instead of sending it.',
468
+ + 'instead of sending it.'
469
+ + CONFIRM_FALLBACK_DESCRIPTION,
380
470
  annotations: { destructiveHint: true },
381
471
  inputSchema: sendReplySchema,
382
- }, async ({ messageId, account, ...flags }, ctx) => {
472
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
383
473
  assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
384
474
  confineReplyPaths(flags);
385
- return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx);
475
+ return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx, confirmToken);
386
476
  });
387
477
 
388
478
  server.registerTool('gog_gmail_reply_all', {
@@ -393,13 +483,14 @@ export function registerGmailTools(server: McpServer): void {
393
483
  + 'confirmation. '
394
484
  + 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
395
485
  + 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
396
- + 'reply-all.',
486
+ + 'reply-all.'
487
+ + CONFIRM_FALLBACK_DESCRIPTION,
397
488
  annotations: { destructiveHint: true },
398
489
  inputSchema: sendReplySchema,
399
- }, async ({ messageId, account, ...flags }, ctx) => {
490
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
400
491
  assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
401
492
  confineReplyPaths(flags);
402
- return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx);
493
+ return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx, confirmToken);
403
494
  });
404
495
 
405
496
  registerRunTool(server, {
@@ -1,18 +1,26 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { rawTextResult } from '@chrischall/mcp-utils';
3
3
  import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';
4
+ import { mkdtempSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { resetConfirmTokenState } from '../src/send-confirm-token.js';
4
8
  import {
5
9
  GMAIL_DISPATCH_OPS,
6
10
  BODY_PREVIEW_MAX,
11
+ CONFIRM_INSTRUCTION,
12
+ attachmentDetails,
13
+ attachmentPreview,
7
14
  attachmentNames,
8
15
  bodyPreview,
16
+ senderPreview,
9
17
  extractEmails,
10
18
  logGmailDispatch,
11
19
  replyDispatchOp,
12
20
  requireGmailDispatchConfirmation,
13
21
  resultText,
14
22
  } from '../src/gmail-dispatch-guard.js';
15
- import type { GmailDispatchOp } from '../src/gmail-dispatch-guard.js';
23
+ import type { DispatchTokenFallback, GmailDispatchOp, TokenSubject } from '../src/gmail-dispatch-guard.js';
16
24
 
17
25
  describe('extractEmails', () => {
18
26
  it('extracts a bare address', () => {
@@ -159,8 +167,8 @@ function ctxDeclaring(capabilities: unknown): ServerContext {
159
167
  /** claude.ai's measured shape: extensions and nothing else. */
160
168
  const CANNOT_BE_ASKED = ctxDeclaring({ extensions: {} });
161
169
 
162
- function refusalNote(op: GmailDispatchOp): string {
163
- const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, op, {}) as CallToolResult;
170
+ async function refusalNote(op: GmailDispatchOp): Promise<string> {
171
+ const result = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, op, {}) as CallToolResult;
164
172
  return JSON.parse(resultText(result)).note as string;
165
173
  }
166
174
 
@@ -185,8 +193,8 @@ const EXPECTED_TWIN: Record<GmailDispatchOp, string | null> = {
185
193
  describe('requireGmailDispatchConfirmation on a client that cannot be asked', () => {
186
194
  it.each(GMAIL_DISPATCH_OPS.map((op) => [op, EXPECTED_TWIN[op]] as const))(
187
195
  'answers %s with its staging twin %s',
188
- (op, twin) => {
189
- const note = refusalNote(op);
196
+ async (op, twin) => {
197
+ const note = await refusalNote(op);
190
198
  expect(note).toContain('cannot show a confirmation prompt');
191
199
  if (twin === null) {
192
200
  // A bulk auto-reply over a search has no draft twin, and naming a tool
@@ -207,20 +215,20 @@ describe('requireGmailDispatchConfirmation on a client that cannot be asked', ()
207
215
  it.each([
208
216
  ['reply', 'gog_gmail_drafts_reply'],
209
217
  ['reply-all', 'gog_gmail_drafts_reply_all'],
210
- ] as const)('derives the %s op from the real call path', (kind, twin) => {
211
- expect(refusalNote(replyDispatchOp(kind))).toContain(`Stage it with ${twin} instead`);
218
+ ] as const)('derives the %s op from the real call path', async (kind, twin) => {
219
+ expect(await refusalNote(replyDispatchOp(kind))).toContain(`Stage it with ${twin} instead`);
212
220
  });
213
221
 
214
222
  // Every staging twin used to point at gog_gmail_drafts_send, which now asks
215
223
  // for confirmation too — so on a client that cannot be asked, the way through
216
224
  // is the user sending the saved draft from Gmail themselves.
217
- it('tells a client that cannot be asked that the user sends the staged draft from Gmail', () => {
218
- expect(refusalNote('gmail.send')).toMatch(/send it from Gmail/);
219
- expect(refusalNote('gmail.drafts-send')).toMatch(/still saved.*send it from Gmail/);
225
+ it('tells a client that cannot be asked that the user sends the staged draft from Gmail', async () => {
226
+ expect(await refusalNote('gmail.send')).toMatch(/send it from Gmail/);
227
+ expect(await refusalNote('gmail.drafts-send')).toMatch(/still saved.*send it from Gmail/);
220
228
  });
221
229
 
222
- it('refuses rather than dispatching, and says so in the payload', () => {
223
- const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.forward', {
230
+ it('refuses rather than dispatching, and says so in the payload', async () => {
231
+ const result = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.forward', {
224
232
  messageId: 'm1',
225
233
  to: 'someone@example.com',
226
234
  }) as CallToolResult;
@@ -233,8 +241,8 @@ describe('requireGmailDispatchConfirmation on a client that cannot be asked', ()
233
241
  });
234
242
  });
235
243
 
236
- it('still asks a client that declares form elicitation', () => {
237
- expect(requireGmailDispatchConfirmation(ctxDeclaring({ elicitation: { form: {} } }), 'gmail.forward', {}))
244
+ it('still asks a client that declares form elicitation', async () => {
245
+ expect(await requireGmailDispatchConfirmation(ctxDeclaring({ elicitation: { form: {} } }), 'gmail.forward', {}))
238
246
  .toMatchObject({ resultType: 'input_required' });
239
247
  });
240
248
  });
@@ -269,3 +277,208 @@ describe('attachmentNames', () => {
269
277
  expect(attachmentNames(undefined, undefined)).toEqual([]);
270
278
  });
271
279
  });
280
+
281
+ describe('attachmentDetails', () => {
282
+ it('stats a server path and measures + fingerprints inline bytes', () => {
283
+ const dir = mkdtempSync(join(tmpdir(), 'gct-'));
284
+ const path = join(dir, 'a.txt');
285
+ writeFileSync(path, 'hello');
286
+ const details = attachmentDetails([path, join(dir, 'missing.pdf')], [{ filename: 'b.png', contentBase64: Buffer.from('abc').toString('base64') }]);
287
+ expect(details[0]).toMatchObject({ name: path, size: 5 });
288
+ expect(details[0]!.sha256).toMatch(/^[0-9a-f]{64}$/);
289
+ expect(details[1]).toEqual({ name: join(dir, 'missing.pdf'), size: null });
290
+ expect(details[2]).toMatchObject({ name: 'b.png', size: 3 });
291
+ expect(details[2]!.sha256).toMatch(/^[0-9a-f]{64}$/);
292
+ expect(attachmentPreview(details)[2]).toEqual({ name: 'b.png', size: 3 });
293
+ });
294
+
295
+ it('is empty when nothing is attached', () => {
296
+ expect(attachmentDetails(undefined, undefined)).toEqual([]);
297
+ });
298
+ });
299
+
300
+ // ============================================================================
301
+ // THE TOKEN FALLBACK (GOG_SEND_CONFIRM_FALLBACK=token). Elicitation stays the
302
+ // primary rail; this only replaces the REFUSAL a client that cannot be prompted
303
+ // would otherwise get.
304
+ // ============================================================================
305
+ describe('requireGmailDispatchConfirmation — token fallback', () => {
306
+ const ORIGINAL_ENV = { ...process.env };
307
+ const CAN_BE_ASKED = ctxDeclaring({ elicitation: { form: {} } });
308
+
309
+ beforeEach(() => {
310
+ process.env = { ...ORIGINAL_ENV };
311
+ delete process.env.GOG_SEND_CONFIRM_FALLBACK;
312
+ delete process.env.GOG_CONFIRM_TTL_SECONDS;
313
+ delete process.env.GOG_CONFIRM_SECRET;
314
+ process.env.GOG_ACCOUNT = 'me@example.com';
315
+ resetConfirmTokenState();
316
+ });
317
+
318
+ afterEach(() => {
319
+ process.env = ORIGINAL_ENV;
320
+ vi.useRealTimers();
321
+ });
322
+
323
+ const subjectOf = (overrides: Partial<TokenSubject> = {}): TokenSubject => ({
324
+ target: 'r1',
325
+ revision: 'msg-1',
326
+ payload: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
327
+ preview: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
328
+ ...overrides,
329
+ });
330
+
331
+ const fallback = (confirmToken?: string, subject: TokenSubject | CallToolResult = subjectOf(), tool = 'gog_gmail_drafts_send'): DispatchTokenFallback => ({
332
+ tool,
333
+ confirmToken,
334
+ subject: vi.fn(() => subject),
335
+ });
336
+
337
+ const parse = (r: unknown) => JSON.parse(resultText(r as CallToolResult));
338
+
339
+ async function phaseOne(subject = subjectOf(), tool = 'gog_gmail_drafts_send') {
340
+ const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(undefined, subject, tool));
341
+ return parse(r);
342
+ }
343
+
344
+ it('with the env unset, keeps the refusal and names the switch', async () => {
345
+ const fb = fallback();
346
+ const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fb));
347
+ expect(r.reason).toBe('confirmation-unsupported');
348
+ expect(r.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
349
+ expect(r.note).toContain('still saved');
350
+ expect(fb.subject).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('names the switch even for an op with no other note (autoreply)', async () => {
354
+ const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.autoreply', {}, fallback()));
355
+ expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
356
+ });
357
+
358
+ it('never offers the switch to a caller that passed no fallback (a forwarding filter)', async () => {
359
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
360
+ const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.filter-forward', {}));
361
+ expect(r.reason).toBe('confirmation-unsupported');
362
+ expect(r.note).not.toContain('GOG_SEND_CONFIRM_FALLBACK');
363
+ });
364
+
365
+ it('leaves elicitation untouched even with the env set and a token passed', async () => {
366
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
367
+ const fb = fallback('gct1.anything.here');
368
+ expect(await requireGmailDispatchConfirmation(CAN_BE_ASKED, 'gmail.drafts-send', {}, fb))
369
+ .toMatchObject({ resultType: 'input_required' });
370
+ expect(fb.subject).not.toHaveBeenCalled();
371
+ });
372
+
373
+ describe('with GOG_SEND_CONFIRM_FALLBACK=token', () => {
374
+ beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
375
+
376
+ it('phase 1 returns the full preview, a token and the instruction, and does not proceed', async () => {
377
+ const r = await phaseOne();
378
+ expect(r).toMatchObject({
379
+ status: 'confirmation-required',
380
+ confirmed: false,
381
+ dispatched: false,
382
+ action: 'gmail.drafts-send',
383
+ preview: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
384
+ ttlSeconds: 600,
385
+ instruction: CONFIRM_INSTRUCTION,
386
+ });
387
+ expect(r.instruction).toBe('Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.');
388
+ expect(r.confirmToken).toMatch(/^gct1\./);
389
+ expect(typeof r.expiresAt).toBe('string');
390
+ });
391
+
392
+ it('phase 2 with a valid token proceeds (undefined)', async () => {
393
+ const { confirmToken } = await phaseOne();
394
+ expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))).toBeUndefined();
395
+ });
396
+
397
+ it('DRAFT_CHANGED on a rotated messageId, with a fresh preview and a token that works', async () => {
398
+ const { confirmToken } = await phaseOne();
399
+ const rotated = subjectOf({ revision: 'msg-2' });
400
+ const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, rotated));
401
+ expect((r as CallToolResult).isError).toBe(true);
402
+ const body = parse(r);
403
+ expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'message-id-rotated', dispatched: false, instruction: CONFIRM_INSTRUCTION });
404
+ expect(body.note).toMatch(/messageId/);
405
+ expect(body.confirmToken).not.toBe(confirmToken);
406
+ expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(body.confirmToken, rotated))).toBeUndefined();
407
+ });
408
+
409
+ it('DRAFT_CHANGED on a changed payload', async () => {
410
+ const { confirmToken } = await phaseOne();
411
+ const edited = subjectOf({ payload: { to: 'a@example.com', body: 'hello!' }, preview: { body: 'hello!' } });
412
+ const body = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, edited)));
413
+ expect(body).toMatchObject({ error: 'DRAFT_CHANGED', reason: 'payload-changed', preview: { body: 'hello!' } });
414
+ });
415
+
416
+ it('TOKEN_REUSED on a second presentation', async () => {
417
+ const { confirmToken } = await phaseOne();
418
+ await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken));
419
+ const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken));
420
+ expect((r as CallToolResult).isError).toBe(true);
421
+ expect(parse(r)).toMatchObject({ status: 'confirmation-rejected', error: 'TOKEN_REUSED', dispatched: false, action: 'gmail.drafts-send' });
422
+ });
423
+
424
+ it('TOKEN_EXPIRED after the TTL', async () => {
425
+ process.env.GOG_CONFIRM_TTL_SECONDS = '60';
426
+ vi.useFakeTimers();
427
+ vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
428
+ const { confirmToken } = await phaseOne();
429
+ vi.setSystemTime(new Date('2026-09-24T10:01:01Z'));
430
+ expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
431
+ .toMatchObject({ error: 'TOKEN_EXPIRED', dispatched: false });
432
+ });
433
+
434
+ it('TOKEN_INVALID for a tampered token', async () => {
435
+ const { confirmToken } = await phaseOne();
436
+ const tampered = `${confirmToken.slice(0, -3)}xyz`;
437
+ expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(tampered))))
438
+ .toMatchObject({ error: 'TOKEN_INVALID' });
439
+ });
440
+
441
+ it('TOKEN_INVALID for a token issued for a different draft', async () => {
442
+ const { confirmToken } = await phaseOne(subjectOf({ target: 'r-other' }));
443
+ expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
444
+ .toMatchObject({ error: 'TOKEN_INVALID' });
445
+ });
446
+
447
+ it('TOKEN_INVALID for a token issued by a different tool', async () => {
448
+ const { confirmToken } = await phaseOne(subjectOf(), 'gog_gmail_send');
449
+ expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
450
+ .toMatchObject({ error: 'TOKEN_INVALID' });
451
+ });
452
+
453
+ it('binds the account: an explicit account wins over GOG_ACCOUNT', async () => {
454
+ const issued = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, { ...fallback(), account: 'other@example.com' }));
455
+ expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(issued.confirmToken))))
456
+ .toMatchObject({ error: 'TOKEN_INVALID' });
457
+ });
458
+
459
+ it('binds to an empty account when none is configured', async () => {
460
+ delete process.env.GOG_ACCOUNT;
461
+ const { confirmToken } = await phaseOne(subjectOf({ revision: undefined }));
462
+ expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, subjectOf({ revision: undefined })))).toBeUndefined();
463
+ });
464
+
465
+ it('passes back an error result from the subject read unchanged', async () => {
466
+ const failure: CallToolResult = { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
467
+ expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(undefined, failure))).toBe(failure);
468
+ });
469
+ });
470
+ });
471
+
472
+ describe('senderPreview', () => {
473
+ const ORIGINAL_ENV = { ...process.env };
474
+ afterEach(() => { process.env = ORIGINAL_ENV; });
475
+
476
+ it('prefers an explicit alias, then the account, then GOG_ACCOUNT, then says it is the default', () => {
477
+ process.env = { ...ORIGINAL_ENV, GOG_ACCOUNT: 'env@example.com' };
478
+ expect(senderPreview('acct@example.com', 'alias@example.com')).toBe('alias@example.com');
479
+ expect(senderPreview('acct@example.com')).toBe('acct@example.com');
480
+ expect(senderPreview(undefined)).toBe('env@example.com');
481
+ delete process.env.GOG_ACCOUNT;
482
+ expect(senderPreview(undefined)).toBe("the gog account's default address");
483
+ });
484
+ });