gogcli-mcp-gmail 2.21.1 → 2.23.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/dist/index.js CHANGED
@@ -452,9 +452,9 @@ var require_codegen = __commonJS({
452
452
  }
453
453
  };
454
454
  var Label = class extends Node {
455
- constructor(label) {
455
+ constructor(label2) {
456
456
  super();
457
- this.label = label;
457
+ this.label = label2;
458
458
  this.names = {};
459
459
  }
460
460
  render({ _n }) {
@@ -462,14 +462,14 @@ var require_codegen = __commonJS({
462
462
  }
463
463
  };
464
464
  var Break = class extends Node {
465
- constructor(label) {
465
+ constructor(label2) {
466
466
  super();
467
- this.label = label;
467
+ this.label = label2;
468
468
  this.names = {};
469
469
  }
470
470
  render({ _n }) {
471
- const label = this.label ? ` ${this.label}` : "";
472
- return `break${label};` + _n;
471
+ const label2 = this.label ? ` ${this.label}` : "";
472
+ return `break${label2};` + _n;
473
473
  }
474
474
  };
475
475
  var Throw = class extends Node {
@@ -881,12 +881,12 @@ var require_codegen = __commonJS({
881
881
  return this._endBlockNode(For);
882
882
  }
883
883
  // `label` statement
884
- label(label) {
885
- return this._leafNode(new Label(label));
884
+ label(label2) {
885
+ return this._leafNode(new Label(label2));
886
886
  }
887
887
  // `break` statement
888
- break(label) {
889
- return this._leafNode(new Break(label));
888
+ break(label2) {
889
+ return this._leafNode(new Break(label2));
890
890
  }
891
891
  // `return` statement
892
892
  return(value) {
@@ -31435,6 +31435,13 @@ var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
31435
31435
  // Calendar event start/end
31436
31436
  "internalDate",
31437
31437
  // Gmail, epoch milliseconds (authoritative)
31438
+ // gog >= 0.35.0 Gmail message AND thread listings. Already offset-bearing
31439
+ // (RFC3339 from internalDate), so it needs no offset repair — it is
31440
+ // allowlisted purely to gain a Display sibling, and to be re-rendered in
31441
+ // DISPLAY_TZ like every other instant. Separately sourced from the sibling
31442
+ // `date`, which is a naive re-format of the sender's Date header; the two may
31443
+ // legitimately disagree. See docs/timestamps.md.
31444
+ "internalDateIso",
31438
31445
  "modifiedTime",
31439
31446
  // Drive
31440
31447
  "createdTime",
@@ -31671,8 +31678,8 @@ function formatOneAccountHealth(a, now) {
31671
31678
  const age = ageInDays(a.created_at, now);
31672
31679
  const ageStr = age === null ? "" : ` Authorized ${age.toFixed(1)} day(s) ago.`;
31673
31680
  if (a.valid === false) {
31674
- const cause = INVALID_GRANT_PATTERN.test(a.error ?? "") ? 'refresh token expired or revoked \u2014 commonly the 7-day limit on OAuth consent screens still in "Testing" mode' : a.error?.trim() || "unknown error";
31675
- return `\u2717 ${email3}: NEEDS RE-AUTH \u2014 ${cause}.${ageStr} Re-authorize with gog_auth_add (browser) or gog_auth_add_url + gog_auth_add_complete (remote/headless).`;
31681
+ const cause2 = INVALID_GRANT_PATTERN.test(a.error ?? "") ? 'refresh token expired or revoked \u2014 commonly the 7-day limit on OAuth consent screens still in "Testing" mode' : a.error?.trim() || "unknown error";
31682
+ return `\u2717 ${email3}: NEEDS RE-AUTH \u2014 ${cause2}.${ageStr} Re-authorize with gog_auth_add (browser) or gog_auth_add_url + gog_auth_add_complete (remote/headless).`;
31676
31683
  }
31677
31684
  if (a.valid === true) {
31678
31685
  let line = `\u2713 ${email3}: token valid.${ageStr}`;
@@ -31714,7 +31721,7 @@ function registerAuthToolsWith(server, defaultServices) {
31714
31721
  }
31715
31722
  });
31716
31723
  server.registerTool("gog_auth_status", {
31717
- description: "Show gogcli auth configuration: keyring backend, credential files, and auth setup.",
31724
+ description: "Show gogcli auth CONFIGURATION: keyring backend, credential files, and auth setup. Despite the name this is not a health check \u2014 it reads local setup and does not contact Google, so it says nothing about whether an account can still authenticate. Use gog_auth_health for that.",
31718
31725
  annotations: { readOnlyHint: true },
31719
31726
  inputSchema: {}
31720
31727
  }, async () => {
@@ -31725,7 +31732,7 @@ function registerAuthToolsWith(server, defaultServices) {
31725
31732
  }
31726
31733
  });
31727
31734
  server.registerTool("gog_auth_health", {
31728
- description: 'Check the LIVE health of each stored Google account. Unlike gog_auth_status (which only reports keyring/config setup), this performs a real token refresh against Google, so it detects expired or revoked (invalid_grant) refresh tokens \u2014 the account-wide sign-out that blocks every service. Reports per account: whether the token is currently valid, the mapped cause when it is not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to re-authorize on your own schedule instead of mid-task.',
31735
+ description: 'Check the LIVE health of each stored Google account. Unlike gog_auth_status (which only reports keyring/config setup), this performs a real token refresh against Google, so it detects expired or revoked (invalid_grant) refresh tokens \u2014 the account-wide sign-out that blocks every service. Reports per account: whether the token is currently valid, the mapped cause when it is not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to re-authorize on your own schedule instead of mid-task. On the hosted connector this is the ONLY check that measures Google: a connector showing "connected" or "refreshed" has verified the connector key that reaches the gog machine, and nothing else \u2014 the Google credential lives on that machine and can be dead while the connection looks perfectly healthy.',
31729
31736
  annotations: { readOnlyHint: true },
31730
31737
  inputSchema: {}
31731
31738
  }, async () => {
@@ -31889,14 +31896,29 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31889
31896
  );
31890
31897
 
31891
31898
  // ../gogcli-mcp/src/server.ts
31892
- var VERSION = true ? "2.21.1" : "0.0.0";
31899
+ var VERSION = true ? "2.23.0" : "0.0.0";
31893
31900
 
31894
31901
  // ../gogcli-mcp/src/auth-log.ts
31895
31902
  var FAILURES = /* @__PURE__ */ new Set([
31896
31903
  "token.mint-failed",
31897
31904
  "grant.dead",
31898
31905
  "replay.failed",
31899
- "runner.auth-failed"
31906
+ "runner.auth-failed",
31907
+ "connect.key-rejected",
31908
+ // An enrolment that could not proceed is a failure even though nobody is at
31909
+ // fault: it is the only trace a half-enrolled connector leaves behind, and
31910
+ // the absence of exactly this record is why DEFECT 4 could not be explained.
31911
+ "connect.runner-unreachable",
31912
+ "connect.google-unhealthy",
31913
+ "refusal.google-unhealthy",
31914
+ // The loudest record on this branch, and the only one that means "we cannot
31915
+ // explain this". Google refused a real call while a live check of the same
31916
+ // credential, taken seconds later, succeeded — so neither the 7-day cliff nor
31917
+ // a revoked grant accounts for it. It is filed as a failure precisely because
31918
+ // it is the record nobody may scroll past: it is the only evidence that could
31919
+ // ever justify building something on the hosted path, and its absence over
31920
+ // time is what retires that theory for good.
31921
+ "refusal.google-ok"
31900
31922
  ]);
31901
31923
  var PREFIX = "gog-auth ";
31902
31924
  var TAG_CHARS = 12;
@@ -32043,10 +32065,39 @@ async function exchange(refreshToken, clientId, clientSecret) {
32043
32065
  return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
32044
32066
  }
32045
32067
 
32068
+ // ../gogcli-mcp/src/google-probe.ts
32069
+ var bool = (value) => typeof value === "boolean" ? value : void 0;
32070
+ var cause = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
32071
+ function readGoogleProbe(body) {
32072
+ const record2 = typeof body === "object" && body !== null ? body : {};
32073
+ const measured = bool(record2.measured);
32074
+ const reported = cause(record2.error);
32075
+ if (measured === false) {
32076
+ return {
32077
+ kind: "unmeasured",
32078
+ reason: reported ?? "the runner reported it could not measure the Google layer"
32079
+ };
32080
+ }
32081
+ if (measured === true) {
32082
+ if (bool(record2.ok) === true) return { kind: "ok" };
32083
+ return {
32084
+ kind: "unhealthy",
32085
+ reason: reported ?? "the runner reported the Google layer unhealthy with no cause"
32086
+ };
32087
+ }
32088
+ return {
32089
+ kind: "unmeasured",
32090
+ reason: reported ? `the runner did not report whether it measured the Google layer; it said: ${reported}` : "the runner did not report whether it measured the Google layer"
32091
+ };
32092
+ }
32093
+
32046
32094
  // ../gogcli-mcp/src/connector-runtime.ts
32047
32095
  var DEFAULT_TIMEOUT_MS = 3e4;
32048
32096
  var DEADLINE_GRACE_MS = 5e3;
32049
32097
  var MIN_REPLAY_BUDGET_MS = 1e3;
32098
+ var REFUSAL_PROBE_TIMEOUT_MS = 4e3;
32099
+ var MIN_PROBE_BUDGET_MS = 1e3;
32100
+ var PROBE_INTERVAL_MS = 6e4;
32050
32101
  var RUNNER_GOG_FAILED = 422;
32051
32102
  var RUNNER_DRAINING = 503;
32052
32103
  var RUNNER_BAD_REQUEST = 400;
@@ -32093,7 +32144,7 @@ function gogTarget(args) {
32093
32144
  }
32094
32145
  return { service };
32095
32146
  }
32096
- async function remintAfterGoogleRejection(err, used, args, readAccessToken, deadlineAt) {
32147
+ async function remintAfterGoogleRejection(err, used, args, readAccessToken, deadlineAt, probeGoogle) {
32097
32148
  if (!(err instanceof GogFailedError)) return void 0;
32098
32149
  const grantDead = REFRESH_TOKEN_DEAD_PATTERN.test(err.stderr);
32099
32150
  if (!grantDead && !GOOGLE_TOKEN_REJECTED_PATTERN.test(err.stderr)) return void 0;
@@ -32108,6 +32159,7 @@ async function remintAfterGoogleRejection(err, used, args, readAccessToken, dead
32108
32159
  return void 0;
32109
32160
  }
32110
32161
  if (!used) {
32162
+ await probeGoogle(where);
32111
32163
  logAuthTransition("replay.declined", {
32112
32164
  ...where,
32113
32165
  reason: "no access token was supplied with the call, so gog acted as the backend volume\u2019s own identity"
@@ -32155,6 +32207,64 @@ async function remintAfterGoogleRejection(err, used, args, readAccessToken, dead
32155
32207
  return { token: fresh, budgetMs, invalidate: readAccessToken.invalidate, ...where };
32156
32208
  }
32157
32209
  function makeFlyExecutor(endpoint, key, readAccessToken) {
32210
+ let lastProbeAt = Number.NEGATIVE_INFINITY;
32211
+ const probeGoogleAfterRefusal = async (where, deadlineAt) => {
32212
+ const record2 = { ...where, endpoint };
32213
+ const now = Date.now();
32214
+ const remainingMs = deadlineAt - now;
32215
+ if (remainingMs < MIN_PROBE_BUDGET_MS) {
32216
+ logAuthTransition("refusal.google-unmeasured", {
32217
+ ...record2,
32218
+ reason: `only ${remainingMs}ms of the call\u2019s deadline remained, so the Google layer was not measured rather than delay the caller\u2019s own error`
32219
+ });
32220
+ return;
32221
+ }
32222
+ if (now - lastProbeAt < PROBE_INTERVAL_MS) {
32223
+ logAuthTransition("refusal.google-unmeasured", {
32224
+ ...record2,
32225
+ // "attempted", not "measured". `lastProbeAt` is stamped before the
32226
+ // fetch and is deliberately NOT reset when the probe comes back with no
32227
+ // verdict (a 404 from a runner too old to have the endpoint, a timeout,
32228
+ // a dead socket) — the backend cost this throttle exists to bound was
32229
+ // paid either way, and resetting it would let a retry loop storm a
32230
+ // runner that is already unwell. So the timestamp stays and the sentence
32231
+ // has to be the true one: on this branch a log line may not assert a
32232
+ // measurement that never happened, and the previous probe may well have
32233
+ // measured nothing at all.
32234
+ reason: "a Google probe was attempted recently, so another was not sent: this probe spawns gog on the backend and takes the keyring\u2019s exclusive lock"
32235
+ });
32236
+ return;
32237
+ }
32238
+ lastProbeAt = now;
32239
+ let event;
32240
+ let reason;
32241
+ try {
32242
+ const res = await fetch(`${endpoint}/health/google`, {
32243
+ headers: { Authorization: `Bearer ${key}` },
32244
+ // Never more than the probe's own budget, never more than the call has
32245
+ // left. `Math.min` rather than a plain constant because the second
32246
+ // bound is the caller's, and it outranks ours.
32247
+ signal: AbortSignal.timeout(Math.min(REFUSAL_PROBE_TIMEOUT_MS, remainingMs))
32248
+ });
32249
+ if (!res.ok) {
32250
+ event = "refusal.google-unmeasured";
32251
+ reason = `the runner did not answer the Google probe (HTTP ${res.status})`;
32252
+ } else {
32253
+ const verdict = readGoogleProbe(await res.json());
32254
+ if (verdict.kind === "ok") {
32255
+ event = "refusal.google-ok";
32256
+ reason = "Google refused this call, yet a live token check on the same volume succeeded \u2014 so a dead or expired refresh token does not explain this refusal";
32257
+ } else {
32258
+ event = verdict.kind === "unhealthy" ? "refusal.google-unhealthy" : "refusal.google-unmeasured";
32259
+ reason = verdict.reason;
32260
+ }
32261
+ }
32262
+ } catch (err) {
32263
+ event = "refusal.google-unmeasured";
32264
+ reason = err instanceof Error ? err.message : String(err);
32265
+ }
32266
+ logAuthTransition(event, { ...record2, reason });
32267
+ };
32158
32268
  return async (args, opts) => {
32159
32269
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
32160
32270
  const accessToken = await readAccessToken?.();
@@ -32167,7 +32277,8 @@ function makeFlyExecutor(endpoint, key, readAccessToken) {
32167
32277
  accessToken,
32168
32278
  args,
32169
32279
  readAccessToken,
32170
- deadlineAt
32280
+ deadlineAt,
32281
+ (where2) => probeGoogleAfterRefusal(where2, deadlineAt)
32171
32282
  );
32172
32283
  if (replay === void 0) throw err;
32173
32284
  const where = { credential: replay.credential, service: replay.service, endpoint };
@@ -32314,6 +32425,7 @@ function trimThread(result, latestN, snippetsOnly) {
32314
32425
  return result;
32315
32426
  }
32316
32427
  }
32428
+ var GOG_DEFAULT_INLINE_MAX_BYTES = 3145728;
32317
32429
  var MIME_BY_EXT = {
32318
32430
  pdf: "application/pdf",
32319
32431
  png: "image/png",
@@ -32375,13 +32487,29 @@ async function resolveBySize(messageId, sizeBytes, account) {
32375
32487
  return void 0;
32376
32488
  }
32377
32489
  }
32490
+ async function resolveByIndex(messageId, index, account) {
32491
+ try {
32492
+ const parsed = JSON.parse(
32493
+ await run(["gmail", "get", messageId, "--use-indexed-attachment-ids"], { account })
32494
+ );
32495
+ const attachments = parsed.attachments;
32496
+ if (!attachments) return void 0;
32497
+ const declared = attachments.find((a) => a.attachmentIndex === index);
32498
+ if (declared) return declared;
32499
+ if (attachments.every((a) => a.attachmentIndex === void 0)) return attachments[index];
32500
+ return void 0;
32501
+ } catch {
32502
+ return void 0;
32503
+ }
32504
+ }
32378
32505
  function defaultOutPath(messageId, filename) {
32379
32506
  return `/tmp/gog-attachments/${messageId}/${filename}`;
32380
32507
  }
32381
32508
  function sanitizeAttachmentError(err, messageId, attachmentId) {
32382
32509
  let msg = err instanceof Error ? err.message : String(err);
32383
32510
  msg = msg.replace(/^Command failed:.*(\n|$)/, "");
32384
- msg = msg.split(attachmentId).join("<attachment>").split(messageId).join("<message>");
32511
+ if (attachmentId) msg = msg.split(attachmentId).join("<attachment>");
32512
+ msg = msg.split(messageId).join("<message>");
32385
32513
  return msg.trim() || "the download failed on the server";
32386
32514
  }
32387
32515
  function inlineImageResult(summary, base643, mimeType) {
@@ -32428,6 +32556,791 @@ async function deliverViaDrive(path, name, driveFolder, account) {
32428
32556
  webViewLink: file2.webViewLink
32429
32557
  });
32430
32558
  }
32559
+ function originFromDraftId(id) {
32560
+ return id.startsWith("s:") ? "non-api" : "api";
32561
+ }
32562
+ function rootsOwnThread(d) {
32563
+ if (!d.messageId || !d.threadId) return false;
32564
+ return d.messageId === d.threadId;
32565
+ }
32566
+ function parseHeaders(payload) {
32567
+ const map2 = /* @__PURE__ */ new Map();
32568
+ for (const h of payload?.headers ?? []) {
32569
+ if (!h.name) continue;
32570
+ const key = h.name.toLowerCase();
32571
+ const existing = map2.get(key);
32572
+ if (existing) existing.push(h.value ?? "");
32573
+ else map2.set(key, [h.value ?? ""]);
32574
+ }
32575
+ return map2;
32576
+ }
32577
+ function headerValue(headers, name) {
32578
+ return headers.get(name.toLowerCase())?.[0];
32579
+ }
32580
+ function appleIdentitySignals(headers) {
32581
+ const out = [];
32582
+ for (const h of headers ?? []) {
32583
+ const name = h.name ?? "";
32584
+ const lower = name.toLowerCase();
32585
+ const value = h.value ?? "";
32586
+ if (lower === "x-uniform-type-identifier") {
32587
+ if (value.toLowerCase().startsWith("com.apple.")) out.push(`${name}: ${value}`);
32588
+ continue;
32589
+ }
32590
+ if (lower === "x-universally-unique-identifier" || lower.startsWith("x-apple-")) {
32591
+ out.push(`${name}: ${value}`);
32592
+ }
32593
+ }
32594
+ return out;
32595
+ }
32596
+ function normalizeMessageId(v) {
32597
+ const trimmed = v?.trim();
32598
+ if (!trimmed) return void 0;
32599
+ const stripped = trimmed.replace(/^</, "").replace(/>$/, "").trim();
32600
+ return stripped.length > 0 ? stripped : void 0;
32601
+ }
32602
+ function messageIdsIn(references) {
32603
+ const matches = references?.match(/<[^<>\s]+>/g) ?? [];
32604
+ return matches.map((m) => m.slice(1, -1));
32605
+ }
32606
+ function normalizeBodyLines(text) {
32607
+ return (text ?? "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter((l) => l.length > 0);
32608
+ }
32609
+ function bodySimilarity(a, b) {
32610
+ const left = new Set(normalizeBodyLines(a));
32611
+ const right = new Set(normalizeBodyLines(b));
32612
+ if (left.size === 0 || right.size === 0) return 0;
32613
+ let shared = 0;
32614
+ for (const line of left) if (right.has(line)) shared += 1;
32615
+ return shared / (left.size + right.size - shared);
32616
+ }
32617
+ var QUOTE_ATTRIBUTION_LINE = /^On\b.*\bwrote:$/i;
32618
+ var QUOTE_SEPARATOR_LINE = /^-{2,}\s*(original message|forwarded message)/i;
32619
+ function isQuotedBodyLine(line) {
32620
+ return line.startsWith(">") || QUOTE_ATTRIBUTION_LINE.test(line) || QUOTE_SEPARATOR_LINE.test(line);
32621
+ }
32622
+ var SIGNATURE_DELIMITER_LINE = /^--$/;
32623
+ var CLIENT_SIGNATURE_LINE = /^(sent from my\b|sent from (mail|outlook|yahoo|windows)\b|sent via\b|get outlook for\b)/i;
32624
+ var GREETING_LINE = /^(hi|hello|hey|dear|good (morning|afternoon|evening)|greetings)\b[^.!?]{0,48}$/i;
32625
+ var SIGN_OFF_ALONE = /^(thanks|thanks again|thanks so much|thank you|thank you so much|many thanks|best|best regards|all the best|regards|kind regards|warmly|warm regards|sincerely|cheers|talk soon|speak soon|love|take care|appreciate it|respectfully|yours|yours truly|yours sincerely)[,.!]*$/i;
32626
+ var SIGN_OFF_WITH_NAME = /^(thanks|thank you|many thanks|best|best regards|all the best|regards|kind regards|warmly|warm regards|sincerely|cheers|love|take care|respectfully|yours)\s*[,\u2014\u2013-]\s*(.+)$/i;
32627
+ var NAME_LINE = /^-{0,2}\s*\p{L}[\p{L}'\u2019.-]*(?:\s+\p{L}[\p{L}'\u2019.-]*){0,2}$/u;
32628
+ var CONTACT_LINE = /^(?:[+(]?\d[\d\s().-]{6,}|[^\s@]+@[^\s@]+\.[^\s@]+|(?:https?:\/\/|www\.)\S+|@[\w.]+)$/i;
32629
+ function isNameLine(line) {
32630
+ return !/[.!?:;]$/.test(line) && NAME_LINE.test(line);
32631
+ }
32632
+ function isSignOffLine(line) {
32633
+ if (SIGN_OFF_ALONE.test(line)) return true;
32634
+ const withName = line.match(SIGN_OFF_WITH_NAME);
32635
+ return withName !== null && isNameLine(withName[2]);
32636
+ }
32637
+ function boilerplateLineFlags(lines) {
32638
+ const flags = lines.map(() => false);
32639
+ const delimiter2 = lines.findIndex((l) => SIGNATURE_DELIMITER_LINE.test(l));
32640
+ if (delimiter2 !== -1) for (let i = delimiter2; i < lines.length; i += 1) flags[i] = true;
32641
+ lines.forEach((line, i) => {
32642
+ if (isSignOffLine(line) || CLIENT_SIGNATURE_LINE.test(line)) flags[i] = true;
32643
+ });
32644
+ if (lines.length > 0 && GREETING_LINE.test(lines[0])) flags[0] = true;
32645
+ lines.forEach((line, i) => {
32646
+ if (!isSignOffLine(line)) return;
32647
+ for (let j = i + 1; j < lines.length; j += 1) {
32648
+ const next = lines[j];
32649
+ if (!isNameLine(next) && !CONTACT_LINE.test(next)) break;
32650
+ flags[j] = true;
32651
+ }
32652
+ });
32653
+ return flags;
32654
+ }
32655
+ function authoredBodyLines(text) {
32656
+ const unquoted = normalizeBodyLines(text).filter((l) => !isQuotedBodyLine(l));
32657
+ const flags = boilerplateLineFlags(unquoted);
32658
+ return unquoted.filter((_, i) => !flags[i]);
32659
+ }
32660
+ function apparatusCounts(text) {
32661
+ const all = normalizeBodyLines(text);
32662
+ const unquoted = all.filter((l) => !isQuotedBodyLine(l));
32663
+ return {
32664
+ quoted: all.length - unquoted.length,
32665
+ boilerplate: unquoted.length - authoredBodyLines(text).length
32666
+ };
32667
+ }
32668
+ var FORK_BODY_SIMILARITY_THRESHOLD = 0.6;
32669
+ var FORK_MIN_SHARED_AUTHORED_LINES = 2;
32670
+ var FORK_MIN_SHARED_AUTHORED_CHARS = 40;
32671
+ var BODY_AGREEMENT_BASIS_NOTE = "Measured over lines NEITHER draft quotes AND that neither draft's mail client generated. Excluded as apparatus: quoted (`>`) lines, the `On ... wrote:` attribution, forward separators, the opening salutation, the closing formula, the name under it, and the signature block (an RFC 3676 `-- ` block, or a line like `Sent from my iPhone` \u2014 Apple Mail's own default). All of those are reproduced IDENTICALLY on every message a client composes, whatever the message says, so counting them pairs two unrelated short notes from one account: `Hi Jennifer,` + `Thanks,` + `Chris` + `Sent from my iPhone` alone is 4 lines and 43 characters. Lines are compared after collapsing runs of whitespace and dropping blanks, so a client that RE-WRAPPED a paragraph at a different width, or swapped straight quotes for curly ones, produces lines that no longer match and drives this number DOWN \u2014 a low score is weak evidence of absence.";
32672
+ function measureBodyAgreement(originalBody, candidateBody) {
32673
+ const left = new Set(authoredBodyLines(originalBody));
32674
+ const right = new Set(authoredBodyLines(candidateBody));
32675
+ const shared = [...left].filter((line) => right.has(line));
32676
+ const similarity = left.size === 0 || right.size === 0 ? 0 : shared.length / (left.size + right.size - shared.length);
32677
+ const sharedAuthoredChars = shared.reduce((n, line) => n + line.length, 0);
32678
+ const originalApparatus = apparatusCounts(originalBody);
32679
+ const candidateApparatus = apparatusCounts(candidateBody);
32680
+ return {
32681
+ similarity,
32682
+ similarityThreshold: FORK_BODY_SIMILARITY_THRESHOLD,
32683
+ sharedAuthoredLines: shared.length,
32684
+ minSharedAuthoredLines: FORK_MIN_SHARED_AUTHORED_LINES,
32685
+ sharedAuthoredChars,
32686
+ minSharedAuthoredChars: FORK_MIN_SHARED_AUTHORED_CHARS,
32687
+ quotedLinesIgnored: { original: originalApparatus.quoted, candidate: candidateApparatus.quoted },
32688
+ boilerplateLinesIgnored: { original: originalApparatus.boilerplate, candidate: candidateApparatus.boilerplate },
32689
+ meetsThreshold: similarity >= FORK_BODY_SIMILARITY_THRESHOLD && shared.length >= FORK_MIN_SHARED_AUTHORED_LINES && sharedAuthoredChars >= FORK_MIN_SHARED_AUTHORED_CHARS,
32690
+ basisNote: BODY_AGREEMENT_BASIS_NOTE
32691
+ };
32692
+ }
32693
+ function normalizeFrom(v) {
32694
+ const angled = v?.match(/<([^<>\s]+)>/);
32695
+ const addr = angled ? angled[1] : v?.trim();
32696
+ return addr ? addr.toLowerCase() : void 0;
32697
+ }
32698
+ async function runNormalized(args, opts) {
32699
+ return normalizeTimestamps(await run(args, opts));
32700
+ }
32701
+ function parseInternalDateMs(v) {
32702
+ if (v === void 0 || v.trim() === "") return void 0;
32703
+ const n = Number(v);
32704
+ return Number.isFinite(n) ? n : void 0;
32705
+ }
32706
+ var FORK_SIGNALS_THAT_NEVER_SUFFICE = [
32707
+ 'A draft id beginning `s:` means non-API (IMAP/sync) origin \u2014 Thunderbird, Outlook-over-IMAP and Gmail offline produce it too. It is not "Apple", and it is not "a fork".',
32708
+ "threadId === messageId means the draft roots its own thread. Measured on a live mailbox, P(Apple | roots-own-thread) was 4/8 = 0.50 \u2014 a coin flip. Report it as a consequence, never use it as a discriminator.",
32709
+ "An identical subject, even minutes apart. Same-subject same-sender drafts are routinely created deliberately; a subject+recency rule fires on all of them and is wrong every time. Subject can also be absent entirely.",
32710
+ "Any single X-Apple-* header. It proves Apple wrote THIS draft; it says nothing about WHICH draft it replaced.",
32711
+ "A UUID-shaped Message-Id, or `Mime-Version: 1.0 (1.0)`. The doubled form is iOS-only \u2014 macOS Mail writes `Mime-Version: 1.0 (Mac OS X Mail 16.0 ...)`, so its absence is not counter-evidence.",
32712
+ "Recency alone.",
32713
+ "A SHARED REPLY ROOT. Two drafts replying into the same conversation share one by construction, and in a mailbox whose threads are all with the same person that is nearly every pair of drafts. It links each draft to a common ANCESTOR \u2014 never the candidate to the original \u2014 so it is reported as corroboration and can never establish a pairing on its own.",
32714
+ "QUOTED TEXT. Body agreement is measured only over lines NEITHER draft quotes, because Apple Mail quotes the original on every reply: two unrelated replies into one thread carry the same 30-line block, which scores 0.79 on a whole-body line metric while proving nothing.",
32715
+ "GREETINGS, SIGN-OFFS AND SIGNATURE BLOCKS, for exactly the same reason as quoted text: a mail client reproduces them identically on every message whatever the message says. `Hi Jennifer,` + `Thanks,` + `Chris` + `Sent from my iPhone` is 4 lines and 43 characters of pure apparatus \u2014 enough, on its own, to clear a naive line-and-character threshold \u2014 and `Sent from my iPhone` is Apple Mail's own default signature. They are excluded from the lineage metric alongside quoting; the divergence report still counts them, because a merge that drops the signature really did drop it.",
32716
+ "THE COMPOSITE TRAP: `s:` prefix AND threadId === messageId together are still insufficient. Both are consequences of the same single fact (non-API origin) and neither references the supposed original. No pairing verdict without a lineage signal.",
32717
+ 'Note the converse error too: a fork does NOT always lose its reply headers. A live Apple-authored draft was found carrying a full 5-deep References chain, so "Apple fork means threading is gone" must not be asserted anywhere.'
32718
+ ];
32719
+ function replyRoots(d) {
32720
+ const roots = messageIdsIn(d.references);
32721
+ const inReplyTo = normalizeMessageId(d.inReplyTo);
32722
+ if (inReplyTo) roots.push(inReplyTo);
32723
+ return roots;
32724
+ }
32725
+ function firstSharedRoot(a, b) {
32726
+ const bRoots = new Set(replyRoots(b));
32727
+ for (const root of replyRoots(a)) if (bRoots.has(root)) return root;
32728
+ return void 0;
32729
+ }
32730
+ function label(d) {
32731
+ return d.draftId ?? "(unknown id)";
32732
+ }
32733
+ function evaluateForkPairing(original, candidate, tier) {
32734
+ const signals = candidate.appleSignals ?? [];
32735
+ if (tier < 2 && signals.length > 0) {
32736
+ throw new Error(
32737
+ `evaluateForkPairing was given Apple identity signals at tier ${tier}, but identity headers can only come from a tier 2 per-draft header fetch. This is a wiring bug: a cheap listing path must never be able to produce a "confirmed" fork pairing.`
32738
+ );
32739
+ }
32740
+ const evidence = [];
32741
+ const missing = [];
32742
+ const agreement = measureBodyAgreement(original.bodyText, candidate.bodyText);
32743
+ const originalMessageId = normalizeMessageId(original.messageIdHeader);
32744
+ let lineage = false;
32745
+ if (originalMessageId !== void 0 && replyRoots(candidate).includes(originalMessageId)) {
32746
+ evidence.push(
32747
+ `LINEAGE: the candidate's In-Reply-To/References cites the ORIGINAL DRAFT's own Message-Id <${originalMessageId}> \u2014 a link to the original itself, not to a shared ancestor`
32748
+ );
32749
+ lineage = true;
32750
+ }
32751
+ if (agreement.meetsThreshold) {
32752
+ evidence.push(
32753
+ `LINEAGE: the two drafts agree on text NEITHER of them quotes \u2014 authored body line similarity ${agreement.similarity.toFixed(2)} meets the ${FORK_BODY_SIMILARITY_THRESHOLD.toFixed(2)} threshold over ${agreement.sharedAuthoredLines} shared line(s) / ${agreement.sharedAuthoredChars} characters`
32754
+ );
32755
+ lineage = true;
32756
+ }
32757
+ const sharedRoot = firstSharedRoot(original, candidate);
32758
+ if (sharedRoot) {
32759
+ evidence.push(
32760
+ `CORROBORATING ONLY (never a pairing on its own): both drafts reply into the same conversation \u2014 shared reply root <${sharedRoot}>. That links each draft to a common ANCESTOR, not the candidate to the original, and EVERY reply in that thread has it.`
32761
+ );
32762
+ }
32763
+ if (!lineage) {
32764
+ missing.push(
32765
+ `no lineage signal: the candidate's In-Reply-To/References does not cite the original draft's Message-Id, and the text the two drafts wrote rather than quoted does not agree (similarity ${agreement.similarity.toFixed(2)} vs the ${FORK_BODY_SIMILARITY_THRESHOLD.toFixed(2)} threshold, ${agreement.sharedAuthoredLines} shared line(s) of ${agreement.sharedAuthoredChars} characters vs the ${FORK_MIN_SHARED_AUTHORED_LINES}/${FORK_MIN_SHARED_AUTHORED_CHARS} minimums)${sharedRoot ? ". A shared reply root is corroboration, not lineage" : ""}`
32766
+ );
32767
+ }
32768
+ const identity = signals.length > 0;
32769
+ if (identity) evidence.push(`the candidate carries Apple identity header(s): ${signals.join("; ")}`);
32770
+ else missing.push("no Apple identity header on the candidate (X-Apple-*, X-Universally-Unique-Identifier, X-Uniform-Type-Identifier)");
32771
+ const originalMs = parseInternalDateMs(original.internalDate);
32772
+ const candidateMs = parseInternalDateMs(candidate.internalDate);
32773
+ let ordering = false;
32774
+ if (originalMs === void 0 || candidateMs === void 0) {
32775
+ missing.push("internalDate is missing on one or both drafts, so it cannot be shown that the candidate is newer");
32776
+ } else if (candidateMs > originalMs) {
32777
+ ordering = true;
32778
+ evidence.push(`the candidate is newer (internalDate ${candidateMs} > ${originalMs})`);
32779
+ } else {
32780
+ missing.push(`the candidate is not newer than the original (internalDate ${candidateMs} <= ${originalMs})`);
32781
+ }
32782
+ const originalFrom = normalizeFrom(original.from);
32783
+ const candidateFrom = normalizeFrom(candidate.from);
32784
+ let sameFrom = false;
32785
+ if (originalFrom === void 0 || candidateFrom === void 0) {
32786
+ missing.push("From missing on one or both drafts");
32787
+ } else if (originalFrom === candidateFrom) {
32788
+ sameFrom = true;
32789
+ evidence.push(`same From (${originalFrom})`);
32790
+ } else {
32791
+ missing.push(`different From (${originalFrom} vs ${candidateFrom})`);
32792
+ }
32793
+ let verdict;
32794
+ if (lineage) verdict = identity && ordering && sameFrom ? "confirmed" : "candidate";
32795
+ else if (sharedRoot !== void 0) verdict = "candidate";
32796
+ else verdict = "none";
32797
+ let note;
32798
+ if (verdict === "confirmed") {
32799
+ note = `Draft ${label(candidate)} replaced draft ${label(original)}. All four signals are present \u2014 see evidence. Reconcile the bodies before sending: neither copy is guaranteed to be a superset of the other.`;
32800
+ } else if (verdict === "candidate" && !lineage) {
32801
+ note = `Unconfirmed and WEAK: could draft ${label(candidate)} be a rewrite of draft ${label(original)}? The ONLY thing connecting them is that both reply into the same conversation, which every reply in that thread does \u2014 it places them under a common ancestor and says nothing about one coming from the other. Nothing here is a link back to draft ${label(original)}, and the text they did not quote does not agree. Read both bodies yourself; do not merge or send on the strength of this.`;
32802
+ } else if (verdict === "candidate") {
32803
+ note = `Unconfirmed: could draft ${label(candidate)} be a rewrite of draft ${label(original)}? Something links them, but not everything a pairing needs \u2014 read "missing" and decide yourself. Do not merge or send on the strength of this alone.`;
32804
+ } else {
32805
+ note = `No lineage signal was found between draft ${label(candidate)} and draft ${label(original)}: neither cites the other and the text they did not quote does not agree. That is a failure to FIND evidence, not proof that they are unrelated \u2014 the comparison is line-based, so a client that re-wrapped the paragraphs or swapped in smart quotes can hide a real link. Read both bodies before concluding either way.`;
32806
+ }
32807
+ return {
32808
+ verdict,
32809
+ tier,
32810
+ evidence,
32811
+ missing,
32812
+ bodyAgreement: agreement,
32813
+ note
32814
+ };
32815
+ }
32816
+ function decodeBase64UrlBytes(data) {
32817
+ if (!data) return void 0;
32818
+ try {
32819
+ const binary = atob(data.replace(/-/g, "+").replace(/_/g, "/"));
32820
+ const bytes = new Uint8Array(binary.length);
32821
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
32822
+ return bytes;
32823
+ } catch {
32824
+ return void 0;
32825
+ }
32826
+ }
32827
+ var CP1252_HIGH = [
32828
+ 8364,
32829
+ 129,
32830
+ 8218,
32831
+ 402,
32832
+ 8222,
32833
+ 8230,
32834
+ 8224,
32835
+ 8225,
32836
+ 710,
32837
+ 8240,
32838
+ 352,
32839
+ 8249,
32840
+ 338,
32841
+ 141,
32842
+ 381,
32843
+ 143,
32844
+ 144,
32845
+ 8216,
32846
+ 8217,
32847
+ 8220,
32848
+ 8221,
32849
+ 8226,
32850
+ 8211,
32851
+ 8212,
32852
+ 732,
32853
+ 8482,
32854
+ 353,
32855
+ 8250,
32856
+ 339,
32857
+ 157,
32858
+ 382,
32859
+ 376
32860
+ ];
32861
+ function decodeCp1252(bytes) {
32862
+ let out = "";
32863
+ for (const b of bytes) out += String.fromCharCode(b >= 128 && b <= 159 ? CP1252_HIGH[b - 128] : b);
32864
+ return out;
32865
+ }
32866
+ function decodeTextBytes(bytes, declaredCharset) {
32867
+ try {
32868
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
32869
+ } catch {
32870
+ }
32871
+ const charset = (declaredCharset ?? "").trim().toLowerCase().replace(/^["']|["']$/g, "");
32872
+ if (charset !== "" && !/^(utf-?8|us-ascii|ascii)$/.test(charset)) {
32873
+ try {
32874
+ return new TextDecoder(charset, { fatal: true }).decode(bytes);
32875
+ } catch {
32876
+ }
32877
+ }
32878
+ return decodeCp1252(bytes);
32879
+ }
32880
+ var QUOTED_PRINTABLE_MARKER = /=(?:\r?\n|[89a-f][0-9a-f]|3d)/i;
32881
+ function decodeQuotedPrintableBytes(bytes) {
32882
+ const src = decodeCp1252(bytes);
32883
+ const out = [];
32884
+ for (let i = 0; i < src.length; i += 1) {
32885
+ if (src[i] !== "=") {
32886
+ out.push(src.charCodeAt(i));
32887
+ continue;
32888
+ }
32889
+ const rest = src.slice(i, i + 3);
32890
+ const soft = /^=\r?\n/.exec(rest);
32891
+ if (soft) {
32892
+ i += soft[0].length - 1;
32893
+ continue;
32894
+ }
32895
+ const hex3 = /^=([0-9A-Fa-f]{2})/.exec(rest);
32896
+ if (hex3) {
32897
+ out.push(parseInt(hex3[1], 16));
32898
+ i += 2;
32899
+ continue;
32900
+ }
32901
+ out.push(61);
32902
+ }
32903
+ return Uint8Array.from(out);
32904
+ }
32905
+ function partMimeType(part) {
32906
+ return (part.mimeType ?? "").split(";")[0].trim().toLowerCase();
32907
+ }
32908
+ function decodePartText(part) {
32909
+ let bytes = decodeBase64UrlBytes(part.body?.data);
32910
+ if (bytes === void 0) return "";
32911
+ const headers = parseHeaders(part);
32912
+ const encoding = headerValue(headers, "Content-Transfer-Encoding")?.trim().toLowerCase();
32913
+ if (encoding === "quoted-printable" && bytes.every((b) => b < 128) && QUOTED_PRINTABLE_MARKER.test(decodeCp1252(bytes))) {
32914
+ bytes = decodeQuotedPrintableBytes(bytes);
32915
+ }
32916
+ const charset = /charset\s*=\s*([^;]+)/i.exec(headerValue(headers, "Content-Type") ?? "")?.[1];
32917
+ return decodeTextBytes(bytes, charset);
32918
+ }
32919
+ function bestBodyText(payload) {
32920
+ const firstOfType = /* @__PURE__ */ new Map();
32921
+ const walk2 = (part) => {
32922
+ if (!part) return;
32923
+ const mime = partMimeType(part);
32924
+ if (part.body?.data && !part.filename && !firstOfType.has(mime)) {
32925
+ firstOfType.set(mime, decodePartText(part));
32926
+ }
32927
+ for (const child of part.parts ?? []) walk2(child);
32928
+ };
32929
+ walk2(payload);
32930
+ return firstOfType.get("text/plain") ?? firstOfType.get("text/html") ?? "";
32931
+ }
32932
+ var DRAFT_DIFF_MAX_LINES = 200;
32933
+ function diffBodyLines(a, b, maxLines) {
32934
+ const left = new Set(normalizeBodyLines(a));
32935
+ const right = new Set(normalizeBodyLines(b));
32936
+ const onlyInA = [...left].filter((line) => !right.has(line));
32937
+ const onlyInB = [...right].filter((line) => !left.has(line));
32938
+ const sharedLineCount = left.size - onlyInA.length;
32939
+ const truncated = onlyInA.length > maxLines || onlyInB.length > maxLines;
32940
+ let comparability = "compared";
32941
+ if (left.size === 0) comparability = right.size === 0 ? "both-unreadable" : "a-unreadable";
32942
+ else if (right.size === 0) comparability = "b-unreadable";
32943
+ let supersetClaim;
32944
+ let base;
32945
+ if (comparability !== "compared") {
32946
+ supersetClaim = "not-assessed";
32947
+ const which = comparability === "both-unreadable" ? "NEITHER draft yielded any body text" : comparability === "a-unreadable" ? "Draft A yielded no body text" : "Draft B yielded no body text";
32948
+ base = `${which} (it normalized to zero lines), so NOTHING WAS COMPARED and no containment claim is made in either direction \u2014 in particular this does NOT say one draft's text is safely present in the other. The draft may genuinely be empty, or its text may sit in a MIME part this server could not decode; read it with gog_gmail_drafts_get before overwriting or deleting either copy.`;
32949
+ } else if (onlyInA.length > 0 && onlyInB.length > 0) {
32950
+ supersetClaim = "neither";
32951
+ base = "NEITHER copy is a superset: each draft holds lines the other does not. Recreating from either one alone LOSES WORK \u2014 merge the two bodies by hand, then write the merged text back with gog_gmail_drafts_update.";
32952
+ } else if (onlyInA.length > 0) {
32953
+ supersetClaim = "a-superset-of-b";
32954
+ base = "Draft A is a superset of draft B: every line of B is present in A, and A has more.";
32955
+ } else if (onlyInB.length > 0) {
32956
+ supersetClaim = "b-superset-of-a";
32957
+ base = "Draft B is a superset of draft A: every line of A is present in B, and B has more.";
32958
+ } else {
32959
+ supersetClaim = "identical";
32960
+ base = "The two bodies are identical once whitespace and blank lines are normalized.";
32961
+ }
32962
+ return {
32963
+ onlyInA: onlyInA.slice(0, maxLines),
32964
+ onlyInB: onlyInB.slice(0, maxLines),
32965
+ onlyInACount: onlyInA.length,
32966
+ onlyInBCount: onlyInB.length,
32967
+ sharedLineCount,
32968
+ similarity: bodySimilarity(a, b),
32969
+ comparability,
32970
+ supersetClaim,
32971
+ neitherIsSuperset: comparability === "compared" ? supersetClaim === "neither" : null,
32972
+ truncated,
32973
+ note: truncated ? `${base} (Line lists truncated to ${maxLines} per side; ${onlyInA.length} line(s) diverged only in A and ${onlyInB.length} only in B \u2014 onlyInACount/onlyInBCount are the true totals.)` : base
32974
+ };
32975
+ }
32976
+ var GOG_DRAFTS_LIST_DEFAULT_MAX = 20;
32977
+ var DRAFT_LIST_ORIGIN_NOTE = '`origin` is derived from the draft id alone and costs nothing: `api` = created through the Gmail API (what this server does); `non-api` = the id begins `s:`, meaning the draft arrived over IMAP/sync. `non-api` is NOT a claim of "Apple Mail" \u2014 Thunderbird, Outlook-over-IMAP and Gmail offline produce `s:` ids too, and confirming Apple authorship needs an actual identity header, which only a per-draft fetch can see (gog_gmail_drafts_diff). `rootsOwnThread` is likewise a CONSEQUENCE, not a fork test: on a live mailbox P(Apple | rootsOwnThread) measured 4/8 = 0.50, a coin flip. Neither field, alone or together, establishes that one draft replaced another.';
32978
+ var DRAFT_ROOTS_OWN_THREAD_NOTE = "threadId equals this draft's own messageId, so the draft is the ROOT of a new thread: sending it starts a NEW conversation rather than replying, in front of every recipient including anyone on Cc. Normal for a draft composed from scratch \u2014 and also what a mail client's replacement of a previously threaded draft looks like.";
32979
+ var DRAFT_IN_THREAD_NOTE = "threadId differs from this draft's messageId, so the draft sits inside an existing thread and sending it continues that conversation. (Whether it also carries In-Reply-To/References is not visible from a listing \u2014 that needs a per-draft fetch.)";
32980
+ var DRAFT_ENRICH_COST_NOTE = "enrich spent ONE extra gog invocation (`gmail messages search in:drafts`), so the spawn cost is flat in the number of drafts. It is not free on the other axis: gog fans that one command out to one Gmail messages.get per matching draft at concurrency 10, so Google reads and wall-clock are linear in the result count. Narrow `max` before turning it on.";
32981
+ function describeDraftSide(draftId, msg) {
32982
+ const headers = parseHeaders(msg.payload);
32983
+ const bodyText = bestBodyText(msg.payload);
32984
+ const side = {
32985
+ draftId,
32986
+ messageId: msg.id,
32987
+ threadId: msg.threadId,
32988
+ origin: originFromDraftId(draftId),
32989
+ rootsOwnThread: rootsOwnThread({ id: draftId, messageId: msg.id, threadId: msg.threadId }),
32990
+ subject: headerValue(headers, "Subject"),
32991
+ from: headerValue(headers, "From"),
32992
+ to: headerValue(headers, "To"),
32993
+ cc: headerValue(headers, "Cc"),
32994
+ internalDate: msg.internalDate,
32995
+ messageIdHeader: headerValue(headers, "Message-Id"),
32996
+ inReplyTo: headerValue(headers, "In-Reply-To"),
32997
+ references: headerValue(headers, "References"),
32998
+ appleIdentitySignals: appleIdentitySignals(msg.payload?.headers),
32999
+ // The DE-DUPLICATED count, matching diffBodyLines/evaluateContentLoss, which
33000
+ // compare Set members. Counting raw lines here broke the arithmetic a reader
33001
+ // naturally checks: onlyInACount + sharedLineCount === bodyLineCount only
33002
+ // holds when both sides count the same unit, and a body that repeats a line
33003
+ // (a divider, a blank-ish separator) made it not hold.
33004
+ bodyLineCount: new Set(normalizeBodyLines(bodyText)).size
33005
+ };
33006
+ return {
33007
+ side,
33008
+ facts: {
33009
+ draftId,
33010
+ messageIdHeader: side.messageIdHeader,
33011
+ inReplyTo: side.inReplyTo,
33012
+ references: side.references,
33013
+ from: side.from,
33014
+ subject: side.subject,
33015
+ internalDate: side.internalDate,
33016
+ bodyText,
33017
+ appleSignals: side.appleIdentitySignals
33018
+ }
33019
+ };
33020
+ }
33021
+ function threadingDifferences(a, b) {
33022
+ const out = [];
33023
+ if (a.threadId !== b.threadId) {
33024
+ out.push(
33025
+ `The two drafts sit on different threadIds (${a.threadId ?? "(none)"} vs ${b.threadId ?? "(none)"}), so they are not the same conversation. Sending the one that roots its own thread starts a NEW conversation in front of every recipient, including anyone on Cc.`
33026
+ );
33027
+ }
33028
+ const aReplies = Boolean(a.inReplyTo ?? a.references);
33029
+ const bReplies = Boolean(b.inReplyTo ?? b.references);
33030
+ if (aReplies !== bReplies) {
33031
+ const withHeaders = aReplies ? a.draftId : b.draftId;
33032
+ const without = aReplies ? b.draftId : a.draftId;
33033
+ out.push(
33034
+ `Draft ${withHeaders} carries reply headers (In-Reply-To/References) and draft ${without} does not: only the first will arrive as a reply. gog_gmail_drafts_update with replyToThreadId re-threads a draft in place, keeping its id \u2014 but it also requires a full body, so reconcile the bodies below first.`
33035
+ );
33036
+ }
33037
+ if (out.length === 0) {
33038
+ out.push("No threading difference: the two drafts share a threadId and agree on whether they carry reply headers.");
33039
+ }
33040
+ return out;
33041
+ }
33042
+ function threadingIntentOf(f) {
33043
+ if (f.replyToMessageId) return { requested: "set", via: "replyToMessageId", target: f.replyToMessageId };
33044
+ if (f.replyToThreadId) return { requested: "set", via: "replyToThreadId", target: f.replyToThreadId };
33045
+ if (f.clearReplyContext) return { requested: "clear" };
33046
+ return void 0;
33047
+ }
33048
+ var BODY_OVERWRITE_CAVEAT = "gog requires a body on every update, so there is no header-only edit: this call REWROTE the whole body. If a sibling draft holds text this body does not, that text now exists only there \u2014 compare them with gog_gmail_drafts_diff before the next write.";
33049
+ var VERIFICATION_PROVENANCE = "These are gog's own report of what it wrote, not an independent re-fetch, and they cost no extra gog invocation. To read the stored headers back from Gmail, use gog_gmail_raw with format=metadata on the draft's messageId.";
33050
+ function verifyThreading(intent, ack) {
33051
+ const str = (name) => {
33052
+ const v = ack[name];
33053
+ return typeof v === "string" && v.trim() !== "" ? v : void 0;
33054
+ };
33055
+ const effective = {
33056
+ threadId: str("threadId"),
33057
+ inReplyTo: str("inReplyTo"),
33058
+ references: str("references"),
33059
+ replyContextSource: str("replyContextSource")
33060
+ };
33061
+ const hasLineage = effective.inReplyTo !== void 0;
33062
+ const threadLabel = effective.threadId ?? "(none reported)";
33063
+ if (intent.requested === "clear") {
33064
+ return {
33065
+ requested: "clear",
33066
+ ok: !hasLineage,
33067
+ effective,
33068
+ note: hasLineage ? `WARNING: clearReplyContext was requested, but gog reports the draft STILL carries In-Reply-To ${effective.inReplyTo}. It has NOT been turned back into a standalone message. Re-read it before sending. ${VERIFICATION_PROVENANCE}` : `Reply context cleared: gog reports no In-Reply-To/References, so this draft will arrive as a standalone message. Its draft id and its threadId (${threadLabel}) are unchanged \u2014 dropping the headers does not move the draft out of the thread in Gmail's own UI, it only stops recipients' clients threading it. ${BODY_OVERWRITE_CAVEAT} ${VERIFICATION_PROVENANCE}`
33069
+ };
33070
+ }
33071
+ return {
33072
+ requested: "set",
33073
+ via: intent.via,
33074
+ target: intent.target,
33075
+ ok: hasLineage,
33076
+ effective,
33077
+ note: hasLineage ? `Threading applied and verified: gog reports the draft now replies to ${effective.inReplyTo}, on thread ${threadLabel}, with the draft id unchanged \u2014 it was updated in place, not recreated. See effective.references and effective.replyContextSource for the rest ("caller" means the lineage was resolved from the target you named, "carried" that it came from the draft's own stored headers). ${BODY_OVERWRITE_CAVEAT} ${VERIFICATION_PROVENANCE}` : `WARNING: ${intent.via} ${intent.target} was accepted, but gog reports NO reply headers at all (inReplyTo is null). The draft HAS been moved onto thread ${threadLabel}, so this was not a no-op \u2014 it simply will not arrive as a reply, because recipients' mail clients thread on In-Reply-To/References, not on Gmail's threadId. And an explicit reply target REPLACES the draft's own stored reply context rather than carrying it forward, so any lineage the draft had before this call is gone. Do not send it as a reply on this evidence. ${VERIFICATION_PROVENANCE}`
33078
+ };
33079
+ }
33080
+ function withThreadingVerification(result, verification) {
33081
+ try {
33082
+ const parsed = JSON.parse(String(resultText(result)));
33083
+ return rawTextResult(JSON.stringify({ ...parsed, threadingVerification: verification }));
33084
+ } catch {
33085
+ return withNote(result, [`threadingVerification: ${verification.note}`]);
33086
+ }
33087
+ }
33088
+ var CONTENT_LOSS_NO_CLAIM_NOTE = "This check compares two bodies and nothing else. It does not say that either draft replaced the other, and it cannot: YOU named this sibling, nothing here searched for it. Identical bodies would not prove a pairing and divergent bodies would not disprove one \u2014 a pairing verdict needs an identity header, a lineage link back to the original and an ordering, which is what gog_gmail_drafts_diff weighs and reports with its evidence.";
33089
+ var CONTENT_LOSS_COMPARISON_NOTE = "Lines are compared after collapsing runs of whitespace and dropping blank lines, and only against the plain-text `body` you passed \u2014 a bodyHtml is not compared, and neither are attachments, recipients or the subject. The comparison is LINE-BASED: a copy whose paragraphs were re-wrapped at a different width, or whose straight quotes became curly ones, no longer matches line for line, so it can be reported as loss even though no words were dropped. Read the listed lines before deciding.";
33090
+ function evaluateContentLoss(siblingDraftId, siblingBody, newBody, maxLines) {
33091
+ const siblingLines = new Set(normalizeBodyLines(siblingBody));
33092
+ const newLines = new Set(normalizeBodyLines(newBody));
33093
+ const missing = [...siblingLines].filter((line) => !newLines.has(line));
33094
+ const truncated = missing.length > maxLines;
33095
+ const base = {
33096
+ siblingDraftId,
33097
+ siblingBodyLineCount: siblingLines.size,
33098
+ newBodyLineCount: newLines.size,
33099
+ linesOnlyInSibling: missing.slice(0, maxLines),
33100
+ linesOnlyInSiblingCount: missing.length,
33101
+ truncated,
33102
+ similarity: bodySimilarity(siblingBody, newBody),
33103
+ forkClaim: null,
33104
+ forkClaimNote: CONTENT_LOSS_NO_CLAIM_NOTE
33105
+ };
33106
+ if (siblingLines.size === 0) {
33107
+ return {
33108
+ ...base,
33109
+ status: "unchecked",
33110
+ note: `No body text could be read from draft ${siblingDraftId}, so NOTHING WAS COMPARED and nothing is proven. The draft may genuinely be empty, or its text may sit in a MIME part this server could not decode. Read it with gog_gmail_drafts_get before overwriting draft text you cannot see. ${CONTENT_LOSS_COMPARISON_NOTE}`
33111
+ };
33112
+ }
33113
+ if (missing.length === 0) {
33114
+ return {
33115
+ ...base,
33116
+ status: "clean",
33117
+ note: `Every line of draft ${siblingDraftId} is already present in the body you passed, so this update leaves nothing behind in that copy. It says nothing about the reverse direction: lines of the draft being UPDATED that your body omits are overwritten regardless \u2014 this check cannot see them, because gog's write acknowledgement never returns the previous body. ${CONTENT_LOSS_COMPARISON_NOTE}`
33118
+ };
33119
+ }
33120
+ return {
33121
+ ...base,
33122
+ status: "would-lose",
33123
+ note: `WARNING: ${missing.length} line(s) of draft ${siblingDraftId} are NOT in the body you passed. gog requires a body on every update, so this call rewrites the WHOLE body \u2014 afterwards those lines exist only in that sibling draft. Merge them into the body and retry, or pass acceptContentLoss:true to write anyway.` + (truncated ? ` (Line list truncated to ${maxLines}; linesOnlyInSiblingCount is the true total.)` : "") + ` ${CONTENT_LOSS_COMPARISON_NOTE}`
33124
+ };
33125
+ }
33126
+ function unreadableSiblingCheck(siblingDraftId, reason) {
33127
+ return {
33128
+ siblingDraftId,
33129
+ status: "unchecked",
33130
+ siblingBodyLineCount: 0,
33131
+ newBodyLineCount: 0,
33132
+ linesOnlyInSibling: [],
33133
+ linesOnlyInSiblingCount: 0,
33134
+ truncated: false,
33135
+ similarity: 0,
33136
+ forkClaim: null,
33137
+ forkClaimNote: CONTENT_LOSS_NO_CLAIM_NOTE,
33138
+ note: `Could not read draft ${siblingDraftId} to check what this update would overwrite: ${reason}. NOTHING WAS COMPARED, so nothing is proven. A draft id that has stopped resolving is itself worth noting \u2014 that is what a mail client leaves behind when it rewrites a draft instead of updating it.`
33139
+ };
33140
+ }
33141
+ async function checkSiblingContentLoss(siblingDraftId, newBody, account) {
33142
+ let raw;
33143
+ try {
33144
+ raw = await runNormalized(["gmail", "drafts", "get", siblingDraftId, "--use-indexed-attachment-ids=false"], { account });
33145
+ } catch (err) {
33146
+ return unreadableSiblingCheck(siblingDraftId, String(err));
33147
+ }
33148
+ let message;
33149
+ try {
33150
+ message = JSON.parse(raw).draft?.message;
33151
+ } catch {
33152
+ message = void 0;
33153
+ }
33154
+ if (!message) {
33155
+ return unreadableSiblingCheck(siblingDraftId, "`gog gmail drafts get` returned no `draft.message` object to read a body from");
33156
+ }
33157
+ return evaluateContentLoss(siblingDraftId, bestBodyText(message.payload), newBody, DRAFT_DIFF_MAX_LINES);
33158
+ }
33159
+ var CONTENT_LOSS_HOW_TO_PROCEED = [
33160
+ "Merge the missing lines into your body and call gog_gmail_drafts_update again. The check re-runs, so a complete merge passes it.",
33161
+ "Run gog_gmail_drafts_diff on the two ids first if you want the full picture \u2014 it reports both directions of divergence, whether either body is a superset, how the threading differs, and (separately, with its evidence) whether there is enough to say one draft replaced the other.",
33162
+ "Pass acceptContentLoss:true to write this body as-is. The sibling draft is not touched either way, so the listed lines are still recoverable from it afterwards \u2014 but the draft you are updating loses whatever your body omits, permanently.",
33163
+ "Drop forkSiblingDraftId to skip the check entirely (and the one gog call it costs)."
33164
+ ];
33165
+ function contentLossRefusal(draftId, check2) {
33166
+ const code = check2.status === "unchecked" ? "DRAFT_CONTENT_LOSS_UNCHECKED" : "DRAFT_CONTENT_LOSS";
33167
+ const headline = check2.status === "unchecked" ? `the content-loss check you asked for could not be run against draft ${check2.siblingDraftId}` : `${check2.linesOnlyInSiblingCount} line(s) of draft ${check2.siblingDraftId} are missing from the body you passed`;
33168
+ const payload = {
33169
+ code,
33170
+ codeMeaning: check2.status === "unchecked" ? "The named sibling could not be read, so the guard could not run. An unrun check is not a passed check, so the write was refused." : "The body passed would have dropped text the named sibling still holds, and gog rewrites the whole body on every update.",
33171
+ tool: "gog_gmail_drafts_update",
33172
+ draftId,
33173
+ forkSiblingDraftId: check2.siblingDraftId,
33174
+ whatHappened: `NOTHING WAS WRITTEN. Draft ${draftId} is byte-for-byte as it was: no body, subject, recipient, attachment or reply-header change was applied, and no gog write ran at all. ${headline}.`,
33175
+ contentLossCheck: check2,
33176
+ howToProceed: CONTENT_LOSS_HOW_TO_PROCEED
33177
+ };
33178
+ return errorResult(
33179
+ `${code}: nothing was written \u2014 ${headline}. gog requires a body on every draft update, so there is no header-only edit and the update would have rewritten the whole body.
33180
+
33181
+ ` + JSON.stringify(payload, null, 2)
33182
+ );
33183
+ }
33184
+ function withContentLossCheck(result, check2) {
33185
+ try {
33186
+ const parsed = JSON.parse(String(resultText(result)));
33187
+ return rawTextResult(JSON.stringify({ ...parsed, contentLossCheck: check2 }));
33188
+ } catch {
33189
+ return withNote(result, [`contentLossCheck: ${check2.note}`]);
33190
+ }
33191
+ }
33192
+ var DRAFT_NOT_FOUND_PATTERN = /Google API error \(404\b|\b404\b[^\n]{0,40}not\s?found/i;
33193
+ var DRAFT_FORK_MAX_CANDIDATES = 20;
33194
+ var DRAFT_FORK_CLAIM_NOTE = "This report names NO replacement, and cannot. The 404'd draft can no longer be fetched, so there is nothing left to establish lineage against \u2014 no References citing it, no shared reply root, no body to compare \u2014 and without a lineage signal no pairing verdict is possible at any cost tier. The drafts below are simply the drafts that exist right now; ordering is presentation, not evidence. To decide whether one draft replaced another, name a PAIR and run gog_gmail_drafts_diff, which reads both sides' headers and bodies.";
33195
+ var DRAFT_FORK_OTHER_EXPLANATIONS = [
33196
+ "The draft was deleted \u2014 by you, by a mail client, or by an earlier gog_gmail_drafts_delete. A deleted draft 404s identically.",
33197
+ "The draft was already sent. Sending consumes the draft, so its id stops resolving; check Sent before recreating anything.",
33198
+ "A mail client rewrote the draft instead of updating it in place, writing a NEW draft and abandoning this id. This is the only one of the three that strands text in two places, and the reason this report exists."
33199
+ ];
33200
+ function replyTargetExplanation(replyTarget) {
33201
+ return `The 404 may have been about your REPLY TARGET rather than the draft. You passed ${replyTarget.via}=${replyTarget.target}, and \`gog gmail drafts update\` resolves up to three different Google entities \u2014 the draft (Users.Drafts.Get/Update), the thread behind --thread-id and the message behind --reply-to-message-id \u2014 which gog renders with the IDENTICAL 404 string (internal/errfmt/googleapi.go). Thread ids and message ids are both 16-hex strings and are routinely confused, and a thread id copied from a stale record may simply no longer exist. Fetch it \u2014 gog_gmail_thread_get for a thread id, gog_gmail_get for a message id \u2014 before concluding anything about the draft.`;
33202
+ }
33203
+ var DRAFT_LISTING_BASIS_NOTE = {
33204
+ "complete-listing": (draftId, listed) => `The listing returned ${listed} draft(s) \u2014 FEWER than the ${DRAFT_FORK_MAX_CANDIDATES}-draft window it asked for, so it covers the whole Drafts folder. Draft ${draftId} really is not in the mailbox.`,
33205
+ "capped-listing": (draftId) => `The listing came back FULL: ${DRAFT_FORK_MAX_CANDIDATES} drafts, which is the entire window it asked for, so it is TRUNCATED and draft ${draftId} could still exist beyond it. "Not listed here" is NOT evidence that the draft is gone \u2014 the window is capped by construction, because this is a failure path and must not grow with the size of the mailbox. Run gog_gmail_drafts_list with a larger max (or all:true) before concluding the draft forked.`,
33206
+ "listing-unavailable": (draftId) => `The listing FAILED, so nothing here shows whether draft ${draftId} still exists. The 404 is the only evidence there is, and gog renders the draft, thread and message 404s identically. Run gog_gmail_drafts_list yourself before acting.`
33207
+ };
33208
+ var DRAFT_FORK_NEXT_STEPS = [
33209
+ "Run gog_gmail_drafts_list \u2014 origin and rootsOwnThread cost nothing there \u2014 and look for a draft you did not create through this server.",
33210
+ "Name a PAIR and run gog_gmail_drafts_diff: it is the only path in this server that can issue a fork verdict, because it is the only one that reads both sides' identity headers, reply lineage and bodies. It cannot be pointed at THIS id \u2014 a 404'd draft cannot be fetched at all \u2014 so diff the survivor against another draft you still have.",
33211
+ "If a replacement lost its reply threading, re-thread it IN PLACE with gog_gmail_drafts_update replyToThreadId=<the original thread id>: the draft keeps its id and gog resolves In-Reply-To/References from that thread's latest message, reporting them back under threadingVerification. It requires a full body, so merge the two bodies FIRST \u2014 whatever you do not pass is lost.",
33212
+ "If the draft was deleted or already sent, nothing forked and there is nothing to reconcile."
33213
+ ];
33214
+ async function currentDraftsForForkReport(account) {
33215
+ let entries;
33216
+ try {
33217
+ const listed = JSON.parse(
33218
+ await run(["gmail", "drafts", "list", `--max=${DRAFT_FORK_MAX_CANDIDATES}`], { account })
33219
+ );
33220
+ if (!Array.isArray(listed.drafts)) throw new Error("`gog gmail drafts list` returned no drafts array");
33221
+ entries = listed.drafts;
33222
+ } catch (err) {
33223
+ return {
33224
+ extraGogCalls: 1,
33225
+ listingBasis: "listing-unavailable",
33226
+ currentDraftsUnavailable: `Could not list the surviving drafts (${String(err)}), so this report names none. Run gog_gmail_drafts_list yourself \u2014 origin and rootsOwnThread are free there.`
33227
+ };
33228
+ }
33229
+ const byMessageId = /* @__PURE__ */ new Map();
33230
+ let enrichmentNote;
33231
+ try {
33232
+ const searched = JSON.parse(await runNormalized([
33233
+ "gmail",
33234
+ "messages",
33235
+ "search",
33236
+ "in:drafts",
33237
+ `--max=${DRAFT_FORK_MAX_CANDIDATES}`,
33238
+ "--include-attachments=false",
33239
+ "--use-indexed-attachment-ids=false"
33240
+ ], { account }));
33241
+ if (!Array.isArray(searched.messages)) throw new Error("`gog gmail messages search in:drafts` returned no messages array");
33242
+ for (const m of searched.messages) {
33243
+ if (m.id) byMessageId.set(m.id, m);
33244
+ }
33245
+ enrichmentNote = DRAFT_ENRICH_COST_NOTE;
33246
+ } catch (err) {
33247
+ enrichmentNote = `subject, from and internalDateIso are missing: the single enrichment call failed (${String(err)}). The free fields (origin, rootsOwnThread) are unaffected.`;
33248
+ }
33249
+ const currentDrafts = entries.map((d) => {
33250
+ const extra = byMessageId.get(d.messageId);
33251
+ return {
33252
+ ...d,
33253
+ origin: originFromDraftId(d.id ?? ""),
33254
+ rootsOwnThread: rootsOwnThread(d),
33255
+ subject: extra?.subject,
33256
+ from: extra?.from,
33257
+ internalDateIso: extra?.internalDateIso
33258
+ };
33259
+ });
33260
+ currentDrafts.sort((x, y) => (y.internalDateIso ?? "").localeCompare(x.internalDateIso ?? ""));
33261
+ const listingBasis = entries.length >= DRAFT_FORK_MAX_CANDIDATES ? "capped-listing" : "complete-listing";
33262
+ return { extraGogCalls: 2, listingBasis, currentDrafts, enrichmentNote };
33263
+ }
33264
+ function splitListingBasis(report) {
33265
+ const { listingBasis, ...rest } = report;
33266
+ return { basis: listingBasis, rest };
33267
+ }
33268
+ function draftForkedResult(tool, draftId, gogError, report, replyTarget) {
33269
+ const { basis, rest } = splitListingBasis(report);
33270
+ const listed = Array.isArray(rest.currentDrafts) ? rest.currentDrafts.length : 0;
33271
+ const proven = basis === "complete-listing";
33272
+ const whatHappened = proven ? `${tool} could not act on draft ${draftId}: Gmail no longer has a draft with that id, and a listing that covered the whole Drafts folder does not contain it either. Editing a draft in a real mail client does not update it in place \u2014 the client writes a NEW draft and abandons the original \u2014 so the id you were given stops resolving, the replacement usually sits on its OWN threadId with no In-Reply-To/References (sending it would start a new conversation in front of every recipient, including anyone on Cc), and each copy can hold text the other lost. Gmail has no draft under this id, so nothing this call carried \u2014 subject, body, recipients \u2014 is saved under it.` : `${tool} did not run and NOTHING WAS WRITTEN: Gmail returned 404 notFound. Whether draft ${draftId} itself still exists is NOT established here \u2014 ${DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed)} A mail client rewriting a draft instead of updating it in place is one explanation for a 404 like this, and the reason this report exists, but on this evidence it is only one of several \u2014 read otherExplanations before acting on any of them.`;
33273
+ const payload = {
33274
+ code: "DRAFT_FORKED",
33275
+ codeMeaning: "Gmail returned 404 notFound for this draft id. DRAFT_FORKED names the most common CAUSE \u2014 a mail client rewriting the draft instead of updating it in place \u2014 not a proven one: deletion and sending produce the same 404, and so does a stale reply target. See otherExplanations, and see listingEvidence for what the post-failure listing could actually show.",
33276
+ tool,
33277
+ draftId,
33278
+ gogError,
33279
+ whatHappened,
33280
+ replyTarget: replyTarget ?? null,
33281
+ listingEvidence: {
33282
+ basis,
33283
+ windowSize: DRAFT_FORK_MAX_CANDIDATES,
33284
+ draftsListed: basis === "listing-unavailable" ? null : listed,
33285
+ draftFoundInListing: false,
33286
+ establishesTheDraftIsGone: proven,
33287
+ note: DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed)
33288
+ },
33289
+ forkClaim: null,
33290
+ forkClaimNote: DRAFT_FORK_CLAIM_NOTE,
33291
+ ...rest,
33292
+ otherExplanations: replyTarget ? [replyTargetExplanation(replyTarget), ...DRAFT_FORK_OTHER_EXPLANATIONS] : DRAFT_FORK_OTHER_EXPLANATIONS,
33293
+ nextSteps: DRAFT_FORK_NEXT_STEPS,
33294
+ signalsThatNeverSuffice: FORK_SIGNALS_THAT_NEVER_SUFFICE
33295
+ };
33296
+ const headline = proven ? `DRAFT_FORKED: draft ${draftId} no longer resolves \u2014 Gmail 404'd it and a listing that covered the whole Drafts folder does not contain it \u2014 so ${tool} did not run. The usual cause is a mail client rewriting the draft under a new id rather than updating it; deletion and sending look identical from here.` : `DRAFT_FORKED: Gmail returned 404 notFound for draft ${draftId}, so ${tool} did not run and nothing was written. Whether that draft still exists is NOT established: ${DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed)}`;
33297
+ return errorResult(
33298
+ `${headline} No replacement is named below \u2014 that judgement needs a named pair and gog_gmail_drafts_diff.
33299
+
33300
+ ` + JSON.stringify(payload, null, 2)
33301
+ );
33302
+ }
33303
+ function draftIsStillListed(report, draftId) {
33304
+ const listed = report.currentDrafts;
33305
+ return Array.isArray(listed) && listed.some((d) => d.id === draftId);
33306
+ }
33307
+ var NOT_THE_DRAFT_RACE_NOTE = "The listing was taken AFTER the failure, so it is evidence about now, not about the instant the call ran. If something recreated a draft under this id in between \u2014 vanishingly unlikely, but not impossible \u2014 the listed draft could be a different one from the draft you addressed.";
33308
+ var NOT_THE_DRAFT_WHY_404 = "`gog gmail drafts update` resolves up to THREE different Google entities, and gog renders all three 404s with the same string (`Google API error (404 notFound): Requested entity was not found.`, internal/errfmt/googleapi.go): the DRAFT itself (Users.Drafts.Get/Update), the THREAD behind --thread-id (Users.Threads.Get, i.e. replyToThreadId) and the MESSAGE behind --reply-to-message-id (Users.Messages.Get). The error text alone cannot tell them apart \u2014 the draft listing can.";
33309
+ function notTheDraftResult(tool, draftId, gogError, report, replyTarget) {
33310
+ const targetClause = replyTarget ? `You passed ${replyTarget.via}=${replyTarget.target}; since the draft resolves, THAT id is the one that did not, and it is the first thing to check. Thread ids and message ids are both 16-hex strings and are routinely confused, and a thread id copied from a stale record may simply no longer exist.` : "This call named no reply target, so the 404 came from somewhere else in it. Whatever it was, it was not this draft id.";
33311
+ const { rest } = splitListingBasis(report);
33312
+ const payload = {
33313
+ code: "GOOGLE_404_NOT_THE_DRAFT",
33314
+ codeMeaning: `Google returned 404 notFound, but draft ${draftId} is STILL LISTED in the mailbox, so the 404 was not about the draft id. It is deliberately NOT reported as a fork: nothing here suggests a mail client replaced anything.`,
33315
+ tool,
33316
+ draftId,
33317
+ gogError,
33318
+ whatHappened: `${tool} did not run and NOTHING WAS WRITTEN \u2014 but draft ${draftId} still exists: it is still listed below, in a listing taken after the failure. ${NOT_THE_DRAFT_WHY_404} ${targetClause}`,
33319
+ replyTarget: replyTarget ?? null,
33320
+ forkClaim: null,
33321
+ forkClaimNote: "No fork is claimed and none is implied. The draft you addressed still resolves, which is the opposite of what a mail client rewriting a draft leaves behind.",
33322
+ ...rest,
33323
+ raceNote: NOT_THE_DRAFT_RACE_NOTE,
33324
+ nextSteps: [
33325
+ "Check the reply target, not the draft: a thread id belongs in replyToThreadId and a message id in replyToMessageId, and both are 16-hex strings. Fetch it \u2014 gog_gmail_thread_get for a thread id, gog_gmail_get for a message id \u2014 and a 404 there confirms the target is what is missing.",
33326
+ "If the thread id came from a stale record (an old fork report, an earlier note), re-find the conversation with gog_gmail_search and take the thread id from a message that still exists.",
33327
+ "Re-run the call without the reply target to confirm the draft itself writes fine. Remember it rewrites the WHOLE body, so pass the body you actually want.",
33328
+ "Do NOT go hunting for a replacement draft. Nothing here says this draft forked."
33329
+ ]
33330
+ };
33331
+ return errorResult(
33332
+ `GOOGLE_404_NOT_THE_DRAFT: Google said 404 notFound, but draft ${draftId} is still listed, so the 404 was not about the draft id \u2014 ${replyTarget ? `the reply target ${replyTarget.via}=${replyTarget.target} is the remaining explanation` : "something else in the call is the explanation"}. ${tool} did not run and nothing was written. This is NOT a fork.
33333
+
33334
+ ` + JSON.stringify(payload, null, 2)
33335
+ );
33336
+ }
33337
+ async function forkAwareDraftFailure(result, tool, draftId, account, replyTarget) {
33338
+ if (result.isError !== true) return result;
33339
+ const text = String(resultText(result));
33340
+ if (!DRAFT_NOT_FOUND_PATTERN.test(text)) return result;
33341
+ const report = await currentDraftsForForkReport(account);
33342
+ return draftIsStillListed(report, draftId) ? notTheDraftResult(tool, draftId, text, report, replyTarget) : draftForkedResult(tool, draftId, text, report, replyTarget);
33343
+ }
32431
33344
  function registerExtraGmailTools(server) {
32432
33345
  server.registerTool("gog_gmail_raw", {
32433
33346
  description: "Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).",
@@ -32445,21 +33358,35 @@ function registerExtraGmailTools(server) {
32445
33358
  return runOrDiagnose(args, { account, lossless: true });
32446
33359
  });
32447
33360
  server.registerTool("gog_gmail_attachment", {
32448
- description: `Download a Gmail attachment and deliver its contents so you can actually read them. The real filename and MIME type are resolved from the message part metadata, so the saved file and response are named correctly (e.g. Guest_Copy.pdf), never a generic *.bin. deliver="auto" (default) is transport-aware: images always come back as a native image block; anything else is delivered by the channel that works on your transport \u2014 a readable server-side file PATH on local (stdio) clients that share the filesystem, or a Google Drive link on the remote connector (whose backend filesystem you can't read, and which rejects inline PDF/binary blobs). deliver="inline" forces the bytes inline as an image or embedded resource blob (use only if your client consumes resource blobs; errors if over gog's 3 MiB cap). deliver="drive" always uploads to Drive; deliver="off" writes the file server-side and returns {path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).`,
33361
+ description: `Download a Gmail attachment and deliver its contents so you can actually read them. Identify the attachment by attachmentIndex (preferred: the 0-based position from a listing fetched with useIndexedAttachmentIds \u2014 stable, and it resolves the real name before the download) or by the legacy opaque attachmentId. The real filename and MIME type are resolved from the message part metadata, so the saved file and response are named correctly (e.g. Guest_Copy.pdf), never a generic *.bin. deliver="auto" (default) is transport-aware: images always come back as a native image block; anything else is delivered by the channel that works on your transport \u2014 a readable server-side file PATH on local (stdio) clients that share the filesystem, or a Google Drive link on the remote connector (whose backend filesystem you can't read, and which rejects inline PDF/binary blobs). deliver="inline" forces the bytes inline as an image or embedded resource blob (use only if your client consumes resource blobs; errors if over gog's 3 MiB cap). deliver="drive" always uploads to Drive; deliver="off" writes the file server-side and returns {path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).`,
32449
33362
  inputSchema: {
32450
33363
  messageId: external_exports.string().describe("Gmail message ID"),
32451
- attachmentId: external_exports.string().describe("Attachment ID (from the message payload)"),
33364
+ attachmentId: external_exports.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."),
33365
+ attachmentIndex: external_exports.number().int().nonnegative().optional().describe("The attachment's 0-based position in its message \u2014 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 \u2014 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."),
33366
+ inlineMaxBytes: external_exports.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."),
32452
33367
  deliver: external_exports.enum(["auto", "inline", "drive", "off"]).optional().describe("How to return the contents: auto (image inline; else a local file path or a Drive link, per transport), inline (force bytes as image/resource blob), drive (always a Drive link), or off (server-side download only). Default: auto."),
32453
33368
  out: external_exports.string().optional().describe("Server-side path where gog writes the file. NOTE: this resolves on the CONNECTOR/gog server's filesystem, not your machine \u2014 on the remote connector it is ignored (you can't read it; you get a Drive link instead). Locally it is honored. Omit it to use an ephemeral temp path."),
32454
33369
  name: external_exports.string().optional().describe("Filename override. Defaults to the attachment's real filename from the message metadata; pass this to skip that lookup or force a name."),
32455
33370
  driveFolder: external_exports.string().optional().describe("Destination Google Drive folder ID for the uploaded copy (drive/auto delivery on the remote connector, or oversized attachments)."),
32456
33371
  account: accountParam
32457
33372
  }
32458
- }, async ({ messageId, attachmentId, deliver = "auto", out, name, driveFolder, account }) => {
33373
+ }, async ({ messageId, attachmentId, attachmentIndex, deliver = "auto", out, name, inlineMaxBytes, driveFolder, account }) => {
33374
+ if (attachmentId === void 0 === (attachmentIndex === void 0)) {
33375
+ return errorResult(
33376
+ "Pass exactly one of attachmentId or attachmentIndex. Prefer attachmentIndex \u2014 the 0-based `attachmentIndex` from a listing fetched with useIndexedAttachmentIds \u2014 because Gmail's opaque attachmentId is not stable across API calls and a copied one may no longer resolve."
33377
+ );
33378
+ }
33379
+ const indexed = attachmentIndex !== void 0;
33380
+ const attachmentRef = indexed ? String(attachmentIndex) : attachmentId;
32459
33381
  const remote = runExecutor.getStore() !== void 0;
32460
33382
  try {
32461
33383
  let filename = name ? sanitizeFilename(name) : void 0;
32462
33384
  let mimeType = filename ? MIME_BY_EXT[extOf(filename)] : void 0;
33385
+ if (indexed && !filename) {
33386
+ const meta3 = await resolveByIndex(messageId, attachmentIndex, account);
33387
+ if (meta3?.filename) filename = sanitizeFilename(meta3.filename);
33388
+ if (meta3?.mimeType) mimeType = meta3.mimeType;
33389
+ }
32463
33390
  const notes = [];
32464
33391
  let outPath = out;
32465
33392
  if (out && remote) {
@@ -32474,12 +33401,16 @@ function registerExtraGmailTools(server) {
32474
33401
  if (!mimeType && (deliver === "auto" || deliver === "inline")) {
32475
33402
  needInline = true;
32476
33403
  }
32477
- const args = ["gmail", "attachment", messageId, attachmentId];
33404
+ const args = ["gmail", "attachment", messageId, attachmentRef];
33405
+ args.push(indexed ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
32478
33406
  if (needInline) args.push("--inline");
33407
+ args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
32479
33408
  args.push(`--out=${outPath}`, `--name=${filename ?? "attachment"}`);
32480
33409
  const info = JSON.parse(await run(args, { account }));
32481
33410
  const path = info.path ?? outPath;
32482
- if (!filename) {
33411
+ if (!filename && info.filename) filename = sanitizeFilename(info.filename);
33412
+ if (!mimeType && info.mimeType) mimeType = info.mimeType;
33413
+ if (!filename && !indexed) {
32483
33414
  const meta3 = await resolveBySize(messageId, info.bytes, account);
32484
33415
  if (meta3?.filename) filename = sanitizeFilename(meta3.filename);
32485
33416
  if (!mimeType && meta3?.mimeType) mimeType = meta3.mimeType;
@@ -32503,7 +33434,7 @@ function registerExtraGmailTools(server) {
32503
33434
  return withNote(isImage ? inlineImageResult(summary, info.contentBase64, mimeType) : inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
32504
33435
  }
32505
33436
  return errorResult(
32506
- `Attachment is too large to return inline (${info.reason ?? "exceeds gog's 3 MiB inline limit"}). Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.`
33437
+ `Attachment is too large to return inline (${info.reason ?? "exceeds gog's inline size limit, 3 MiB by default \u2014 raise inlineMaxBytes"}). Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.`
32507
33438
  );
32508
33439
  }
32509
33440
  if (isImage && info.contentBase64) {
@@ -32645,15 +33576,17 @@ function registerExtraGmailTools(server) {
32645
33576
  sanitizeContent: external_exports.boolean().optional().describe("Strip HTML, remove URLs, omit raw payloads from JSON (largest payload-size reduction)"),
32646
33577
  latestN: external_exports.number().int().positive().optional().describe("Return only the most recent N messages in the thread (wrapper-side trim; avoids overflowing context on long threads)"),
32647
33578
  snippetsOnly: external_exports.boolean().optional().describe("Reduce each message to its id, labels, snippet, and key headers (From/To/Cc/Subject/Date), dropping full bodies"),
33579
+ useIndexedAttachmentIds: external_exports.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."),
32648
33580
  outDir: external_exports.string().optional().describe("Directory to write attachments to (default: current directory)"),
32649
33581
  account: accountParam
32650
33582
  }
32651
- }, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, outDir, account }) => {
33583
+ }, async ({ threadId, download, full, sanitizeContent, latestN, snippetsOnly, useIndexedAttachmentIds, outDir, account }) => {
32652
33584
  const args = ["gmail", "thread", "get", threadId];
32653
33585
  if (download) args.push("--download");
32654
33586
  if (full) args.push("--full");
32655
33587
  if (sanitizeContent) args.push("--sanitize-content");
32656
33588
  if (outDir) args.push(`--out-dir=${outDir}`);
33589
+ args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
32657
33590
  const result = await runOrDiagnose(args, { account });
32658
33591
  if (latestN === void 0 && !snippetsOnly) return result;
32659
33592
  return trimThread(result, latestN, snippetsOnly);
@@ -32679,13 +33612,15 @@ function registerExtraGmailTools(server) {
32679
33612
  inputSchema: {
32680
33613
  threadId: external_exports.string().describe("Gmail thread ID"),
32681
33614
  download: external_exports.boolean().optional().describe("Download all attachments to the SERVER filesystem (see the note above; on the remote connector the files aren't reachable \u2014 fetch individually with gog_gmail_attachment instead)."),
33615
+ useIndexedAttachmentIds: external_exports.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 \u2014 so pair each row's `messageId` with its own `attachmentIndex`; a row's position in the flat array is NOT the index."),
32682
33616
  outDir: external_exports.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."),
32683
33617
  account: accountParam
32684
33618
  }
32685
- }, async ({ threadId, download, outDir, account }) => {
33619
+ }, async ({ threadId, download, useIndexedAttachmentIds, outDir, account }) => {
32686
33620
  const args = ["gmail", "thread", "attachments", threadId];
32687
33621
  if (download) args.push("--download");
32688
33622
  if (outDir) args.push(`--out-dir=${outDir}`);
33623
+ args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
32689
33624
  return runOrDiagnose(args, { account });
32690
33625
  });
32691
33626
  server.registerTool("gog_gmail_labels_list", {
@@ -32753,20 +33688,137 @@ function registerExtraGmailTools(server) {
32753
33688
  return runOrDiagnose(args, { account });
32754
33689
  });
32755
33690
  server.registerTool("gog_gmail_drafts_list", {
32756
- description: "List Gmail drafts.",
33691
+ description: 'List Gmail drafts. Each entry is annotated FOR FREE \u2014 no extra gog invocation, whatever the number of drafts \u2014 with `origin` (`api` = created through the Gmail API; `non-api` = the id begins `s:`, i.e. it arrived over IMAP/sync) and `rootsOwnThread` (threadId equals the draft\'s own messageId, so sending it starts a NEW conversation instead of replying). Read those two as facts about the draft, NOT as a fork verdict: `non-api` is not "Apple Mail" (Thunderbird, Outlook-over-IMAP and Gmail offline all produce `s:` ids), and rootsOwnThread was a 4/8 = 0.50 coin flip for Apple authorship on a live mailbox. The prose behind rootsOwnThread is one of exactly two constants, so it rides along ONCE per result under `threadingNotes` (`rootsOwnThread` / `inThread`) and the per-row boolean selects between them. To decide whether one draft actually replaced another, diff the named pair with gog_gmail_drafts_diff.',
32757
33692
  annotations: { readOnlyHint: true },
32758
33693
  inputSchema: {
32759
33694
  max: external_exports.number().optional().describe("Max results (default: 20)"),
32760
33695
  page: external_exports.string().optional().describe("Page token"),
32761
33696
  all: external_exports.boolean().optional().describe("Fetch all pages"),
33697
+ enrich: external_exports.boolean().optional().describe(
33698
+ "Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) regardless of how many drafts there are \u2014 but that single command makes gog fetch every matching draft server-side at concurrency 10, so Google reads and wall-clock are linear in the result count even though gog spawns are not. Narrow `max` before enabling it. If the search fails the listing silently degrades to the free fields rather than erroring."
33699
+ ),
32762
33700
  account: accountParam
32763
33701
  }
32764
- }, async ({ max, page, all, account }) => {
33702
+ }, async ({ max, page, all, enrich, account }) => {
32765
33703
  const args = ["gmail", "drafts", "list"];
32766
33704
  if (max !== void 0) args.push(`--max=${max}`);
32767
33705
  if (page) args.push(`--page=${page}`);
32768
33706
  if (all) args.push("--all");
32769
- return runOrDiagnose(args, { account });
33707
+ const result = await runOrDiagnose(args, { account });
33708
+ let parsed;
33709
+ let entries;
33710
+ try {
33711
+ parsed = JSON.parse(resultText(result) ?? "");
33712
+ const rawDrafts = parsed.drafts;
33713
+ if (!Array.isArray(rawDrafts)) return result;
33714
+ entries = rawDrafts;
33715
+ } catch {
33716
+ return result;
33717
+ }
33718
+ const byMessageId = /* @__PURE__ */ new Map();
33719
+ let enrichment;
33720
+ if (enrich) {
33721
+ const searchArgs = ["gmail", "messages", "search", "in:drafts", `--max=${max ?? GOG_DRAFTS_LIST_DEFAULT_MAX}`];
33722
+ if (all) searchArgs.push("--all");
33723
+ if (page) searchArgs.push(`--page=${page}`);
33724
+ searchArgs.push("--include-attachments=false", "--use-indexed-attachment-ids=false");
33725
+ try {
33726
+ const messages = JSON.parse(await runNormalized(searchArgs, { account })).messages;
33727
+ if (!Array.isArray(messages)) throw new Error("`gog gmail messages search in:drafts` returned no messages array");
33728
+ for (const m of messages) {
33729
+ if (m.id) byMessageId.set(m.id, m);
33730
+ }
33731
+ enrichment = { requested: true, applied: true, extraGogCalls: 1, matched: 0, unmatched: 0, costNote: DRAFT_ENRICH_COST_NOTE };
33732
+ } catch (err) {
33733
+ enrichment = {
33734
+ requested: true,
33735
+ applied: false,
33736
+ extraGogCalls: 1,
33737
+ reason: `Enrichment failed, so the listing degraded to the free tier-0 fields (origin, rootsOwnThread): ${String(err)}`
33738
+ };
33739
+ }
33740
+ }
33741
+ let matched = 0;
33742
+ const drafts = entries.map((d) => {
33743
+ const roots = rootsOwnThread(d);
33744
+ const extra = byMessageId.get(d.messageId);
33745
+ if (extra) matched += 1;
33746
+ return {
33747
+ ...d,
33748
+ origin: originFromDraftId(d.id ?? ""),
33749
+ rootsOwnThread: roots,
33750
+ ...extra ? { subject: extra.subject, from: extra.from, internalDateIso: extra.internalDateIso } : {}
33751
+ };
33752
+ });
33753
+ if (enrichment?.applied === true) {
33754
+ enrichment.matched = matched;
33755
+ enrichment.unmatched = entries.length - matched;
33756
+ }
33757
+ return rawTextResult(JSON.stringify({
33758
+ ...parsed,
33759
+ drafts,
33760
+ originNote: DRAFT_LIST_ORIGIN_NOTE,
33761
+ threadingNotes: { rootsOwnThread: DRAFT_ROOTS_OWN_THREAD_NOTE, inThread: DRAFT_IN_THREAD_NOTE },
33762
+ ...enrichment ? { enrichment } : {}
33763
+ }));
33764
+ });
33765
+ server.registerTool("gog_gmail_drafts_diff", {
33766
+ description: "Compare TWO NAMED DRAFTS and report exactly how they diverged: which body lines exist only in one, whether either is a superset of the other, how their threading differs, and \u2014 kept deliberately separate from all of that \u2014 whether there is enough evidence to say one REPLACED the other. Use it when a draft you created stopped resolving (`gog_gmail_drafts_update` returning `Google API error (404 notFound)` is the usual first symptom) and a newer draft has appeared: editing a draft in a real mail client does not update it in place, it writes a new draft and abandons the original, so both copies can hold text the other lost. COST: exactly 2 gog invocations, one `gmail drafts get` per named draft. It never scans the mailbox and never grows with the number of drafts. THE PAIRING VERDICT IS `confirmed` ONLY when all four of these hold: an Apple identity header on the candidate, a real LINEAGE link FROM THE CANDIDATE TO THE ORIGINAL, a strictly newer candidate, and the same From. Exactly two things count as lineage, and both point at the original itself: (a) the ORIGINAL DRAFT's own Message-Id appearing in the candidate's In-Reply-To/References, or (b) agreement on text NEITHER draft quoted AND NEITHER CLIENT GENERATED \u2014 the salutation, the closing formula, the name under it and the signature block are excluded alongside quoting, because a mail client reproduces all of them identically on every message it composes \u2014 meeting all three printed minimums (similarity, shared lines, shared characters \u2014 all reported under bodyAgreement, alongside quotedLinesIgnored and boilerplateLinesIgnored so you can see what each filter removed). A SHARED REPLY ROOT IS NOT LINEAGE: it links both drafts to a common ANCESTOR, which every reply in a thread has, so it is reported as corroboration and can raise the answer no higher than an explicitly WEAK `candidate`. Anything less than all four is `candidate` and names every missing signal; with neither lineage nor corroboration it is `none` \u2014 which means no evidence was FOUND, not that the drafts are proven unrelated (the comparison is line-based, so re-wrapping and smart quotes can hide a real link). NONE OF THE FOLLOWING EVER SUFFICES, alone or combined: " + FORK_SIGNALS_THAT_NEVER_SUFFICE.join(" ") + " Act on `confirmed` only after reading the evidence list; treat `candidate` as a question to verify by hand. Merging the wrong pair sends the wrong text to the wrong thread, in front of everyone on Cc.",
33767
+ annotations: { readOnlyHint: true },
33768
+ inputSchema: {
33769
+ draftIdA: external_exports.string().describe("First draft id \u2014 conventionally the ORIGINAL (the one you created). Direction is decided by internalDate, not by this order, and the answer says which it treated as the original."),
33770
+ draftIdB: external_exports.string().describe("Second draft id \u2014 conventionally the SUSPECTED REPLACEMENT."),
33771
+ maxDiffLines: external_exports.number().int().positive().optional().describe(`Cap on the per-side line lists (default ${DRAFT_DIFF_MAX_LINES}); must be a positive integer. The counts and the verdict are computed on the FULL bodies; only the printed lists are capped, \`truncated\` says when they were, and onlyInACount/onlyInBCount give the untruncated totals.`),
33772
+ account: accountParam
33773
+ }
33774
+ }, async ({ draftIdA, draftIdB, maxDiffLines, account }) => {
33775
+ const fetchArgs = (id) => ["gmail", "drafts", "get", id, "--use-indexed-attachment-ids=false"];
33776
+ let rawA;
33777
+ let rawB;
33778
+ try {
33779
+ rawA = await runNormalized(fetchArgs(draftIdA), { account });
33780
+ rawB = await runNormalized(fetchArgs(draftIdB), { account });
33781
+ } catch (err) {
33782
+ return diagnose(new Error(
33783
+ `gog_gmail_drafts_diff could not fetch both drafts (${draftIdA}, ${draftIdB}): ${String(err)}. A draft id that has stopped resolving is exactly what a mail client leaves behind when it rewrites a draft instead of updating it \u2014 the old id 404s and a new draft holds the edited text. Run gog_gmail_drafts_list (origin and rootsOwnThread are free there), pick the surviving id, and diff it against the one that still resolves.`
33784
+ ));
33785
+ }
33786
+ const parseDraft = (raw) => {
33787
+ try {
33788
+ return JSON.parse(raw).draft?.message;
33789
+ } catch {
33790
+ return void 0;
33791
+ }
33792
+ };
33793
+ const msgA = parseDraft(rawA);
33794
+ const msgB = parseDraft(rawB);
33795
+ if (!msgA || !msgB) {
33796
+ return errorResult(
33797
+ `Could not read the stored message for draft ${msgA ? draftIdB : draftIdA} \u2014 \`gog gmail drafts get\` returned no \`draft.message\` object. Nothing is reported rather than diffing half a pair and letting the missing side read as "empty".`
33798
+ );
33799
+ }
33800
+ const a = describeDraftSide(draftIdA, msgA);
33801
+ const b = describeDraftSide(draftIdB, msgB);
33802
+ const aMs = parseInternalDateMs(msgA.internalDate);
33803
+ const bMs = parseInternalDateMs(msgB.internalDate);
33804
+ const bIsCandidate = !(aMs !== void 0 && bMs !== void 0 && bMs < aMs);
33805
+ const original = bIsCandidate ? a : b;
33806
+ const candidate = bIsCandidate ? b : a;
33807
+ return textResult({
33808
+ drafts: { a: a.side, b: b.side },
33809
+ bodyDiff: diffBodyLines(
33810
+ bestBodyText(msgA.payload),
33811
+ bestBodyText(msgB.payload),
33812
+ maxDiffLines ?? DRAFT_DIFF_MAX_LINES
33813
+ ),
33814
+ threadingDifferences: threadingDifferences(a.side, b.side),
33815
+ forkPairing: {
33816
+ originalDraftId: original.side.draftId,
33817
+ candidateDraftId: candidate.side.draftId,
33818
+ ...evaluateForkPairing(original.facts, candidate.facts, 2)
33819
+ },
33820
+ costNote: "This call made exactly 2 gog invocations, one `gmail drafts get` per named draft, and is capped there by construction."
33821
+ });
32770
33822
  });
32771
33823
  server.registerTool("gog_gmail_drafts_get", {
32772
33824
  description: "Get a Gmail draft by ID.",
@@ -32774,11 +33826,13 @@ function registerExtraGmailTools(server) {
32774
33826
  inputSchema: {
32775
33827
  draftId: external_exports.string().describe("Draft ID"),
32776
33828
  download: external_exports.boolean().optional().describe("Download draft attachments"),
33829
+ useIndexedAttachmentIds: external_exports.boolean().optional().describe("Report each attachment as a 0-based `attachmentIndex` instead of an opaque `attachmentId` (stable across calls, unlike the id)."),
32777
33830
  account: accountParam
32778
33831
  }
32779
- }, async ({ draftId, download, account }) => {
33832
+ }, async ({ draftId, download, useIndexedAttachmentIds, account }) => {
32780
33833
  const args = ["gmail", "drafts", "get", draftId];
32781
33834
  if (download) args.push("--download");
33835
+ args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
32782
33836
  return runOrDiagnose(args, { account });
32783
33837
  });
32784
33838
  const draftWriteSchema = {
@@ -32788,7 +33842,7 @@ function registerExtraGmailTools(server) {
32788
33842
  subject: external_exports.string().describe("Subject"),
32789
33843
  body: external_exports.string().describe("Body (plain text). Any size \u2014 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."),
32790
33844
  bodyHtml: external_exports.string().optional().describe("Body (HTML; optional). Pass the HTML itself at any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile."),
32791
- bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
33845
+ bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.`),
32792
33846
  replyToMessageId: external_exports.string().optional().describe("Reply to a specific Gmail MESSAGE id \u2014 the short hex `id` field from gog_gmail_get / _search / _thread_get (e.g. 19e7593d77fd9636), NOT a thread id and NOT the RFC822 `<\u2026@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."),
32793
33847
  replyToThreadId: external_exports.string().optional().describe(`Reply to a Gmail THREAD id \u2014 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 \u2014 use this param, not replyToMessageId, when the id came from a thread.`),
32794
33848
  replyTo: external_exports.string().optional().describe("Reply-To header address"),
@@ -32796,6 +33850,7 @@ function registerExtraGmailTools(server) {
32796
33850
  replyAll: external_exports.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."),
32797
33851
  attach: external_exports.array(external_exports.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 \u2014 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)."),
32798
33852
  from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
33853
+ autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
32799
33854
  omitRecipients: external_exports.boolean().optional().describe("Create the draft with no recipients even if to/cc/bcc are supplied \u2014 an accidental-send guard. Populate recipients in a later update before sending."),
32800
33855
  returnFull: external_exports.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."),
32801
33856
  account: accountParam
@@ -32818,19 +33873,42 @@ function registerExtraGmailTools(server) {
32818
33873
  if (f.quote) args.push("--quote");
32819
33874
  if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
32820
33875
  if (f.from) args.push(`--from=${f.from}`);
33876
+ args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
33877
+ }
33878
+ function withRefetchNote(written, draftId) {
33879
+ return {
33880
+ ...written,
33881
+ content: [
33882
+ ...written.content,
33883
+ {
33884
+ type: "text",
33885
+ text: `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up read-back requested by returnFull could not be performed \u2014 the id did not resolve, which on this mailbox usually means the draft was forked by a mail client between the write and the read. Nothing was lost. Run gog_gmail_drafts_list to find the current id, or gog_gmail_drafts_get on ${draftId} to confirm.`
33886
+ }
33887
+ ]
33888
+ };
32821
33889
  }
32822
- async function writeDraft(args, account, returnFull, knownDraftId) {
33890
+ async function writeDraft(args, account, returnFull, knownDraftId, intent) {
32823
33891
  const result = await runOrDiagnose(args, { account });
32824
- if (!returnFull) return result;
32825
- let parsed;
33892
+ let ack;
33893
+ let ackDraftId;
32826
33894
  try {
32827
- parsed = JSON.parse(resultText(result) ?? "");
33895
+ ack = JSON.parse(resultText(result) ?? "");
33896
+ ackDraftId = typeof ack.draftId === "string" ? ack.draftId : void 0;
32828
33897
  } catch {
32829
33898
  return result;
32830
33899
  }
32831
- const draftId = knownDraftId ?? parsed.draftId;
32832
- if (!draftId) return result;
32833
- return runOrDiagnose(["gmail", "drafts", "get", draftId], { account });
33900
+ const verification = intent ? verifyThreading(intent, ack) : void 0;
33901
+ let final = result;
33902
+ if (returnFull) {
33903
+ const draftId = knownDraftId ?? ackDraftId;
33904
+ if (draftId) {
33905
+ const refetched = await runOrDiagnose(["gmail", "drafts", "get", draftId, "--use-indexed-attachment-ids=false"], { account });
33906
+ if (refetched.isError !== true) final = refetched;
33907
+ else final = withRefetchNote(result, draftId);
33908
+ }
33909
+ }
33910
+ if (!verification) return final;
33911
+ return withThreadingVerification(final, verification);
32834
33912
  }
32835
33913
  server.registerTool("gog_gmail_drafts_create", {
32836
33914
  description: "Create a new Gmail draft. Recipients (to/cc/bcc) are optional; omit them (or set omitRecipients) to create a recipient-less draft as an accidental-send guard. For replies, prefer replyToThreadId (anchors to the thread's latest message) or replyToMessageId (a specific message) \u2014 don't pass a thread id into replyToMessageId, which mis-threads silently.",
@@ -32841,20 +33919,51 @@ function registerExtraGmailTools(server) {
32841
33919
  return writeDraft(args, account, returnFull);
32842
33920
  });
32843
33921
  server.registerTool("gog_gmail_drafts_update", {
32844
- description: "Update an existing Gmail draft. For replies, prefer replyToThreadId (threads off the thread's latest message) or replyToMessageId (a specific message) over passing a thread id into replyToMessageId. An update preserves the draft's existing reply context (In-Reply-To/References) and its threadId; it never invents reply headers for a draft that is not a reply. The result reports the effective inReplyTo/references so you can verify threading without a raw-header fetch. Attachment semantics: supplying attach REPLACES the draft's existing attachments; omitting it preserves them; set clearAttachments to remove all.",
33922
+ description: "Update an existing Gmail draft. For replies, prefer replyToThreadId (threads off the thread's latest message) or replyToMessageId (a specific message) over passing a thread id into replyToMessageId. An update preserves the draft's existing reply context (In-Reply-To/References) and its threadId; it never invents reply headers for a draft that is not a reply. Attachment semantics: supplying attach REPLACES the draft's existing attachments; omitting it preserves them; set clearAttachments to remove all. REPAIRING THREADING IN PLACE: passing replyToThreadId re-anchors the draft onto that thread and lets gog resolve In-Reply-To/References from the thread's latest sent-or-received message, KEEPING THE SAME DRAFT ID \u2014 so a draft that lost its reply headers (typically one a mail client rewrote from scratch) is adopted back onto the conversation in a single call. Whenever you change reply context (replyToThreadId, replyToMessageId or clearReplyContext) the result gains a `threadingVerification` block reporting the effective threadId/inReplyTo/references/replyContextSource, an `ok` flag and a plain-English note, so you can confirm the repair WITHOUT a raw-header fetch and without a second call. Read it: an explicit reply target REPLACES the draft's stored lineage rather than merging with it, and if the target thread yields no reply headers the draft is still MOVED onto that thread \u2014 it would arrive inside the conversation but not as a reply. THE BODY IS ALWAYS OVERWRITTEN: gog requires a body on every update, so there is no header-only edit. If a sibling copy of this draft exists, diff them with gog_gmail_drafts_diff and merge BEFORE updating, or whatever text you do not pass is lost. A 404 comes back diagnosed rather than as a bare notFound, and the diagnosis is checked against a draft listing first: GOOGLE_404_NOT_THE_DRAFT when the draft is still listed \u2014 because replyToThreadId and replyToMessageId resolve their own Google entities and a miss on either 404s with the identical message \u2014 and DRAFT_FORKED otherwise. That listing is capped at 20 drafts, so DRAFT_FORKED reports under `listingEvidence` whether its own evidence actually covers the mailbox: only `complete-listing` (the window came back short of 20, so it saw the whole Drafts folder) says the draft is gone. `capped-listing` and `listing-unavailable` say in words that they establish nothing about the draft, and any reply target you passed is echoed there with its explanation listed first.",
32845
33923
  annotations: { destructiveHint: true },
32846
33924
  inputSchema: {
32847
33925
  draftId: external_exports.string().describe("Draft ID"),
32848
33926
  ...draftWriteSchema,
32849
33927
  clearAttachments: external_exports.boolean().optional().describe("Remove all attachments from the draft. By default, omitting attach preserves the draft's existing attachments; this intentionally clears them. Ignored if attach is also supplied (attach replaces)."),
32850
- clearReplyContext: external_exports.boolean().optional().describe("Strip In-Reply-To/References from the draft, turning a reply back into a standalone message while keeping the same draft id and threadId. Use this to repair a mis-threaded draft in place instead of deleting and recreating it. Mutually exclusive with replyToMessageId, replyToThreadId and quote \u2014 gog rejects the call if any of them is combined with this.")
33928
+ clearReplyContext: external_exports.boolean().optional().describe("Strip In-Reply-To/References from the draft, turning a reply back into a standalone message while keeping the same draft id and threadId. Use this to repair a mis-threaded draft in place instead of deleting and recreating it. Mutually exclusive with replyToMessageId, replyToThreadId and quote \u2014 gog rejects the call if any of them is combined with this."),
33929
+ forkSiblingDraftId: external_exports.string().optional().describe("Id of the OTHER copy of this draft \u2014 the one a mail client left behind, or the one you are merging from. Because gog requires a body on every update, this call rewrites the WHOLE body; naming a sibling makes the tool read that draft FIRST (one extra gog call, on this id only \u2014 it never scans) and refuse to write if your body omits any line the sibling still holds, naming the exact lines. Set acceptContentLoss to write anyway. Omit this param and nothing extra is spent. It is purely a text comparison and makes NO claim that either draft replaced the other \u2014 for that verdict use gog_gmail_drafts_diff."),
33930
+ acceptContentLoss: external_exports.boolean().optional().describe("Write even though the forkSiblingDraftId check found lines your body drops \u2014 or could not be run at all (sibling unfetchable/unreadable). Without it either outcome refuses the write and changes nothing. The lines are still reported on the result under contentLossCheck. Ignored when forkSiblingDraftId is not set.")
33931
+ }
33932
+ }, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, forkSiblingDraftId, acceptContentLoss, ...flags }) => {
33933
+ let check2;
33934
+ let overridden = false;
33935
+ if (forkSiblingDraftId) {
33936
+ check2 = await checkSiblingContentLoss(forkSiblingDraftId, flags.body, account);
33937
+ if (check2.status !== "clean" && !acceptContentLoss) return contentLossRefusal(draftId, check2);
33938
+ overridden = check2.status !== "clean";
32851
33939
  }
32852
- }, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, ...flags }) => {
32853
33940
  const args = ["gmail", "drafts", "update", draftId];
32854
33941
  appendDraftFlags(args, flags);
32855
33942
  if (clearAttachments) args.push("--clear-attachments");
32856
33943
  if (clearReplyContext) args.push("--clear-reply-context");
32857
- return writeDraft(args, account, returnFull, draftId);
33944
+ const intent = threadingIntentOf({ ...flags, clearReplyContext });
33945
+ const result = await writeDraft(args, account, returnFull, draftId, intent);
33946
+ const reported = await forkAwareDraftFailure(
33947
+ result,
33948
+ "gog_gmail_drafts_update",
33949
+ draftId,
33950
+ account,
33951
+ intent?.requested === "set" ? { via: intent.via, target: intent.target } : void 0
33952
+ );
33953
+ if (check2 !== void 0 && overridden) {
33954
+ check2 = reported.isError === true ? {
33955
+ ...check2,
33956
+ acknowledged: true,
33957
+ written: false,
33958
+ note: `${check2.note} acceptContentLoss was set, so the write was ATTEMPTED \u2014 but it FAILED and NOTHING WAS SAVED. Draft ${draftId} is unchanged and the lines listed above still exist in draft ${check2.siblingDraftId}; nothing was lost by this call. Read the error above before retrying.`
33959
+ } : {
33960
+ ...check2,
33961
+ acknowledged: true,
33962
+ written: true,
33963
+ note: `${check2.note} acceptContentLoss was set, so the update WAS written despite this: draft ${draftId} now holds only the body you passed, and the lines listed above exist only in draft ${check2.siblingDraftId}.`
33964
+ };
33965
+ }
33966
+ return check2 ? withContentLossCheck(reported, check2) : reported;
32858
33967
  });
32859
33968
  server.registerTool("gog_gmail_drafts_delete", {
32860
33969
  description: "Permanently delete a Gmail draft (not reversible \u2014 drafts do not go to Trash). Requires force:true to delete non-interactively.",
@@ -32870,14 +33979,33 @@ function registerExtraGmailTools(server) {
32870
33979
  return runOrDiagnose(args, { account });
32871
33980
  });
32872
33981
  server.registerTool("gog_gmail_drafts_send", {
32873
- description: "Send an existing Gmail draft.",
33982
+ description: "Send an existing Gmail draft. If the id no longer resolves, the 404 comes back as a DRAFT_FORKED report \u2014 what happened, the drafts that do exist (with their free origin/rootsOwnThread fields) and what to do next \u2014 rather than a bare notFound. It names no replacement: that judgement needs a named pair and gog_gmail_drafts_diff. If the draft turns out to be still listed, the answer is GOOGLE_404_NOT_THE_DRAFT instead and claims no fork at all.",
32874
33983
  annotations: { destructiveHint: true },
32875
33984
  inputSchema: {
32876
33985
  draftId: external_exports.string().describe("Draft ID to send"),
32877
33986
  account: accountParam
32878
33987
  }
32879
33988
  }, async ({ draftId, account }) => {
32880
- return runOrDiagnose(["gmail", "drafts", "send", draftId], { account });
33989
+ const result = await runOrDiagnose(["gmail", "drafts", "send", draftId], { account });
33990
+ return forkAwareDraftFailure(result, "gog_gmail_drafts_send", draftId, account);
33991
+ });
33992
+ server.registerTool("gog_gmail_import", {
33993
+ description: "Import an existing RFC822/EML message INTO the mailbox. This is Gmail's import path, not a send: nothing leaves the account, and the message keeps its own From/Date/Message-Id headers so it files where it belongs chronologically. Use it to restore an exported message or to file a .eml under a label; to actually send mail use gog_gmail_send, and to stage one use gog_gmail_drafts_create. The file is read on the gog SERVER, not your machine.",
33994
+ inputSchema: {
33995
+ file: external_exports.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.`),
33996
+ labels: external_exports.array(external_exports.string()).optional().describe(`Labels to apply to the imported message (repeatable). Each may be a label ID or a label name \u2014 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 \u2014 ids never contain a comma; gog_gmail_labels_list gives you one.`),
33997
+ internalDateSource: external_exports.enum(["dateHeader", "receivedTime"]).optional().describe("Which clock sets Gmail's internal date: dateHeader (gog default \u2014 the message's own Date header, so it sorts into the mailbox at its original time) or receivedTime (now)."),
33998
+ neverMarkSpam: external_exports.boolean().optional().describe("Never classify the imported message as spam."),
33999
+ processForCalendar: external_exports.boolean().optional().describe("Process calendar invitations inside the imported message \u2014 this can ADD EVENTS to your calendar."),
34000
+ account: accountParam
34001
+ }
34002
+ }, async ({ file: file2, labels, internalDateSource, neverMarkSpam, processForCalendar, account }) => {
34003
+ const args = ["gmail", "import", file2];
34004
+ if (labels) for (const label2 of labels) args.push(`--label=${label2}`);
34005
+ if (internalDateSource) args.push(`--internal-date-source=${internalDateSource}`);
34006
+ if (neverMarkSpam) args.push("--never-mark-spam");
34007
+ if (processForCalendar) args.push("--process-for-calendar");
34008
+ return runOrDiagnose(args, { account });
32881
34009
  });
32882
34010
  server.registerTool("gog_gmail_forward", {
32883
34011
  description: "Forward an existing Gmail message to new recipients.",
@@ -32905,7 +34033,7 @@ function registerExtraGmailTools(server) {
32905
34033
  messageId: external_exports.string().describe("Gmail message ID to reply to \u2014 the short hex `id` from gog_gmail_get / _search / _messages_search (NOT the threadId, NOT the RFC822 `<\u2026@host>` Message-Id header)."),
32906
34034
  body: external_exports.string().optional().describe("Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size \u2014 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."),
32907
34035
  bodyHtml: external_exports.string().optional().describe("Reply body (HTML; optional). Pass the HTML itself at any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile."),
32908
- bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
34036
+ bodyHtmlFile: external_exports.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 \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.`),
32909
34037
  to: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message."),
32910
34038
  cc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Cc (repeatable)"),
32911
34039
  bcc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Bcc (repeatable)"),
@@ -32914,6 +34042,7 @@ function registerExtraGmailTools(server) {
32914
34042
  noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
32915
34043
  attach: external_exports.array(external_exports.string()).optional().describe("Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension."),
32916
34044
  from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
34045
+ autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
32917
34046
  signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
32918
34047
  signatureFrom: external_exports.string().optional().describe("Append the Gmail signature from this send-as email address"),
32919
34048
  signatureFile: external_exports.string().optional().describe("Append a local signature file (plain text or HTML), read on the gog server"),
@@ -32935,6 +34064,7 @@ function registerExtraGmailTools(server) {
32935
34064
  if (f.signature) args.push("--signature");
32936
34065
  if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
32937
34066
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
34067
+ args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
32938
34068
  }
32939
34069
  server.registerTool("gog_gmail_reply", {
32940
34070
  description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage a reply without sending use gog_gmail_drafts_create.',
@@ -32972,7 +34102,7 @@ function registerExtraGmailTools(server) {
32972
34102
  allowSelf: external_exports.boolean().optional().describe("Allow replying to messages sent by your own address"),
32973
34103
  account: accountParam
32974
34104
  }
32975
- }, async ({ query, max, subject, body, bodyHtml, from, replyTo, label, archive, markRead, skipBulk, allowSelf, account }) => {
34105
+ }, async ({ query, max, subject, body, bodyHtml, from, replyTo, label: label2, archive, markRead, skipBulk, allowSelf, account }) => {
32976
34106
  const args = ["gmail", "autoreply", query];
32977
34107
  if (max !== void 0) args.push(`--max=${max}`);
32978
34108
  if (subject) args.push(`--subject=${subject}`);
@@ -32980,7 +34110,7 @@ function registerExtraGmailTools(server) {
32980
34110
  if (bodyHtml) args.push(`--body-html=${bodyHtml}`);
32981
34111
  if (from) args.push(`--from=${from}`);
32982
34112
  if (replyTo) args.push(`--reply-to=${replyTo}`);
32983
- if (label) args.push(`--label=${label}`);
34113
+ if (label2) args.push(`--label=${label2}`);
32984
34114
  if (archive) args.push("--archive");
32985
34115
  if (markRead) args.push("--mark-read");
32986
34116
  if (skipBulk) args.push("--skip-bulk");
@@ -32998,9 +34128,11 @@ function registerExtraGmailTools(server) {
32998
34128
  includeBody: external_exports.boolean().optional().describe("Include the decoded message body in each result"),
32999
34129
  full: external_exports.boolean().optional().describe("Show full message bodies without truncation (implies includeBody)"),
33000
34130
  bodyFormat: external_exports.enum(["text", "html"]).optional().describe("Body format preference when includeBody is set"),
34131
+ includeAttachments: external_exports.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 \u2014 narrow the query or lower max before turning it on."),
34132
+ useIndexedAttachmentIds: external_exports.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."),
33001
34133
  account: accountParam
33002
34134
  }
33003
- }, async ({ query, max, page, all, includeBody, full, bodyFormat, account }) => {
34135
+ }, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
33004
34136
  const args = ["gmail", "messages", "search", query];
33005
34137
  if (max !== void 0) args.push(`--max=${max}`);
33006
34138
  if (page) args.push(`--page=${page}`);
@@ -33008,6 +34140,8 @@ function registerExtraGmailTools(server) {
33008
34140
  if (includeBody) args.push("--include-body");
33009
34141
  if (full) args.push("--full");
33010
34142
  if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
34143
+ args.push(includeAttachments ? "--include-attachments" : "--include-attachments=false");
34144
+ args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
33011
34145
  return runOrDiagnose(args, { account });
33012
34146
  });
33013
34147
  server.registerTool("gog_gmail_labels_style", {