gogcli-mcp-gmail 2.21.1 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -8
- package/SKILL.md +55 -6
- package/dist/index.js +1183 -49
- package/manifest.json +13 -5
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +2296 -45
- package/tests/tools/attachment-index-resolve.test.ts +76 -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 +81 -0
- package/tests/tools/gmail-extra.test.ts +1645 -48
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
3
|
+
|
|
4
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (orig) => {
|
|
5
|
+
const actual = await orig<Record<string, unknown>>();
|
|
6
|
+
return { ...actual, run: vi.fn() };
|
|
7
|
+
});
|
|
8
|
+
const { run } = await import('../../../gogcli-mcp/src/lib.js');
|
|
9
|
+
const { registerExtraGmailTools } = await import('../../src/tools/gmail-extra.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #252: resolveByIndex took `attachments[index]` — array POSITION — while
|
|
13
|
+
* resolveBySize matches on a declared field. gog assigns AttachmentIndex = i
|
|
14
|
+
* today, so position happens to agree; the moment it does not (a filtered or
|
|
15
|
+
* reordered listing) the download is silently named after the WRONG part, which
|
|
16
|
+
* is worse than failing.
|
|
17
|
+
*
|
|
18
|
+
* The declared `attachmentIndex` is the contract. Match it.
|
|
19
|
+
*/
|
|
20
|
+
describe('resolveByIndex matches the declared attachmentIndex', () => {
|
|
21
|
+
let h: TestHarness;
|
|
22
|
+
beforeEach(async () => { vi.clearAllMocks(); h = await createTestHarness(registerExtraGmailTools); });
|
|
23
|
+
|
|
24
|
+
it('picks the part whose attachmentIndex equals the request, not its array slot', async () => {
|
|
25
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
26
|
+
if (args[1] === 'get') {
|
|
27
|
+
// Declared indexes deliberately NOT in array order.
|
|
28
|
+
return JSON.stringify({ attachments: [
|
|
29
|
+
{ filename: 'second.pdf', attachmentIndex: 1, size: 100, mimeType: 'application/pdf' },
|
|
30
|
+
{ filename: 'first.pdf', attachmentIndex: 0, size: 200, mimeType: 'application/pdf' },
|
|
31
|
+
] });
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
34
|
+
});
|
|
35
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
36
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
37
|
+
// index 0 is declared by the SECOND array element (first.pdf)
|
|
38
|
+
expect(dl.join(' ')).toContain('first.pdf');
|
|
39
|
+
expect(dl.join(' ')).not.toContain('second.pdf');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('still resolves when gog omits attachmentIndex, by position', async () => {
|
|
43
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
44
|
+
if (args[1] === 'get') {
|
|
45
|
+
return JSON.stringify({ attachments: [
|
|
46
|
+
{ filename: 'a.pdf', size: 100, mimeType: 'application/pdf' },
|
|
47
|
+
{ filename: 'b.pdf', size: 200, mimeType: 'application/pdf' },
|
|
48
|
+
] });
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
51
|
+
});
|
|
52
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 1 });
|
|
53
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
54
|
+
expect(dl.join(' ')).toContain('b.pdf');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('refuses to guess when indexes are declared but none matches', async () => {
|
|
58
|
+
// The caller asked for an index this message does not have. Falling back to
|
|
59
|
+
// position here would hand back a real-but-wrong attachment, which is the
|
|
60
|
+
// silent mis-naming this resolver exists to prevent — so it resolves to
|
|
61
|
+
// nothing and the download keeps its provisional name instead.
|
|
62
|
+
(run as any).mockImplementation(async (args: string[]) => {
|
|
63
|
+
if (args[1] === 'get') {
|
|
64
|
+
return JSON.stringify({ attachments: [
|
|
65
|
+
{ filename: 'a.pdf', attachmentIndex: 0, size: 100, mimeType: 'application/pdf' },
|
|
66
|
+
{ filename: 'b.pdf', attachmentIndex: 1, size: 200, mimeType: 'application/pdf' },
|
|
67
|
+
] });
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify({ path: '/tmp/x', size: 200 });
|
|
70
|
+
});
|
|
71
|
+
await h.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 7 });
|
|
72
|
+
const dl = (run as any).mock.calls.map((c: any[]) => c[0]).find((a: string[]) => a[1] === 'attachment');
|
|
73
|
+
expect(dl.join(' ')).not.toContain('a.pdf');
|
|
74
|
+
expect(dl.join(' ')).not.toContain('b.pdf');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { authoredBodyLines, measureBodyAgreement } from '../../src/tools/gmail-extra.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HAZARD A — the lineage judgement must not fire on two unrelated drafts.
|
|
6
|
+
*
|
|
7
|
+
* `boilerplateLineFlags` stripped the RFC 3676 `-- ` block, the client's own
|
|
8
|
+
* `Sent from my iPhone`, and the NAME under a sign-off — but the run stopped at
|
|
9
|
+
* the first line that was not name-shaped, so a USER-CONFIGURED signature
|
|
10
|
+
* survived as authored prose:
|
|
11
|
+
*
|
|
12
|
+
* Thanks, -> stripped
|
|
13
|
+
* Chris Hall -> stripped
|
|
14
|
+
* (704) 555-0142 -> run stopped; survived
|
|
15
|
+
* chris.c.hall@gmail.com -> survived
|
|
16
|
+
* https://example.com/chris -> survived
|
|
17
|
+
*
|
|
18
|
+
* Those lines are identical on every message the account composes. Measured
|
|
19
|
+
* before the fix: two unrelated drafts shared 3 authored lines / 61 chars and
|
|
20
|
+
* scored similarity 0.60 against a 0.60 threshold — meetsThreshold TRUE on a
|
|
21
|
+
* plumber note and a soccer note. In a mailbox whose drafts go to a parenting
|
|
22
|
+
* coordinator, a wrong pairing is the expensive direction.
|
|
23
|
+
*/
|
|
24
|
+
const SIGNATURE = ['Thanks,', 'Chris Hall', '(704) 555-0142', 'chris.c.hall@gmail.com', 'https://example.com/chris'];
|
|
25
|
+
const withSig = (body: string[]) => ['Hi Sam,', '', ...body, '', ...SIGNATURE].join('\n');
|
|
26
|
+
|
|
27
|
+
describe('a shared signature is never lineage evidence', () => {
|
|
28
|
+
it('does not pair two unrelated drafts that share one signature', () => {
|
|
29
|
+
const m = measureBodyAgreement(withSig(['The plumber comes Tuesday at 9am.']), withSig(['Soccer registration closes Friday.']));
|
|
30
|
+
expect(m.sharedAuthoredLines).toBe(0);
|
|
31
|
+
expect(m.meetsThreshold).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('strips the whole signature block, not just the name', () => {
|
|
35
|
+
expect(authoredBodyLines(withSig(['The plumber comes Tuesday at 9am.']))).toEqual(['The plumber comes Tuesday at 9am.']);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('and it still stops at authored content', () => {
|
|
40
|
+
it('keeps a postscript under the signature', () => {
|
|
41
|
+
expect(authoredBodyLines([...SIGNATURE, '', 'P.S. The invoice is attached.'].join('\n')))
|
|
42
|
+
.toEqual(['P.S. The invoice is attached.']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps prose under the sign-off', () => {
|
|
46
|
+
expect(authoredBodyLines(['Thanks,', 'Chris', '', 'Actually the plumber moved to Wednesday.'].join('\n')))
|
|
47
|
+
.toEqual(['Actually the plumber moved to Wednesday.']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps contact-shaped lines that are NOT under a sign-off', () => {
|
|
51
|
+
// The rule is positional. A bare phone number in the body is content.
|
|
52
|
+
expect(authoredBodyLines(['(704) 555-0142', 'chris@example.com'].join('\n')))
|
|
53
|
+
.toEqual(['(704) 555-0142', 'chris@example.com']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps a phone number inside a sentence', () => {
|
|
57
|
+
expect(authoredBodyLines(['Call me at (704) 555-0142 about the roof.', '', 'Thanks,', 'Chris'].join('\n')))
|
|
58
|
+
.toEqual(['Call me at (704) 555-0142 about the roof.']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still pairs two drafts that genuinely share authored prose', () => {
|
|
62
|
+
const shared = ['The plumber comes Tuesday at 9am.', 'He will need access to the crawlspace.', 'I left a key under the mat for him.'];
|
|
63
|
+
const m = measureBodyAgreement(withSig(shared), withSig([...shared, 'One more thing: the gate code is 4417.']));
|
|
64
|
+
expect(m.sharedAuthoredLines).toBeGreaterThanOrEqual(3);
|
|
65
|
+
expect(m.meetsThreshold).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|