@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.
- package/build/accounts/oauth.js +6 -1
- package/build/accounts/oauth.js.map +1 -1
- package/build/coverage/compare.js +11 -1
- package/build/coverage/compare.js.map +1 -1
- package/build/factory/manifest/gmail.yaml +257 -6
- package/build/factory/safety.js +35 -11
- package/build/factory/safety.js.map +1 -1
- package/build/server/call-context.d.ts +12 -0
- package/build/server/call-context.js +20 -0
- package/build/server/call-context.js.map +1 -0
- package/build/server/handler.d.ts +4 -0
- package/build/server/handler.js +9 -1
- package/build/server/handler.js.map +1 -1
- package/build/server/preview-gate.d.ts +38 -0
- package/build/server/preview-gate.js +107 -0
- package/build/server/preview-gate.js.map +1 -0
- package/build/server/preview-sets.d.ts +25 -0
- package/build/server/preview-sets.js +58 -0
- package/build/server/preview-sets.js.map +1 -0
- package/build/services/gmail/filters.d.ts +81 -0
- package/build/services/gmail/filters.js +457 -0
- package/build/services/gmail/filters.js.map +1 -0
- package/build/services/gmail/hydrate.d.ts +29 -0
- package/build/services/gmail/hydrate.js +116 -0
- package/build/services/gmail/hydrate.js.map +1 -0
- package/build/services/gmail/labels.d.ts +31 -0
- package/build/services/gmail/labels.js +108 -0
- package/build/services/gmail/labels.js.map +1 -0
- package/build/services/gmail/modify-matching.d.ts +16 -0
- package/build/services/gmail/modify-matching.js +149 -0
- package/build/services/gmail/modify-matching.js.map +1 -0
- package/build/services/gmail/patch.js +76 -116
- package/build/services/gmail/patch.js.map +1 -1
- package/build/version.d.ts +1 -1
- package/build/version.js +1 -1
- 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":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAqB9C,MAAM,YAAY,GAAG,+CAA+C,CAAC;AACrE,MAAM,WAAW,GAAG,8CAA8C,CAAC;AAEnE,6EAA6E;AAC7E,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AAExD,MAAM,oBAAoB,GAAwD;IAChF,KAAK,EAAE;QACL,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,mBAAmB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,EAAE;QAClF,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,sCAAsC,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,kCAAkC,EAAE,EAAE;KACzH;IACD,MAAM,EAAE;QACN,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE;QAC3E,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE;KACtF;IACD,OAAO,EAAE;QACP,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;QAC/D,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE;KAC3E;IACD,SAAS,EAAE;QACT,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,EAAE;QACjF,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,sBAAsB,EAAE,EAAE;KAC/F;IACD,KAAK,EAAE;QACL,GAAG,EAAE;YACH,OAAO,EAAE,EAAE,IAAI,EAAE,2BAA2B,EAAE,MAAM,EAAE,GAAG,YAAY,uDAAuD,EAAE,IAAI,EAAE,IAAI,EAAE;YAC1I,GAAG,EAAE;gBACH,IAAI,EAAE,gBAAgB;gBACtB,MAAM,EAAE,yBAAyB,YAAY,uGAAuG;gBACpJ,IAAI,EAAE,IAAI;aACX;SACF;QACD,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,mBAAmB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,qBAAqB,EAAE,EAAE;KACzF;IACD,IAAI,EAAE;QACJ,GAAG,EAAE;YACH,OAAO,EAAE,EAAE,IAAI,EAAE,yBAAyB,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE;YAC7E,GAAG,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE;SAChE;QACD,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,oBAAoB,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,mBAAmB,EAAE,EAAE;KACxF;CACF,CAAC;AAEF,mGAAmG;AACnG,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxF,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5E,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;AACtC,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,OAAe;IAC9C,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,mBAAmB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,OAAO,EAAE,CAA4B,CAAC;IAC/G,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,CAA0C,CAAC;IAC5E,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AAChG,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,MAAmB,EAAE,KAA0B;IAC3E,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC3E,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,cAAc,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;QACnF,IAAI,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,kEAAkE,CAAC,CAAC;YACrF,SAAS;QACX,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;YAAE,SAAS;QAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;QAC1C,MAAM,CAAC,IAAI,CAAC,IAAI;YACd,CAAC,CAAC,IAAI,EAAE,yCAAyC,IAAI,GAAG;YACxD,CAAC,CAAC,qBAAqB,EAAE,gFAAgF,CAAC,CAAC;IAC/G,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,MAAmB,EAAE,KAA0B,EAAE,MAAc;IAClG,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,IAAI,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;IACjD,MAAM,QAAQ,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;IAC5E,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,WAAW,IAAI,EAAE,EAAE,CAAC;QAC1C,MAAM,MAAM,GAAG,oBAAoB,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC;QACvD,IAAI,MAAM;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;aAC5B,IAAI,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,mBAAmB,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;;YACnG,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,YAAY,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;IACvD,CAAC;IACD,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,cAAc,IAAI,EAAE,EAAE,CAAC;QAC7C,MAAM,MAAM,GAAG,oBAAoB,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC;QAC1D,IAAI,MAAM;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;aAC5B,IAAI,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,cAAc,QAAQ,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;;YAChJ,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAC7D,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,UAAU,CAAC,CAAS;IAClC,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;AAC9E,CAAC"}
|
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
*
|