@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview gate for standing-effect writes (ADR-309).
|
|
3
|
+
*
|
|
4
|
+
* A preview mints a token over the exact payload it evaluated; the write refuses without
|
|
5
|
+
* one. The token is an HMAC under a key made at process start, so nothing is stored and
|
|
6
|
+
* nothing needs cleaning up. A restart invalidates every outstanding token, and the remedy
|
|
7
|
+
* is to preview again.
|
|
8
|
+
*
|
|
9
|
+
* A token proves the evaluation was returned to the caller. It does not prove a person
|
|
10
|
+
* read it; that is the MCP client's per-call permission prompt.
|
|
11
|
+
*/
|
|
12
|
+
/** How long a preview stays good. Match counts and conflicts drift as mail arrives. */
|
|
13
|
+
export declare const PREVIEW_TTL_MS: number;
|
|
14
|
+
export type TokenFailure = 'missing' | 'malformed' | 'wrong-kind' | 'mismatch' | 'expired' | 'same-call';
|
|
15
|
+
export type TokenCheck = {
|
|
16
|
+
ok: true;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
failure: TokenFailure;
|
|
20
|
+
issuedFor?: string;
|
|
21
|
+
};
|
|
22
|
+
/** JSON with object keys sorted at every depth, so equal payloads serialize equally. */
|
|
23
|
+
export declare function canonicalJson(value: unknown): string;
|
|
24
|
+
/** Mint a token for `kind`, bound to this account, this payload, now, and this call. */
|
|
25
|
+
export declare function mintPreviewToken(kind: string, account: string, payload: unknown, now?: number): string;
|
|
26
|
+
/** Check a token against the write about to happen. */
|
|
27
|
+
export declare function checkPreviewToken(token: unknown, kind: string, account: string, payload: unknown, now?: number): TokenCheck;
|
|
28
|
+
/**
|
|
29
|
+
* The sentence a refused write returns.
|
|
30
|
+
*
|
|
31
|
+
* @param write The operation refused, e.g. `createFilter`.
|
|
32
|
+
* @param preview The operation that mints its token, e.g. `previewFilter`.
|
|
33
|
+
* @param why What makes the write need a preview, in a clause, e.g. "It changes what
|
|
34
|
+
* happens to mail that has not arrived yet". Shown when the token is missing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function describeTokenFailure(check: Extract<TokenCheck, {
|
|
37
|
+
ok: false;
|
|
38
|
+
}>, write: string, preview: string, why: string): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview gate for standing-effect writes (ADR-309).
|
|
3
|
+
*
|
|
4
|
+
* A preview mints a token over the exact payload it evaluated; the write refuses without
|
|
5
|
+
* one. The token is an HMAC under a key made at process start, so nothing is stored and
|
|
6
|
+
* nothing needs cleaning up. A restart invalidates every outstanding token, and the remedy
|
|
7
|
+
* is to preview again.
|
|
8
|
+
*
|
|
9
|
+
* A token proves the evaluation was returned to the caller. It does not prove a person
|
|
10
|
+
* read it; that is the MCP client's per-call permission prompt.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { currentCallId } from './call-context.js';
|
|
14
|
+
const KEY = randomBytes(32);
|
|
15
|
+
/** How long a preview stays good. Match counts and conflicts drift as mail arrives. */
|
|
16
|
+
export const PREVIEW_TTL_MS = 30 * 60_000;
|
|
17
|
+
/** Tolerated clock skew for a token that claims to come from the future. */
|
|
18
|
+
const SKEW_MS = 60_000;
|
|
19
|
+
/** JSON with object keys sorted at every depth, so equal payloads serialize equally. */
|
|
20
|
+
export function canonicalJson(value) {
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
const entries = Object.entries(value)
|
|
25
|
+
.filter(([, v]) => v !== undefined)
|
|
26
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
27
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
function mac(headerPart, account, payload) {
|
|
32
|
+
return createHmac('sha256', KEY)
|
|
33
|
+
.update(headerPart).update('\n')
|
|
34
|
+
.update(account.toLowerCase()).update('\n')
|
|
35
|
+
.update(canonicalJson(payload))
|
|
36
|
+
.digest();
|
|
37
|
+
}
|
|
38
|
+
/** Mint a token for `kind`, bound to this account, this payload, now, and this call. */
|
|
39
|
+
export function mintPreviewToken(kind, account, payload, now = Date.now()) {
|
|
40
|
+
const header = { k: kind, t: now, c: currentCallId() };
|
|
41
|
+
const headerPart = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
42
|
+
return `${headerPart}.${mac(headerPart, account, payload).toString('base64url')}`;
|
|
43
|
+
}
|
|
44
|
+
/** Check a token against the write about to happen. */
|
|
45
|
+
export function checkPreviewToken(token, kind, account, payload, now = Date.now()) {
|
|
46
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
47
|
+
return { ok: false, failure: 'missing' };
|
|
48
|
+
const [headerPart, macPart, extra] = token.trim().split('.');
|
|
49
|
+
if (!headerPart || !macPart || extra !== undefined)
|
|
50
|
+
return { ok: false, failure: 'malformed' };
|
|
51
|
+
let header;
|
|
52
|
+
try {
|
|
53
|
+
header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf-8'));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { ok: false, failure: 'malformed' };
|
|
57
|
+
}
|
|
58
|
+
if (typeof header?.k !== 'string' || typeof header.t !== 'number' || typeof header.c !== 'number') {
|
|
59
|
+
return { ok: false, failure: 'malformed' };
|
|
60
|
+
}
|
|
61
|
+
// The kind is read before the MAC because sibling operations sign different payloads, so
|
|
62
|
+
// a token meant for one always fails the other's MAC and would be reported as a changed
|
|
63
|
+
// parameter. Reading the unverified header here can only choose which refusal to give;
|
|
64
|
+
// nothing in it is trusted until the MAC agrees.
|
|
65
|
+
if (header.k !== kind)
|
|
66
|
+
return { ok: false, failure: 'wrong-kind', issuedFor: header.k };
|
|
67
|
+
const expected = mac(headerPart, account, payload);
|
|
68
|
+
const given = Buffer.from(macPart, 'base64url');
|
|
69
|
+
const genuine = given.length === expected.length && timingSafeEqual(given, expected);
|
|
70
|
+
if (!genuine)
|
|
71
|
+
return { ok: false, failure: 'mismatch' };
|
|
72
|
+
if (header.t > now + SKEW_MS || now - header.t > PREVIEW_TTL_MS)
|
|
73
|
+
return { ok: false, failure: 'expired' };
|
|
74
|
+
if (header.c !== 0 && header.c === currentCallId())
|
|
75
|
+
return { ok: false, failure: 'same-call' };
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The sentence a refused write returns.
|
|
80
|
+
*
|
|
81
|
+
* @param write The operation refused, e.g. `createFilter`.
|
|
82
|
+
* @param preview The operation that mints its token, e.g. `previewFilter`.
|
|
83
|
+
* @param why What makes the write need a preview, in a clause, e.g. "It changes what
|
|
84
|
+
* happens to mail that has not arrived yet". Shown when the token is missing.
|
|
85
|
+
*/
|
|
86
|
+
export function describeTokenFailure(check, write, preview, why) {
|
|
87
|
+
const again = `Call \`${preview}\` with these exact parameters and pass the previewToken it returns.`;
|
|
88
|
+
switch (check.failure) {
|
|
89
|
+
case 'missing':
|
|
90
|
+
return `\`${write}\` needs a previewToken. ${why}, so it runs only after a preview of this exact change ` +
|
|
91
|
+
`has been returned. ${again}`;
|
|
92
|
+
case 'malformed':
|
|
93
|
+
return `The previewToken is not one this server issued. ${again}`;
|
|
94
|
+
case 'wrong-kind':
|
|
95
|
+
return `This previewToken came from a preview for ${check.issuedFor ?? 'another operation'}, not \`${write}\`. ${again}`;
|
|
96
|
+
case 'mismatch':
|
|
97
|
+
return `This previewToken does not match this change on this account. Either a parameter changed since ` +
|
|
98
|
+
`the preview, the token came from another account, or the server restarted. ${again}`;
|
|
99
|
+
case 'expired':
|
|
100
|
+
return `This previewToken has expired; previews are good for ${PREVIEW_TTL_MS / 60_000} minutes because ` +
|
|
101
|
+
`what they match changes as mail arrives. ${again}`;
|
|
102
|
+
case 'same-call':
|
|
103
|
+
return `This previewToken was minted earlier in this same tool call, so the preview was never returned ` +
|
|
104
|
+
`before \`${write}\` ran. Run the preview as its own call, read it, then call \`${write}\`.`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=preview-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview-gate.js","sourceRoot":"","sources":["../../src/server/preview-gate.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets resolved at preview and held for the write that applies them (ADR-310).
|
|
3
|
+
*
|
|
4
|
+
* A query-scoped bulk write resolves its query once, at preview, so the apply changes
|
|
5
|
+
* exactly what the preview counted. The set is kept here, keyed by the preview token,
|
|
6
|
+
* and the token covers a digest of it, so a set that went missing or changed cannot be
|
|
7
|
+
* applied. Memory is bounded three ways: ids per set, a set's age, and sets per process.
|
|
8
|
+
* A restart drops every set, as it already invalidates every token.
|
|
9
|
+
*/
|
|
10
|
+
/** Most ids one preview may hold. A larger result is narrowed by the caller instead. */
|
|
11
|
+
export declare const MAX_SET_SIZE = 10000;
|
|
12
|
+
/** Most previews held at once; the oldest goes first. */
|
|
13
|
+
export declare const MAX_PENDING_SETS = 20;
|
|
14
|
+
/** A digest of the set, independent of order, for the token to cover. */
|
|
15
|
+
export declare function setDigest(ids: readonly string[]): string;
|
|
16
|
+
/** Hold a set under its preview token. */
|
|
17
|
+
export declare function holdSet(token: string, account: string, ids: readonly string[], now?: number): void;
|
|
18
|
+
/** The set held under this token for this account, if it is still held. Does not remove it. */
|
|
19
|
+
export declare function heldSet(token: string, account: string, now?: number): string[] | undefined;
|
|
20
|
+
/** Remove a set; an applied preview is used once. */
|
|
21
|
+
export declare function releaseSet(token: string): void;
|
|
22
|
+
/** How many sets are held. For tests and diagnostics. */
|
|
23
|
+
export declare function heldSetCount(now?: number): number;
|
|
24
|
+
/** Drop every held set. For tests. */
|
|
25
|
+
export declare function clearHeldSets(): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets resolved at preview and held for the write that applies them (ADR-310).
|
|
3
|
+
*
|
|
4
|
+
* A query-scoped bulk write resolves its query once, at preview, so the apply changes
|
|
5
|
+
* exactly what the preview counted. The set is kept here, keyed by the preview token,
|
|
6
|
+
* and the token covers a digest of it, so a set that went missing or changed cannot be
|
|
7
|
+
* applied. Memory is bounded three ways: ids per set, a set's age, and sets per process.
|
|
8
|
+
* A restart drops every set, as it already invalidates every token.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { PREVIEW_TTL_MS } from './preview-gate.js';
|
|
12
|
+
/** Most ids one preview may hold. A larger result is narrowed by the caller instead. */
|
|
13
|
+
export const MAX_SET_SIZE = 10_000;
|
|
14
|
+
/** Most previews held at once; the oldest goes first. */
|
|
15
|
+
export const MAX_PENDING_SETS = 20;
|
|
16
|
+
const held = new Map();
|
|
17
|
+
/** A digest of the set, independent of order, for the token to cover. */
|
|
18
|
+
export function setDigest(ids) {
|
|
19
|
+
return createHash('sha256').update([...ids].sort().join('\n')).digest('base64url');
|
|
20
|
+
}
|
|
21
|
+
function dropExpired(now) {
|
|
22
|
+
for (const [key, set] of held) {
|
|
23
|
+
if (now - set.heldAt > PREVIEW_TTL_MS)
|
|
24
|
+
held.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Hold a set under its preview token. */
|
|
28
|
+
export function holdSet(token, account, ids, now = Date.now()) {
|
|
29
|
+
if (ids.length > MAX_SET_SIZE)
|
|
30
|
+
throw new Error(`A previewed set holds at most ${MAX_SET_SIZE} ids, not ${ids.length}.`);
|
|
31
|
+
dropExpired(now);
|
|
32
|
+
held.delete(token);
|
|
33
|
+
while (held.size >= MAX_PENDING_SETS) {
|
|
34
|
+
// Map iterates in insertion order, so the first key is the oldest preview.
|
|
35
|
+
held.delete(held.keys().next().value);
|
|
36
|
+
}
|
|
37
|
+
held.set(token, { account: account.toLowerCase(), ids: [...ids], heldAt: now });
|
|
38
|
+
}
|
|
39
|
+
/** The set held under this token for this account, if it is still held. Does not remove it. */
|
|
40
|
+
export function heldSet(token, account, now = Date.now()) {
|
|
41
|
+
dropExpired(now);
|
|
42
|
+
const set = held.get(token);
|
|
43
|
+
return set && set.account === account.toLowerCase() ? [...set.ids] : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Remove a set; an applied preview is used once. */
|
|
46
|
+
export function releaseSet(token) {
|
|
47
|
+
held.delete(token);
|
|
48
|
+
}
|
|
49
|
+
/** How many sets are held. For tests and diagnostics. */
|
|
50
|
+
export function heldSetCount(now = Date.now()) {
|
|
51
|
+
dropExpired(now);
|
|
52
|
+
return held.size;
|
|
53
|
+
}
|
|
54
|
+
/** Drop every held set. For tests. */
|
|
55
|
+
export function clearHeldSets() {
|
|
56
|
+
held.clear();
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=preview-sets.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview-sets.js","sourceRoot":"","sources":["../../src/server/preview-sets.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,wFAAwF;AACxF,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC;AAEnC,yDAAyD;AACzD,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAC;AAQnC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAmB,CAAC;AAExC,yEAAyE;AACzE,MAAM,UAAU,SAAS,CAAC,GAAsB;IAC9C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AACrF,CAAC;AAED,SAAS,WAAW,CAAC,GAAW;IAC9B,KAAK,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;QAC9B,IAAI,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,cAAc;YAAE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1D,CAAC;AACH,CAAC;AAED,0CAA0C;AAC1C,MAAM,UAAU,OAAO,CAAC,KAAa,EAAE,OAAe,EAAE,GAAsB,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IAC9F,IAAI,GAAG,CAAC,MAAM,GAAG,YAAY;QAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,YAAY,aAAa,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;IACxH,WAAW,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACnB,OAAO,IAAI,CAAC,IAAI,IAAI,gBAAgB,EAAE,CAAC;QACrC,2EAA2E;QAC3E,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAe,CAAC,CAAC;IAClD,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,WAAW,EAAE,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;AAClF,CAAC;AAED,+FAA+F;AAC/F,MAAM,UAAU,OAAO,CAAC,KAAa,EAAE,OAAe,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IACtE,WAAW,CAAC,GAAG,CAAC,CAAC;IACjB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC5B,OAAO,GAAG,IAAI,GAAG,CAAC,OAAO,KAAK,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACjF,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACrB,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,YAAY,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IAC3C,WAAW,CAAC,GAAG,CAAC,CAAC;IACjB,OAAO,IAAI,CAAC,IAAI,CAAC;AACnB,CAAC;AAED,sCAAsC;AACtC,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC,KAAK,EAAE,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail filters — routing applied to mail as it arrives. ADR-309, #202.
|
|
3
|
+
*
|
|
4
|
+
* A filter is a standing-effect write: it keeps acting after the call, on messages the
|
|
5
|
+
* call never saw. So the lifecycle is gated. `previewFilter` evaluates a filter against the
|
|
6
|
+
* mail already there and mints a token for that exact filter; `createFilter` and
|
|
7
|
+
* `updateFilter` refuse without it. Every action Gmail supports stays available, and the
|
|
8
|
+
* preview is where a dangerous one (trash, forward) is explained.
|
|
9
|
+
*
|
|
10
|
+
* Gmail has no `filters.update`. `updateFilter` creates the replacement first and deletes
|
|
11
|
+
* the original after, so a failure part-way leaves two filters, never none.
|
|
12
|
+
*
|
|
13
|
+
* Parameters are flat, like every other operation on this tool: criteria are `match*`,
|
|
14
|
+
* actions are `addLabelIds`, `removeLabelIds` and `forwardTo`.
|
|
15
|
+
*/
|
|
16
|
+
import { type HandlerResponse } from '../../server/formatting/markdown.js';
|
|
17
|
+
import { type Effect } from './labels.js';
|
|
18
|
+
export interface FilterCriteria {
|
|
19
|
+
from?: string;
|
|
20
|
+
to?: string;
|
|
21
|
+
subject?: string;
|
|
22
|
+
query?: string;
|
|
23
|
+
negatedQuery?: string;
|
|
24
|
+
hasAttachment?: boolean;
|
|
25
|
+
excludeChats?: boolean;
|
|
26
|
+
size?: number;
|
|
27
|
+
sizeComparison?: 'larger' | 'smaller';
|
|
28
|
+
}
|
|
29
|
+
export interface FilterAction {
|
|
30
|
+
addLabelIds?: string[];
|
|
31
|
+
removeLabelIds?: string[];
|
|
32
|
+
forward?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Field names match Gmail's `Filter` resource, so a spec is also the request body. */
|
|
35
|
+
export interface FilterSpec {
|
|
36
|
+
criteria: FilterCriteria;
|
|
37
|
+
action: FilterAction;
|
|
38
|
+
}
|
|
39
|
+
/** A filter as Google returns it, before normalizing. */
|
|
40
|
+
type RawFilter = {
|
|
41
|
+
criteria?: Record<string, unknown>;
|
|
42
|
+
action?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Reduce any filter, from our params or from Google, to one canonical spec, so two
|
|
46
|
+
* filters that do the same thing compare equal and a token minted over one matches the
|
|
47
|
+
* other. Empty strings, false booleans and empty label lists are absent.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeSpec(raw: RawFilter): FilterSpec;
|
|
50
|
+
/** Build and validate the filter a call describes. Throws a sentence naming what is missing. */
|
|
51
|
+
export declare function buildFilterSpec(params: Record<string, unknown>): FilterSpec;
|
|
52
|
+
/** The Gmail search a filter's criteria amount to. Filters and search share one syntax. */
|
|
53
|
+
export declare function criteriaToQuery(c: FilterCriteria): string;
|
|
54
|
+
/** The tool params that describe this filter, for recreating or editing it. */
|
|
55
|
+
export declare function specToParams(spec: FilterSpec): Record<string, string | number | boolean>;
|
|
56
|
+
export declare function describeActions(action: FilterAction, names: Map<string, string>): Effect[];
|
|
57
|
+
/** `filters` — every filter on the account, one line each. */
|
|
58
|
+
export declare function handleListFilters(_params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
59
|
+
/** `getFilter` — one filter, in words, with the parameters that recreate or edit it. */
|
|
60
|
+
export declare function handleGetFilter(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
61
|
+
/**
|
|
62
|
+
* `previewFilter` — evaluate a filter without creating it, and mint the token the write
|
|
63
|
+
* needs. With `filterId`, evaluates replacing that filter and mints an update token.
|
|
64
|
+
*
|
|
65
|
+
* Problems that would make the write fail or do nothing (an unknown label, an unverified
|
|
66
|
+
* forwarding address, an exact duplicate) are errors and withhold the token. Consequences
|
|
67
|
+
* worth a second look are shown, not refused.
|
|
68
|
+
*/
|
|
69
|
+
export declare function handlePreviewFilter(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
70
|
+
/** `createFilter` — create a filter its preview has already shown. */
|
|
71
|
+
export declare function handleCreateFilter(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
72
|
+
/**
|
|
73
|
+
* `updateFilter` — replace a filter with the one its preview showed.
|
|
74
|
+
*
|
|
75
|
+
* The token covers the filter being replaced as it was at preview time, so a filter that
|
|
76
|
+
* changed in the meantime is not replaced on the strength of a preview of something else.
|
|
77
|
+
*/
|
|
78
|
+
export declare function handleUpdateFilter(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
79
|
+
/** `deleteFilter` — stop a filter, and return what it was so it can be put back. */
|
|
80
|
+
export declare function handleDeleteFilter(params: Record<string, unknown>, account: string): Promise<HandlerResponse>;
|
|
81
|
+
export {};
|