gogcli-mcp-gmail 2.22.0 → 2.23.1
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/README.md +91 -6
- package/SKILL.md +32 -4
- package/dist/index.js +989 -30
- package/manifest.json +8 -4
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +2114 -21
- package/tests/tools/draft-diff-arithmetic.test.ts +57 -0
- package/tests/tools/draft-fork-signature.test.ts +67 -0
- package/tests/tools/draft-fork.test.ts +995 -0
- package/tests/tools/draft-write-vs-refetch.test.ts +118 -0
- package/tests/tools/gmail-extra.test.ts +1295 -0
package/src/tools/gmail-extra.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { rawTextResult, textResult, errorResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor } from '../../../gogcli-mcp/src/lib.js';
|
|
5
|
+
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps } from '../../../gogcli-mcp/src/lib.js';
|
|
6
6
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
7
|
|
|
8
8
|
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
@@ -381,6 +381,1788 @@ async function deliverViaDrive(
|
|
|
381
381
|
});
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
// APPLE MAIL DRAFT FORKS — shared signal primitives and the pairing verdict.
|
|
386
|
+
//
|
|
387
|
+
// A draft created by gog_gmail_drafts_create and then edited in a real mail
|
|
388
|
+
// client is not updated in place: Apple Mail writes a NEW draft and abandons
|
|
389
|
+
// the original. The id changes (a later update 404s), the reply headers are
|
|
390
|
+
// usually gone (sending starts a NEW conversation in front of every Cc'd
|
|
391
|
+
// recipient), and the two bodies diverge with NEITHER being a superset.
|
|
392
|
+
//
|
|
393
|
+
// Everything below is PURE — no gog invocation, no I/O. Cost decisions live
|
|
394
|
+
// with the callers; these functions only turn already-fetched fields into
|
|
395
|
+
// signals, so the N+1 budget is decided once, at the call site.
|
|
396
|
+
//
|
|
397
|
+
// THE HAZARD THIS CODE EXISTS FOR: telling a caller "draft X replaced draft Y"
|
|
398
|
+
// when it did not causes two unrelated messages to be merged, in
|
|
399
|
+
// legal-adjacent co-parenting correspondence with a parenting coordinator on
|
|
400
|
+
// Cc. A missed fork costs a re-check; a WRONG fork sends the wrong text to the
|
|
401
|
+
// wrong thread. So the verdict is deliberately biased toward precision, and it
|
|
402
|
+
// returns the evidence as a list the caller can judge rather than a bare
|
|
403
|
+
// boolean.
|
|
404
|
+
// ===========================================================================
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `api` = created through the Gmail API (what this server does).
|
|
408
|
+
* `non-api` = arrived over IMAP/sync.
|
|
409
|
+
*
|
|
410
|
+
* NOT `apple-mail`. An `s:` prefix means "some IMAP/sync client wrote this" —
|
|
411
|
+
* Thunderbird, Outlook-over-IMAP and Gmail offline all produce it too.
|
|
412
|
+
* Upgrading `non-api` to `apple-mail` requires an actual Apple identity header
|
|
413
|
+
* (see appleIdentitySignals), which costs a per-draft header fetch.
|
|
414
|
+
*
|
|
415
|
+
* Draft ids can be NEGATIVE (`r-457330811034304502` is a real API draft), so
|
|
416
|
+
* this tests the `s:` prefix rather than matching `/^r\d/`.
|
|
417
|
+
*/
|
|
418
|
+
export type DraftOrigin = 'api' | 'non-api';
|
|
419
|
+
|
|
420
|
+
export function originFromDraftId(id: string): DraftOrigin {
|
|
421
|
+
return id.startsWith('s:') ? 'non-api' : 'api';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The three fields `gog gmail drafts list` actually returns. That is all. */
|
|
425
|
+
export type DraftListEntry = { id?: string; messageId?: string; threadId?: string };
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* True when the draft's threadId is its own messageId — i.e. it is the ROOT of
|
|
429
|
+
* a new thread, so sending it starts a new conversation instead of replying.
|
|
430
|
+
*
|
|
431
|
+
* This is free (both fields are in the listing) and it is the CONSEQUENCE the
|
|
432
|
+
* owner cares about. It is NOT a fork discriminator: the live probe measured
|
|
433
|
+
* P(Apple | roots-own-thread) = 4/8 = 0.50, a coin flip.
|
|
434
|
+
*
|
|
435
|
+
* Absent fields yield `false`, not `true` — `undefined === undefined` would
|
|
436
|
+
* otherwise report every field-less draft as rooting its own thread.
|
|
437
|
+
*/
|
|
438
|
+
export function rootsOwnThread(d: DraftListEntry): boolean {
|
|
439
|
+
if (!d.messageId || !d.threadId) return false;
|
|
440
|
+
return d.messageId === d.threadId;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Case-insensitive header index. Apple writes `Mime-Version`, the Gmail API
|
|
445
|
+
* writes `MIME-Version`; a case-sensitive lookup silently misses one of them.
|
|
446
|
+
* Repeated headers (Received, References) keep every value, in order.
|
|
447
|
+
*/
|
|
448
|
+
export type HeaderMap = ReadonlyMap<string, string[]>;
|
|
449
|
+
|
|
450
|
+
export function parseHeaders(payload: { headers?: GmailHeader[] } | undefined): HeaderMap {
|
|
451
|
+
const map = new Map<string, string[]>();
|
|
452
|
+
for (const h of payload?.headers ?? []) {
|
|
453
|
+
if (!h.name) continue;
|
|
454
|
+
const key = h.name.toLowerCase();
|
|
455
|
+
const existing = map.get(key);
|
|
456
|
+
if (existing) existing.push(h.value ?? '');
|
|
457
|
+
else map.set(key, [h.value ?? '']);
|
|
458
|
+
}
|
|
459
|
+
return map;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function headerValue(headers: HeaderMap, name: string): string | undefined {
|
|
463
|
+
return headers.get(name.toLowerCase())?.[0];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Apple identity headers found on a message, reported as `Name: value` strings
|
|
468
|
+
* so the caller can read the evidence rather than trust a boolean.
|
|
469
|
+
*
|
|
470
|
+
* Proves only that APPLE WROTE THIS DRAFT. It says nothing about WHICH draft
|
|
471
|
+
* it replaced — on its own it can never establish a pairing.
|
|
472
|
+
*
|
|
473
|
+
* `X-Uniform-Type-Identifier` counts only when it names an Apple type; the
|
|
474
|
+
* header name alone is not evidence of the value it carries.
|
|
475
|
+
*/
|
|
476
|
+
export function appleIdentitySignals(headers: GmailHeader[] | undefined): string[] {
|
|
477
|
+
const out: string[] = [];
|
|
478
|
+
for (const h of headers ?? []) {
|
|
479
|
+
const name = h.name ?? '';
|
|
480
|
+
const lower = name.toLowerCase();
|
|
481
|
+
const value = h.value ?? '';
|
|
482
|
+
if (lower === 'x-uniform-type-identifier') {
|
|
483
|
+
if (value.toLowerCase().startsWith('com.apple.')) out.push(`${name}: ${value}`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (lower === 'x-universally-unique-identifier' || lower.startsWith('x-apple-')) {
|
|
487
|
+
out.push(`${name}: ${value}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `<ABC@gmail.com>` -> `ABC@gmail.com`. Case is preserved: RFC822 Message-Ids
|
|
494
|
+
* are case-sensitive, and lowercasing them would merge distinct ids. */
|
|
495
|
+
export function normalizeMessageId(v: string | undefined): string | undefined {
|
|
496
|
+
const trimmed = v?.trim();
|
|
497
|
+
if (!trimmed) return undefined;
|
|
498
|
+
const stripped = trimmed.replace(/^</, '').replace(/>$/, '').trim();
|
|
499
|
+
return stripped.length > 0 ? stripped : undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Every bracketed id in a References / In-Reply-To chain, bracket-stripped.
|
|
504
|
+
* Bare unbracketed ids are deliberately ignored: an unparseable chain must
|
|
505
|
+
* yield NO lineage rather than a guessed one.
|
|
506
|
+
*/
|
|
507
|
+
export function messageIdsIn(references: string | undefined): string[] {
|
|
508
|
+
const matches = references?.match(/<[^<>\s]+>/g) ?? [];
|
|
509
|
+
return matches.map((m) => m.slice(1, -1));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Whitespace-normalized, blank-stripped body lines. This is the unit of the
|
|
513
|
+
* DIVERGENCE report (what each copy would lose), where quoted lines are real
|
|
514
|
+
* content and must be kept: a merge that drops the quote block loses it.
|
|
515
|
+
*
|
|
516
|
+
* It is NOT the unit of the lineage judgement — see authoredBodyLines. */
|
|
517
|
+
export function normalizeBodyLines(text: string | undefined): string[] {
|
|
518
|
+
return (text ?? '')
|
|
519
|
+
.replace(/\r\n?/g, '\n')
|
|
520
|
+
.split('\n')
|
|
521
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
522
|
+
.filter((l) => l.length > 0);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Jaccard overlap of ALL normalized body lines, 0..1 — quoted lines included.
|
|
527
|
+
* Reported by the divergence and content-loss paths, which are asking "how
|
|
528
|
+
* much of this text would I lose", a question the quote block is part of.
|
|
529
|
+
*
|
|
530
|
+
* NEVER use it as a lineage signal: two replies into the same thread quote the
|
|
531
|
+
* same original, so this scores them high while proving nothing. measureBody-
|
|
532
|
+
* Agreement is the lineage metric.
|
|
533
|
+
*
|
|
534
|
+
* An empty body on either side scores 0 — absence of content is absence of
|
|
535
|
+
* evidence, never a match.
|
|
536
|
+
*/
|
|
537
|
+
export function bodySimilarity(a: string | undefined, b: string | undefined): number {
|
|
538
|
+
const left = new Set(normalizeBodyLines(a));
|
|
539
|
+
const right = new Set(normalizeBodyLines(b));
|
|
540
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
541
|
+
let shared = 0;
|
|
542
|
+
for (const line of left) if (right.has(line)) shared += 1;
|
|
543
|
+
return shared / (left.size + right.size - shared);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// AUTHORED TEXT vs QUOTING APPARATUS.
|
|
548
|
+
//
|
|
549
|
+
// Apple Mail quotes the original on reply BY DEFAULT, and writes an
|
|
550
|
+
// attribution line above the quote. Two unrelated replies into the same thread
|
|
551
|
+
// therefore share a large, byte-identical block — measured on a real pair, a
|
|
552
|
+
// 30-line quote under a 5-line reply scores 0.79 on whole-line Jaccard, well
|
|
553
|
+
// past the 0.60 threshold. Counting that as evidence pairs every two replies
|
|
554
|
+
// in a thread, which is the default shape of the mailbox this feature serves.
|
|
555
|
+
//
|
|
556
|
+
// So the lineage metric looks ONLY at lines neither draft quoted. The
|
|
557
|
+
// attribution line counts as apparatus, not authorship: it is generated from
|
|
558
|
+
// the quoted message, identically, by every client on every reply.
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
/** `On <anything> wrote:` — the attribution line Apple Mail and Gmail write
|
|
562
|
+
* above a quote. Anchored at both ends so ordinary prose ("On the whole I
|
|
563
|
+
* agree", "She wrote: bring the seat") is not swallowed. */
|
|
564
|
+
const QUOTE_ATTRIBUTION_LINE = /^On\b.*\bwrote:$/i;
|
|
565
|
+
|
|
566
|
+
/** `-----Original Message-----`, `---------- Forwarded message ---------`. */
|
|
567
|
+
const QUOTE_SEPARATOR_LINE = /^-{2,}\s*(original message|forwarded message)/i;
|
|
568
|
+
|
|
569
|
+
export function isQuotedBodyLine(line: string): boolean {
|
|
570
|
+
return line.startsWith('>') || QUOTE_ATTRIBUTION_LINE.test(line) || QUOTE_SEPARATOR_LINE.test(line);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// CLIENT BOILERPLATE IS APPARATUS TOO.
|
|
575
|
+
//
|
|
576
|
+
// The property that disqualified quoted text — a mail client reproduces it
|
|
577
|
+
// identically on every message, whatever the message says — is just as true of
|
|
578
|
+
// the salutation, the closing formula, the name under it and the signature
|
|
579
|
+
// block. `Sent from my iPhone` is Apple Mail's OWN DEFAULT signature.
|
|
580
|
+
//
|
|
581
|
+
// Measured: `Hi Jennifer,` + `Thanks,` + `Chris` + `Sent from my iPhone` is 4
|
|
582
|
+
// lines and 43 characters, which cleared the 2-line and 40-character minimums
|
|
583
|
+
// on its own; and for two symmetric n-line drafts the similarity gate reduces
|
|
584
|
+
// to `shared >= 0.75n`, which 4-of-6 lines clears. So two genuinely unrelated
|
|
585
|
+
// one-sentence notes — different subjects, different threads, no shared reply
|
|
586
|
+
// root — paired as `confirmed`. Short confirmation plus a signature is the
|
|
587
|
+
// DOMINANT shape of the co-parenting mailbox this feature serves, so that was
|
|
588
|
+
// the default case, not a corner.
|
|
589
|
+
//
|
|
590
|
+
// Everything here is DELIBERATELY position-independent where it can be. A rule
|
|
591
|
+
// that strips `Best, Chris` only when it is the last line removes it from one
|
|
592
|
+
// copy of a message and keeps it in the other the moment one copy has a
|
|
593
|
+
// sentence after it — manufacturing divergence as readily as agreement. Only
|
|
594
|
+
// the two things that genuinely ARE positional stay positional: the salutation
|
|
595
|
+
// (first line) and a bare name (directly under a closing formula).
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
/** RFC 3676's `-- ` signature delimiter. normalizeBodyLines has already
|
|
599
|
+
* trimmed the trailing space by the time this runs. */
|
|
600
|
+
const SIGNATURE_DELIMITER_LINE = /^--$/;
|
|
601
|
+
|
|
602
|
+
/** The signature a mail CLIENT appends by itself, not the author. */
|
|
603
|
+
const CLIENT_SIGNATURE_LINE = /^(sent from my\b|sent from (mail|outlook|yahoo|windows)\b|sent via\b|get outlook for\b)/i;
|
|
604
|
+
|
|
605
|
+
/** A salutation. Consulted for the FIRST line only, so ordinary prose that
|
|
606
|
+
* happens to open with one of these words mid-body is left alone. Sentence
|
|
607
|
+
* punctuation rules it out: `Hi Jennifer,` is a greeting, `Hi — I paid the
|
|
608
|
+
* invoice. Details below.` is content. */
|
|
609
|
+
const GREETING_LINE = /^(hi|hello|hey|dear|good (morning|afternoon|evening)|greetings)\b[^.!?]{0,48}$/i;
|
|
610
|
+
|
|
611
|
+
/** A closing formula alone on its line: `Thanks,` `Best regards!` `Sincerely`. */
|
|
612
|
+
const SIGN_OFF_ALONE =
|
|
613
|
+
/^(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;
|
|
614
|
+
|
|
615
|
+
/** The same formula with the name on the SAME line — `Best, Chris`. The tail
|
|
616
|
+
* must still look like a name; `Thanks, I will send the invoice tomorrow` is a
|
|
617
|
+
* sentence and stays. */
|
|
618
|
+
const SIGN_OFF_WITH_NAME =
|
|
619
|
+
/^(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;
|
|
620
|
+
|
|
621
|
+
/** A person's name on a line of its own: at most three words, no sentence
|
|
622
|
+
* punctuation. Deliberately narrow, and only ever consulted for the lines
|
|
623
|
+
* directly beneath a closing formula. */
|
|
624
|
+
const NAME_LINE = /^-{0,2}\s*\p{L}[\p{L}'\u2019.-]*(?:\s+\p{L}[\p{L}'\u2019.-]*){0,2}$/u;
|
|
625
|
+
|
|
626
|
+
/** A CONTACT line in a personal signature block: phone, email, URL, handle, or
|
|
627
|
+
* a short title/org line. Only ever consulted for the run directly beneath a
|
|
628
|
+
* closing formula, for the same reason NAME_LINE is.
|
|
629
|
+
*
|
|
630
|
+
* This exists because a user-configured signature is not the client default.
|
|
631
|
+
* `boilerplateLineFlags` stripped the RFC 3676 `-- ` block and the client's own
|
|
632
|
+
* `Sent from my iPhone`, and it stripped the NAME under a sign-off — but the run
|
|
633
|
+
* stopped at the first line that was not name-shaped, so everything below the
|
|
634
|
+
* name survived as authored prose:
|
|
635
|
+
*
|
|
636
|
+
* Thanks, -> stripped (sign-off)
|
|
637
|
+
* Chris Hall -> stripped (name-shaped)
|
|
638
|
+
* (704) 555-0142 -> NOT name-shaped, run stops here
|
|
639
|
+
* chris.c.hall@gmail.com -> survived
|
|
640
|
+
* https://example.com/chris -> survived
|
|
641
|
+
*
|
|
642
|
+
* Those three lines are identical on every message this account composes, so
|
|
643
|
+
* two entirely unrelated drafts shared 3 authored lines / 61 chars and scored
|
|
644
|
+
* similarity 0.60 against a 0.60 threshold — `meetsThreshold: true` on a plumber
|
|
645
|
+
* note and a soccer note. Measured, not hypothesised. */
|
|
646
|
+
const CONTACT_LINE =
|
|
647
|
+
/^(?:[+(]?\d[\d\s().-]{6,}|[^\s@]+@[^\s@]+\.[^\s@]+|(?:https?:\/\/|www\.)\S+|@[\w.]+)$/i;
|
|
648
|
+
|
|
649
|
+
function isNameLine(line: string): boolean {
|
|
650
|
+
return !/[.!?:;]$/.test(line) && NAME_LINE.test(line);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isSignOffLine(line: string): boolean {
|
|
654
|
+
if (SIGN_OFF_ALONE.test(line)) return true;
|
|
655
|
+
const withName = line.match(SIGN_OFF_WITH_NAME);
|
|
656
|
+
return withName !== null && isNameLine(withName[2]!);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Which of these (already unquoted) lines are client boilerplate. */
|
|
660
|
+
function boilerplateLineFlags(lines: readonly string[]): boolean[] {
|
|
661
|
+
const flags = lines.map(() => false);
|
|
662
|
+
|
|
663
|
+
// The `-- ` delimiter and EVERYTHING under it is the signature block.
|
|
664
|
+
const delimiter = lines.findIndex((l) => SIGNATURE_DELIMITER_LINE.test(l));
|
|
665
|
+
if (delimiter !== -1) for (let i = delimiter; i < lines.length; i += 1) flags[i] = true;
|
|
666
|
+
|
|
667
|
+
// Closing formulas and client signatures, wherever they sit.
|
|
668
|
+
lines.forEach((line, i) => {
|
|
669
|
+
if (isSignOffLine(line) || CLIENT_SIGNATURE_LINE.test(line)) flags[i] = true;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// The salutation — first line only.
|
|
673
|
+
if (lines.length > 0 && GREETING_LINE.test(lines[0]!)) flags[0] = true;
|
|
674
|
+
|
|
675
|
+
// The signature block directly under a closing formula, and only there:
|
|
676
|
+
// `Thanks,` / `Chris Hall` / `(704) 555-0142` / `chris@example.com`.
|
|
677
|
+
//
|
|
678
|
+
// The run continues through NAME-shaped AND CONTACT-shaped lines. It used to
|
|
679
|
+
// stop at the first non-name-shaped line, which meant a user-configured
|
|
680
|
+
// signature (phone, email, URL under the name) survived as authored content —
|
|
681
|
+
// identical on every message the account sends, and enough on its own to push
|
|
682
|
+
// two unrelated drafts to `meetsThreshold: true`.
|
|
683
|
+
//
|
|
684
|
+
// It still stops at genuine prose, so a postscript under the sign-off is kept:
|
|
685
|
+
// neither shape matches a sentence.
|
|
686
|
+
lines.forEach((line, i) => {
|
|
687
|
+
if (!isSignOffLine(line)) return;
|
|
688
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
689
|
+
const next = lines[j]!;
|
|
690
|
+
if (!isNameLine(next) && !CONTACT_LINE.test(next)) break;
|
|
691
|
+
flags[j] = true;
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
return flags;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The normalized lines a draft actually WROTE: quoting apparatus AND client
|
|
699
|
+
* boilerplate removed. This is the unit of the LINEAGE judgement only — the
|
|
700
|
+
* divergence report still counts every line, because a merge that drops the
|
|
701
|
+
* signature really did drop it. */
|
|
702
|
+
export function authoredBodyLines(text: string | undefined): string[] {
|
|
703
|
+
const unquoted = normalizeBodyLines(text).filter((l) => !isQuotedBodyLine(l));
|
|
704
|
+
const flags = boilerplateLineFlags(unquoted);
|
|
705
|
+
return unquoted.filter((_, i) => !flags[i]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** How many lines each filter removed, reported so the caller can redo the
|
|
709
|
+
* arithmetic rather than trust the verdict. */
|
|
710
|
+
function apparatusCounts(text: string | undefined): { quoted: number; boilerplate: number } {
|
|
711
|
+
const all = normalizeBodyLines(text);
|
|
712
|
+
const unquoted = all.filter((l) => !isQuotedBodyLine(l));
|
|
713
|
+
return {
|
|
714
|
+
quoted: all.length - unquoted.length,
|
|
715
|
+
boilerplate: unquoted.length - authoredBodyLines(text).length,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Overlap at or above this fraction of AUTHORED lines is one of the three
|
|
720
|
+
* conditions of the body-agreement lineage signal. */
|
|
721
|
+
export const FORK_BODY_SIMILARITY_THRESHOLD = 0.6;
|
|
722
|
+
|
|
723
|
+
/** ...and the agreement must rest on at least this many shared AUTHORED lines
|
|
724
|
+
* — after quoting AND client boilerplate are removed, so this is a count of
|
|
725
|
+
* substance. Kept at 2 deliberately: the comparison is line-based, and a
|
|
726
|
+
* single shared line can be a stock sentence two unrelated notes both use
|
|
727
|
+
* ("Let me know if that works for you."). Requiring two makes the agreement
|
|
728
|
+
* structural rather than coincidental. The cost is a real one — a genuine
|
|
729
|
+
* fork of a ONE-SENTENCE note comes back `candidate`, not `confirmed` — and
|
|
730
|
+
* that is the intended direction: a missed fork costs a re-check, a wrong one
|
|
731
|
+
* sends the wrong text to the wrong thread. `missing` names the shortfall in
|
|
732
|
+
* words so the caller can see exactly what was and was not found. */
|
|
733
|
+
export const FORK_MIN_SHARED_AUTHORED_LINES = 2;
|
|
734
|
+
|
|
735
|
+
/** ...totalling at least this many characters. `Ok.` + `Thanks.` is two lines
|
|
736
|
+
* and 10 characters: identical, and evidence of nothing. Real correspondence
|
|
737
|
+
* clears this on a single sentence. */
|
|
738
|
+
export const FORK_MIN_SHARED_AUTHORED_CHARS = 40;
|
|
739
|
+
|
|
740
|
+
const BODY_AGREEMENT_BASIS_NOTE =
|
|
741
|
+
'Measured over lines NEITHER draft quotes AND that neither draft\'s mail client generated. Excluded as apparatus: quoted (`>`) ' +
|
|
742
|
+
'lines, the `On ... wrote:` attribution, forward separators, the opening salutation, the closing formula, the name under it, ' +
|
|
743
|
+
'and the signature block (an RFC 3676 `-- ` block, or a line like `Sent from my iPhone` — Apple Mail\'s own default). All of ' +
|
|
744
|
+
'those are reproduced IDENTICALLY on every message a client composes, whatever the message says, so counting them pairs two ' +
|
|
745
|
+
'unrelated short notes from one account: `Hi Jennifer,` + `Thanks,` + `Chris` + `Sent from my iPhone` alone is 4 lines and 43 ' +
|
|
746
|
+
'characters. Lines are compared after collapsing runs of whitespace and dropping blanks, so a client that RE-WRAPPED a ' +
|
|
747
|
+
'paragraph at a different width, or swapped straight quotes for curly ones, produces lines that no longer match and drives ' +
|
|
748
|
+
'this number DOWN — a low score is weak evidence of absence.';
|
|
749
|
+
|
|
750
|
+
export type BodyAgreement = {
|
|
751
|
+
/** Jaccard overlap of the two authored-line sets, 0..1. */
|
|
752
|
+
similarity: number;
|
|
753
|
+
similarityThreshold: number;
|
|
754
|
+
sharedAuthoredLines: number;
|
|
755
|
+
minSharedAuthoredLines: number;
|
|
756
|
+
sharedAuthoredChars: number;
|
|
757
|
+
minSharedAuthoredChars: number;
|
|
758
|
+
/** How many lines were excluded as quoting apparatus on each side. */
|
|
759
|
+
quotedLinesIgnored: { original: number; candidate: number };
|
|
760
|
+
/** ...and how many as client boilerplate (salutation, closing formula, name,
|
|
761
|
+
* signature block). Reported separately from quoting so the caller can see
|
|
762
|
+
* which filter did the work. */
|
|
763
|
+
boilerplateLinesIgnored: { original: number; candidate: number };
|
|
764
|
+
/** True only when all three minimums are met. */
|
|
765
|
+
meetsThreshold: boolean;
|
|
766
|
+
basisNote: string;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* How much of what the two drafts actually WROTE (as opposed to quoted) is the
|
|
771
|
+
* same text. Every input to the judgement is returned, not just the verdict,
|
|
772
|
+
* so a caller can re-do the arithmetic.
|
|
773
|
+
*/
|
|
774
|
+
export function measureBodyAgreement(
|
|
775
|
+
originalBody: string | undefined,
|
|
776
|
+
candidateBody: string | undefined,
|
|
777
|
+
): BodyAgreement {
|
|
778
|
+
const left = new Set(authoredBodyLines(originalBody));
|
|
779
|
+
const right = new Set(authoredBodyLines(candidateBody));
|
|
780
|
+
const shared = [...left].filter((line) => right.has(line));
|
|
781
|
+
const similarity = left.size === 0 || right.size === 0
|
|
782
|
+
? 0
|
|
783
|
+
: shared.length / (left.size + right.size - shared.length);
|
|
784
|
+
const sharedAuthoredChars = shared.reduce((n, line) => n + line.length, 0);
|
|
785
|
+
const originalApparatus = apparatusCounts(originalBody);
|
|
786
|
+
const candidateApparatus = apparatusCounts(candidateBody);
|
|
787
|
+
return {
|
|
788
|
+
similarity,
|
|
789
|
+
similarityThreshold: FORK_BODY_SIMILARITY_THRESHOLD,
|
|
790
|
+
sharedAuthoredLines: shared.length,
|
|
791
|
+
minSharedAuthoredLines: FORK_MIN_SHARED_AUTHORED_LINES,
|
|
792
|
+
sharedAuthoredChars,
|
|
793
|
+
minSharedAuthoredChars: FORK_MIN_SHARED_AUTHORED_CHARS,
|
|
794
|
+
quotedLinesIgnored: { original: originalApparatus.quoted, candidate: candidateApparatus.quoted },
|
|
795
|
+
boilerplateLinesIgnored: { original: originalApparatus.boilerplate, candidate: candidateApparatus.boilerplate },
|
|
796
|
+
meetsThreshold: similarity >= FORK_BODY_SIMILARITY_THRESHOLD
|
|
797
|
+
&& shared.length >= FORK_MIN_SHARED_AUTHORED_LINES
|
|
798
|
+
&& sharedAuthoredChars >= FORK_MIN_SHARED_AUTHORED_CHARS,
|
|
799
|
+
basisNote: BODY_AGREEMENT_BASIS_NOTE,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** `Chris Hall <Chris.C.Hall@Gmail.com>` -> `chris.c.hall@gmail.com`. */
|
|
804
|
+
export function normalizeFrom(v: string | undefined): string | undefined {
|
|
805
|
+
const angled = v?.match(/<([^<>\s]+)>/);
|
|
806
|
+
const addr = angled ? angled[1] : v?.trim();
|
|
807
|
+
return addr ? addr.toLowerCase() : undefined;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** `run()` + the timestamp repair `runOrDiagnose` would have applied.
|
|
811
|
+
*
|
|
812
|
+
* These paths read gog JSON through bare `run()` because they parse it rather
|
|
813
|
+
* than hand it back verbatim — but the values they lift out (internalDate,
|
|
814
|
+
* internalDateIso) are then re-emitted to the caller, and skipping the seam
|
|
815
|
+
* meant they arrived without the explicit offset and without the `<field>Display`
|
|
816
|
+
* sibling every other tool in this repo returns. Two shapes of timestamp in one
|
|
817
|
+
* response, with nothing marking which is which, is the exact defect
|
|
818
|
+
* docs/timestamps.md exists to prevent. */
|
|
819
|
+
async function runNormalized(args: GogArg[], opts: { account?: string }): Promise<string> {
|
|
820
|
+
return normalizeTimestamps(await run(args, opts));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Gmail's `internalDate` is epoch millis as a string. Anything else — absent,
|
|
824
|
+
* blank, non-numeric — is `undefined`, so ordering is reported as UNKNOWN
|
|
825
|
+
* rather than silently coerced (Number('') is 0, which would date a draft to
|
|
826
|
+
* 1970 and make every other draft look newer). */
|
|
827
|
+
export function parseInternalDateMs(v: string | undefined): number | undefined {
|
|
828
|
+
if (v === undefined || v.trim() === '') return undefined;
|
|
829
|
+
const n = Number(v);
|
|
830
|
+
return Number.isFinite(n) ? n : undefined;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Signals that MUST NEVER establish a pairing on their own. Exported so tool
|
|
835
|
+
* descriptions quote one canonical list instead of drifting copies — this is
|
|
836
|
+
* caller-facing copy, not an internal comment.
|
|
837
|
+
*/
|
|
838
|
+
export const FORK_SIGNALS_THAT_NEVER_SUFFICE: readonly string[] = [
|
|
839
|
+
'A draft id beginning `s:` means non-API (IMAP/sync) origin — Thunderbird, Outlook-over-IMAP and Gmail offline produce it too. It is not "Apple", and it is not "a fork".',
|
|
840
|
+
'threadId === messageId means the draft roots its own thread. Measured on a live mailbox, P(Apple | roots-own-thread) was 4/8 = 0.50 — a coin flip. Report it as a consequence, never use it as a discriminator.',
|
|
841
|
+
'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.',
|
|
842
|
+
'Any single X-Apple-* header. It proves Apple wrote THIS draft; it says nothing about WHICH draft it replaced.',
|
|
843
|
+
'A UUID-shaped Message-Id, or `Mime-Version: 1.0 (1.0)`. The doubled form is iOS-only — macOS Mail writes `Mime-Version: 1.0 (Mac OS X Mail 16.0 ...)`, so its absence is not counter-evidence.',
|
|
844
|
+
'Recency alone.',
|
|
845
|
+
'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 — never the candidate to the original — so it is reported as corroboration and can never establish a pairing on its own.',
|
|
846
|
+
'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.',
|
|
847
|
+
'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 — enough, on its own, to clear a naive line-and-character threshold — 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.',
|
|
848
|
+
'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.',
|
|
849
|
+
'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.',
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
/** Which cost tier the facts were gathered at. 0 = fields already in
|
|
853
|
+
* `drafts list` (no extra spawn); 1 = one `messages search` fan-out; 2 = a
|
|
854
|
+
* per-draft header fetch, hard-capped at a named pair. */
|
|
855
|
+
export type ForkPairingTier = 0 | 1 | 2;
|
|
856
|
+
|
|
857
|
+
export type ForkPairingVerdict = 'confirmed' | 'candidate' | 'none';
|
|
858
|
+
|
|
859
|
+
/** Everything the verdict may look at. Fields are optional because which of
|
|
860
|
+
* them exist depends on the tier the caller paid for. */
|
|
861
|
+
export type DraftFacts = {
|
|
862
|
+
draftId?: string;
|
|
863
|
+
messageIdHeader?: string;
|
|
864
|
+
inReplyTo?: string;
|
|
865
|
+
references?: string;
|
|
866
|
+
from?: string;
|
|
867
|
+
subject?: string;
|
|
868
|
+
internalDate?: string;
|
|
869
|
+
bodyText?: string;
|
|
870
|
+
/** From appleIdentitySignals — TIER 2 ONLY. */
|
|
871
|
+
appleSignals?: string[];
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
export type ForkPairing = {
|
|
875
|
+
verdict: ForkPairingVerdict;
|
|
876
|
+
tier: ForkPairingTier;
|
|
877
|
+
evidence: string[];
|
|
878
|
+
missing: string[];
|
|
879
|
+
/** Every number the LINEAGE decision rests on, so the threshold can be
|
|
880
|
+
* judged rather than trusted. */
|
|
881
|
+
bodyAgreement: BodyAgreement;
|
|
882
|
+
note: string;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
function replyRoots(d: DraftFacts): string[] {
|
|
886
|
+
const roots = messageIdsIn(d.references);
|
|
887
|
+
const inReplyTo = normalizeMessageId(d.inReplyTo);
|
|
888
|
+
if (inReplyTo) roots.push(inReplyTo);
|
|
889
|
+
return roots;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function firstSharedRoot(a: DraftFacts, b: DraftFacts): string | undefined {
|
|
893
|
+
const bRoots = new Set(replyRoots(b));
|
|
894
|
+
for (const root of replyRoots(a)) if (bRoots.has(root)) return root;
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function label(d: DraftFacts): string {
|
|
899
|
+
return d.draftId ?? '(unknown id)';
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Decide whether `candidate` replaced `original`, and SHOW THE WORK.
|
|
904
|
+
*
|
|
905
|
+
* Four independent requirements; `confirmed` needs all four:
|
|
906
|
+
* 1. IDENTITY — the candidate carries an Apple identity header (tier 2).
|
|
907
|
+
* 2. LINEAGE — a link from THE CANDIDATE to THE ORIGINAL. Exactly two
|
|
908
|
+
* things qualify, and both point at the original itself:
|
|
909
|
+
* (a) BACK-LINK: the original draft's own Message-Id appears
|
|
910
|
+
* in the candidate's In-Reply-To/References; or
|
|
911
|
+
* (b) BODY AGREEMENT: the text the two drafts WROTE (quoting
|
|
912
|
+
* excluded) meets all three printed minimums.
|
|
913
|
+
* A SHARED REPLY ROOT IS NOT LINEAGE. It links both drafts to
|
|
914
|
+
* a common ancestor — the co-parent's message — which every
|
|
915
|
+
* reply in the thread does. It is reported as corroboration
|
|
916
|
+
* and can raise the answer no higher than a weak `candidate`.
|
|
917
|
+
* 3. ORDERING — the candidate is strictly newer.
|
|
918
|
+
* 4. SAME FROM — both drafts are from the same address.
|
|
919
|
+
*
|
|
920
|
+
* Without LINEAGE the verdict can never be `confirmed`, no matter how many
|
|
921
|
+
* other signals fire. That rule is what defeats the composite trap: origin,
|
|
922
|
+
* thread-rooting, recency, Apple headers and a shared reply root are all
|
|
923
|
+
* consequences of "Apple wrote this reply", and none of them mentions the
|
|
924
|
+
* original. With lineage but not all four, the verdict is `candidate` and
|
|
925
|
+
* `missing` names each absent signal in words.
|
|
926
|
+
*
|
|
927
|
+
* STRUCTURAL GUARANTEE: `confirmed` requires IDENTITY, identity signals can
|
|
928
|
+
* only come from a tier-2 header fetch, and supplying them below tier 2 throws.
|
|
929
|
+
* Therefore no tier-0/tier-1-only path can ever emit `confirmed`.
|
|
930
|
+
*/
|
|
931
|
+
export function evaluateForkPairing(
|
|
932
|
+
original: DraftFacts,
|
|
933
|
+
candidate: DraftFacts,
|
|
934
|
+
tier: ForkPairingTier,
|
|
935
|
+
): ForkPairing {
|
|
936
|
+
const signals = candidate.appleSignals ?? [];
|
|
937
|
+
if (tier < 2 && signals.length > 0) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`evaluateForkPairing was given Apple identity signals at tier ${tier}, but identity headers ` +
|
|
940
|
+
'can only come from a tier 2 per-draft header fetch. This is a wiring bug: a cheap listing path ' +
|
|
941
|
+
'must never be able to produce a "confirmed" fork pairing.',
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const evidence: string[] = [];
|
|
946
|
+
const missing: string[] = [];
|
|
947
|
+
|
|
948
|
+
// ---- 2. LINEAGE (required for `confirmed`) ----
|
|
949
|
+
const agreement = measureBodyAgreement(original.bodyText, candidate.bodyText);
|
|
950
|
+
const originalMessageId = normalizeMessageId(original.messageIdHeader);
|
|
951
|
+
let lineage = false;
|
|
952
|
+
|
|
953
|
+
// (a) BACK-LINK. In-Reply-To as well as References: a client that re-threads
|
|
954
|
+
// onto the original draft writes the id into either one.
|
|
955
|
+
if (originalMessageId !== undefined && replyRoots(candidate).includes(originalMessageId)) {
|
|
956
|
+
evidence.push(
|
|
957
|
+
`LINEAGE: the candidate's In-Reply-To/References cites the ORIGINAL DRAFT's own Message-Id <${originalMessageId}> — ` +
|
|
958
|
+
'a link to the original itself, not to a shared ancestor',
|
|
959
|
+
);
|
|
960
|
+
lineage = true;
|
|
961
|
+
}
|
|
962
|
+
// (b) BODY AGREEMENT, on text neither draft quoted.
|
|
963
|
+
if (agreement.meetsThreshold) {
|
|
964
|
+
evidence.push(
|
|
965
|
+
`LINEAGE: the two drafts agree on text NEITHER of them quotes — authored body line similarity ` +
|
|
966
|
+
`${agreement.similarity.toFixed(2)} meets the ${FORK_BODY_SIMILARITY_THRESHOLD.toFixed(2)} threshold over ` +
|
|
967
|
+
`${agreement.sharedAuthoredLines} shared line(s) / ${agreement.sharedAuthoredChars} characters`,
|
|
968
|
+
);
|
|
969
|
+
lineage = true;
|
|
970
|
+
}
|
|
971
|
+
// CORROBORATION ONLY. Reported because it is true and worth seeing, labelled
|
|
972
|
+
// because on its own it is the mailbox's default state, not evidence.
|
|
973
|
+
const sharedRoot = firstSharedRoot(original, candidate);
|
|
974
|
+
if (sharedRoot) {
|
|
975
|
+
evidence.push(
|
|
976
|
+
`CORROBORATING ONLY (never a pairing on its own): both drafts reply into the same conversation — shared reply root ` +
|
|
977
|
+
`<${sharedRoot}>. That links each draft to a common ANCESTOR, not the candidate to the original, and EVERY reply in ` +
|
|
978
|
+
'that thread has it.',
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
if (!lineage) {
|
|
982
|
+
missing.push(
|
|
983
|
+
`no lineage signal: the candidate's In-Reply-To/References does not cite the original draft's Message-Id, and the text ` +
|
|
984
|
+
`the two drafts wrote rather than quoted does not agree (similarity ${agreement.similarity.toFixed(2)} vs the ` +
|
|
985
|
+
`${FORK_BODY_SIMILARITY_THRESHOLD.toFixed(2)} threshold, ${agreement.sharedAuthoredLines} shared line(s) of ` +
|
|
986
|
+
`${agreement.sharedAuthoredChars} characters vs the ${FORK_MIN_SHARED_AUTHORED_LINES}/${FORK_MIN_SHARED_AUTHORED_CHARS} ` +
|
|
987
|
+
`minimums)${sharedRoot ? '. A shared reply root is corroboration, not lineage' : ''}`,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ---- 1. IDENTITY ----
|
|
992
|
+
const identity = signals.length > 0;
|
|
993
|
+
if (identity) evidence.push(`the candidate carries Apple identity header(s): ${signals.join('; ')}`);
|
|
994
|
+
else missing.push('no Apple identity header on the candidate (X-Apple-*, X-Universally-Unique-Identifier, X-Uniform-Type-Identifier)');
|
|
995
|
+
|
|
996
|
+
// ---- 3. ORDERING ----
|
|
997
|
+
const originalMs = parseInternalDateMs(original.internalDate);
|
|
998
|
+
const candidateMs = parseInternalDateMs(candidate.internalDate);
|
|
999
|
+
let ordering = false;
|
|
1000
|
+
if (originalMs === undefined || candidateMs === undefined) {
|
|
1001
|
+
missing.push('internalDate is missing on one or both drafts, so it cannot be shown that the candidate is newer');
|
|
1002
|
+
} else if (candidateMs > originalMs) {
|
|
1003
|
+
ordering = true;
|
|
1004
|
+
evidence.push(`the candidate is newer (internalDate ${candidateMs} > ${originalMs})`);
|
|
1005
|
+
} else {
|
|
1006
|
+
missing.push(`the candidate is not newer than the original (internalDate ${candidateMs} <= ${originalMs})`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ---- 4. SAME FROM ----
|
|
1010
|
+
const originalFrom = normalizeFrom(original.from);
|
|
1011
|
+
const candidateFrom = normalizeFrom(candidate.from);
|
|
1012
|
+
let sameFrom = false;
|
|
1013
|
+
if (originalFrom === undefined || candidateFrom === undefined) {
|
|
1014
|
+
missing.push('From missing on one or both drafts');
|
|
1015
|
+
} else if (originalFrom === candidateFrom) {
|
|
1016
|
+
sameFrom = true;
|
|
1017
|
+
evidence.push(`same From (${originalFrom})`);
|
|
1018
|
+
} else {
|
|
1019
|
+
missing.push(`different From (${originalFrom} vs ${candidateFrom})`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let verdict: ForkPairingVerdict;
|
|
1023
|
+
if (lineage) verdict = identity && ordering && sameFrom ? 'confirmed' : 'candidate';
|
|
1024
|
+
else if (sharedRoot !== undefined) verdict = 'candidate';
|
|
1025
|
+
else verdict = 'none';
|
|
1026
|
+
|
|
1027
|
+
// Phrasing is load-bearing: `candidate` must read as a question, never as a
|
|
1028
|
+
// statement of fact, because a caller acting on it merges two messages.
|
|
1029
|
+
let note: string;
|
|
1030
|
+
if (verdict === 'confirmed') {
|
|
1031
|
+
note = `Draft ${label(candidate)} replaced draft ${label(original)}. All four signals are present — see evidence. Reconcile the bodies before sending: neither copy is guaranteed to be a superset of the other.`;
|
|
1032
|
+
} else if (verdict === 'candidate' && !lineage) {
|
|
1033
|
+
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 — 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.`;
|
|
1034
|
+
} else if (verdict === 'candidate') {
|
|
1035
|
+
note = `Unconfirmed: could draft ${label(candidate)} be a rewrite of draft ${label(original)}? Something links them, but not everything a pairing needs — read "missing" and decide yourself. Do not merge or send on the strength of this alone.`;
|
|
1036
|
+
} else {
|
|
1037
|
+
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 — 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.`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
verdict,
|
|
1042
|
+
tier,
|
|
1043
|
+
evidence,
|
|
1044
|
+
missing,
|
|
1045
|
+
bodyAgreement: agreement,
|
|
1046
|
+
note,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
// BODY EXTRACTION.
|
|
1053
|
+
//
|
|
1054
|
+
// `gog gmail drafts get --json` hands back the RAW Gmail payload — gog's own
|
|
1055
|
+
// text renderer (gmailcontent.BestBodyText) only runs on its human output, so
|
|
1056
|
+
// over --json the wrapper has to walk the MIME tree itself. A body this fails
|
|
1057
|
+
// to find would surface as a diff claiming an entire draft is empty, which is
|
|
1058
|
+
// exactly the kind of confident-and-wrong answer this feature must not give.
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
/** One MIME node of a Gmail message payload. */
|
|
1062
|
+
export type GmailPayloadPart = {
|
|
1063
|
+
mimeType?: string;
|
|
1064
|
+
filename?: string;
|
|
1065
|
+
headers?: GmailHeader[];
|
|
1066
|
+
body?: { data?: string };
|
|
1067
|
+
parts?: GmailPayloadPart[];
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
/** `draft.message` out of `gog gmail drafts get --json`. */
|
|
1071
|
+
export type GmailDraftMessage = {
|
|
1072
|
+
id?: string;
|
|
1073
|
+
threadId?: string;
|
|
1074
|
+
internalDate?: string;
|
|
1075
|
+
payload?: GmailPayloadPart;
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
/** Gmail encodes part bodies as unpadded base64url. Anything undecodable
|
|
1079
|
+
* yields no bytes rather than throwing: a diff must degrade to "no body
|
|
1080
|
+
* found", never take down the whole call. */
|
|
1081
|
+
function decodeBase64UrlBytes(data: string | undefined): Uint8Array | undefined {
|
|
1082
|
+
if (!data) return undefined;
|
|
1083
|
+
try {
|
|
1084
|
+
const binary = atob(data.replace(/-/g, '+').replace(/_/g, '/'));
|
|
1085
|
+
const bytes = new Uint8Array(binary.length);
|
|
1086
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
1087
|
+
return bytes;
|
|
1088
|
+
} catch {
|
|
1089
|
+
return undefined;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Code points for bytes 0x80-0x9F, the only range where windows-1252 differs
|
|
1094
|
+
* from latin-1 — and exactly where Apple Mail and Outlook put curly quotes and
|
|
1095
|
+
* dashes, so it is the range whose loss shows up as `don?t`. Written as
|
|
1096
|
+
* numbers because five of the slots are unassigned control characters. */
|
|
1097
|
+
const CP1252_HIGH = [
|
|
1098
|
+
0x20ac, 0x0081, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021,
|
|
1099
|
+
0x02c6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008d, 0x017d, 0x008f,
|
|
1100
|
+
0x0090, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
|
|
1101
|
+
0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x009d, 0x017e, 0x0178,
|
|
1102
|
+
];
|
|
1103
|
+
|
|
1104
|
+
function decodeCp1252(bytes: Uint8Array): string {
|
|
1105
|
+
let out = '';
|
|
1106
|
+
for (const b of bytes) out += String.fromCharCode(b >= 0x80 && b <= 0x9f ? CP1252_HIGH[b - 0x80]! : b);
|
|
1107
|
+
return out;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Bytes -> text, by SNIFFING rather than by trusting the declared charset.
|
|
1112
|
+
*
|
|
1113
|
+
* Gmail transcodes part bodies to UTF-8 while leaving the message's original
|
|
1114
|
+
* `Content-Type: ...; charset=` in place, so that header describes the bytes
|
|
1115
|
+
* the SENDER wrote, not the bytes the API just handed us: decoding on it alone
|
|
1116
|
+
* would mangle a UTF-8 body labelled windows-1252. Valid UTF-8 is not produced
|
|
1117
|
+
* by accident, so a strict UTF-8 decode is the reliable discriminator. Only
|
|
1118
|
+
* when that fails do we fall back — to the declared charset if the runtime
|
|
1119
|
+
* knows it, else to windows-1252, a superset of latin-1 that covers what
|
|
1120
|
+
* desktop mail clients actually emit.
|
|
1121
|
+
*/
|
|
1122
|
+
export function decodeTextBytes(bytes: Uint8Array, declaredCharset?: string): string {
|
|
1123
|
+
try {
|
|
1124
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1125
|
+
} catch {
|
|
1126
|
+
// Not UTF-8. Fall through to the legacy decoders.
|
|
1127
|
+
}
|
|
1128
|
+
const charset = (declaredCharset ?? '').trim().toLowerCase().replace(/^["']|["']$/g, '');
|
|
1129
|
+
if (charset !== '' && !/^(utf-?8|us-ascii|ascii)$/.test(charset)) {
|
|
1130
|
+
try {
|
|
1131
|
+
return new TextDecoder(charset, { fatal: true }).decode(bytes);
|
|
1132
|
+
} catch {
|
|
1133
|
+
// Unknown to this runtime, or the bytes are not valid in it either.
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return decodeCp1252(bytes);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** A soft line break, a high-byte escape, or an escaped `=`. Requiring one of
|
|
1140
|
+
* these — rather than any `=XX` — is what keeps an already-decoded body such
|
|
1141
|
+
* as `2+2=44` from being "decoded" into `2+2D`. */
|
|
1142
|
+
const QUOTED_PRINTABLE_MARKER = /=(?:\r?\n|[89a-f][0-9a-f]|3d)/i;
|
|
1143
|
+
|
|
1144
|
+
function decodeQuotedPrintableBytes(bytes: Uint8Array): Uint8Array {
|
|
1145
|
+
const src = decodeCp1252(bytes); // 1:1 for the 7-bit input QP is by definition
|
|
1146
|
+
const out: number[] = [];
|
|
1147
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
1148
|
+
if (src[i] !== '=') { out.push(src.charCodeAt(i)); continue; }
|
|
1149
|
+
const rest = src.slice(i, i + 3);
|
|
1150
|
+
const soft = /^=\r?\n/.exec(rest);
|
|
1151
|
+
if (soft) { i += soft[0]!.length - 1; continue; }
|
|
1152
|
+
const hex = /^=([0-9A-Fa-f]{2})/.exec(rest);
|
|
1153
|
+
if (hex) { out.push(parseInt(hex[1]!, 16)); i += 2; continue; }
|
|
1154
|
+
out.push(0x3d); // a lone `=`: keep it exactly as the encoder left it
|
|
1155
|
+
}
|
|
1156
|
+
return Uint8Array.from(out);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** `text/plain; charset="UTF-8"` -> `text/plain`. Gmail returns the full media
|
|
1160
|
+
* type with its parameters, so keying on the raw string silently misses the
|
|
1161
|
+
* body (gog normalizes with mime.ParseMediaType for the same reason). */
|
|
1162
|
+
export function partMimeType(part: GmailPayloadPart): string {
|
|
1163
|
+
return (part.mimeType ?? '').split(';')[0]!.trim().toLowerCase();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* One part's body as text, honouring what the part says about itself.
|
|
1168
|
+
*
|
|
1169
|
+
* Transfer encoding is applied CONDITIONALLY, not on the header alone: Gmail
|
|
1170
|
+
* has already decoded `body.data` while keeping the original
|
|
1171
|
+
* Content-Transfer-Encoding header, so decoding unconditionally would destroy
|
|
1172
|
+
* every base64 body (double-decode) and rewrite `2+2=44` in every text one. The
|
|
1173
|
+
* quoted-printable decoder therefore runs only when the bytes still LOOK
|
|
1174
|
+
* quoted-printable: 7-bit throughout (QP is 7-bit by definition, so any high
|
|
1175
|
+
* byte proves it is already decoded) and carrying a real QP marker. base64 is
|
|
1176
|
+
* never re-applied — a false positive there is unrecoverable.
|
|
1177
|
+
*/
|
|
1178
|
+
export function decodePartText(part: GmailPayloadPart): string {
|
|
1179
|
+
let bytes = decodeBase64UrlBytes(part.body?.data);
|
|
1180
|
+
if (bytes === undefined) return '';
|
|
1181
|
+
const headers = parseHeaders(part);
|
|
1182
|
+
const encoding = headerValue(headers, 'Content-Transfer-Encoding')?.trim().toLowerCase();
|
|
1183
|
+
if (encoding === 'quoted-printable'
|
|
1184
|
+
&& bytes.every((b) => b < 0x80)
|
|
1185
|
+
&& QUOTED_PRINTABLE_MARKER.test(decodeCp1252(bytes))) {
|
|
1186
|
+
bytes = decodeQuotedPrintableBytes(bytes);
|
|
1187
|
+
}
|
|
1188
|
+
const charset = /charset\s*=\s*([^;]+)/i.exec(headerValue(headers, 'Content-Type') ?? '')?.[1];
|
|
1189
|
+
return decodeTextBytes(bytes, charset);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** For the callers that hold raw base64url and no part metadata. */
|
|
1193
|
+
export function decodeBase64UrlText(data: string | undefined): string {
|
|
1194
|
+
const bytes = decodeBase64UrlBytes(data);
|
|
1195
|
+
return bytes === undefined ? '' : decodeTextBytes(bytes);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* The best plain-text rendering of a message payload: the FIRST inline
|
|
1200
|
+
* text/plain part found anywhere in the tree, else the first inline text/html,
|
|
1201
|
+
* else ''. Parts carrying a `filename` are attachments and are skipped — an
|
|
1202
|
+
* attached .txt is not the body.
|
|
1203
|
+
*/
|
|
1204
|
+
export function bestBodyText(payload: GmailPayloadPart | undefined): string {
|
|
1205
|
+
const firstOfType = new Map<string, string>();
|
|
1206
|
+
const walk = (part: GmailPayloadPart | undefined): void => {
|
|
1207
|
+
if (!part) return;
|
|
1208
|
+
const mime = partMimeType(part);
|
|
1209
|
+
if (part.body?.data && !part.filename && !firstOfType.has(mime)) {
|
|
1210
|
+
firstOfType.set(mime, decodePartText(part));
|
|
1211
|
+
}
|
|
1212
|
+
for (const child of part.parts ?? []) walk(child);
|
|
1213
|
+
};
|
|
1214
|
+
walk(payload);
|
|
1215
|
+
return firstOfType.get('text/plain') ?? firstOfType.get('text/html') ?? '';
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ---------------------------------------------------------------------------
|
|
1219
|
+
// THE DIVERGENCE REPORT.
|
|
1220
|
+
//
|
|
1221
|
+
// In the observed fork a whole paragraph had been deleted in the mail client
|
|
1222
|
+
// while the Gmail copy had later additions: NEITHER copy was a superset, and
|
|
1223
|
+
// recreating from either alone lost work. That fact is the single most useful
|
|
1224
|
+
// thing this feature can tell a caller, so it is a named field rather than
|
|
1225
|
+
// something to be inferred from two lists.
|
|
1226
|
+
// ---------------------------------------------------------------------------
|
|
1227
|
+
|
|
1228
|
+
/** Whether there was anything to compare. A body this server could not decode
|
|
1229
|
+
* normalizes to zero lines, which is INDISTINGUISHABLE from an empty draft —
|
|
1230
|
+
* so neither is allowed to produce a containment claim. */
|
|
1231
|
+
export type BodyDiffComparability = 'compared' | 'a-unreadable' | 'b-unreadable' | 'both-unreadable';
|
|
1232
|
+
|
|
1233
|
+
export type BodySupersetClaim = 'a-superset-of-b' | 'b-superset-of-a' | 'identical' | 'neither' | 'not-assessed';
|
|
1234
|
+
|
|
1235
|
+
export type BodyDiff = {
|
|
1236
|
+
onlyInA: string[];
|
|
1237
|
+
onlyInB: string[];
|
|
1238
|
+
/** How many lines diverged IN TOTAL on each side, before the per-side cap.
|
|
1239
|
+
* Without these, `truncated: true` and 200 printed lines cannot be told
|
|
1240
|
+
* apart from 200-of-201 and 200-of-500 — and the whole point of the diff is
|
|
1241
|
+
* deciding what to merge before an overwrite. Mirrors
|
|
1242
|
+
* ContentLossCheck.linesOnlyInSiblingCount. */
|
|
1243
|
+
onlyInACount: number;
|
|
1244
|
+
onlyInBCount: number;
|
|
1245
|
+
sharedLineCount: number;
|
|
1246
|
+
similarity: number;
|
|
1247
|
+
comparability: BodyDiffComparability;
|
|
1248
|
+
supersetClaim: BodySupersetClaim;
|
|
1249
|
+
/** null when comparability !== 'compared': with one side unread, "neither is
|
|
1250
|
+
* a superset" and "one is" are both unsupported, and `false` would read as
|
|
1251
|
+
* the latter. */
|
|
1252
|
+
neitherIsSuperset: boolean | null;
|
|
1253
|
+
truncated: boolean;
|
|
1254
|
+
note: string;
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
/** Default cap on the per-side line lists. A diff is for a human to read; an
|
|
1258
|
+
* uncapped one on a quoted 200-message thread is not. */
|
|
1259
|
+
export const DRAFT_DIFF_MAX_LINES = 200;
|
|
1260
|
+
|
|
1261
|
+
export function diffBodyLines(a: string, b: string, maxLines: number): BodyDiff {
|
|
1262
|
+
const left = new Set(normalizeBodyLines(a));
|
|
1263
|
+
const right = new Set(normalizeBodyLines(b));
|
|
1264
|
+
const onlyInA = [...left].filter((line) => !right.has(line));
|
|
1265
|
+
const onlyInB = [...right].filter((line) => !left.has(line));
|
|
1266
|
+
const sharedLineCount = left.size - onlyInA.length;
|
|
1267
|
+
const truncated = onlyInA.length > maxLines || onlyInB.length > maxLines;
|
|
1268
|
+
|
|
1269
|
+
// A side that yielded no lines was not READ, as far as anything here can
|
|
1270
|
+
// tell. Saying "every line of A is present in B" about it invites deleting
|
|
1271
|
+
// or overwriting A on the strength of a body this server never saw.
|
|
1272
|
+
let comparability: BodyDiffComparability = 'compared';
|
|
1273
|
+
if (left.size === 0) comparability = right.size === 0 ? 'both-unreadable' : 'a-unreadable';
|
|
1274
|
+
else if (right.size === 0) comparability = 'b-unreadable';
|
|
1275
|
+
|
|
1276
|
+
let supersetClaim: BodySupersetClaim;
|
|
1277
|
+
let base: string;
|
|
1278
|
+
if (comparability !== 'compared') {
|
|
1279
|
+
supersetClaim = 'not-assessed';
|
|
1280
|
+
const which = comparability === 'both-unreadable' ? 'NEITHER draft yielded any body text'
|
|
1281
|
+
: comparability === 'a-unreadable' ? 'Draft A yielded no body text' : 'Draft B yielded no body text';
|
|
1282
|
+
base =
|
|
1283
|
+
`${which} (it normalized to zero lines), so NOTHING WAS COMPARED and no containment claim is ` +
|
|
1284
|
+
'made in either direction — in particular this does NOT say one draft\'s text is safely present in the other. The ' +
|
|
1285
|
+
'draft may genuinely be empty, or its text may sit in a MIME part this server could not decode; read it with ' +
|
|
1286
|
+
'gog_gmail_drafts_get before overwriting or deleting either copy.';
|
|
1287
|
+
} else if (onlyInA.length > 0 && onlyInB.length > 0) {
|
|
1288
|
+
supersetClaim = 'neither';
|
|
1289
|
+
base = 'NEITHER copy is a superset: each draft holds lines the other does not. Recreating from either one alone LOSES WORK — merge the two bodies by hand, then write the merged text back with gog_gmail_drafts_update.';
|
|
1290
|
+
} else if (onlyInA.length > 0) {
|
|
1291
|
+
supersetClaim = 'a-superset-of-b';
|
|
1292
|
+
base = 'Draft A is a superset of draft B: every line of B is present in A, and A has more.';
|
|
1293
|
+
} else if (onlyInB.length > 0) {
|
|
1294
|
+
supersetClaim = 'b-superset-of-a';
|
|
1295
|
+
base = 'Draft B is a superset of draft A: every line of A is present in B, and B has more.';
|
|
1296
|
+
} else {
|
|
1297
|
+
supersetClaim = 'identical';
|
|
1298
|
+
base = 'The two bodies are identical once whitespace and blank lines are normalized.';
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
onlyInA: onlyInA.slice(0, maxLines),
|
|
1303
|
+
onlyInB: onlyInB.slice(0, maxLines),
|
|
1304
|
+
onlyInACount: onlyInA.length,
|
|
1305
|
+
onlyInBCount: onlyInB.length,
|
|
1306
|
+
sharedLineCount,
|
|
1307
|
+
similarity: bodySimilarity(a, b),
|
|
1308
|
+
comparability,
|
|
1309
|
+
supersetClaim,
|
|
1310
|
+
neitherIsSuperset: comparability === 'compared' ? supersetClaim === 'neither' : null,
|
|
1311
|
+
truncated,
|
|
1312
|
+
note: truncated
|
|
1313
|
+
? `${base} (Line lists truncated to ${maxLines} per side; ${onlyInA.length} line(s) diverged only in A and ` +
|
|
1314
|
+
`${onlyInB.length} only in B — onlyInACount/onlyInBCount are the true totals.)`
|
|
1315
|
+
: base,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ---------------------------------------------------------------------------
|
|
1320
|
+
// TIER 0 — what a listing can say for FREE.
|
|
1321
|
+
//
|
|
1322
|
+
// `gog gmail drafts list` returns id, messageId and threadId. That is all. Both
|
|
1323
|
+
// fields below are computed from those three, so enriching a 20-draft listing
|
|
1324
|
+
// costs ZERO extra gog spawns on the shared runner. Anything that needs a
|
|
1325
|
+
// header or a body needs a per-draft fetch and lives behind an explicit opt-in.
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
|
|
1328
|
+
/** gog's own default for `gmail drafts list --max` (gmail_drafts.go,
|
|
1329
|
+
* `GmailDraftsListCmd.Max`, `default:"20"` at upstream-v0.35.0). Restated so
|
|
1330
|
+
* the enrichment search can be told to cover exactly the same window — its own
|
|
1331
|
+
* default is 10, which would silently under-cover the listing. */
|
|
1332
|
+
const GOG_DRAFTS_LIST_DEFAULT_MAX = 20;
|
|
1333
|
+
|
|
1334
|
+
export const DRAFT_LIST_ORIGIN_NOTE =
|
|
1335
|
+
'`origin` is derived from the draft id alone and costs nothing: `api` = created through the Gmail API (what this server does); ' +
|
|
1336
|
+
'`non-api` = the id begins `s:`, meaning the draft arrived over IMAP/sync. `non-api` is NOT a claim of "Apple Mail" — ' +
|
|
1337
|
+
'Thunderbird, Outlook-over-IMAP and Gmail offline produce `s:` ids too, and confirming Apple authorship needs an actual ' +
|
|
1338
|
+
'identity header, which only a per-draft fetch can see (gog_gmail_drafts_diff). `rootsOwnThread` is likewise a CONSEQUENCE, ' +
|
|
1339
|
+
'not a fork test: on a live mailbox P(Apple | rootsOwnThread) measured 4/8 = 0.50, a coin flip. Neither field, alone or together, ' +
|
|
1340
|
+
'establishes that one draft replaced another.';
|
|
1341
|
+
|
|
1342
|
+
// One of exactly TWO constants, so they ride along ONCE per result and the
|
|
1343
|
+
// per-row `rootsOwnThread` boolean selects between them. Attaching the text to
|
|
1344
|
+
// every row cost 8.9x the payload on a 20-draft listing (942 -> 8428 bytes) on
|
|
1345
|
+
// the free path every caller takes, for zero extra information.
|
|
1346
|
+
const DRAFT_ROOTS_OWN_THREAD_NOTE =
|
|
1347
|
+
'threadId equals this draft\'s own messageId, so the draft is the ROOT of a new thread: sending it starts a NEW conversation ' +
|
|
1348
|
+
'rather than replying, in front of every recipient including anyone on Cc. Normal for a draft composed from scratch — and also ' +
|
|
1349
|
+
'what a mail client\'s replacement of a previously threaded draft looks like.';
|
|
1350
|
+
|
|
1351
|
+
const DRAFT_IN_THREAD_NOTE =
|
|
1352
|
+
'threadId differs from this draft\'s messageId, so the draft sits inside an existing thread and sending it continues that ' +
|
|
1353
|
+
'conversation. (Whether it also carries In-Reply-To/References is not visible from a listing — that needs a per-draft fetch.)';
|
|
1354
|
+
|
|
1355
|
+
const DRAFT_ENRICH_COST_NOTE =
|
|
1356
|
+
'enrich spent ONE extra gog invocation (`gmail messages search in:drafts`), so the spawn cost is flat in the number of drafts. ' +
|
|
1357
|
+
'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, ' +
|
|
1358
|
+
'so Google reads and wall-clock are linear in the result count. Narrow `max` before turning it on.';
|
|
1359
|
+
|
|
1360
|
+
/** One row of `gog gmail messages search --json`; only the fields the join needs. */
|
|
1361
|
+
type EnrichedDraftMessage = { id?: string; from?: string; subject?: string; internalDateIso?: string };
|
|
1362
|
+
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
// TIER 2 — a NAMED PAIR only, never a scan.
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
/** The per-draft facts the diff reports back. Deliberately excludes the body
|
|
1368
|
+
* text itself: the body is reported once, as a diff, not twice verbatim. */
|
|
1369
|
+
type DraftDiffSide = {
|
|
1370
|
+
draftId: string;
|
|
1371
|
+
messageId?: string;
|
|
1372
|
+
threadId?: string;
|
|
1373
|
+
origin: DraftOrigin;
|
|
1374
|
+
rootsOwnThread: boolean;
|
|
1375
|
+
subject?: string;
|
|
1376
|
+
from?: string;
|
|
1377
|
+
to?: string;
|
|
1378
|
+
cc?: string;
|
|
1379
|
+
internalDate?: string;
|
|
1380
|
+
messageIdHeader?: string;
|
|
1381
|
+
inReplyTo?: string;
|
|
1382
|
+
references?: string;
|
|
1383
|
+
appleIdentitySignals: string[];
|
|
1384
|
+
bodyLineCount: number;
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
function describeDraftSide(draftId: string, msg: GmailDraftMessage): { side: DraftDiffSide; facts: DraftFacts } {
|
|
1388
|
+
const headers = parseHeaders(msg.payload);
|
|
1389
|
+
const bodyText = bestBodyText(msg.payload);
|
|
1390
|
+
const side: DraftDiffSide = {
|
|
1391
|
+
draftId,
|
|
1392
|
+
messageId: msg.id,
|
|
1393
|
+
threadId: msg.threadId,
|
|
1394
|
+
origin: originFromDraftId(draftId),
|
|
1395
|
+
rootsOwnThread: rootsOwnThread({ id: draftId, messageId: msg.id, threadId: msg.threadId }),
|
|
1396
|
+
subject: headerValue(headers, 'Subject'),
|
|
1397
|
+
from: headerValue(headers, 'From'),
|
|
1398
|
+
to: headerValue(headers, 'To'),
|
|
1399
|
+
cc: headerValue(headers, 'Cc'),
|
|
1400
|
+
internalDate: msg.internalDate,
|
|
1401
|
+
messageIdHeader: headerValue(headers, 'Message-Id'),
|
|
1402
|
+
inReplyTo: headerValue(headers, 'In-Reply-To'),
|
|
1403
|
+
references: headerValue(headers, 'References'),
|
|
1404
|
+
appleIdentitySignals: appleIdentitySignals(msg.payload?.headers),
|
|
1405
|
+
// The DE-DUPLICATED count, matching diffBodyLines/evaluateContentLoss, which
|
|
1406
|
+
// compare Set members. Counting raw lines here broke the arithmetic a reader
|
|
1407
|
+
// naturally checks: onlyInACount + sharedLineCount === bodyLineCount only
|
|
1408
|
+
// holds when both sides count the same unit, and a body that repeats a line
|
|
1409
|
+
// (a divider, a blank-ish separator) made it not hold.
|
|
1410
|
+
bodyLineCount: new Set(normalizeBodyLines(bodyText)).size,
|
|
1411
|
+
};
|
|
1412
|
+
return {
|
|
1413
|
+
side,
|
|
1414
|
+
facts: {
|
|
1415
|
+
draftId,
|
|
1416
|
+
messageIdHeader: side.messageIdHeader,
|
|
1417
|
+
inReplyTo: side.inReplyTo,
|
|
1418
|
+
references: side.references,
|
|
1419
|
+
from: side.from,
|
|
1420
|
+
subject: side.subject,
|
|
1421
|
+
internalDate: side.internalDate,
|
|
1422
|
+
bodyText,
|
|
1423
|
+
appleSignals: side.appleIdentitySignals,
|
|
1424
|
+
},
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* The threading consequences, stated as sentences rather than left for the
|
|
1430
|
+
* caller to infer from two threadIds. This is the part of a fork that actually
|
|
1431
|
+
* hurts: the replacement sits on its own thread with no reply headers, so
|
|
1432
|
+
* sending it arrives as a new conversation in front of every Cc'd recipient.
|
|
1433
|
+
*/
|
|
1434
|
+
function threadingDifferences(a: DraftDiffSide, b: DraftDiffSide): string[] {
|
|
1435
|
+
const out: string[] = [];
|
|
1436
|
+
if (a.threadId !== b.threadId) {
|
|
1437
|
+
out.push(
|
|
1438
|
+
`The two drafts sit on different threadIds (${a.threadId ?? '(none)'} vs ${b.threadId ?? '(none)'}), so they are not the ` +
|
|
1439
|
+
'same conversation. Sending the one that roots its own thread starts a NEW conversation in front of every recipient, ' +
|
|
1440
|
+
'including anyone on Cc.',
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
const aReplies = Boolean(a.inReplyTo ?? a.references);
|
|
1444
|
+
const bReplies = Boolean(b.inReplyTo ?? b.references);
|
|
1445
|
+
if (aReplies !== bReplies) {
|
|
1446
|
+
const withHeaders = aReplies ? a.draftId : b.draftId;
|
|
1447
|
+
const without = aReplies ? b.draftId : a.draftId;
|
|
1448
|
+
out.push(
|
|
1449
|
+
`Draft ${withHeaders} carries reply headers (In-Reply-To/References) and draft ${without} does not: only the first will ` +
|
|
1450
|
+
'arrive as a reply. gog_gmail_drafts_update with replyToThreadId re-threads a draft in place, keeping its id — but it ' +
|
|
1451
|
+
'also requires a full body, so reconcile the bodies below first.',
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
if (out.length === 0) {
|
|
1455
|
+
out.push('No threading difference: the two drafts share a threadId and agree on whether they carry reply headers.');
|
|
1456
|
+
}
|
|
1457
|
+
return out;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ---------------------------------------------------------------------------
|
|
1461
|
+
// REQUIREMENT 4 — VERIFY THE THREADING gog ACTUALLY WROTE.
|
|
1462
|
+
//
|
|
1463
|
+
// The REPAIR already exists upstream and is not rebuilt here. On
|
|
1464
|
+
// `gmail drafts update`, `--thread-id` sets replyToThreadID, so buildDraftMessage
|
|
1465
|
+
// RESOLVES In-Reply-To/References from the thread's latest non-draft message,
|
|
1466
|
+
// `Users.Drafts.Update("me", draftID, ...)` KEEPS the draft id, and
|
|
1467
|
+
// writeDraftResult reports threadId/inReplyTo/references/replyContextSource
|
|
1468
|
+
// (internal/cmd/gmail_drafts.go, upstream-v0.35.0). Adopting a mail client's
|
|
1469
|
+
// replacement onto the original thread is therefore ONE call of an existing
|
|
1470
|
+
// tool. What was missing is that nobody read gog's report back.
|
|
1471
|
+
//
|
|
1472
|
+
// THE SILENT CASE THIS CATCHES. fetchReplyInfo's thread branch has no "target
|
|
1473
|
+
// has no Message-ID header" guard — the message-id branch does
|
|
1474
|
+
// (internal/cmd/gmail_reply.go:79 vs :105) — so a thread whose latest non-draft
|
|
1475
|
+
// message carries no Message-Id resolves NO lineage. And because an explicit
|
|
1476
|
+
// reply target suppresses the carry-forward branch (gmail_drafts.go:967), the
|
|
1477
|
+
// draft is MOVED onto the new thread and ends up with no reply headers at all:
|
|
1478
|
+
// worse off than before the call, reported as a plain success.
|
|
1479
|
+
//
|
|
1480
|
+
// Zero extra gog invocations: every field read here is already in the write's
|
|
1481
|
+
// own acknowledgement.
|
|
1482
|
+
// ---------------------------------------------------------------------------
|
|
1483
|
+
|
|
1484
|
+
/** What the caller asked to happen to the draft's reply context. Mirrors
|
|
1485
|
+
* appendDraftFlags' own precedence — replyToMessageId wins over
|
|
1486
|
+
* replyToThreadId — so the verification can never describe a target gog was
|
|
1487
|
+
* not given. */
|
|
1488
|
+
export type ThreadingIntent =
|
|
1489
|
+
| { requested: 'set'; via: 'replyToMessageId' | 'replyToThreadId'; target: string }
|
|
1490
|
+
| { requested: 'clear' };
|
|
1491
|
+
|
|
1492
|
+
export function threadingIntentOf(f: {
|
|
1493
|
+
replyToMessageId?: string;
|
|
1494
|
+
replyToThreadId?: string;
|
|
1495
|
+
clearReplyContext?: boolean;
|
|
1496
|
+
}): ThreadingIntent | undefined {
|
|
1497
|
+
if (f.replyToMessageId) return { requested: 'set', via: 'replyToMessageId', target: f.replyToMessageId };
|
|
1498
|
+
if (f.replyToThreadId) return { requested: 'set', via: 'replyToThreadId', target: f.replyToThreadId };
|
|
1499
|
+
if (f.clearReplyContext) return { requested: 'clear' };
|
|
1500
|
+
return undefined;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
export type ThreadingVerification = {
|
|
1504
|
+
requested: 'set' | 'clear';
|
|
1505
|
+
via?: 'replyToMessageId' | 'replyToThreadId';
|
|
1506
|
+
target?: string;
|
|
1507
|
+
ok: boolean;
|
|
1508
|
+
effective: {
|
|
1509
|
+
threadId?: string;
|
|
1510
|
+
inReplyTo?: string;
|
|
1511
|
+
references?: string;
|
|
1512
|
+
replyContextSource?: string;
|
|
1513
|
+
};
|
|
1514
|
+
note: string;
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// gog requires --body on EVERY update (draftComposeInput.validate,
|
|
1518
|
+
// gmail_drafts.go:322: "required: --body, --body-file, --body-html, or
|
|
1519
|
+
// --body-html-file"). There is no header-only edit, so re-threading and
|
|
1520
|
+
// overwriting the body are the same operation — which is exactly the operation
|
|
1521
|
+
// that can drop the paragraph living only in the sibling copy.
|
|
1522
|
+
const BODY_OVERWRITE_CAVEAT =
|
|
1523
|
+
'gog requires a body on every update, so there is no header-only edit: this call REWROTE the whole body. If a sibling ' +
|
|
1524
|
+
'draft holds text this body does not, that text now exists only there — compare them with gog_gmail_drafts_diff before ' +
|
|
1525
|
+
'the next write.';
|
|
1526
|
+
|
|
1527
|
+
const VERIFICATION_PROVENANCE =
|
|
1528
|
+
'These are gog\'s own report of what it wrote, not an independent re-fetch, and they cost no extra gog invocation. ' +
|
|
1529
|
+
'To read the stored headers back from Gmail, use gog_gmail_raw with format=metadata on the draft\'s messageId.';
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Read gog's write acknowledgement back against what the caller asked for.
|
|
1533
|
+
*
|
|
1534
|
+
* `ok` is deliberately narrow: for a `set` it means gog reported an actual
|
|
1535
|
+
* In-Reply-To, and for a `clear` it means gog reported none. Everything else
|
|
1536
|
+
* the ack said is passed through under `effective` so the caller can judge the
|
|
1537
|
+
* claim rather than trust the boolean.
|
|
1538
|
+
*/
|
|
1539
|
+
export function verifyThreading(intent: ThreadingIntent, ack: Record<string, unknown>): ThreadingVerification {
|
|
1540
|
+
// gog writes an explicit JSON null for "no reply context" (nilIfEmpty,
|
|
1541
|
+
// gmail_drafts.go:551), so anything that is not a non-blank string is absent.
|
|
1542
|
+
const str = (name: string): string | undefined => {
|
|
1543
|
+
const v = ack[name];
|
|
1544
|
+
return typeof v === 'string' && v.trim() !== '' ? v : undefined;
|
|
1545
|
+
};
|
|
1546
|
+
const effective = {
|
|
1547
|
+
threadId: str('threadId'),
|
|
1548
|
+
inReplyTo: str('inReplyTo'),
|
|
1549
|
+
references: str('references'),
|
|
1550
|
+
replyContextSource: str('replyContextSource'),
|
|
1551
|
+
};
|
|
1552
|
+
const hasLineage = effective.inReplyTo !== undefined;
|
|
1553
|
+
const threadLabel = effective.threadId ?? '(none reported)';
|
|
1554
|
+
|
|
1555
|
+
if (intent.requested === 'clear') {
|
|
1556
|
+
return {
|
|
1557
|
+
requested: 'clear',
|
|
1558
|
+
ok: !hasLineage,
|
|
1559
|
+
effective,
|
|
1560
|
+
note: hasLineage
|
|
1561
|
+
? `WARNING: clearReplyContext was requested, but gog reports the draft STILL carries In-Reply-To ${effective.inReplyTo}. ` +
|
|
1562
|
+
`It has NOT been turned back into a standalone message. Re-read it before sending. ${VERIFICATION_PROVENANCE}`
|
|
1563
|
+
: `Reply context cleared: gog reports no In-Reply-To/References, so this draft will arrive as a standalone message. ` +
|
|
1564
|
+
`Its draft id and its threadId (${threadLabel}) are unchanged — dropping the headers does not move the draft out of ` +
|
|
1565
|
+
`the thread in Gmail's own UI, it only stops recipients' clients threading it. ${BODY_OVERWRITE_CAVEAT} ${VERIFICATION_PROVENANCE}`,
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return {
|
|
1570
|
+
requested: 'set',
|
|
1571
|
+
via: intent.via,
|
|
1572
|
+
target: intent.target,
|
|
1573
|
+
ok: hasLineage,
|
|
1574
|
+
effective,
|
|
1575
|
+
note: hasLineage
|
|
1576
|
+
? `Threading applied and verified: gog reports the draft now replies to ${effective.inReplyTo}, on thread ${threadLabel}, ` +
|
|
1577
|
+
`with the draft id unchanged — it was updated in place, not recreated. See effective.references and ` +
|
|
1578
|
+
`effective.replyContextSource for the rest ("caller" means the lineage was resolved from the target you named, ` +
|
|
1579
|
+
`"carried" that it came from the draft's own stored headers). ${BODY_OVERWRITE_CAVEAT} ${VERIFICATION_PROVENANCE}`
|
|
1580
|
+
: `WARNING: ${intent.via} ${intent.target} was accepted, but gog reports NO reply headers at all (inReplyTo is null). ` +
|
|
1581
|
+
`The draft HAS been moved onto thread ${threadLabel}, so this was not a no-op — it simply will not arrive as a reply, ` +
|
|
1582
|
+
`because recipients' mail clients thread on In-Reply-To/References, not on Gmail's threadId. And an explicit reply ` +
|
|
1583
|
+
`target REPLACES the draft's own stored reply context rather than carrying it forward, so any lineage the draft had ` +
|
|
1584
|
+
`before this call is gone. Do not send it as a reply on this evidence. ${VERIFICATION_PROVENANCE}`,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Hang the verification off the result the caller is getting anyway. Additive,
|
|
1589
|
+
// exactly like gog_gmail_drafts_list: gog's own fields are never removed or
|
|
1590
|
+
// renamed. A result that is not JSON (gog's human table output, an error) keeps
|
|
1591
|
+
// its text and gets the note prepended instead of being reshaped.
|
|
1592
|
+
function withThreadingVerification(result: CallToolResult, verification: ThreadingVerification): CallToolResult {
|
|
1593
|
+
try {
|
|
1594
|
+
// String() rather than a nullish guard: a non-text result stringifies to
|
|
1595
|
+
// 'undefined', which is not valid JSON and lands in the same catch.
|
|
1596
|
+
const parsed = JSON.parse(String(resultText(result))) as Record<string, unknown>;
|
|
1597
|
+
return rawTextResult(JSON.stringify({ ...parsed, threadingVerification: verification }));
|
|
1598
|
+
} catch {
|
|
1599
|
+
return withNote(result, [`threadingVerification: ${verification.note}`]);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ---------------------------------------------------------------------------
|
|
1604
|
+
// REQUIREMENT 5 — AN ADOPTION MUST NOT SILENTLY DROP THE OTHER COPY'S TEXT.
|
|
1605
|
+
//
|
|
1606
|
+
// `draftComposeInput.validate()` hard-requires a body on every update
|
|
1607
|
+
// ("required: --body, --body-file, --body-html, or --body-html-file",
|
|
1608
|
+
// internal/cmd/gmail_drafts.go:321 at upstream-v0.35.0). There is NO
|
|
1609
|
+
// header-only edit. So re-threading a mail client's replacement back onto the
|
|
1610
|
+
// original conversation is also a full body overwrite — precisely the
|
|
1611
|
+
// operation that destroys the paragraph living only in the other copy. In the
|
|
1612
|
+
// observed case NEITHER copy was a superset: the client copy had lost a
|
|
1613
|
+
// paragraph, the Gmail copy had gained sentences, and writing either one over
|
|
1614
|
+
// the other lost work.
|
|
1615
|
+
//
|
|
1616
|
+
// HAZARD B — COST. Opt-in, and exactly ONE extra gog invocation: a `drafts
|
|
1617
|
+
// get` on the sibling the CALLER named. It never scans for a sibling, never
|
|
1618
|
+
// grows with the mailbox, and with `forkSiblingDraftId` absent it spends
|
|
1619
|
+
// nothing and changes no argv.
|
|
1620
|
+
//
|
|
1621
|
+
// HAZARD A — CLAIMS. This compares two bodies. That is the whole of it. Two
|
|
1622
|
+
// unrelated drafts produce a total-divergence report, which is the same shape
|
|
1623
|
+
// a genuine fork produces, so nothing here may read as "this replaced that" —
|
|
1624
|
+
// only gog_gmail_drafts_diff weighs identity, lineage and ordering.
|
|
1625
|
+
//
|
|
1626
|
+
// FAIL CLOSED. A caller who names a sibling asked for a guard, so an
|
|
1627
|
+
// un-runnable check (sibling unfetchable, unparseable, or carrying no readable
|
|
1628
|
+
// body) refuses the write exactly as a detected loss does. An unrun check is
|
|
1629
|
+
// not a passed check. `acceptContentLoss` is the single, explicit override for
|
|
1630
|
+
// both.
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
|
|
1633
|
+
export type ContentLossStatus = 'clean' | 'would-lose' | 'unchecked';
|
|
1634
|
+
|
|
1635
|
+
export type ContentLossCheck = {
|
|
1636
|
+
siblingDraftId: string;
|
|
1637
|
+
status: ContentLossStatus;
|
|
1638
|
+
siblingBodyLineCount: number;
|
|
1639
|
+
newBodyLineCount: number;
|
|
1640
|
+
linesOnlyInSibling: string[];
|
|
1641
|
+
linesOnlyInSiblingCount: number;
|
|
1642
|
+
truncated: boolean;
|
|
1643
|
+
similarity: number;
|
|
1644
|
+
/** Always null. Present so the absence of a pairing verdict is explicit in
|
|
1645
|
+
* the payload rather than something the caller has to notice is missing. */
|
|
1646
|
+
forkClaim: null;
|
|
1647
|
+
forkClaimNote: string;
|
|
1648
|
+
note: string;
|
|
1649
|
+
/** Set only when the caller overrode a non-clean check with acceptContentLoss. */
|
|
1650
|
+
acknowledged?: boolean;
|
|
1651
|
+
/** ...and whether the write it authorised ACTUALLY SUCCEEDED. Only ever set
|
|
1652
|
+
* alongside `acknowledged`, and only after the write has returned, because
|
|
1653
|
+
* the one thing this field must never do is report a save that did not
|
|
1654
|
+
* happen: a caller who believes the merged body is stored may delete or
|
|
1655
|
+
* overwrite the sibling that now holds the only copy of the listed lines. */
|
|
1656
|
+
written?: boolean;
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
const CONTENT_LOSS_NO_CLAIM_NOTE =
|
|
1660
|
+
'This check compares two bodies and nothing else. It does not say that either draft replaced the other, and it cannot: YOU ' +
|
|
1661
|
+
'named this sibling, nothing here searched for it. Identical bodies would not prove a pairing and divergent bodies would not ' +
|
|
1662
|
+
'disprove one — a pairing verdict needs an identity header, a lineage link back to the original and an ordering, which is ' +
|
|
1663
|
+
'what gog_gmail_drafts_diff weighs and reports with its evidence.';
|
|
1664
|
+
|
|
1665
|
+
const CONTENT_LOSS_COMPARISON_NOTE =
|
|
1666
|
+
'Lines are compared after collapsing runs of whitespace and dropping blank lines, and only against the plain-text `body` you ' +
|
|
1667
|
+
'passed — a bodyHtml is not compared, and neither are attachments, recipients or the subject. The comparison is LINE-BASED: ' +
|
|
1668
|
+
'a copy whose paragraphs were re-wrapped at a different width, or whose straight quotes became curly ones, no longer matches ' +
|
|
1669
|
+
'line for line, so it can be reported as loss even though no words were dropped. Read the listed lines before deciding.';
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Which lines of the sibling draft the body about to be written does not
|
|
1673
|
+
* contain — i.e. what this update would leave existing only in the sibling.
|
|
1674
|
+
*
|
|
1675
|
+
* A sibling with no readable body is `unchecked`, never `clean`: reporting
|
|
1676
|
+
* "nothing would be lost" because nothing could be read is the one answer this
|
|
1677
|
+
* guard must never give.
|
|
1678
|
+
*/
|
|
1679
|
+
export function evaluateContentLoss(
|
|
1680
|
+
siblingDraftId: string,
|
|
1681
|
+
siblingBody: string,
|
|
1682
|
+
newBody: string,
|
|
1683
|
+
maxLines: number,
|
|
1684
|
+
): ContentLossCheck {
|
|
1685
|
+
const siblingLines = new Set(normalizeBodyLines(siblingBody));
|
|
1686
|
+
const newLines = new Set(normalizeBodyLines(newBody));
|
|
1687
|
+
const missing = [...siblingLines].filter((line) => !newLines.has(line));
|
|
1688
|
+
const truncated = missing.length > maxLines;
|
|
1689
|
+
const base = {
|
|
1690
|
+
siblingDraftId,
|
|
1691
|
+
siblingBodyLineCount: siblingLines.size,
|
|
1692
|
+
newBodyLineCount: newLines.size,
|
|
1693
|
+
linesOnlyInSibling: missing.slice(0, maxLines),
|
|
1694
|
+
linesOnlyInSiblingCount: missing.length,
|
|
1695
|
+
truncated,
|
|
1696
|
+
similarity: bodySimilarity(siblingBody, newBody),
|
|
1697
|
+
forkClaim: null,
|
|
1698
|
+
forkClaimNote: CONTENT_LOSS_NO_CLAIM_NOTE,
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
if (siblingLines.size === 0) {
|
|
1702
|
+
return {
|
|
1703
|
+
...base,
|
|
1704
|
+
status: 'unchecked',
|
|
1705
|
+
note:
|
|
1706
|
+
`No body text could be read from draft ${siblingDraftId}, so NOTHING WAS COMPARED and nothing is proven. The draft may ` +
|
|
1707
|
+
'genuinely be empty, or its text may sit in a MIME part this server could not decode. Read it with gog_gmail_drafts_get ' +
|
|
1708
|
+
`before overwriting draft text you cannot see. ${CONTENT_LOSS_COMPARISON_NOTE}`,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if (missing.length === 0) {
|
|
1713
|
+
return {
|
|
1714
|
+
...base,
|
|
1715
|
+
status: 'clean',
|
|
1716
|
+
note:
|
|
1717
|
+
`Every line of draft ${siblingDraftId} is already present in the body you passed, so this update leaves nothing behind ` +
|
|
1718
|
+
`in that copy. It says nothing about the reverse direction: lines of the draft being UPDATED that your body omits are ` +
|
|
1719
|
+
`overwritten regardless — this check cannot see them, because gog's write acknowledgement never returns the previous ` +
|
|
1720
|
+
`body. ${CONTENT_LOSS_COMPARISON_NOTE}`,
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
return {
|
|
1725
|
+
...base,
|
|
1726
|
+
status: 'would-lose',
|
|
1727
|
+
note:
|
|
1728
|
+
`WARNING: ${missing.length} line(s) of draft ${siblingDraftId} are NOT in the body you passed. gog requires a body on ` +
|
|
1729
|
+
'every update, so this call rewrites the WHOLE body — afterwards those lines exist only in that sibling draft. Merge them ' +
|
|
1730
|
+
'into the body and retry, or pass acceptContentLoss:true to write anyway.' +
|
|
1731
|
+
(truncated ? ` (Line list truncated to ${maxLines}; linesOnlyInSiblingCount is the true total.)` : '') +
|
|
1732
|
+
` ${CONTENT_LOSS_COMPARISON_NOTE}`,
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** The check could not be RUN — the sibling was unfetchable or unreadable.
|
|
1737
|
+
* Deliberately the same shape and the same gate as a detected loss. */
|
|
1738
|
+
export function unreadableSiblingCheck(siblingDraftId: string, reason: string): ContentLossCheck {
|
|
1739
|
+
return {
|
|
1740
|
+
siblingDraftId,
|
|
1741
|
+
status: 'unchecked',
|
|
1742
|
+
siblingBodyLineCount: 0,
|
|
1743
|
+
newBodyLineCount: 0,
|
|
1744
|
+
linesOnlyInSibling: [],
|
|
1745
|
+
linesOnlyInSiblingCount: 0,
|
|
1746
|
+
truncated: false,
|
|
1747
|
+
similarity: 0,
|
|
1748
|
+
forkClaim: null,
|
|
1749
|
+
forkClaimNote: CONTENT_LOSS_NO_CLAIM_NOTE,
|
|
1750
|
+
note:
|
|
1751
|
+
`Could not read draft ${siblingDraftId} to check what this update would overwrite: ${reason}. NOTHING WAS COMPARED, so ` +
|
|
1752
|
+
'nothing is proven. A draft id that has stopped resolving is itself worth noting — that is what a mail client leaves ' +
|
|
1753
|
+
'behind when it rewrites a draft instead of updating it.',
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** ONE gog invocation, on the id the caller named. No scan, no fallback search. */
|
|
1758
|
+
async function checkSiblingContentLoss(
|
|
1759
|
+
siblingDraftId: string,
|
|
1760
|
+
newBody: string,
|
|
1761
|
+
account: string | undefined,
|
|
1762
|
+
): Promise<ContentLossCheck> {
|
|
1763
|
+
let raw: string;
|
|
1764
|
+
try {
|
|
1765
|
+
raw = await runNormalized(['gmail', 'drafts', 'get', siblingDraftId, '--use-indexed-attachment-ids=false'], { account });
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
return unreadableSiblingCheck(siblingDraftId, String(err));
|
|
1768
|
+
}
|
|
1769
|
+
let message: GmailDraftMessage | undefined;
|
|
1770
|
+
try {
|
|
1771
|
+
message = (JSON.parse(raw) as { draft?: { message?: GmailDraftMessage } }).draft?.message;
|
|
1772
|
+
} catch {
|
|
1773
|
+
message = undefined;
|
|
1774
|
+
}
|
|
1775
|
+
if (!message) {
|
|
1776
|
+
return unreadableSiblingCheck(siblingDraftId, '`gog gmail drafts get` returned no `draft.message` object to read a body from');
|
|
1777
|
+
}
|
|
1778
|
+
return evaluateContentLoss(siblingDraftId, bestBodyText(message.payload), newBody, DRAFT_DIFF_MAX_LINES);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
const CONTENT_LOSS_HOW_TO_PROCEED: readonly string[] = [
|
|
1782
|
+
'Merge the missing lines into your body and call gog_gmail_drafts_update again. The check re-runs, so a complete merge passes it.',
|
|
1783
|
+
'Run gog_gmail_drafts_diff on the two ids first if you want the full picture — it reports both directions of divergence, ' +
|
|
1784
|
+
'whether either body is a superset, how the threading differs, and (separately, with its evidence) whether there is enough ' +
|
|
1785
|
+
'to say one draft replaced the other.',
|
|
1786
|
+
'Pass acceptContentLoss:true to write this body as-is. The sibling draft is not touched either way, so the listed lines are ' +
|
|
1787
|
+
'still recoverable from it afterwards — but the draft you are updating loses whatever your body omits, permanently.',
|
|
1788
|
+
'Drop forkSiblingDraftId to skip the check entirely (and the one gog call it costs).',
|
|
1789
|
+
];
|
|
1790
|
+
|
|
1791
|
+
function contentLossRefusal(draftId: string, check: ContentLossCheck): CallToolResult {
|
|
1792
|
+
const code = check.status === 'unchecked' ? 'DRAFT_CONTENT_LOSS_UNCHECKED' : 'DRAFT_CONTENT_LOSS';
|
|
1793
|
+
const headline =
|
|
1794
|
+
check.status === 'unchecked'
|
|
1795
|
+
? `the content-loss check you asked for could not be run against draft ${check.siblingDraftId}`
|
|
1796
|
+
: `${check.linesOnlyInSiblingCount} line(s) of draft ${check.siblingDraftId} are missing from the body you passed`;
|
|
1797
|
+
const payload = {
|
|
1798
|
+
code,
|
|
1799
|
+
codeMeaning:
|
|
1800
|
+
check.status === 'unchecked'
|
|
1801
|
+
? '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.'
|
|
1802
|
+
: 'The body passed would have dropped text the named sibling still holds, and gog rewrites the whole body on every update.',
|
|
1803
|
+
tool: 'gog_gmail_drafts_update',
|
|
1804
|
+
draftId,
|
|
1805
|
+
forkSiblingDraftId: check.siblingDraftId,
|
|
1806
|
+
whatHappened:
|
|
1807
|
+
`NOTHING WAS WRITTEN. Draft ${draftId} is byte-for-byte as it was: no body, subject, recipient, attachment or reply-header ` +
|
|
1808
|
+
`change was applied, and no gog write ran at all. ${headline}.`,
|
|
1809
|
+
contentLossCheck: check,
|
|
1810
|
+
howToProceed: CONTENT_LOSS_HOW_TO_PROCEED,
|
|
1811
|
+
};
|
|
1812
|
+
return errorResult(
|
|
1813
|
+
`${code}: nothing was written — ${headline}. gog requires a body on every draft update, so there is no header-only edit ` +
|
|
1814
|
+
'and the update would have rewritten the whole body.\n\n' +
|
|
1815
|
+
JSON.stringify(payload, null, 2),
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Additive, like every other block this file hangs off a result: gog's own
|
|
1820
|
+
// fields are never removed or renamed, and a non-JSON result keeps its text and
|
|
1821
|
+
// gets the note prepended instead of being reshaped.
|
|
1822
|
+
function withContentLossCheck(result: CallToolResult, check: ContentLossCheck): CallToolResult {
|
|
1823
|
+
try {
|
|
1824
|
+
const parsed = JSON.parse(String(resultText(result))) as Record<string, unknown>;
|
|
1825
|
+
return rawTextResult(JSON.stringify({ ...parsed, contentLossCheck: check }));
|
|
1826
|
+
} catch {
|
|
1827
|
+
return withNote(result, [`contentLossCheck: ${check.note}`]);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ---------------------------------------------------------------------------
|
|
1832
|
+
// REQUIREMENT 1 — A 404 ON A DRAFT ID IS A REPORT, NOT A BARE notFound.
|
|
1833
|
+
//
|
|
1834
|
+
// A draft created here and then edited in a mail client is not updated in
|
|
1835
|
+
// place: the client writes a NEW draft and abandons the original, so the id
|
|
1836
|
+
// stops resolving and the next write returns `Google API error (404 notFound)`
|
|
1837
|
+
// — which reads exactly like "your draft was deleted" and leaves the caller to
|
|
1838
|
+
// rebuild the state by hand.
|
|
1839
|
+
//
|
|
1840
|
+
// HAZARD A DECIDES THE SHAPE. The 404'd draft cannot be fetched, so there is
|
|
1841
|
+
// nothing left to establish LINEAGE against, and without lineage no pairing
|
|
1842
|
+
// verdict is possible at any tier. This report therefore names NO replacement,
|
|
1843
|
+
// ever. It reports what exists and hands over the one tool that can decide.
|
|
1844
|
+
//
|
|
1845
|
+
// HAZARD B DECIDES THE COST. At most 2 extra gog invocations, CONSTANT in the
|
|
1846
|
+
// number of drafts, and only on a call that has already failed. A non-404
|
|
1847
|
+
// failure spends nothing. Both are asserted by tests.
|
|
1848
|
+
// ---------------------------------------------------------------------------
|
|
1849
|
+
|
|
1850
|
+
/** gog renders a Google 404 as `Google API error (404 notFound): ...`, or
|
|
1851
|
+
* `Google API error (404): ...` when the error carries no reason
|
|
1852
|
+
* (internal/errfmt/googleapi.go:63-66 at upstream-v0.35.0). The second
|
|
1853
|
+
* alternative catches a 404 that reached us through some other rendering.
|
|
1854
|
+
* Both require the literal 404: the word "notFound" alone never triggers it. */
|
|
1855
|
+
const DRAFT_NOT_FOUND_PATTERN = /Google API error \(404\b|\b404\b[^\n]{0,40}not\s?found/i;
|
|
1856
|
+
|
|
1857
|
+
/** Hard cap on the fork report's candidate window. Constant by construction —
|
|
1858
|
+
* this is a failure path and must not grow with the mailbox. */
|
|
1859
|
+
const DRAFT_FORK_MAX_CANDIDATES = 20;
|
|
1860
|
+
|
|
1861
|
+
const DRAFT_FORK_CLAIM_NOTE =
|
|
1862
|
+
'This report names NO replacement, and cannot. The 404\'d draft can no longer be fetched, so there is nothing left to ' +
|
|
1863
|
+
'establish lineage against — no References citing it, no shared reply root, no body to compare — and without a lineage ' +
|
|
1864
|
+
'signal no pairing verdict is possible at any cost tier. The drafts below are simply the drafts that exist right now; ' +
|
|
1865
|
+
'ordering is presentation, not evidence. To decide whether one draft replaced another, name a PAIR and run ' +
|
|
1866
|
+
'gog_gmail_drafts_diff, which reads both sides\' headers and bodies.';
|
|
1867
|
+
|
|
1868
|
+
const DRAFT_FORK_OTHER_EXPLANATIONS: readonly string[] = [
|
|
1869
|
+
'The draft was deleted — by you, by a mail client, or by an earlier gog_gmail_drafts_delete. A deleted draft 404s identically.',
|
|
1870
|
+
'The draft was already sent. Sending consumes the draft, so its id stops resolving; check Sent before recreating anything.',
|
|
1871
|
+
'A mail client rewrote the draft instead of updating it in place, writing a NEW draft and abandoning this id. This is the ' +
|
|
1872
|
+
'only one of the three that strands text in two places, and the reason this report exists.',
|
|
1873
|
+
];
|
|
1874
|
+
|
|
1875
|
+
/** The explanation that belongs FIRST whenever the caller passed a reply
|
|
1876
|
+
* target: `gmail drafts update` resolves the draft, the thread behind
|
|
1877
|
+
* --thread-id and the message behind --reply-to-message-id, and gog renders
|
|
1878
|
+
* all three 404s with the identical string. GOOGLE_404_NOT_THE_DRAFT already
|
|
1879
|
+
* treats this as the leading alternative when the draft IS listed; the branch
|
|
1880
|
+
* that could NOT find the draft has strictly less evidence, so it must not be
|
|
1881
|
+
* the one that stays silent about it. */
|
|
1882
|
+
function replyTargetExplanation(replyTarget: ReplyTarget): string {
|
|
1883
|
+
return `The 404 may have been about your REPLY TARGET rather than the draft. You passed ${replyTarget.via}=${replyTarget.target}, ` +
|
|
1884
|
+
'and `gog gmail drafts update` resolves up to three different Google entities — the draft (Users.Drafts.Get/Update), the ' +
|
|
1885
|
+
'thread behind --thread-id and the message behind --reply-to-message-id — which gog renders with the IDENTICAL 404 string ' +
|
|
1886
|
+
'(internal/errfmt/googleapi.go). Thread ids and message ids are both 16-hex strings and are routinely confused, and a ' +
|
|
1887
|
+
'thread id copied from a stale record may simply no longer exist. Fetch it — gog_gmail_thread_get for a thread id, ' +
|
|
1888
|
+
'gog_gmail_get for a message id — before concluding anything about the draft.';
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** What the post-failure listing is actually able to say about the draft id.
|
|
1892
|
+
* `absence of evidence` and `evidence of absence` are different answers, and
|
|
1893
|
+
* only one of them is a fork story. */
|
|
1894
|
+
type DraftListingBasis = 'complete-listing' | 'capped-listing' | 'listing-unavailable';
|
|
1895
|
+
|
|
1896
|
+
const DRAFT_LISTING_BASIS_NOTE: Record<DraftListingBasis, (draftId: string, listed: number) => string> = {
|
|
1897
|
+
'complete-listing': (draftId, listed) =>
|
|
1898
|
+
`The listing returned ${listed} draft(s) — FEWER than the ${DRAFT_FORK_MAX_CANDIDATES}-draft window it asked for, so it ` +
|
|
1899
|
+
`covers the whole Drafts folder. Draft ${draftId} really is not in the mailbox.`,
|
|
1900
|
+
'capped-listing': (draftId) =>
|
|
1901
|
+
`The listing came back FULL: ${DRAFT_FORK_MAX_CANDIDATES} drafts, which is the entire window it asked for, so it is ` +
|
|
1902
|
+
`TRUNCATED and draft ${draftId} could still exist beyond it. "Not listed here" is NOT evidence that the draft is gone — ` +
|
|
1903
|
+
'the window is capped by construction, because this is a failure path and must not grow with the size of the mailbox. ' +
|
|
1904
|
+
`Run gog_gmail_drafts_list with a larger max (or all:true) before concluding the draft forked.`,
|
|
1905
|
+
'listing-unavailable': (draftId) =>
|
|
1906
|
+
`The listing FAILED, so nothing here shows whether draft ${draftId} still exists. The 404 is the only evidence there is, ` +
|
|
1907
|
+
'and gog renders the draft, thread and message 404s identically. Run gog_gmail_drafts_list yourself before acting.',
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
const DRAFT_FORK_NEXT_STEPS: readonly string[] = [
|
|
1911
|
+
'Run gog_gmail_drafts_list — origin and rootsOwnThread cost nothing there — and look for a draft you did not create through this server.',
|
|
1912
|
+
'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 ' +
|
|
1913
|
+
'the only one that reads both sides\' identity headers, reply lineage and bodies. It cannot be pointed at THIS id — a 404\'d ' +
|
|
1914
|
+
'draft cannot be fetched at all — so diff the survivor against another draft you still have.',
|
|
1915
|
+
'If a replacement lost its reply threading, re-thread it IN PLACE with gog_gmail_drafts_update replyToThreadId=<the original ' +
|
|
1916
|
+
'thread id>: the draft keeps its id and gog resolves In-Reply-To/References from that thread\'s latest message, reporting ' +
|
|
1917
|
+
'them back under threadingVerification. It requires a full body, so merge the two bodies FIRST — whatever you do not pass is lost.',
|
|
1918
|
+
'If the draft was deleted or already sent, nothing forked and there is nothing to reconcile.',
|
|
1919
|
+
];
|
|
1920
|
+
|
|
1921
|
+
/** Tier-0 + tier-1 facts about the drafts that DO exist, for the fork report.
|
|
1922
|
+
* Never more than 2 gog invocations, and it degrades rather than throwing: a
|
|
1923
|
+
* failed lookup must not replace the explanation the caller came for. */
|
|
1924
|
+
async function currentDraftsForForkReport(account: string | undefined): Promise<Record<string, unknown>> {
|
|
1925
|
+
let entries: DraftListEntry[];
|
|
1926
|
+
try {
|
|
1927
|
+
const listed = JSON.parse(
|
|
1928
|
+
await run(['gmail', 'drafts', 'list', `--max=${DRAFT_FORK_MAX_CANDIDATES}`], { account }),
|
|
1929
|
+
) as { drafts?: unknown };
|
|
1930
|
+
if (!Array.isArray(listed.drafts)) throw new Error('`gog gmail drafts list` returned no drafts array');
|
|
1931
|
+
entries = listed.drafts as DraftListEntry[];
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
return {
|
|
1934
|
+
extraGogCalls: 1,
|
|
1935
|
+
listingBasis: 'listing-unavailable' satisfies DraftListingBasis,
|
|
1936
|
+
currentDraftsUnavailable:
|
|
1937
|
+
`Could not list the surviving drafts (${String(err)}), so this report names none. Run gog_gmail_drafts_list yourself — ` +
|
|
1938
|
+
'origin and rootsOwnThread are free there.',
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Tier 1: ONE more invocation buys subject/from/date for every draft. Worth
|
|
1943
|
+
// it here because "which of these is mine" is unanswerable from three opaque
|
|
1944
|
+
// ids — but it is still one command that makes gog fetch each matching draft
|
|
1945
|
+
// server-side, so the cap above is what keeps that bounded.
|
|
1946
|
+
const byMessageId = new Map<string | undefined, EnrichedDraftMessage>();
|
|
1947
|
+
let enrichmentNote: string;
|
|
1948
|
+
try {
|
|
1949
|
+
const searched = JSON.parse(await runNormalized([
|
|
1950
|
+
'gmail', 'messages', 'search', 'in:drafts', `--max=${DRAFT_FORK_MAX_CANDIDATES}`,
|
|
1951
|
+
'--include-attachments=false', '--use-indexed-attachment-ids=false',
|
|
1952
|
+
], { account })) as { messages?: EnrichedDraftMessage[] };
|
|
1953
|
+
if (!Array.isArray(searched.messages)) throw new Error('`gog gmail messages search in:drafts` returned no messages array');
|
|
1954
|
+
for (const m of searched.messages) {
|
|
1955
|
+
if (m.id) byMessageId.set(m.id, m);
|
|
1956
|
+
}
|
|
1957
|
+
enrichmentNote = DRAFT_ENRICH_COST_NOTE;
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
enrichmentNote =
|
|
1960
|
+
`subject, from and internalDateIso are missing: the single enrichment call failed (${String(err)}). The free fields ` +
|
|
1961
|
+
'(origin, rootsOwnThread) are unaffected.';
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const currentDrafts = entries.map((d) => {
|
|
1965
|
+
const extra = byMessageId.get(d.messageId);
|
|
1966
|
+
return {
|
|
1967
|
+
...d,
|
|
1968
|
+
origin: originFromDraftId(d.id ?? ''),
|
|
1969
|
+
rootsOwnThread: rootsOwnThread(d),
|
|
1970
|
+
subject: extra?.subject,
|
|
1971
|
+
from: extra?.from,
|
|
1972
|
+
internalDateIso: extra?.internalDateIso,
|
|
1973
|
+
};
|
|
1974
|
+
});
|
|
1975
|
+
// Newest first WHERE A DATE WAS AVAILABLE, and nowhere else: ISO strings sort
|
|
1976
|
+
// chronologically, and a missing date sorts last rather than winning by
|
|
1977
|
+
// accident. This is presentation only — forkClaimNote says so in words,
|
|
1978
|
+
// because "listed first" is exactly the kind of thing a caller reads as a
|
|
1979
|
+
// verdict.
|
|
1980
|
+
currentDrafts.sort((x, y) => (y.internalDateIso ?? '').localeCompare(x.internalDateIso ?? ''));
|
|
1981
|
+
|
|
1982
|
+
// A window that came back FULL may have cut the mailbox off; one that came
|
|
1983
|
+
// back short covered all of it. That difference is the whole difference
|
|
1984
|
+
// between "the draft is gone" and "I did not see the draft".
|
|
1985
|
+
const listingBasis: DraftListingBasis =
|
|
1986
|
+
entries.length >= DRAFT_FORK_MAX_CANDIDATES ? 'capped-listing' : 'complete-listing';
|
|
1987
|
+
return { extraGogCalls: 2, listingBasis, currentDrafts, enrichmentNote };
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/** Split the listing's self-assessment out of the rows it returned, so the
|
|
1991
|
+
* basis can be reported as its own block rather than as a loose field. */
|
|
1992
|
+
function splitListingBasis(report: Record<string, unknown>): { basis: DraftListingBasis; rest: Record<string, unknown> } {
|
|
1993
|
+
const { listingBasis, ...rest } = report;
|
|
1994
|
+
return { basis: listingBasis as DraftListingBasis, rest };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function draftForkedResult(
|
|
1998
|
+
tool: string,
|
|
1999
|
+
draftId: string,
|
|
2000
|
+
gogError: string,
|
|
2001
|
+
report: Record<string, unknown>,
|
|
2002
|
+
replyTarget: ReplyTarget | undefined,
|
|
2003
|
+
): CallToolResult {
|
|
2004
|
+
const { basis, rest } = splitListingBasis(report);
|
|
2005
|
+
const listed = Array.isArray(rest.currentDrafts) ? rest.currentDrafts.length : 0;
|
|
2006
|
+
// ONLY a listing that covered the whole folder is entitled to the sentence
|
|
2007
|
+
// "the draft no longer resolves". A capped window that came back full, or a
|
|
2008
|
+
// listing that failed outright, has not looked everywhere — and treating
|
|
2009
|
+
// absence of evidence as the fork story sends the caller hunting for a
|
|
2010
|
+
// replacement that does not exist, and possibly recreating correspondence
|
|
2011
|
+
// that is already in the mailbox.
|
|
2012
|
+
const proven = basis === 'complete-listing';
|
|
2013
|
+
const whatHappened = proven
|
|
2014
|
+
? `${tool} could not act on draft ${draftId}: Gmail no longer has a draft with that id, and a listing that covered the ` +
|
|
2015
|
+
'whole Drafts folder does not contain it either. Editing a draft in a real mail client does not update it in place — the ' +
|
|
2016
|
+
'client writes a NEW draft and abandons the original — so the id you were given stops resolving, the replacement usually ' +
|
|
2017
|
+
'sits on its OWN threadId with no In-Reply-To/References (sending it would start a new conversation in front of every ' +
|
|
2018
|
+
'recipient, including anyone on Cc), and each copy can hold text the other lost. Gmail has no draft under this id, so ' +
|
|
2019
|
+
'nothing this call carried — subject, body, recipients — is saved under it.'
|
|
2020
|
+
: `${tool} did not run and NOTHING WAS WRITTEN: Gmail returned 404 notFound. Whether draft ${draftId} itself still exists ` +
|
|
2021
|
+
`is NOT established here — ${DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed)} A mail client rewriting a draft instead of ` +
|
|
2022
|
+
'updating it in place is one explanation for a 404 like this, and the reason this report exists, but on this evidence it ' +
|
|
2023
|
+
'is only one of several — read otherExplanations before acting on any of them.';
|
|
2024
|
+
const payload = {
|
|
2025
|
+
code: 'DRAFT_FORKED',
|
|
2026
|
+
codeMeaning:
|
|
2027
|
+
'Gmail returned 404 notFound for this draft id. DRAFT_FORKED names the most common CAUSE — a mail client rewriting the ' +
|
|
2028
|
+
'draft instead of updating it in place — not a proven one: deletion and sending produce the same 404, and so does a ' +
|
|
2029
|
+
'stale reply target. See otherExplanations, and see listingEvidence for what the post-failure listing could actually show.',
|
|
2030
|
+
tool,
|
|
2031
|
+
draftId,
|
|
2032
|
+
gogError,
|
|
2033
|
+
whatHappened,
|
|
2034
|
+
replyTarget: replyTarget ?? null,
|
|
2035
|
+
listingEvidence: {
|
|
2036
|
+
basis,
|
|
2037
|
+
windowSize: DRAFT_FORK_MAX_CANDIDATES,
|
|
2038
|
+
draftsListed: basis === 'listing-unavailable' ? null : listed,
|
|
2039
|
+
draftFoundInListing: false,
|
|
2040
|
+
establishesTheDraftIsGone: proven,
|
|
2041
|
+
note: DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed),
|
|
2042
|
+
},
|
|
2043
|
+
forkClaim: null,
|
|
2044
|
+
forkClaimNote: DRAFT_FORK_CLAIM_NOTE,
|
|
2045
|
+
...rest,
|
|
2046
|
+
otherExplanations: replyTarget
|
|
2047
|
+
? [replyTargetExplanation(replyTarget), ...DRAFT_FORK_OTHER_EXPLANATIONS]
|
|
2048
|
+
: DRAFT_FORK_OTHER_EXPLANATIONS,
|
|
2049
|
+
nextSteps: DRAFT_FORK_NEXT_STEPS,
|
|
2050
|
+
signalsThatNeverSuffice: FORK_SIGNALS_THAT_NEVER_SUFFICE,
|
|
2051
|
+
};
|
|
2052
|
+
const headline = proven
|
|
2053
|
+
? `DRAFT_FORKED: draft ${draftId} no longer resolves — Gmail 404'd it and a listing that covered the whole Drafts folder ` +
|
|
2054
|
+
`does not contain it — so ${tool} did not run. The usual cause is a mail client rewriting the draft under a new id ` +
|
|
2055
|
+
'rather than updating it; deletion and sending look identical from here.'
|
|
2056
|
+
: `DRAFT_FORKED: Gmail returned 404 notFound for draft ${draftId}, so ${tool} did not run and nothing was written. ` +
|
|
2057
|
+
`Whether that draft still exists is NOT established: ${DRAFT_LISTING_BASIS_NOTE[basis](draftId, listed)}`;
|
|
2058
|
+
return errorResult(
|
|
2059
|
+
`${headline} No replacement is named below — that judgement needs a named pair and gog_gmail_drafts_diff.\n\n` +
|
|
2060
|
+
JSON.stringify(payload, null, 2),
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** What the caller asked the draft to reply to, if anything. `gmail drafts
|
|
2065
|
+
* update` resolves this SEPARATELY from the draft, and a miss 404s
|
|
2066
|
+
* identically. */
|
|
2067
|
+
type ReplyTarget = { via: 'replyToMessageId' | 'replyToThreadId'; target: string };
|
|
2068
|
+
|
|
2069
|
+
/** Did the listing we just took still contain the id we failed on? An id-less
|
|
2070
|
+
* row (every field is `omitempty` in gog) can never match. */
|
|
2071
|
+
function draftIsStillListed(report: Record<string, unknown>, draftId: string): boolean {
|
|
2072
|
+
const listed = report.currentDrafts;
|
|
2073
|
+
return Array.isArray(listed) && listed.some((d) => (d as { id?: string }).id === draftId);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const NOT_THE_DRAFT_RACE_NOTE =
|
|
2077
|
+
'The listing was taken AFTER the failure, so it is evidence about now, not about the instant the call ran. If something ' +
|
|
2078
|
+
'recreated a draft under this id in between — vanishingly unlikely, but not impossible — the listed draft could be a ' +
|
|
2079
|
+
'different one from the draft you addressed.';
|
|
2080
|
+
|
|
2081
|
+
const NOT_THE_DRAFT_WHY_404 =
|
|
2082
|
+
'`gog gmail drafts update` resolves up to THREE different Google entities, and gog renders all three 404s with the same ' +
|
|
2083
|
+
'string (`Google API error (404 notFound): Requested entity was not found.`, internal/errfmt/googleapi.go): the DRAFT itself ' +
|
|
2084
|
+
'(Users.Drafts.Get/Update), the THREAD behind --thread-id (Users.Threads.Get, i.e. replyToThreadId) and the MESSAGE behind ' +
|
|
2085
|
+
'--reply-to-message-id (Users.Messages.Get). The error text alone cannot tell them apart — the draft listing can.';
|
|
2086
|
+
|
|
2087
|
+
function notTheDraftResult(
|
|
2088
|
+
tool: string,
|
|
2089
|
+
draftId: string,
|
|
2090
|
+
gogError: string,
|
|
2091
|
+
report: Record<string, unknown>,
|
|
2092
|
+
replyTarget: ReplyTarget | undefined,
|
|
2093
|
+
): CallToolResult {
|
|
2094
|
+
const targetClause = replyTarget
|
|
2095
|
+
? `You passed ${replyTarget.via}=${replyTarget.target}; since the draft resolves, THAT id is the one that did not, and it ` +
|
|
2096
|
+
'is the first thing to check. Thread ids and message ids are both 16-hex strings and are routinely confused, and a ' +
|
|
2097
|
+
'thread id copied from a stale record may simply no longer exist.'
|
|
2098
|
+
: 'This call named no reply target, so the 404 came from somewhere else in it. Whatever it was, it was not this draft id.';
|
|
2099
|
+
const { rest } = splitListingBasis(report);
|
|
2100
|
+
const payload = {
|
|
2101
|
+
code: 'GOOGLE_404_NOT_THE_DRAFT',
|
|
2102
|
+
codeMeaning:
|
|
2103
|
+
`Google returned 404 notFound, but draft ${draftId} is STILL LISTED in the mailbox, so the 404 was not about the draft ` +
|
|
2104
|
+
'id. It is deliberately NOT reported as a fork: nothing here suggests a mail client replaced anything.',
|
|
2105
|
+
tool,
|
|
2106
|
+
draftId,
|
|
2107
|
+
gogError,
|
|
2108
|
+
whatHappened:
|
|
2109
|
+
`${tool} did not run and NOTHING WAS WRITTEN — but draft ${draftId} still exists: it is still listed below, in a ` +
|
|
2110
|
+
`listing taken after the failure. ${NOT_THE_DRAFT_WHY_404} ${targetClause}`,
|
|
2111
|
+
replyTarget: replyTarget ?? null,
|
|
2112
|
+
forkClaim: null,
|
|
2113
|
+
forkClaimNote:
|
|
2114
|
+
'No fork is claimed and none is implied. The draft you addressed still resolves, which is the opposite of what a mail ' +
|
|
2115
|
+
'client rewriting a draft leaves behind.',
|
|
2116
|
+
...rest,
|
|
2117
|
+
raceNote: NOT_THE_DRAFT_RACE_NOTE,
|
|
2118
|
+
nextSteps: [
|
|
2119
|
+
'Check the reply target, not the draft: a thread id belongs in replyToThreadId and a message id in replyToMessageId, ' +
|
|
2120
|
+
'and both are 16-hex strings. Fetch it — gog_gmail_thread_get for a thread id, gog_gmail_get for a message id — and a ' +
|
|
2121
|
+
'404 there confirms the target is what is missing.',
|
|
2122
|
+
'If the thread id came from a stale record (an old fork report, an earlier note), re-find the conversation with ' +
|
|
2123
|
+
'gog_gmail_search and take the thread id from a message that still exists.',
|
|
2124
|
+
'Re-run the call without the reply target to confirm the draft itself writes fine. Remember it rewrites the WHOLE body, ' +
|
|
2125
|
+
'so pass the body you actually want.',
|
|
2126
|
+
'Do NOT go hunting for a replacement draft. Nothing here says this draft forked.',
|
|
2127
|
+
],
|
|
2128
|
+
};
|
|
2129
|
+
return errorResult(
|
|
2130
|
+
`GOOGLE_404_NOT_THE_DRAFT: Google said 404 notFound, but draft ${draftId} is still listed, so the 404 was not about the ` +
|
|
2131
|
+
`draft id — ${replyTarget ? `the reply target ${replyTarget.via}=${replyTarget.target} is the remaining explanation` : 'something else in the call is the explanation'}. ` +
|
|
2132
|
+
`${tool} did not run and nothing was written. This is NOT a fork.\n\n` +
|
|
2133
|
+
JSON.stringify(payload, null, 2),
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Turn a draft-not-found failure into the report above; pass everything else
|
|
2139
|
+
* through untouched, having spent nothing.
|
|
2140
|
+
*
|
|
2141
|
+
* The 404 is only attributed to the DRAFT ID once the listing has failed to
|
|
2142
|
+
* find it. When the draft is still there, the same (already-paid-for) listing
|
|
2143
|
+
* refutes the fork story, and the report says so instead of sending the caller
|
|
2144
|
+
* after a replacement that does not exist.
|
|
2145
|
+
*/
|
|
2146
|
+
async function forkAwareDraftFailure(
|
|
2147
|
+
result: CallToolResult,
|
|
2148
|
+
tool: string,
|
|
2149
|
+
draftId: string,
|
|
2150
|
+
account: string | undefined,
|
|
2151
|
+
replyTarget?: ReplyTarget,
|
|
2152
|
+
): Promise<CallToolResult> {
|
|
2153
|
+
// A success is never inspected: a body that happens to quote "404 not found"
|
|
2154
|
+
// must not trigger this. String() rather than a nullish guard because an
|
|
2155
|
+
// error result always carries a text block, and the literal 'undefined'
|
|
2156
|
+
// matches no 404 pattern anyway.
|
|
2157
|
+
if (result.isError !== true) return result;
|
|
2158
|
+
const text = String(resultText(result));
|
|
2159
|
+
if (!DRAFT_NOT_FOUND_PATTERN.test(text)) return result;
|
|
2160
|
+
const report = await currentDraftsForForkReport(account);
|
|
2161
|
+
return draftIsStillListed(report, draftId)
|
|
2162
|
+
? notTheDraftResult(tool, draftId, text, report, replyTarget)
|
|
2163
|
+
: draftForkedResult(tool, draftId, text, report, replyTarget);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
384
2166
|
export function registerExtraGmailTools(server: McpServer): void {
|
|
385
2167
|
server.registerTool('gog_gmail_raw', {
|
|
386
2168
|
description: 'Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).',
|
|
@@ -830,20 +2612,203 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
830
2612
|
});
|
|
831
2613
|
|
|
832
2614
|
server.registerTool('gog_gmail_drafts_list', {
|
|
833
|
-
description:
|
|
2615
|
+
description:
|
|
2616
|
+
'List Gmail drafts. Each entry is annotated FOR FREE — no extra gog invocation, whatever the number of drafts — with ' +
|
|
2617
|
+
'`origin` (`api` = created through the Gmail API; `non-api` = the id begins `s:`, i.e. it arrived over IMAP/sync) and ' +
|
|
2618
|
+
'`rootsOwnThread` (threadId equals the draft\'s own messageId, so sending it starts a NEW conversation instead of replying). ' +
|
|
2619
|
+
'Read those two as facts about the draft, NOT as a fork verdict: `non-api` is not "Apple Mail" (Thunderbird, Outlook-over-IMAP ' +
|
|
2620
|
+
'and Gmail offline all produce `s:` ids), and rootsOwnThread was a 4/8 = 0.50 coin flip for Apple authorship on a live mailbox. ' +
|
|
2621
|
+
'The prose behind rootsOwnThread is one of exactly two constants, so it rides along ONCE per result under `threadingNotes` ' +
|
|
2622
|
+
'(`rootsOwnThread` / `inThread`) and the per-row boolean selects between them. ' +
|
|
2623
|
+
'To decide whether one draft actually replaced another, diff the named pair with gog_gmail_drafts_diff.',
|
|
834
2624
|
annotations: { readOnlyHint: true },
|
|
835
2625
|
inputSchema: {
|
|
836
2626
|
max: z.number().optional().describe('Max results (default: 20)'),
|
|
837
2627
|
page: z.string().optional().describe('Page token'),
|
|
838
2628
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
2629
|
+
enrich: z.boolean().optional().describe(
|
|
2630
|
+
'Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) ' +
|
|
2631
|
+
'regardless of how many drafts there are — but that single command makes gog fetch every matching draft server-side at ' +
|
|
2632
|
+
'concurrency 10, so Google reads and wall-clock are linear in the result count even though gog spawns are not. Narrow `max` ' +
|
|
2633
|
+
'before enabling it. If the search fails the listing silently degrades to the free fields rather than erroring.',
|
|
2634
|
+
),
|
|
839
2635
|
account: accountParam,
|
|
840
2636
|
},
|
|
841
|
-
}, async ({ max, page, all, account }) => {
|
|
2637
|
+
}, async ({ max, page, all, enrich, account }) => {
|
|
842
2638
|
const args = ['gmail', 'drafts', 'list'];
|
|
843
2639
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
844
2640
|
if (page) args.push(`--page=${page}`);
|
|
845
2641
|
if (all) args.push('--all');
|
|
846
|
-
|
|
2642
|
+
const result = await runOrDiagnose(args, { account });
|
|
2643
|
+
|
|
2644
|
+
// Post-process gog's own JSON, exactly as trimThread does: anything that is
|
|
2645
|
+
// not a drafts listing (an error, a "No drafts" line, an unexpected shape)
|
|
2646
|
+
// passes through untouched, and gog's fields are only ever ADDED to.
|
|
2647
|
+
let parsed: Record<string, unknown>;
|
|
2648
|
+
let entries: DraftListEntry[];
|
|
2649
|
+
try {
|
|
2650
|
+
parsed = JSON.parse(resultText(result) ?? '') as Record<string, unknown>;
|
|
2651
|
+
const rawDrafts = parsed.drafts;
|
|
2652
|
+
if (!Array.isArray(rawDrafts)) return result;
|
|
2653
|
+
entries = rawDrafts as DraftListEntry[];
|
|
2654
|
+
} catch {
|
|
2655
|
+
return result;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// Tier 1 is opt-in and capped at ONE extra invocation. It is deliberately
|
|
2659
|
+
// spent only AFTER the listing parsed, so an unparseable listing never
|
|
2660
|
+
// costs a second spawn. The key type allows `undefined` so the join below
|
|
2661
|
+
// needs no branch; `undefined` is never inserted.
|
|
2662
|
+
const byMessageId = new Map<string | undefined, EnrichedDraftMessage>();
|
|
2663
|
+
let enrichment: Record<string, unknown> | undefined;
|
|
2664
|
+
if (enrich) {
|
|
2665
|
+
const searchArgs = ['gmail', 'messages', 'search', 'in:drafts', `--max=${max ?? GOG_DRAFTS_LIST_DEFAULT_MAX}`];
|
|
2666
|
+
if (all) searchArgs.push('--all');
|
|
2667
|
+
// The caller's page token, or enrichment silently searches page 1 while the
|
|
2668
|
+
// list is on page N: an extra gog spawn that joins ZERO rows and still
|
|
2669
|
+
// reported applied:true. The token is a drafts-list cursor, so it is only
|
|
2670
|
+
// meaningful to the paged search.
|
|
2671
|
+
if (page) searchArgs.push(`--page=${page}`);
|
|
2672
|
+
// Both PINNED for the same reason as gog_gmail_messages_search: the env
|
|
2673
|
+
// vars behind them change the result shape (and the per-message cost).
|
|
2674
|
+
searchArgs.push('--include-attachments=false', '--use-indexed-attachment-ids=false');
|
|
2675
|
+
try {
|
|
2676
|
+
const messages = (JSON.parse(await runNormalized(searchArgs, { account })) as { messages?: EnrichedDraftMessage[] }).messages;
|
|
2677
|
+
if (!Array.isArray(messages)) throw new Error('`gog gmail messages search in:drafts` returned no messages array');
|
|
2678
|
+
for (const m of messages) {
|
|
2679
|
+
if (m.id) byMessageId.set(m.id, m);
|
|
2680
|
+
}
|
|
2681
|
+
enrichment = { requested: true, applied: true, extraGogCalls: 1, matched: 0, unmatched: 0, costNote: DRAFT_ENRICH_COST_NOTE };
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
// Never an error: the caller asked for a listing and gets one, with the
|
|
2684
|
+
// free tier-0 fields intact and an explicit reason the rest is missing.
|
|
2685
|
+
enrichment = {
|
|
2686
|
+
requested: true,
|
|
2687
|
+
applied: false,
|
|
2688
|
+
extraGogCalls: 1,
|
|
2689
|
+
reason: `Enrichment failed, so the listing degraded to the free tier-0 fields (origin, rootsOwnThread): ${String(err)}`,
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
let matched = 0;
|
|
2695
|
+
const drafts = entries.map((d) => {
|
|
2696
|
+
const roots = rootsOwnThread(d);
|
|
2697
|
+
const extra = byMessageId.get(d.messageId);
|
|
2698
|
+
if (extra) matched += 1;
|
|
2699
|
+
return {
|
|
2700
|
+
...d,
|
|
2701
|
+
origin: originFromDraftId(d.id ?? ''),
|
|
2702
|
+
rootsOwnThread: roots,
|
|
2703
|
+
...(extra ? { subject: extra.subject, from: extra.from, internalDateIso: extra.internalDateIso } : {}),
|
|
2704
|
+
};
|
|
2705
|
+
});
|
|
2706
|
+
if (enrichment?.applied === true) {
|
|
2707
|
+
enrichment.matched = matched;
|
|
2708
|
+
enrichment.unmatched = entries.length - matched;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
return rawTextResult(JSON.stringify({
|
|
2712
|
+
...parsed,
|
|
2713
|
+
drafts,
|
|
2714
|
+
originNote: DRAFT_LIST_ORIGIN_NOTE,
|
|
2715
|
+
threadingNotes: { rootsOwnThread: DRAFT_ROOTS_OWN_THREAD_NOTE, inThread: DRAFT_IN_THREAD_NOTE },
|
|
2716
|
+
...(enrichment ? { enrichment } : {}),
|
|
2717
|
+
}));
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
server.registerTool('gog_gmail_drafts_diff', {
|
|
2721
|
+
description:
|
|
2722
|
+
'Compare TWO NAMED DRAFTS and report exactly how they diverged: which body lines exist only in one, whether either is a ' +
|
|
2723
|
+
'superset of the other, how their threading differs, and — kept deliberately separate from all of that — whether there is ' +
|
|
2724
|
+
'enough evidence to say one REPLACED the other. Use it when a draft you created stopped resolving (`gog_gmail_drafts_update` ' +
|
|
2725
|
+
'returning `Google API error (404 notFound)` is the usual first symptom) and a newer draft has appeared: editing a draft in a ' +
|
|
2726
|
+
'real mail client does not update it in place, it writes a new draft and abandons the original, so both copies can hold text ' +
|
|
2727
|
+
'the other lost. COST: exactly 2 gog invocations, one `gmail drafts get` per named draft. It never scans the mailbox and never ' +
|
|
2728
|
+
'grows with the number of drafts. ' +
|
|
2729
|
+
'THE PAIRING VERDICT IS `confirmed` ONLY when all four of these hold: an Apple identity header on the candidate, a real ' +
|
|
2730
|
+
'LINEAGE link FROM THE CANDIDATE TO THE ORIGINAL, a strictly newer candidate, and the same From. Exactly two things count ' +
|
|
2731
|
+
'as lineage, and both point at the original itself: (a) the ORIGINAL DRAFT\'s own Message-Id appearing in the candidate\'s ' +
|
|
2732
|
+
'In-Reply-To/References, or (b) agreement on text NEITHER draft quoted AND NEITHER CLIENT GENERATED — the salutation, the ' +
|
|
2733
|
+
'closing formula, the name under it and the signature block are excluded alongside quoting, because a mail client ' +
|
|
2734
|
+
'reproduces all of them identically on every message it composes — meeting all three printed minimums (similarity, ' +
|
|
2735
|
+
'shared lines, shared characters — all reported under bodyAgreement, alongside quotedLinesIgnored and ' +
|
|
2736
|
+
'boilerplateLinesIgnored so you can see what each filter removed). A SHARED REPLY ROOT IS NOT LINEAGE: it links both ' +
|
|
2737
|
+
'drafts to a common ANCESTOR, which every reply in a thread has, so it is reported as corroboration and can raise the ' +
|
|
2738
|
+
'answer no higher than an explicitly WEAK `candidate`. Anything less than all four is `candidate` and names every missing ' +
|
|
2739
|
+
'signal; with neither lineage nor corroboration it is `none` — which means no evidence was FOUND, not that the drafts are ' +
|
|
2740
|
+
'proven unrelated (the comparison is line-based, so re-wrapping and smart quotes can hide a real link). NONE OF THE ' +
|
|
2741
|
+
'FOLLOWING EVER SUFFICES, alone or combined: ' +
|
|
2742
|
+
FORK_SIGNALS_THAT_NEVER_SUFFICE.join(' ') +
|
|
2743
|
+
' Act on `confirmed` only after reading the evidence list; treat `candidate` as a question to verify by hand. Merging the ' +
|
|
2744
|
+
'wrong pair sends the wrong text to the wrong thread, in front of everyone on Cc.',
|
|
2745
|
+
annotations: { readOnlyHint: true },
|
|
2746
|
+
inputSchema: {
|
|
2747
|
+
draftIdA: z.string().describe('First draft id — 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.'),
|
|
2748
|
+
draftIdB: z.string().describe('Second draft id — conventionally the SUSPECTED REPLACEMENT.'),
|
|
2749
|
+
maxDiffLines: z.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.`),
|
|
2750
|
+
account: accountParam,
|
|
2751
|
+
},
|
|
2752
|
+
}, async ({ draftIdA, draftIdB, maxDiffLines, account }) => {
|
|
2753
|
+
const fetchArgs = (id: string): string[] => ['gmail', 'drafts', 'get', id, '--use-indexed-attachment-ids=false'];
|
|
2754
|
+
let rawA: string;
|
|
2755
|
+
let rawB: string;
|
|
2756
|
+
try {
|
|
2757
|
+
rawA = await runNormalized(fetchArgs(draftIdA), { account });
|
|
2758
|
+
rawB = await runNormalized(fetchArgs(draftIdB), { account });
|
|
2759
|
+
} catch (err) {
|
|
2760
|
+
return diagnose(new Error(
|
|
2761
|
+
`gog_gmail_drafts_diff could not fetch both drafts (${draftIdA}, ${draftIdB}): ${String(err)}. ` +
|
|
2762
|
+
'A draft id that has stopped resolving is exactly what a mail client leaves behind when it rewrites a draft instead of ' +
|
|
2763
|
+
'updating it — the old id 404s and a new draft holds the edited text. Run gog_gmail_drafts_list (origin and rootsOwnThread ' +
|
|
2764
|
+
'are free there), pick the surviving id, and diff it against the one that still resolves.',
|
|
2765
|
+
));
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
const parseDraft = (raw: string): GmailDraftMessage | undefined => {
|
|
2769
|
+
try {
|
|
2770
|
+
return (JSON.parse(raw) as { draft?: { message?: GmailDraftMessage } }).draft?.message;
|
|
2771
|
+
} catch {
|
|
2772
|
+
return undefined;
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
const msgA = parseDraft(rawA);
|
|
2776
|
+
const msgB = parseDraft(rawB);
|
|
2777
|
+
if (!msgA || !msgB) {
|
|
2778
|
+
return errorResult(
|
|
2779
|
+
`Could not read the stored message for draft ${msgA ? draftIdB : draftIdA} — \`gog gmail drafts get\` returned no ` +
|
|
2780
|
+
'`draft.message` object. Nothing is reported rather than diffing half a pair and letting the missing side read as "empty".',
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
const a = describeDraftSide(draftIdA, msgA);
|
|
2785
|
+
const b = describeDraftSide(draftIdB, msgB);
|
|
2786
|
+
|
|
2787
|
+
// Which one is the possible REPLACEMENT? Time decides, not argument order.
|
|
2788
|
+
// B is treated as the candidate unless it is strictly older than A; when
|
|
2789
|
+
// either internalDate is unreadable the caller's own order stands, and the
|
|
2790
|
+
// answer names both roles so the choice is never implicit.
|
|
2791
|
+
const aMs = parseInternalDateMs(msgA.internalDate);
|
|
2792
|
+
const bMs = parseInternalDateMs(msgB.internalDate);
|
|
2793
|
+
const bIsCandidate = !(aMs !== undefined && bMs !== undefined && bMs < aMs);
|
|
2794
|
+
const original = bIsCandidate ? a : b;
|
|
2795
|
+
const candidate = bIsCandidate ? b : a;
|
|
2796
|
+
|
|
2797
|
+
return textResult({
|
|
2798
|
+
drafts: { a: a.side, b: b.side },
|
|
2799
|
+
bodyDiff: diffBodyLines(
|
|
2800
|
+
bestBodyText(msgA.payload),
|
|
2801
|
+
bestBodyText(msgB.payload),
|
|
2802
|
+
maxDiffLines ?? DRAFT_DIFF_MAX_LINES,
|
|
2803
|
+
),
|
|
2804
|
+
threadingDifferences: threadingDifferences(a.side, b.side),
|
|
2805
|
+
forkPairing: {
|
|
2806
|
+
originalDraftId: original.side.draftId,
|
|
2807
|
+
candidateDraftId: candidate.side.draftId,
|
|
2808
|
+
...evaluateForkPairing(original.facts, candidate.facts, 2),
|
|
2809
|
+
},
|
|
2810
|
+
costNote: 'This call made exactly 2 gog invocations, one `gmail drafts get` per named draft, and is capped there by construction.',
|
|
2811
|
+
});
|
|
847
2812
|
});
|
|
848
2813
|
|
|
849
2814
|
server.registerTool('gog_gmail_drafts_get', {
|
|
@@ -938,29 +2903,89 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
938
2903
|
// separate gog_gmail_drafts_get round trip. For updates the id is known up
|
|
939
2904
|
// front; for creates it's read from the write response's draftId. Degrades to
|
|
940
2905
|
// the raw write result if the id can't be determined.
|
|
2906
|
+
/** The write succeeded; only the `returnFull` re-read did not. Hand back the
|
|
2907
|
+
* acknowledgement, and say plainly which half failed — a caller that cannot
|
|
2908
|
+
* tell those apart will either re-send or delete the wrong copy.
|
|
2909
|
+
*
|
|
2910
|
+
* A missing id and any OTHER read failure are different stories and get
|
|
2911
|
+
* different text. Gating on DRAFT_NOT_FOUND_PATTERN — the same test
|
|
2912
|
+
* forkAwareDraftFailure uses — keeps the fork explanation for the case that
|
|
2913
|
+
* actually looks like one; a permission error, a timeout or a transport fault
|
|
2914
|
+
* keeps its own message instead of being retold as a fork, which would both
|
|
2915
|
+
* mislead and discard the only text saying what really went wrong. */
|
|
2916
|
+
function withRefetchNote(written: CallToolResult, draftId: string, refetch: CallToolResult): CallToolResult {
|
|
2917
|
+
const detail = resultText(refetch)?.trim();
|
|
2918
|
+
const looksForked = detail !== undefined && DRAFT_NOT_FOUND_PATTERN.test(detail);
|
|
2919
|
+
const because = looksForked
|
|
2920
|
+
? 'the id did not resolve, which on this mailbox usually means the draft was forked by a mail client ' +
|
|
2921
|
+
'between the write and the read. Run gog_gmail_drafts_list to find the current id'
|
|
2922
|
+
: `the read failed for a different reason, reported verbatim here: ${detail ?? '(no detail supplied)'}. ` +
|
|
2923
|
+
'That is a failure of the READ ONLY';
|
|
2924
|
+
return {
|
|
2925
|
+
...written,
|
|
2926
|
+
content: [
|
|
2927
|
+
...written.content,
|
|
2928
|
+
{
|
|
2929
|
+
type: 'text' as const,
|
|
2930
|
+
text:
|
|
2931
|
+
`Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up ` +
|
|
2932
|
+
`read-back requested by returnFull could not be performed — ${because}. Nothing was lost; ` +
|
|
2933
|
+
`run gog_gmail_drafts_get on ${draftId} to confirm the saved content.`,
|
|
2934
|
+
},
|
|
2935
|
+
],
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
|
|
941
2939
|
async function writeDraft(
|
|
942
2940
|
args: GogArg[],
|
|
943
2941
|
account: string | undefined,
|
|
944
2942
|
returnFull: boolean | undefined,
|
|
945
2943
|
knownDraftId?: string,
|
|
2944
|
+
intent?: ThreadingIntent,
|
|
946
2945
|
): Promise<CallToolResult> {
|
|
947
2946
|
const result = await runOrDiagnose(args, { account });
|
|
948
|
-
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
|
|
2947
|
+
// The write must have returned a JSON acknowledgement before anything is
|
|
2948
|
+
// read from it. A failed write (an error result, not JSON) is surfaced
|
|
2949
|
+
// as-is rather than masked by re-fetching the unchanged draft — this
|
|
2950
|
+
// matters for the update path, where a known draftId would otherwise
|
|
2951
|
+
// re-fetch a stale draft — and there is nothing to verify threading
|
|
2952
|
+
// against either.
|
|
2953
|
+
let ack: Record<string, unknown>;
|
|
2954
|
+
let ackDraftId: string | undefined;
|
|
954
2955
|
try {
|
|
955
|
-
|
|
2956
|
+
ack = JSON.parse(resultText(result) ?? '') as Record<string, unknown>;
|
|
2957
|
+
ackDraftId = typeof ack.draftId === 'string' ? ack.draftId : undefined;
|
|
956
2958
|
} catch {
|
|
957
2959
|
return result;
|
|
958
2960
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
2961
|
+
// Threading is read from the WRITE ack: inReplyTo/references/
|
|
2962
|
+
// replyContextSource are reported only there, and the returnFull re-fetch
|
|
2963
|
+
// below does not carry them. Costs no extra gog invocation.
|
|
2964
|
+
const verification = intent ? verifyThreading(intent, ack) : undefined;
|
|
2965
|
+
let final = result;
|
|
2966
|
+
if (returnFull) {
|
|
2967
|
+
const draftId = knownDraftId ?? ackDraftId;
|
|
2968
|
+
// Same PIN as gog_gmail_drafts_get — this re-fetch is handed to the caller
|
|
2969
|
+
// verbatim, so its attachments[] shape must not depend on the host env.
|
|
2970
|
+
if (draftId) {
|
|
2971
|
+
const refetched = await runOrDiagnose(['gmail', 'drafts', 'get', draftId, '--use-indexed-attachment-ids=false'], { account });
|
|
2972
|
+
// ONLY adopt the re-fetch when it worked. `returnFull` is a convenience
|
|
2973
|
+
// read AFTER an acknowledged write; a 404 here means the draft moved (an
|
|
2974
|
+
// Apple Mail fork between write and read is exactly the case this file
|
|
2975
|
+
// exists for), NOT that the write failed.
|
|
2976
|
+
//
|
|
2977
|
+
// Returning the failed read in place of the successful write made every
|
|
2978
|
+
// downstream consumer read the write as failed: forkAwareDraftFailure
|
|
2979
|
+
// diagnosed DRAFT_FORKED, and the content-loss note told the caller
|
|
2980
|
+
// "NOTHING WAS SAVED" about content that had just been saved. That is the
|
|
2981
|
+
// most destructive thing this tool can say to someone about to tidy up
|
|
2982
|
+
// the sibling copy.
|
|
2983
|
+
if (refetched.isError !== true) final = refetched;
|
|
2984
|
+
else final = withRefetchNote(result, draftId, refetched);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
if (!verification) return final;
|
|
2988
|
+
return withThreadingVerification(final, verification);
|
|
964
2989
|
}
|
|
965
2990
|
|
|
966
2991
|
server.registerTool('gog_gmail_drafts_create', {
|
|
@@ -973,20 +2998,83 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
973
2998
|
});
|
|
974
2999
|
|
|
975
3000
|
server.registerTool('gog_gmail_drafts_update', {
|
|
976
|
-
description:
|
|
3001
|
+
description:
|
|
3002
|
+
'Update an existing Gmail draft. For replies, prefer replyToThreadId (threads off the thread\'s latest message) or ' +
|
|
3003
|
+
'replyToMessageId (a specific message) over passing a thread id into replyToMessageId. An update preserves the draft\'s ' +
|
|
3004
|
+
'existing reply context (In-Reply-To/References) and its threadId; it never invents reply headers for a draft that is not ' +
|
|
3005
|
+
'a reply. Attachment semantics: supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them; ' +
|
|
3006
|
+
'set clearAttachments to remove all. ' +
|
|
3007
|
+
'REPAIRING THREADING IN PLACE: passing replyToThreadId re-anchors the draft onto that thread and lets gog resolve ' +
|
|
3008
|
+
'In-Reply-To/References from the thread\'s latest sent-or-received message, KEEPING THE SAME DRAFT ID — so a draft that ' +
|
|
3009
|
+
'lost its reply headers (typically one a mail client rewrote from scratch) is adopted back onto the conversation in a ' +
|
|
3010
|
+
'single call. Whenever you change reply context (replyToThreadId, replyToMessageId or clearReplyContext) the result gains ' +
|
|
3011
|
+
'a `threadingVerification` block reporting the effective threadId/inReplyTo/references/replyContextSource, an `ok` flag ' +
|
|
3012
|
+
'and a plain-English note, so you can confirm the repair WITHOUT a raw-header fetch and without a second call. Read it: an ' +
|
|
3013
|
+
'explicit reply target REPLACES the draft\'s stored lineage rather than merging with it, and if the target thread yields ' +
|
|
3014
|
+
'no reply headers the draft is still MOVED onto that thread — it would arrive inside the conversation but not as a reply. ' +
|
|
3015
|
+
'THE BODY IS ALWAYS OVERWRITTEN: gog requires a body on every update, so there is no header-only edit. If a sibling copy ' +
|
|
3016
|
+
'of this draft exists, diff them with gog_gmail_drafts_diff and merge BEFORE updating, or whatever text you do not pass ' +
|
|
3017
|
+
'is lost. A 404 comes back diagnosed rather than as a bare notFound, and the diagnosis is checked against a draft ' +
|
|
3018
|
+
'listing first: GOOGLE_404_NOT_THE_DRAFT when the draft is still listed — because replyToThreadId and replyToMessageId ' +
|
|
3019
|
+
'resolve their own Google entities and a miss on either 404s with the identical message — and DRAFT_FORKED otherwise. ' +
|
|
3020
|
+
'That listing is capped at 20 drafts, so DRAFT_FORKED reports under `listingEvidence` whether its own evidence actually ' +
|
|
3021
|
+
'covers the mailbox: only `complete-listing` (the window came back short of 20, so it saw the whole Drafts folder) says ' +
|
|
3022
|
+
'the draft is gone. `capped-listing` and `listing-unavailable` say in words that they establish nothing about the draft, ' +
|
|
3023
|
+
'and any reply target you passed is echoed there with its explanation listed first.',
|
|
977
3024
|
annotations: { destructiveHint: true },
|
|
978
3025
|
inputSchema: {
|
|
979
3026
|
draftId: z.string().describe('Draft ID'),
|
|
980
3027
|
...draftWriteSchema,
|
|
981
3028
|
clearAttachments: z.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).'),
|
|
982
3029
|
clearReplyContext: z.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 — gog rejects the call if any of them is combined with this.'),
|
|
3030
|
+
forkSiblingDraftId: z.string().optional().describe('Id of the OTHER copy of this draft — 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 — 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 — for that verdict use gog_gmail_drafts_diff.'),
|
|
3031
|
+
acceptContentLoss: z.boolean().optional().describe('Write even though the forkSiblingDraftId check found lines your body drops — 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.'),
|
|
983
3032
|
},
|
|
984
|
-
}, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, ...flags }) => {
|
|
3033
|
+
}, async ({ draftId, account, returnFull, clearAttachments, clearReplyContext, forkSiblingDraftId, acceptContentLoss, ...flags }) => {
|
|
3034
|
+
// BEFORE the write, never after: a report on an overwrite that already
|
|
3035
|
+
// happened is not a guard. Skipped entirely — zero extra invocations — when
|
|
3036
|
+
// no sibling was named.
|
|
3037
|
+
let check: ContentLossCheck | undefined;
|
|
3038
|
+
let overridden = false;
|
|
3039
|
+
if (forkSiblingDraftId) {
|
|
3040
|
+
check = await checkSiblingContentLoss(forkSiblingDraftId, flags.body, account);
|
|
3041
|
+
if (check.status !== 'clean' && !acceptContentLoss) return contentLossRefusal(draftId, check);
|
|
3042
|
+
overridden = check.status !== 'clean';
|
|
3043
|
+
}
|
|
985
3044
|
const args: GogArg[] = ['gmail', 'drafts', 'update', draftId];
|
|
986
3045
|
appendDraftFlags(args, flags);
|
|
987
3046
|
if (clearAttachments) args.push('--clear-attachments');
|
|
988
3047
|
if (clearReplyContext) args.push('--clear-reply-context');
|
|
989
|
-
|
|
3048
|
+
const intent = threadingIntentOf({ ...flags, clearReplyContext });
|
|
3049
|
+
const result = await writeDraft(args, account, returnFull, draftId, intent);
|
|
3050
|
+
const reported = await forkAwareDraftFailure(
|
|
3051
|
+
result, 'gog_gmail_drafts_update', draftId, account,
|
|
3052
|
+
intent?.requested === 'set' ? { via: intent.via, target: intent.target } : undefined,
|
|
3053
|
+
);
|
|
3054
|
+
// The acknowledgement is derived from the OUTCOME, never predicted before
|
|
3055
|
+
// it. Appending "the update WAS written" ahead of the write meant every
|
|
3056
|
+
// failed write — a 404, a permission error — came back `isError: true`
|
|
3057
|
+
// carrying that sentence, which is the single most destructive thing this
|
|
3058
|
+
// tool could tell a caller who is about to tidy up the sibling copy.
|
|
3059
|
+
if (check !== undefined && overridden) {
|
|
3060
|
+
check = reported.isError === true
|
|
3061
|
+
? {
|
|
3062
|
+
...check,
|
|
3063
|
+
acknowledged: true,
|
|
3064
|
+
written: false,
|
|
3065
|
+
note: `${check.note} acceptContentLoss was set, so the write was ATTEMPTED — but it FAILED and NOTHING WAS SAVED. ` +
|
|
3066
|
+
`Draft ${draftId} is unchanged and the lines listed above still exist in draft ${check.siblingDraftId}; nothing was ` +
|
|
3067
|
+
'lost by this call. Read the error above before retrying.',
|
|
3068
|
+
}
|
|
3069
|
+
: {
|
|
3070
|
+
...check,
|
|
3071
|
+
acknowledged: true,
|
|
3072
|
+
written: true,
|
|
3073
|
+
note: `${check.note} acceptContentLoss was set, so the update WAS written despite this: draft ${draftId} now holds ` +
|
|
3074
|
+
`only the body you passed, and the lines listed above exist only in draft ${check.siblingDraftId}.`,
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
return check ? withContentLossCheck(reported, check) : reported;
|
|
990
3078
|
});
|
|
991
3079
|
|
|
992
3080
|
server.registerTool('gog_gmail_drafts_delete', {
|
|
@@ -1004,14 +3092,19 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
1004
3092
|
});
|
|
1005
3093
|
|
|
1006
3094
|
server.registerTool('gog_gmail_drafts_send', {
|
|
1007
|
-
description:
|
|
3095
|
+
description:
|
|
3096
|
+
'Send an existing Gmail draft. If the id no longer resolves, the 404 comes back as a DRAFT_FORKED report — what happened, ' +
|
|
3097
|
+
'the drafts that do exist (with their free origin/rootsOwnThread fields) and what to do next — rather than a bare ' +
|
|
3098
|
+
'notFound. It names no replacement: that judgement needs a named pair and gog_gmail_drafts_diff. If the draft turns out ' +
|
|
3099
|
+
'to be still listed, the answer is GOOGLE_404_NOT_THE_DRAFT instead and claims no fork at all.',
|
|
1008
3100
|
annotations: { destructiveHint: true },
|
|
1009
3101
|
inputSchema: {
|
|
1010
3102
|
draftId: z.string().describe('Draft ID to send'),
|
|
1011
3103
|
account: accountParam,
|
|
1012
3104
|
},
|
|
1013
3105
|
}, async ({ draftId, account }) => {
|
|
1014
|
-
|
|
3106
|
+
const result = await runOrDiagnose(['gmail', 'drafts', 'send', draftId], { account });
|
|
3107
|
+
return forkAwareDraftFailure(result, 'gog_gmail_drafts_send', draftId, account);
|
|
1015
3108
|
});
|
|
1016
3109
|
|
|
1017
3110
|
server.registerTool('gog_gmail_import', {
|