gogcli-mcp-gmail 2.22.0 → 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) {
@@ -31896,7 +31896,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31896
31896
  );
31897
31897
 
31898
31898
  // ../gogcli-mcp/src/server.ts
31899
- var VERSION = true ? "2.22.0" : "0.0.0";
31899
+ var VERSION = true ? "2.23.0" : "0.0.0";
31900
31900
 
31901
31901
  // ../gogcli-mcp/src/auth-log.ts
31902
31902
  var FAILURES = /* @__PURE__ */ new Set([
@@ -32556,6 +32556,791 @@ async function deliverViaDrive(path, name, driveFolder, account) {
32556
32556
  webViewLink: file2.webViewLink
32557
32557
  });
32558
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
+ }
32559
33344
  function registerExtraGmailTools(server) {
32560
33345
  server.registerTool("gog_gmail_raw", {
32561
33346
  description: "Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).",
@@ -32903,20 +33688,137 @@ function registerExtraGmailTools(server) {
32903
33688
  return runOrDiagnose(args, { account });
32904
33689
  });
32905
33690
  server.registerTool("gog_gmail_drafts_list", {
32906
- 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.',
32907
33692
  annotations: { readOnlyHint: true },
32908
33693
  inputSchema: {
32909
33694
  max: external_exports.number().optional().describe("Max results (default: 20)"),
32910
33695
  page: external_exports.string().optional().describe("Page token"),
32911
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
+ ),
32912
33700
  account: accountParam
32913
33701
  }
32914
- }, async ({ max, page, all, account }) => {
33702
+ }, async ({ max, page, all, enrich, account }) => {
32915
33703
  const args = ["gmail", "drafts", "list"];
32916
33704
  if (max !== void 0) args.push(`--max=${max}`);
32917
33705
  if (page) args.push(`--page=${page}`);
32918
33706
  if (all) args.push("--all");
32919
- 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
+ });
32920
33822
  });
32921
33823
  server.registerTool("gog_gmail_drafts_get", {
32922
33824
  description: "Get a Gmail draft by ID.",
@@ -32973,18 +33875,40 @@ function registerExtraGmailTools(server) {
32973
33875
  if (f.from) args.push(`--from=${f.from}`);
32974
33876
  args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
32975
33877
  }
32976
- async function writeDraft(args, account, returnFull, knownDraftId) {
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
+ };
33889
+ }
33890
+ async function writeDraft(args, account, returnFull, knownDraftId, intent) {
32977
33891
  const result = await runOrDiagnose(args, { account });
32978
- if (!returnFull) return result;
32979
- let parsed;
33892
+ let ack;
33893
+ let ackDraftId;
32980
33894
  try {
32981
- parsed = JSON.parse(resultText(result) ?? "");
33895
+ ack = JSON.parse(resultText(result) ?? "");
33896
+ ackDraftId = typeof ack.draftId === "string" ? ack.draftId : void 0;
32982
33897
  } catch {
32983
33898
  return result;
32984
33899
  }
32985
- const draftId = knownDraftId ?? parsed.draftId;
32986
- if (!draftId) return result;
32987
- return runOrDiagnose(["gmail", "drafts", "get", draftId, "--use-indexed-attachment-ids=false"], { 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);
32988
33912
  }
32989
33913
  server.registerTool("gog_gmail_drafts_create", {
32990
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.",
@@ -32995,20 +33919,51 @@ function registerExtraGmailTools(server) {
32995
33919
  return writeDraft(args, account, returnFull);
32996
33920
  });
32997
33921
  server.registerTool("gog_gmail_drafts_update", {
32998
- 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.",
32999
33923
  annotations: { destructiveHint: true },
33000
33924
  inputSchema: {
33001
33925
  draftId: external_exports.string().describe("Draft ID"),
33002
33926
  ...draftWriteSchema,
33003
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)."),
33004
- 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";
33005
33939
  }
33006
- }, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, ...flags }) => {
33007
33940
  const args = ["gmail", "drafts", "update", draftId];
33008
33941
  appendDraftFlags(args, flags);
33009
33942
  if (clearAttachments) args.push("--clear-attachments");
33010
33943
  if (clearReplyContext) args.push("--clear-reply-context");
33011
- 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;
33012
33967
  });
33013
33968
  server.registerTool("gog_gmail_drafts_delete", {
33014
33969
  description: "Permanently delete a Gmail draft (not reversible \u2014 drafts do not go to Trash). Requires force:true to delete non-interactively.",
@@ -33024,14 +33979,15 @@ function registerExtraGmailTools(server) {
33024
33979
  return runOrDiagnose(args, { account });
33025
33980
  });
33026
33981
  server.registerTool("gog_gmail_drafts_send", {
33027
- 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.",
33028
33983
  annotations: { destructiveHint: true },
33029
33984
  inputSchema: {
33030
33985
  draftId: external_exports.string().describe("Draft ID to send"),
33031
33986
  account: accountParam
33032
33987
  }
33033
33988
  }, async ({ draftId, account }) => {
33034
- 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);
33035
33991
  });
33036
33992
  server.registerTool("gog_gmail_import", {
33037
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.",
@@ -33045,7 +34001,7 @@ function registerExtraGmailTools(server) {
33045
34001
  }
33046
34002
  }, async ({ file: file2, labels, internalDateSource, neverMarkSpam, processForCalendar, account }) => {
33047
34003
  const args = ["gmail", "import", file2];
33048
- if (labels) for (const label of labels) args.push(`--label=${label}`);
34004
+ if (labels) for (const label2 of labels) args.push(`--label=${label2}`);
33049
34005
  if (internalDateSource) args.push(`--internal-date-source=${internalDateSource}`);
33050
34006
  if (neverMarkSpam) args.push("--never-mark-spam");
33051
34007
  if (processForCalendar) args.push("--process-for-calendar");
@@ -33146,7 +34102,7 @@ function registerExtraGmailTools(server) {
33146
34102
  allowSelf: external_exports.boolean().optional().describe("Allow replying to messages sent by your own address"),
33147
34103
  account: accountParam
33148
34104
  }
33149
- }, 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 }) => {
33150
34106
  const args = ["gmail", "autoreply", query];
33151
34107
  if (max !== void 0) args.push(`--max=${max}`);
33152
34108
  if (subject) args.push(`--subject=${subject}`);
@@ -33154,7 +34110,7 @@ function registerExtraGmailTools(server) {
33154
34110
  if (bodyHtml) args.push(`--body-html=${bodyHtml}`);
33155
34111
  if (from) args.push(`--from=${from}`);
33156
34112
  if (replyTo) args.push(`--reply-to=${replyTo}`);
33157
- if (label) args.push(`--label=${label}`);
34113
+ if (label2) args.push(`--label=${label2}`);
33158
34114
  if (archive) args.push("--archive");
33159
34115
  if (markRead) args.push("--mark-read");
33160
34116
  if (skipBulk) args.push("--skip-bulk");