@aaronsb/google-workspace-mcp 4.6.1 → 4.8.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.
Files changed (36) hide show
  1. package/build/accounts/oauth.js +6 -1
  2. package/build/accounts/oauth.js.map +1 -1
  3. package/build/coverage/compare.js +11 -1
  4. package/build/coverage/compare.js.map +1 -1
  5. package/build/factory/manifest/gmail.yaml +257 -6
  6. package/build/factory/safety.js +35 -11
  7. package/build/factory/safety.js.map +1 -1
  8. package/build/server/call-context.d.ts +12 -0
  9. package/build/server/call-context.js +20 -0
  10. package/build/server/call-context.js.map +1 -0
  11. package/build/server/handler.d.ts +4 -0
  12. package/build/server/handler.js +9 -1
  13. package/build/server/handler.js.map +1 -1
  14. package/build/server/preview-gate.d.ts +38 -0
  15. package/build/server/preview-gate.js +107 -0
  16. package/build/server/preview-gate.js.map +1 -0
  17. package/build/server/preview-sets.d.ts +25 -0
  18. package/build/server/preview-sets.js +58 -0
  19. package/build/server/preview-sets.js.map +1 -0
  20. package/build/services/gmail/filters.d.ts +81 -0
  21. package/build/services/gmail/filters.js +457 -0
  22. package/build/services/gmail/filters.js.map +1 -0
  23. package/build/services/gmail/hydrate.d.ts +29 -0
  24. package/build/services/gmail/hydrate.js +116 -0
  25. package/build/services/gmail/hydrate.js.map +1 -0
  26. package/build/services/gmail/labels.d.ts +31 -0
  27. package/build/services/gmail/labels.js +108 -0
  28. package/build/services/gmail/labels.js.map +1 -0
  29. package/build/services/gmail/modify-matching.d.ts +16 -0
  30. package/build/services/gmail/modify-matching.js +149 -0
  31. package/build/services/gmail/modify-matching.js.map +1 -0
  32. package/build/services/gmail/patch.js +76 -116
  33. package/build/services/gmail/patch.js.map +1 -1
  34. package/build/version.d.ts +1 -1
  35. package/build/version.js +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Label changes, in words. Shared by the two previews that describe a label change before
3
+ * it happens: a filter's (on each message as it arrives) and a query-scoped bulk change's
4
+ * (on every message the preview counted). ADR-309, ADR-310.
5
+ */
6
+ export interface Effect {
7
+ /** What happens, in a clause: "skips the inbox". */
8
+ text: string;
9
+ /** Why it is worth a second look, shown in full previews only. */
10
+ detail?: string;
11
+ /** A consequence worth a second look before approving: disposal, or mail leaving the account. */
12
+ warn?: boolean;
13
+ }
14
+ export interface LabelChange {
15
+ addLabelIds?: string[];
16
+ removeLabelIds?: string[];
17
+ }
18
+ /** When the change lands: as each future match arrives, or now on the set already there. */
19
+ export type Timing = 'arrival' | 'now';
20
+ /** Comma-separated (or array) label ids, trimmed, deduplicated and sorted; undefined when none. */
21
+ export declare function parseLabelIds(value: unknown): string[] | undefined;
22
+ /** Label id → name, for every label on the account, system labels included. */
23
+ export declare function labelNames(account: string): Promise<Map<string, string>>;
24
+ /**
25
+ * One error per label id the account does not have. A label NAME passed where an id
26
+ * belongs is the common mistake, so it is answered with the id.
27
+ */
28
+ export declare function labelIdErrors(change: LabelChange, names: Map<string, string>): string[];
29
+ export declare function describeLabelChanges(change: LabelChange, names: Map<string, string>, timing: Timing): Effect[];
30
+ /** A bullet for a preview: the warning mark, the effect, and its detail. */
31
+ export declare function effectLine(e: Effect): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Label changes, in words. Shared by the two previews that describe a label change before
3
+ * it happens: a filter's (on each message as it arrives) and a query-scoped bulk change's
4
+ * (on every message the preview counted). ADR-309, ADR-310.
5
+ */
6
+ import { call } from '../../google/client.js';
7
+ const TRASH_DETAIL = 'Gmail deletes trash permanently after 30 days';
8
+ const SPAM_DETAIL = 'Gmail deletes spam permanently after 30 days';
9
+ /** System labels a label change cannot add or remove; Gmail refuses them. */
10
+ const UNCHANGEABLE = new Set(['SENT', 'DRAFT', 'CHAT']);
11
+ const SYSTEM_LABEL_EFFECTS = {
12
+ INBOX: {
13
+ add: { arrival: { text: 'kept in the inbox' }, now: { text: 'put in the inbox' } },
14
+ remove: { arrival: { text: 'skips the inbox: archived on arrival' }, now: { text: 'archived: removed from the inbox' } },
15
+ },
16
+ UNREAD: {
17
+ add: { arrival: { text: 'marked unread' }, now: { text: 'marked unread' } },
18
+ remove: { arrival: { text: 'marked read on arrival' }, now: { text: 'marked read' } },
19
+ },
20
+ STARRED: {
21
+ add: { arrival: { text: 'starred' }, now: { text: 'starred' } },
22
+ remove: { arrival: { text: 'never starred' }, now: { text: 'unstarred' } },
23
+ },
24
+ IMPORTANT: {
25
+ add: { arrival: { text: 'marked important' }, now: { text: 'marked important' } },
26
+ remove: { arrival: { text: 'never marked important' }, now: { text: 'marked not important' } },
27
+ },
28
+ TRASH: {
29
+ add: {
30
+ arrival: { text: 'moved to trash on arrival', detail: `${TRASH_DETAIL}, so matching mail nobody looks at is gone after that`, warn: true },
31
+ now: {
32
+ text: 'moved to trash',
33
+ detail: `This is a bulk trash. ${TRASH_DETAIL}; until then, preview the same query with \`in:trash\` added and removeLabelIds TRASH to restore them`,
34
+ warn: true,
35
+ },
36
+ },
37
+ remove: { arrival: { text: 'kept out of trash' }, now: { text: 'restored from trash' } },
38
+ },
39
+ SPAM: {
40
+ add: {
41
+ arrival: { text: 'sent to spam on arrival', detail: SPAM_DETAIL, warn: true },
42
+ now: { text: 'moved to spam', detail: SPAM_DETAIL, warn: true },
43
+ },
44
+ remove: { arrival: { text: 'never sent to spam' }, now: { text: 'taken out of spam' } },
45
+ },
46
+ };
47
+ /** Comma-separated (or array) label ids, trimmed, deduplicated and sorted; undefined when none. */
48
+ export function parseLabelIds(value) {
49
+ const items = Array.isArray(value) ? value.map(String) : String(value ?? '').split(',');
50
+ const ids = [...new Set(items.map((s) => s.trim()).filter(Boolean))].sort();
51
+ return ids.length ? ids : undefined;
52
+ }
53
+ /** Label id → name, for every label on the account, system labels included. */
54
+ export async function labelNames(account) {
55
+ const raw = await call('gmail', 'users.labels.list', { userId: 'me' }, { account });
56
+ const labels = (raw?.labels ?? []);
57
+ return new Map(labels.filter((l) => l.id).map((l) => [String(l.id), String(l.name ?? l.id)]));
58
+ }
59
+ /**
60
+ * One error per label id the account does not have. A label NAME passed where an id
61
+ * belongs is the common mistake, so it is answered with the id.
62
+ */
63
+ export function labelIdErrors(change, names) {
64
+ const byName = new Map([...names].map(([id, n]) => [n.toLowerCase(), id]));
65
+ const errors = [];
66
+ for (const id of [...(change.addLabelIds ?? []), ...(change.removeLabelIds ?? [])]) {
67
+ if (UNCHANGEABLE.has(id)) {
68
+ errors.push(`${id} is set by Gmail itself; a label change cannot add or remove it.`);
69
+ continue;
70
+ }
71
+ if (names.has(id))
72
+ continue;
73
+ const hint = byName.get(id.toLowerCase());
74
+ errors.push(hint
75
+ ? `'${id}' is a label name; this takes its id, ${hint}.`
76
+ : `No label with id '${id}' on this account. Create it with \`createLabel\`, or see ids with \`labels\`.`);
77
+ }
78
+ return errors;
79
+ }
80
+ export function describeLabelChanges(change, names, timing) {
81
+ const effects = [];
82
+ const name = (id) => names.get(id) ?? id;
83
+ const category = (id) => id.slice('CATEGORY_'.length).toLowerCase();
84
+ for (const id of change.addLabelIds ?? []) {
85
+ const system = SYSTEM_LABEL_EFFECTS[id]?.add?.[timing];
86
+ if (system)
87
+ effects.push(system);
88
+ else if (id.startsWith('CATEGORY_'))
89
+ effects.push({ text: `sorted into the ${category(id)} category` });
90
+ else
91
+ effects.push({ text: `labeled "${name(id)}"` });
92
+ }
93
+ for (const id of change.removeLabelIds ?? []) {
94
+ const system = SYSTEM_LABEL_EFFECTS[id]?.remove?.[timing];
95
+ if (system)
96
+ effects.push(system);
97
+ else if (id.startsWith('CATEGORY_'))
98
+ effects.push({ text: `${timing === 'arrival' ? 'kept out of' : 'taken out of'} the ${category(id)} category` });
99
+ else
100
+ effects.push({ text: `"${name(id)}" label removed` });
101
+ }
102
+ return effects;
103
+ }
104
+ /** A bullet for a preview: the warning mark, the effect, and its detail. */
105
+ export function effectLine(e) {
106
+ return `- ${e.warn ? '⚠ ' : ''}${e.text}${e.detail ? `. ${e.detail}` : ''}`;
107
+ }
108
+ //# sourceMappingURL=labels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.js","sourceRoot":"","sources":["../../../src/services/gmail/labels.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Label changes over everything a query matches, with no ids in the conversation (ADR-310, #206).
3
+ *
4
+ * `previewModifyMatching` resolves the query to the full set of message ids, holds it
5
+ * server-side under the preview token, and returns the exact count, a sample, and what the
6
+ * change does to each message. `modifyMatching` applies the change to exactly that set, in
7
+ * `batchModify` chunks, and reports how many it changed. The set is used once.
8
+ *
9
+ * Mail that starts matching after the preview is untouched: routing future mail is a
10
+ * filter's job (filters.ts).
11
+ */
12
+ import { type HandlerResponse } from '../../server/formatting/markdown.js';
13
+ /** `previewModifyMatching` — count, sample and describe the change, and hold the set. */
14
+ export declare function handlePreviewModifyMatching(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
15
+ /** `modifyMatching` — apply the previewed change to exactly the previewed set. */
16
+ export declare function handleModifyMatching(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Label changes over everything a query matches, with no ids in the conversation (ADR-310, #206).
3
+ *
4
+ * `previewModifyMatching` resolves the query to the full set of message ids, holds it
5
+ * server-side under the preview token, and returns the exact count, a sample, and what the
6
+ * change does to each message. `modifyMatching` applies the change to exactly that set, in
7
+ * `batchModify` chunks, and reports how many it changed. The set is used once.
8
+ *
9
+ * Mail that starts matching after the preview is untouched: routing future mail is a
10
+ * filter's job (filters.ts).
11
+ */
12
+ import { call } from '../../google/client.js';
13
+ import { formatShortDate, truncate } from '../../server/formatting/markdown.js';
14
+ import { requireString } from '../../server/handlers/validate.js';
15
+ import { PREVIEW_TTL_MS, checkPreviewToken, describeTokenFailure, mintPreviewToken, } from '../../server/preview-gate.js';
16
+ import { MAX_SET_SIZE, heldSet, holdSet, releaseSet, setDigest } from '../../server/preview-sets.js';
17
+ import { hydrateMessages } from './hydrate.js';
18
+ import { describeLabelChanges, effectLine, labelIdErrors, labelNames, parseLabelIds } from './labels.js';
19
+ const KIND = 'manage_email.modifyMatching';
20
+ /** Why the write is gated, for the refusal when no token is given. */
21
+ const GATED_BECAUSE = 'It changes every message a query matched, a set the caller never sees row by row';
22
+ /** `users.messages.batchModify` takes at most this many ids per call. */
23
+ const BATCH_LIMIT = 1000;
24
+ const PAGE_SIZE = 500;
25
+ const SAMPLE_SIZE = 5;
26
+ /** The query and the change a call describes. Throws a sentence naming what is wrong. */
27
+ function buildRequest(params) {
28
+ const query = requireString(params, 'query').trim();
29
+ const change = {};
30
+ const add = parseLabelIds(params.addLabelIds);
31
+ const remove = parseLabelIds(params.removeLabelIds);
32
+ if (add)
33
+ change.addLabelIds = add;
34
+ if (remove)
35
+ change.removeLabelIds = remove;
36
+ if (!add && !remove) {
37
+ throw new Error('Give at least one change: addLabelIds, or removeLabelIds (e.g. INBOX to archive, UNREAD to mark read).');
38
+ }
39
+ const both = (add ?? []).filter((id) => remove?.includes(id));
40
+ if (both.length)
41
+ throw new Error(`A label cannot be both added and removed: ${both.join(', ')}.`);
42
+ return { query, change };
43
+ }
44
+ /** Every id the query matches, or the first MAX_SET_SIZE + 1 when there are more. */
45
+ async function resolveQuery(account, q) {
46
+ const ids = [];
47
+ let pageToken;
48
+ do {
49
+ const page = await call('gmail', 'users.messages.list', {
50
+ userId: 'me', q, maxResults: PAGE_SIZE, ...(pageToken ? { pageToken } : {}),
51
+ }, { account });
52
+ ids.push(...(page?.messages ?? []).map((m) => m.id));
53
+ pageToken = typeof page?.nextPageToken === 'string' && page.nextPageToken ? page.nextPageToken : undefined;
54
+ } while (pageToken && ids.length <= MAX_SET_SIZE);
55
+ return ids.length > MAX_SET_SIZE ? { ids: [], over: true } : { ids, over: false };
56
+ }
57
+ const plural = (n) => `${n.toLocaleString('en-US')} message${n === 1 ? '' : 's'}`;
58
+ function refused(text, refs = {}) {
59
+ return { text, refs: { ...refs, blocked: true } };
60
+ }
61
+ /** `previewModifyMatching` — count, sample and describe the change, and hold the set. */
62
+ export async function handlePreviewModifyMatching(params, account) {
63
+ const { query, change } = buildRequest(params);
64
+ const [names, resolved] = await Promise.all([labelNames(account), resolveQuery(account, query)]);
65
+ const { ids } = resolved;
66
+ const errors = labelIdErrors(change, names);
67
+ if (resolved.over) {
68
+ errors.push(`More than ${MAX_SET_SIZE.toLocaleString('en-US')} messages match, more than one preview holds. ` +
69
+ 'Narrow the query, for example with after: and before: dates, and preview each part.');
70
+ }
71
+ const sample = ids.length ? await hydrateMessages(ids.slice(0, SAMPLE_SIZE).map((id) => ({ id })), account) : [];
72
+ const lines = [`## Preview: change every message matching \`${query}\``, ''];
73
+ lines.push(resolved.over
74
+ ? `**Matches:** more than ${plural(MAX_SET_SIZE)}.`
75
+ : `**Matches:** ${ids.length === 0 ? 'none' : plural(ids.length)}.`);
76
+ lines.push('**Each is:**', ...describeLabelChanges(change, names, 'now').map(effectLine));
77
+ if (sample.length) {
78
+ lines.push('', 'Most recent matches:');
79
+ for (const m of sample) {
80
+ lines.push(m.error
81
+ ? `${String(m.id)} | ⚠ ${String(m.error)}`
82
+ : `${String(m.id)} | ${truncate(String(m.from ?? ''), 30)} | ${truncate(String(m.subject ?? '(no subject)'), 50)} | ${formatShortDate(m.date)}`);
83
+ }
84
+ }
85
+ // Gmail leaves spam and trash out of a search unless the query names them.
86
+ const namesSpamOrTrash = /\bin:(trash|spam|anywhere)\b/i.test(query);
87
+ lines.push('', (namesSpamOrTrash ? '' : 'Spam and trash are not included. ') +
88
+ 'Mail that starts matching after this preview is not changed; to route it as it arrives, preview a filter with `previewFilter`.');
89
+ const refs = { query, matchCount: resolved.over ? undefined : ids.length };
90
+ if (!errors.length && ids.length === 0) {
91
+ lines.push('', 'Nothing matches, so there is nothing to change and no previewToken.');
92
+ return { text: lines.join('\n'), refs };
93
+ }
94
+ if (errors.length) {
95
+ lines.push('', '### Cannot apply this change', ...errors.map((e) => `- ${e}`), '', 'No previewToken issued. Fix the parameters and preview again.');
96
+ return { text: lines.join('\n'), refs: { ...refs, errors } };
97
+ }
98
+ const token = mintPreviewToken(KIND, account, { query, change, digest: setDigest(ids) });
99
+ holdSet(token, account, ids);
100
+ lines.push('', 'To apply it, call `modifyMatching` with the same parameters and previewToken:', `\`${token}\``, `Good for ${PREVIEW_TTL_MS / 60_000} minutes and once. It changes exactly the ${plural(ids.length)} counted here.`);
101
+ return { text: lines.join('\n'), refs: { ...refs, previewToken: token } };
102
+ }
103
+ /** `modifyMatching` — apply the previewed change to exactly the previewed set. */
104
+ export async function handleModifyMatching(params, account) {
105
+ const { query, change } = buildRequest(params);
106
+ const token = params.previewToken;
107
+ const ids = typeof token === 'string' ? heldSet(token.trim(), account) : undefined;
108
+ if (!ids) {
109
+ // No set to check against. A token that is absent, not ours, or for another operation
110
+ // says so; anything else was a real preview whose set is gone.
111
+ const check = checkPreviewToken(token, KIND, account, { query, change, digest: '' });
112
+ if (!check.ok && (check.failure === 'missing' || check.failure === 'malformed' || check.failure === 'wrong-kind')) {
113
+ return refused(`**Nothing changed.** ${describeTokenFailure(check, 'modifyMatching', 'previewModifyMatching', GATED_BECAUSE)}`);
114
+ }
115
+ return refused('**Nothing changed.** This preview is no longer held: it was applied already, expired after ' +
116
+ `${PREVIEW_TTL_MS / 60_000} minutes, was displaced by newer previews, or the server restarted. ` +
117
+ 'Call `previewModifyMatching` with these parameters and pass the previewToken it returns.');
118
+ }
119
+ const check = checkPreviewToken(token, KIND, account, { query, change, digest: setDigest(ids) });
120
+ if (!check.ok) {
121
+ return refused(`**Nothing changed.** ${describeTokenFailure(check, 'modifyMatching', 'previewModifyMatching', GATED_BECAUSE)}`);
122
+ }
123
+ // Fetched before the set is released, so a failure here leaves the preview usable and
124
+ // nothing changed, rather than a completed write reported as a failure.
125
+ const names = await labelNames(account);
126
+ // Used once, whatever happens next: a retry resolves the query again.
127
+ releaseSet(token.trim());
128
+ let changed = 0;
129
+ for (let i = 0; i < ids.length; i += BATCH_LIMIT) {
130
+ const chunk = ids.slice(i, i + BATCH_LIMIT);
131
+ try {
132
+ await call('gmail', 'users.messages.batchModify', { userId: 'me', ids: chunk, ...change }, { account });
133
+ }
134
+ catch (err) {
135
+ // Thrown, so a queue with onError 'bail' stops here.
136
+ const why = err instanceof Error ? err.message : String(err);
137
+ throw new Error(`modifyMatching stopped part way: changed ${changed} of ${ids.length} messages, then the batch starting at ` +
138
+ `message ${i + 1} failed (${why}). The preview is used up. Preview again to finish; the query now resolves ` +
139
+ 'against the mailbox as it is, without any message deleted since.');
140
+ }
141
+ changed += chunk.length;
142
+ }
143
+ return {
144
+ text: [`Changed ${plural(changed)} matching \`${query}\`. Each was:`,
145
+ ...describeLabelChanges(change, names, 'now').map((e) => `- ${e.text}`)].join('\n'),
146
+ refs: { query, changed },
147
+ };
148
+ }
149
+ //# sourceMappingURL=modify-matching.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modify-matching.js","sourceRoot":"","sources":["../../../src/services/gmail/modify-matching.ts"],"names":[],"mappings":"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"}
@@ -9,119 +9,14 @@
9
9
  import { mkdir, writeFile } from 'node:fs/promises';
10
10
  import { dirname } from 'node:path';
11
11
  import { call } from '../../google/client.js';
12
- import { GoogleApiError } from '../../google/errors.js';
13
12
  import { formatEmailList, formatEmailDetail, extractBodyFromPayload, extractAttachments, decodeSnippet, truncate, formatShortDate } from '../../server/formatting/markdown.js';
14
- import { requireString } from '../../server/handlers/validate.js';
13
+ import { requireString, clamp } from '../../server/handlers/validate.js';
15
14
  import { ensureWorkspaceDir, resolveWorkspacePath, verifyPathSafety } from '../../executor/workspace.js';
16
15
  import { handleGetAttachment, handleViewAttachment } from './attachments.js';
16
+ import { hydrateMessages, hydrateDrafts } from './hydrate.js';
17
+ import { handleListFilters, handleGetFilter, handlePreviewFilter, handleCreateFilter, handleUpdateFilter, handleDeleteFilter, } from './filters.js';
18
+ import { handlePreviewModifyMatching, handleModifyMatching } from './modify-matching.js';
17
19
  import { sendMail, replyMail, forwardMail } from './mail.js';
18
- /**
19
- * Hydrate message IDs with metadata (From, Subject, Date, snippet).
20
- * Reused from the original email handler.
21
- */
22
- async function hydrateMessages(messageIds, account) {
23
- // Google throttles per USER, not per process, so this burst competes with every
24
- // other client signed in to the same account. Firing all 50 at once is the surest
25
- // way to be told no. A modest window is barely slower — the calls still overlap —
26
- // and it is far less likely to be throttled in the first place.
27
- return mapLimited(messageIds, HYDRATE_CONCURRENCY, async (msg) => {
28
- try {
29
- const data = await call('gmail', 'users.messages.get', {
30
- userId: 'me',
31
- id: msg.id,
32
- format: 'metadata',
33
- metadataHeaders: ['From', 'Subject', 'Date'],
34
- }, { account });
35
- const headers = (data.payload?.headers ?? []);
36
- const getHeader = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value;
37
- return {
38
- id: data.id,
39
- threadId: data.threadId,
40
- from: getHeader('from'),
41
- subject: getHeader('subject'),
42
- date: getHeader('date'),
43
- snippet: data.snippet,
44
- };
45
- }
46
- catch (err) {
47
- // A message we could not READ must never look like a message with NOTHING IN IT.
48
- //
49
- // This used to `catch { return { id: msg.id } }` — every failure became a row
50
- // with no sender, no subject and no date, indistinguishable from a genuinely
51
- // empty message. Under rate limiting the tail of an inbox silently rendered as
52
- // blank lines, and the tool reported success while showing the user nothing.
53
- //
54
- // The client retries a 429 now, so reaching here means it did not recover. Say
55
- // so, in the row, where the person reading the list will actually see it.
56
- return {
57
- id: msg.id,
58
- error: err instanceof GoogleApiError
59
- ? `could not load (${err.status}${err.reason ? ' ' + err.reason : ''})`
60
- : `could not load (${err instanceof Error ? err.message : String(err)})`,
61
- };
62
- }
63
- });
64
- }
65
- /** Concurrent, but bounded. */
66
- const HYDRATE_CONCURRENCY = 8;
67
- /**
68
- * Hydrate draft resources with message metadata (From, To, Subject, Date, snippet).
69
- *
70
- * users.drafts.list returns one bare {id: draftId, message: {id, threadId}} per draft —
71
- * enough to identify a draft, nothing to tell drafts apart. Each draft's message is
72
- * fetched (metadata format, exactly like the search/triage hydration) and the DRAFT id
73
- * stays on the row, because it — not the message id — is what deleteDraft addresses.
74
- */
75
- async function hydrateDrafts(drafts, account) {
76
- return mapLimited(drafts, HYDRATE_CONCURRENCY, async (draft) => {
77
- const draftId = draft.id;
78
- const messageId = draft.message?.id;
79
- if (!messageId) {
80
- return { draftId, error: 'draft carries no message id' };
81
- }
82
- try {
83
- const data = await call('gmail', 'users.messages.get', {
84
- userId: 'me',
85
- id: messageId,
86
- format: 'metadata',
87
- metadataHeaders: ['From', 'To', 'Subject', 'Date'],
88
- }, { account });
89
- const headers = (data.payload?.headers ?? []);
90
- const getHeader = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value;
91
- return {
92
- draftId,
93
- messageId: data.id,
94
- threadId: data.threadId,
95
- to: getHeader('to'),
96
- from: getHeader('from'),
97
- subject: getHeader('subject'),
98
- date: getHeader('date'),
99
- snippet: data.snippet,
100
- };
101
- }
102
- catch (err) {
103
- return {
104
- draftId,
105
- messageId,
106
- error: err instanceof GoogleApiError
107
- ? `could not load (${err.status}${err.reason ? ' ' + err.reason : ''})`
108
- : `could not load (${err instanceof Error ? err.message : String(err)})`,
109
- };
110
- }
111
- });
112
- }
113
- async function mapLimited(items, limit, fn) {
114
- const results = new Array(items.length);
115
- let next = 0;
116
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
117
- while (next < items.length) {
118
- const i = next++;
119
- results[i] = await fn(items[i]); // fn never rejects — it catches internally
120
- }
121
- });
122
- await Promise.all(workers);
123
- return results;
124
- }
125
20
  /** "Tue, 19 Aug 2026 ..." -> "2026-08-19"; '' when the header is not parseable. */
126
21
  const MONTH_TO_MM = {
127
22
  Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
@@ -281,26 +176,81 @@ function formatThreadDetail(data) {
281
176
  },
282
177
  };
283
178
  }
179
+ /** Largest page `messages.list` returns; the ids-only page size. */
180
+ const IDS_ONLY_PAGE = 500;
181
+ function isIdsOnly(ctx) {
182
+ return ctx.operation === 'search' && (ctx.params.idsOnly === true || ctx.params.idsOnly === 'true');
183
+ }
184
+ /** The paging fields of a Gmail list response, kept through hydration. */
185
+ function pagingFrom(raw) {
186
+ return {
187
+ ...(typeof raw?.nextPageToken === 'string' && raw.nextPageToken ? { nextPageToken: raw.nextPageToken } : {}),
188
+ ...(typeof raw?.resultSizeEstimate === 'number' ? { resultSizeEstimate: raw.resultSizeEstimate } : {}),
189
+ };
190
+ }
191
+ /**
192
+ * Say when a list stopped before the end (ADR-309).
193
+ *
194
+ * A full page and a complete result look identical otherwise: 50 rows of 50 and 50 rows
195
+ * of 400 render the same, and the reader sizes the problem from the wrong one.
196
+ */
197
+ function withPaging(response, data, ctx) {
198
+ const raw = (data ?? {});
199
+ const nextPageToken = typeof raw.nextPageToken === 'string' ? raw.nextPageToken : '';
200
+ if (!nextPageToken)
201
+ return response;
202
+ const estimate = typeof raw.resultSizeEstimate === 'number' ? raw.resultSizeEstimate : undefined;
203
+ const sized = estimate !== undefined
204
+ ? ` Google estimates about ${estimate} in total; that figure is an estimate and is often wrong.`
205
+ : '';
206
+ return {
207
+ text: `${response.text}\n\nMore results exist.${sized} Call \`${ctx.operation}\` again with the same parameters and pageToken: ${nextPageToken}`,
208
+ refs: { ...response.refs, nextPageToken, ...(estimate !== undefined ? { resultSizeEstimate: estimate } : {}) },
209
+ };
210
+ }
211
+ /** An ids-only search page: the ids, comma-separated, ready for bulk_operations batch. */
212
+ function formatIdList(data) {
213
+ const ids = (data?.ids ?? []);
214
+ if (ids.length === 0)
215
+ return { text: 'No messages found.', refs: { count: 0, ids: [] } };
216
+ return {
217
+ text: `## Message ids (${ids.length})\n\n${ids.join(',')}`,
218
+ refs: { count: ids.length, ids },
219
+ };
220
+ }
284
221
  export const gmailPatch = {
222
+ beforeExecute: {
223
+ // An ids-only page costs one call however long it is, so it pages at the API's own
224
+ // limit rather than at the cap that exists to bound hydration.
225
+ search: (callParams, ctx) => isIdsOnly(ctx)
226
+ ? { ...callParams, maxResults: clamp(ctx.params.maxResults, IDS_ONLY_PAGE, IDS_ONLY_PAGE) }
227
+ : callParams,
228
+ },
285
229
  afterExecute: {
286
230
  // triage IS search, with a different default query. Same raw Google shape
287
231
  // (messages.list -> bare ids), same hydrate, same formatter.
288
232
  triage: async (result, ctx) => gmailPatch.afterExecute.search(result, ctx),
289
233
  search: async (result, ctx) => {
290
- // messages.list returns bare IDs — hydrate with metadata
234
+ // messages.list returns bare IDs. idsOnly hands them back as they are, which is what
235
+ // makes walking thousands of matches cheap; otherwise each row is hydrated.
291
236
  const raw = result;
292
237
  const ids = (raw?.messages ?? []);
238
+ const paging = pagingFrom(raw);
239
+ if (isIdsOnly(ctx)) {
240
+ return { ids: ids.map((m) => m.id), idsOnly: true, ...paging };
241
+ }
293
242
  if (ids.length === 0) {
294
243
  // Preserve resultSizeEstimate so formatters can distinguish
295
244
  // "search ran, no matches" from unexpected empty responses
296
245
  return {
297
246
  messages: [],
247
+ ...paging,
298
248
  resultSizeEstimate: raw?.resultSizeEstimate ?? 0,
299
249
  query: ctx.params.query ?? '',
300
250
  };
301
251
  }
302
252
  const messages = await hydrateMessages(ids, ctx.account);
303
- return { messages, resultSizeEstimate: raw?.resultSizeEstimate };
253
+ return { messages, ...paging };
304
254
  },
305
255
  // users.drafts.list returns bare draft resources ({id, message:{id}}) — hydrate
306
256
  // each with metadata so listDrafts shows recipient/subject/date, not just ids.
@@ -308,10 +258,10 @@ export const gmailPatch = {
308
258
  const raw = result;
309
259
  const drafts = (raw?.drafts ?? []);
310
260
  if (drafts.length === 0) {
311
- return { drafts: [], resultSizeEstimate: raw?.resultSizeEstimate ?? 0 };
261
+ return { drafts: [], ...pagingFrom(raw), resultSizeEstimate: raw?.resultSizeEstimate ?? 0 };
312
262
  }
313
263
  const hydrated = await hydrateDrafts(drafts, ctx.account);
314
- return { drafts: hydrated, resultSizeEstimate: raw?.resultSizeEstimate };
264
+ return { drafts: hydrated, ...pagingFrom(raw) };
315
265
  },
316
266
  },
317
267
  formatList: (data, ctx) => {
@@ -319,11 +269,11 @@ export const gmailPatch = {
319
269
  case 'labels':
320
270
  return formatLabelList(data);
321
271
  case 'threads':
322
- return formatThreadList(data);
272
+ return withPaging(formatThreadList(data), data, ctx);
323
273
  case 'listDrafts':
324
- return formatDraftList(data);
274
+ return withPaging(formatDraftList(data), data, ctx);
325
275
  default:
326
- return formatEmailList(data);
276
+ return withPaging(isIdsOnly(ctx) ? formatIdList(data) : formatEmailList(data), data, ctx);
327
277
  }
328
278
  },
329
279
  formatDetail: (data, ctx) => {
@@ -492,6 +442,16 @@ export const gmailPatch = {
492
442
  },
493
443
  getAttachment: handleGetAttachment,
494
444
  viewAttachment: handleViewAttachment,
445
+ // Filters — the lifecycle and its preview gate live in filters.ts (ADR-309).
446
+ filters: handleListFilters,
447
+ getFilter: handleGetFilter,
448
+ previewFilter: handlePreviewFilter,
449
+ createFilter: handleCreateFilter,
450
+ updateFilter: handleUpdateFilter,
451
+ deleteFilter: handleDeleteFilter,
452
+ // Label changes over a whole query, ids held server-side (ADR-310).
453
+ previewModifyMatching: handlePreviewModifyMatching,
454
+ modifyMatching: handleModifyMatching,
495
455
  /**
496
456
  * Delete a draft — permanently discard an unsent message.
497
457
  *