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.
@@ -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
+ });