@ni-c/imap-mcp 0.2.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/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/analyze.d.ts +129 -0
- package/dist/analyze.js +313 -0
- package/dist/analyze.js.map +1 -0
- package/dist/approval.d.ts +45 -0
- package/dist/approval.js +69 -0
- package/dist/approval.js.map +1 -0
- package/dist/attachments.d.ts +55 -0
- package/dist/attachments.js +270 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audit.d.ts +17 -0
- package/dist/audit.js +33 -0
- package/dist/audit.js.map +1 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +202 -0
- package/dist/config.js.map +1 -0
- package/dist/confirm.d.ts +59 -0
- package/dist/confirm.js +92 -0
- package/dist/confirm.js.map +1 -0
- package/dist/download.d.ts +23 -0
- package/dist/download.js +65 -0
- package/dist/download.js.map +1 -0
- package/dist/draft.d.ts +34 -0
- package/dist/draft.js +119 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/imap.d.ts +161 -0
- package/dist/imap.js +300 -0
- package/dist/imap.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/message.d.ts +51 -0
- package/dist/message.js +155 -0
- package/dist/message.js.map +1 -0
- package/dist/resources.d.ts +16 -0
- package/dist/resources.js +89 -0
- package/dist/resources.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.js +193 -0
- package/dist/result.js.map +1 -0
- package/dist/schema.d.ts +42 -0
- package/dist/schema.js +99 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/stream.d.ts +9 -0
- package/dist/stream.js +25 -0
- package/dist/stream.js.map +1 -0
- package/dist/tool-filter.d.ts +45 -0
- package/dist/tool-filter.js +171 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/tools/catalogue.d.ts +46 -0
- package/dist/tools/catalogue.js +67 -0
- package/dist/tools/catalogue.js.map +1 -0
- package/dist/tools/read.d.ts +4 -0
- package/dist/tools/read.js +576 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +291 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issues short-lived confirmation tokens for irreversible operations.
|
|
3
|
+
*
|
|
4
|
+
* A plain boolean `confirm` parameter could be set by the model on the very
|
|
5
|
+
* first call — or be talked into it by instructions hidden in upstream content —
|
|
6
|
+
* whereas a random token that only ever appears in a *previous* tool result
|
|
7
|
+
* cannot be guessed. The token is bound to a resource key, so a confirmation for
|
|
8
|
+
* one target cannot be replayed for another.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ConfirmationStore {
|
|
11
|
+
private readonly ttlMs;
|
|
12
|
+
private readonly pending;
|
|
13
|
+
constructor(ttlMs?: number);
|
|
14
|
+
/** Creates (or replaces) the pending token for `resource`. */
|
|
15
|
+
issue(resource: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Returns true and consumes the token when it matches the pending one for
|
|
18
|
+
* `resource` and has not expired. Tokens are single-use.
|
|
19
|
+
*/
|
|
20
|
+
consume(resource: string, token: string | undefined): boolean;
|
|
21
|
+
/** Minutes the issued tokens stay valid, for use in messages. */
|
|
22
|
+
get ttlMinutes(): number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resource key for an operation on a *set* of targets. Without the fingerprint a
|
|
26
|
+
* confirmation for ["a.txt"] would also execute ["a.txt", "secrets.env"] — the
|
|
27
|
+
* model chooses the second list, and only the id would have been checked.
|
|
28
|
+
*/
|
|
29
|
+
export declare function setResourceKey(operation: string, targets: string[]): string;
|
|
30
|
+
/**
|
|
31
|
+
* A name this server did not choose, shown alongside a confirmation.
|
|
32
|
+
*
|
|
33
|
+
* Mailbox names look like server-side metadata and are not: they come from the
|
|
34
|
+
* model's arguments, and `list_mailboxes` sources them from the account — which
|
|
35
|
+
* on a shared mailbox or a public namespace means a colleague, or whoever
|
|
36
|
+
* compromised one, picks them. A folder called
|
|
37
|
+
* `Archive" — routine cleanup, pre-approved by IT` interpolated into the middle
|
|
38
|
+
* of "This will move 12 message(s) from X to Y" reads as part of the server's
|
|
39
|
+
* own sentence, in the one string a human is given before losing a folder.
|
|
40
|
+
*
|
|
41
|
+
* So they are never interpolated: {@link renderDetails} puts each on its own
|
|
42
|
+
* labelled line. `mailboxParam` refuses CR, LF and NUL, so a value cannot open a
|
|
43
|
+
* second line and forge a label of its own — the single-line rendering is what
|
|
44
|
+
* that validation is worth.
|
|
45
|
+
*/
|
|
46
|
+
export interface ConfirmationDetail {
|
|
47
|
+
label: string;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
declare function renderDetails(details: readonly ConfirmationDetail[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Builds the text returned by the first call of a destructive tool.
|
|
53
|
+
*
|
|
54
|
+
* Note what is NOT in here: no subject, sender or filename coming out of the
|
|
55
|
+
* mailbox. Those are attacker-controllable and this string is read by a model.
|
|
56
|
+
* Mailbox names are attacker-reachable too, which is what `details` is for.
|
|
57
|
+
*/
|
|
58
|
+
export declare function confirmationPrompt(what: string, token: string, ttlMinutes: number, consequence?: string, details?: readonly ConfirmationDetail[]): string;
|
|
59
|
+
export { renderDetails };
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
3
|
+
/** Bounds the map so a loop of refused calls cannot grow it without limit. */
|
|
4
|
+
const MAX_PENDING = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Issues short-lived confirmation tokens for irreversible operations.
|
|
7
|
+
*
|
|
8
|
+
* A plain boolean `confirm` parameter could be set by the model on the very
|
|
9
|
+
* first call — or be talked into it by instructions hidden in upstream content —
|
|
10
|
+
* whereas a random token that only ever appears in a *previous* tool result
|
|
11
|
+
* cannot be guessed. The token is bound to a resource key, so a confirmation for
|
|
12
|
+
* one target cannot be replayed for another.
|
|
13
|
+
*/
|
|
14
|
+
export class ConfirmationStore {
|
|
15
|
+
ttlMs;
|
|
16
|
+
pending = new Map();
|
|
17
|
+
constructor(ttlMs = TOKEN_TTL_MS) {
|
|
18
|
+
this.ttlMs = ttlMs;
|
|
19
|
+
}
|
|
20
|
+
/** Creates (or replaces) the pending token for `resource`. */
|
|
21
|
+
issue(resource) {
|
|
22
|
+
if (this.pending.size >= MAX_PENDING) {
|
|
23
|
+
const oldest = this.pending.keys().next();
|
|
24
|
+
if (!oldest.done)
|
|
25
|
+
this.pending.delete(oldest.value);
|
|
26
|
+
}
|
|
27
|
+
const token = randomBytes(16).toString('hex');
|
|
28
|
+
this.pending.set(resource, { token, expiresAt: Date.now() + this.ttlMs });
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Returns true and consumes the token when it matches the pending one for
|
|
33
|
+
* `resource` and has not expired. Tokens are single-use.
|
|
34
|
+
*/
|
|
35
|
+
consume(resource, token) {
|
|
36
|
+
const entry = this.pending.get(resource);
|
|
37
|
+
if (entry === undefined || token === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
const supplied = Buffer.from(token);
|
|
40
|
+
const expected = Buffer.from(entry.token);
|
|
41
|
+
// Constant-time comparison. Guessing 128 random bits through a timing
|
|
42
|
+
// side channel is not a realistic attack on a local tool, but the safe
|
|
43
|
+
// comparison costs one line and removes the question.
|
|
44
|
+
const matches = supplied.length === expected.length &&
|
|
45
|
+
timingSafeEqual(supplied, expected);
|
|
46
|
+
if (!matches)
|
|
47
|
+
return false;
|
|
48
|
+
// Delete on any match, expired or not. Leaving a matched-but-dead entry
|
|
49
|
+
// behind kept it competing for space with live ones, and the eviction in
|
|
50
|
+
// issue() is insertion-order rather than LRU, so a long-lived key could be
|
|
51
|
+
// dropped ahead of a token that was already spent.
|
|
52
|
+
this.pending.delete(resource);
|
|
53
|
+
return Date.now() < entry.expiresAt;
|
|
54
|
+
}
|
|
55
|
+
/** Minutes the issued tokens stay valid, for use in messages. */
|
|
56
|
+
get ttlMinutes() {
|
|
57
|
+
return Math.round(this.ttlMs / 60_000);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resource key for an operation on a *set* of targets. Without the fingerprint a
|
|
62
|
+
* confirmation for ["a.txt"] would also execute ["a.txt", "secrets.env"] — the
|
|
63
|
+
* model chooses the second list, and only the id would have been checked.
|
|
64
|
+
*/
|
|
65
|
+
export function setResourceKey(operation, targets) {
|
|
66
|
+
const fingerprint = createHash('sha256')
|
|
67
|
+
.update(JSON.stringify([...targets].sort()))
|
|
68
|
+
.digest('hex')
|
|
69
|
+
.slice(0, 16);
|
|
70
|
+
return `${operation}:${fingerprint}`;
|
|
71
|
+
}
|
|
72
|
+
function renderDetails(details) {
|
|
73
|
+
if (details.length === 0)
|
|
74
|
+
return '';
|
|
75
|
+
return ('\n\nNames below are supplied by the caller, not by this server:\n' +
|
|
76
|
+
details.map((d) => ` ${d.label}: ${d.value}`).join('\n'));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Builds the text returned by the first call of a destructive tool.
|
|
80
|
+
*
|
|
81
|
+
* Note what is NOT in here: no subject, sender or filename coming out of the
|
|
82
|
+
* mailbox. Those are attacker-controllable and this string is read by a model.
|
|
83
|
+
* Mailbox names are attacker-reachable too, which is what `details` is for.
|
|
84
|
+
*/
|
|
85
|
+
export function confirmationPrompt(what, token, ttlMinutes, consequence = 'The operation is irreversible.', details = []) {
|
|
86
|
+
return (`This will ${what}. ${consequence}` +
|
|
87
|
+
`${renderDetails(details)}\n\n` +
|
|
88
|
+
`To proceed, call this tool again with confirm_token="${token}".\n` +
|
|
89
|
+
`The token is valid for ${ttlMinutes} minutes and can be used once.`);
|
|
90
|
+
}
|
|
91
|
+
export { renderDetails };
|
|
92
|
+
//# sourceMappingURL=confirm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confirm.js","sourceRoot":"","sources":["../src/confirm.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface SavedAttachment {
|
|
2
|
+
path: string;
|
|
3
|
+
bytes: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Writes an attachment into the configured download directory.
|
|
7
|
+
*
|
|
8
|
+
* Everything here exists because this is the first point in the server where
|
|
9
|
+
* bytes from a stranger get a name in the filesystem:
|
|
10
|
+
*
|
|
11
|
+
* - The directory comes only from `IMAP_DOWNLOAD_DIR`. A caller — and therefore
|
|
12
|
+
* a message that talked the model into a tool call — cannot choose where
|
|
13
|
+
* anything lands.
|
|
14
|
+
* - The filename is already stripped of path separators and directional
|
|
15
|
+
* overrides by `sanitizeFilename`, but the resolved path is checked against
|
|
16
|
+
* the directory anyway, because one guard is not a guard.
|
|
17
|
+
* - `wx` refuses to open an existing path. That covers two attacks at once:
|
|
18
|
+
* overwriting a file the user cares about, and following a symlink somebody
|
|
19
|
+
* planted under a predictable attachment name.
|
|
20
|
+
* - Mode 0600, because the content is untrusted and possibly confidential at
|
|
21
|
+
* the same time.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveAttachment(directory: string, filename: string, content: Buffer): Promise<SavedAttachment>;
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { ToolInputError } from './errors.js';
|
|
4
|
+
/** How many `name (2).pdf`, `name (3).pdf` … variants are tried. */
|
|
5
|
+
const MAX_COLLISION_ATTEMPTS = 50;
|
|
6
|
+
/**
|
|
7
|
+
* Writes an attachment into the configured download directory.
|
|
8
|
+
*
|
|
9
|
+
* Everything here exists because this is the first point in the server where
|
|
10
|
+
* bytes from a stranger get a name in the filesystem:
|
|
11
|
+
*
|
|
12
|
+
* - The directory comes only from `IMAP_DOWNLOAD_DIR`. A caller — and therefore
|
|
13
|
+
* a message that talked the model into a tool call — cannot choose where
|
|
14
|
+
* anything lands.
|
|
15
|
+
* - The filename is already stripped of path separators and directional
|
|
16
|
+
* overrides by `sanitizeFilename`, but the resolved path is checked against
|
|
17
|
+
* the directory anyway, because one guard is not a guard.
|
|
18
|
+
* - `wx` refuses to open an existing path. That covers two attacks at once:
|
|
19
|
+
* overwriting a file the user cares about, and following a symlink somebody
|
|
20
|
+
* planted under a predictable attachment name.
|
|
21
|
+
* - Mode 0600, because the content is untrusted and possibly confidential at
|
|
22
|
+
* the same time.
|
|
23
|
+
*/
|
|
24
|
+
export async function saveAttachment(directory, filename, content) {
|
|
25
|
+
const base = resolve(directory);
|
|
26
|
+
const extension = extname(filename);
|
|
27
|
+
const stem = extension === '' ? filename : filename.slice(0, -extension.length);
|
|
28
|
+
for (let attempt = 1; attempt <= MAX_COLLISION_ATTEMPTS; attempt += 1) {
|
|
29
|
+
const candidate = attempt === 1 ? filename : `${stem} (${attempt})${extension}`;
|
|
30
|
+
const target = resolve(join(base, candidate));
|
|
31
|
+
// The sanitizer should already have made this impossible; if it ever stops
|
|
32
|
+
// being true, the write must not be the place where that is discovered.
|
|
33
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
34
|
+
throw new ToolInputError('imap-mcp: refused to write outside the configured download directory.');
|
|
35
|
+
}
|
|
36
|
+
let handle;
|
|
37
|
+
try {
|
|
38
|
+
handle = await open(target, 'wx', 0o600);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'EEXIST')
|
|
42
|
+
continue;
|
|
43
|
+
throw new ToolInputError(`imap-mcp: could not write to the download directory: ${describe(error)}`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await handle.write(content);
|
|
47
|
+
return { path: target, bytes: content.length };
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new ToolInputError(`imap-mcp: ${MAX_COLLISION_ATTEMPTS} files with this name already exist in the download directory.`);
|
|
54
|
+
}
|
|
55
|
+
function describe(error) {
|
|
56
|
+
const code = error?.code;
|
|
57
|
+
if (code === 'ENOENT')
|
|
58
|
+
return 'the directory does not exist';
|
|
59
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
60
|
+
return 'permission denied';
|
|
61
|
+
if (code === 'ENOSPC')
|
|
62
|
+
return 'no space left on the device';
|
|
63
|
+
return code ?? 'unknown error';
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=download.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,oEAAoE;AACpE,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAOlC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,SAAiB,EACjB,QAAgB,EAChB,OAAe;IAEf,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,MAAM,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpC,MAAM,IAAI,GACR,SAAS,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAErE,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,sBAAsB,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC;QACtE,MAAM,SAAS,GACb,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,OAAO,IAAI,SAAS,EAAE,CAAC;QAChE,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;QAE9C,2EAA2E;QAC3E,wEAAwE;QACxE,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;YACtD,MAAM,IAAI,cAAc,CACtB,uEAAuE,CACxE,CAAC;QACJ,CAAC;QAED,IAAI,MAAM,CAAC;QACX,IAAI,CAAC;YACH,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAC3C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;gBAAE,SAAS;YACjE,MAAM,IAAI,cAAc,CACtB,wDAAwD,QAAQ,CAAC,KAAK,CAAC,EAAE,CAC1E,CAAC;QACJ,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAC5B,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;QACjD,CAAC;gBAAS,CAAC;YACT,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACvB,CAAC;IACH,CAAC;IAED,MAAM,IAAI,cAAc,CACtB,aAAa,sBAAsB,gEAAgE,CACpG,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,MAAM,IAAI,GAAI,KAAsC,EAAE,IAAI,CAAC;IAC3D,IAAI,IAAI,KAAK,QAAQ;QAAE,OAAO,8BAA8B,CAAC;IAC7D,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO,mBAAmB,CAAC;IACtE,IAAI,IAAI,KAAK,QAAQ;QAAE,OAAO,6BAA6B,CAAC;IAC5D,OAAO,IAAI,IAAI,eAAe,CAAC;AACjC,CAAC"}
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Headers linking a draft into an existing conversation. */
|
|
2
|
+
export interface ThreadHeaders {
|
|
3
|
+
messageId: string | undefined;
|
|
4
|
+
references: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface DraftInput {
|
|
7
|
+
from: string | undefined;
|
|
8
|
+
to: string[];
|
|
9
|
+
cc?: string[];
|
|
10
|
+
bcc?: string[];
|
|
11
|
+
subject: string;
|
|
12
|
+
body: string;
|
|
13
|
+
thread?: ThreadHeaders;
|
|
14
|
+
/** Injected by the tests; real calls stamp the current time. */
|
|
15
|
+
date?: Date;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Builds an RFC 5322 message for `APPEND`.
|
|
19
|
+
*
|
|
20
|
+
* Written by hand rather than pulled from a library because the only consumer
|
|
21
|
+
* is this one path, and because every field here needs the same treatment: a
|
|
22
|
+
* bare CR or LF in a header value would let the caller append headers of its
|
|
23
|
+
* own — a Bcc, a Reply-To pointing elsewhere — to a message a human will later
|
|
24
|
+
* send under their own name. The schemas reject line breaks already; this is
|
|
25
|
+
* the second lock on the same door.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildDraft(input: DraftInput): Buffer;
|
|
28
|
+
/**
|
|
29
|
+
* Encodes a header value as MIME encoded-words when it is not plain ASCII.
|
|
30
|
+
*
|
|
31
|
+
* A raw UTF-8 subject is rejected or mangled by a fair number of servers, and
|
|
32
|
+
* the draft has to survive being opened in whatever mail client the person uses.
|
|
33
|
+
*/
|
|
34
|
+
export declare function encodeHeaderValue(value: string): string;
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ToolInputError } from './errors.js';
|
|
3
|
+
/** Encoded-words must not exceed 75 characters including the delimiters. */
|
|
4
|
+
const ENCODED_WORD_PAYLOAD = 45;
|
|
5
|
+
/**
|
|
6
|
+
* Builds an RFC 5322 message for `APPEND`.
|
|
7
|
+
*
|
|
8
|
+
* Written by hand rather than pulled from a library because the only consumer
|
|
9
|
+
* is this one path, and because every field here needs the same treatment: a
|
|
10
|
+
* bare CR or LF in a header value would let the caller append headers of its
|
|
11
|
+
* own — a Bcc, a Reply-To pointing elsewhere — to a message a human will later
|
|
12
|
+
* send under their own name. The schemas reject line breaks already; this is
|
|
13
|
+
* the second lock on the same door.
|
|
14
|
+
*/
|
|
15
|
+
export function buildDraft(input) {
|
|
16
|
+
const from = input.from;
|
|
17
|
+
if (from === undefined || from === '') {
|
|
18
|
+
throw new ToolInputError('imap-mcp: no sender address available — IMAP_USER is not set.');
|
|
19
|
+
}
|
|
20
|
+
const headers = [
|
|
21
|
+
['From', from],
|
|
22
|
+
['To', input.to.join(', ')],
|
|
23
|
+
];
|
|
24
|
+
if (input.cc !== undefined && input.cc.length > 0) {
|
|
25
|
+
headers.push(['Cc', input.cc.join(', ')]);
|
|
26
|
+
}
|
|
27
|
+
if (input.bcc !== undefined && input.bcc.length > 0) {
|
|
28
|
+
headers.push(['Bcc', input.bcc.join(', ')]);
|
|
29
|
+
}
|
|
30
|
+
headers.push(['Subject', encodeHeaderValue(input.subject)]);
|
|
31
|
+
headers.push(['Date', (input.date ?? new Date()).toUTCString()]);
|
|
32
|
+
headers.push(['Message-ID', `<${randomUUID()}@imap-mcp.invalid>`]);
|
|
33
|
+
if (input.thread?.messageId !== undefined) {
|
|
34
|
+
headers.push(['In-Reply-To', input.thread.messageId]);
|
|
35
|
+
}
|
|
36
|
+
if (input.thread !== undefined && input.thread.references.length > 0) {
|
|
37
|
+
headers.push(['References', foldReferences(input.thread.references)]);
|
|
38
|
+
}
|
|
39
|
+
headers.push(['MIME-Version', '1.0']);
|
|
40
|
+
headers.push(['Content-Type', 'text/plain; charset=utf-8']);
|
|
41
|
+
headers.push(['Content-Transfer-Encoding', 'base64']);
|
|
42
|
+
const lines = headers.map(([name, value]) => {
|
|
43
|
+
assertHeaderSafe(name, value);
|
|
44
|
+
return `${name}: ${value}`;
|
|
45
|
+
});
|
|
46
|
+
// base64 for the body: it survives any charset, cannot contain a line that
|
|
47
|
+
// looks like a header, and keeps lines inside the 998-octet limit.
|
|
48
|
+
const encoded = Buffer.from(input.body, 'utf-8')
|
|
49
|
+
.toString('base64')
|
|
50
|
+
.replace(/(.{76})/g, '$1\r\n');
|
|
51
|
+
return Buffer.from(`${lines.join('\r\n')}\r\n\r\n${encoded}\r\n`, 'utf-8');
|
|
52
|
+
}
|
|
53
|
+
/** Keep every folded header line under the RFC 5322 hard limit of 998 octets. */
|
|
54
|
+
const MAX_HEADER_LINE = 900;
|
|
55
|
+
/**
|
|
56
|
+
* Joins the References chain, folding onto continuation lines as needed.
|
|
57
|
+
*
|
|
58
|
+
* Twenty Message-IDs of up to 257 characters each on a single line would pass
|
|
59
|
+
* 5 kB — five times the 998-octet line limit, which some servers answer by
|
|
60
|
+
* refusing the APPEND and others by mangling the header. Folding (CRLF
|
|
61
|
+
* followed by a space) is how RFC 5322 says a long header is written.
|
|
62
|
+
*/
|
|
63
|
+
function foldReferences(references) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
let line = '';
|
|
66
|
+
for (const reference of references) {
|
|
67
|
+
if (line === '') {
|
|
68
|
+
line = reference;
|
|
69
|
+
}
|
|
70
|
+
else if (line.length + 1 + reference.length > MAX_HEADER_LINE) {
|
|
71
|
+
lines.push(line);
|
|
72
|
+
line = reference;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
line = `${line} ${reference}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (line !== '')
|
|
79
|
+
lines.push(line);
|
|
80
|
+
return lines.join('\r\n ');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A CRLF is only legal in a header when the next line starts with whitespace —
|
|
84
|
+
* that is folding. Anything else ends the header and starts a new one, which is
|
|
85
|
+
* exactly the injection this guards against.
|
|
86
|
+
*/
|
|
87
|
+
function assertHeaderSafe(name, value) {
|
|
88
|
+
if (/\r(?!\n)|(?<!\r)\n|\r\n(?![ \t])/.test(value)) {
|
|
89
|
+
throw new ToolInputError(`imap-mcp: the ${name} header must not contain line breaks.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Encodes a header value as MIME encoded-words when it is not plain ASCII.
|
|
94
|
+
*
|
|
95
|
+
* A raw UTF-8 subject is rejected or mangled by a fair number of servers, and
|
|
96
|
+
* the draft has to survive being opened in whatever mail client the person uses.
|
|
97
|
+
*/
|
|
98
|
+
export function encodeHeaderValue(value) {
|
|
99
|
+
if (!/[^ -~]/.test(value))
|
|
100
|
+
return value;
|
|
101
|
+
const words = [];
|
|
102
|
+
let chunk = '';
|
|
103
|
+
for (const character of value) {
|
|
104
|
+
const candidate = chunk + character;
|
|
105
|
+
if (Buffer.byteLength(candidate, 'utf-8') > ENCODED_WORD_PAYLOAD) {
|
|
106
|
+
words.push(chunk);
|
|
107
|
+
chunk = character;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
chunk = candidate;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (chunk !== '')
|
|
114
|
+
words.push(chunk);
|
|
115
|
+
return words
|
|
116
|
+
.map((word) => `=?UTF-8?B?${Buffer.from(word, 'utf-8').toString('base64')}?=`)
|
|
117
|
+
.join('\r\n ');
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=draft.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draft.js","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Errors that come from the caller's arguments rather than from the server. */
|
|
2
|
+
export declare class ToolInputError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* An IMAP failure, carrying whatever the server said.
|
|
7
|
+
*
|
|
8
|
+
* `responseText` is upstream output and gets the same truncation treatment as
|
|
9
|
+
* any other remote string before it reaches the model.
|
|
10
|
+
*/
|
|
11
|
+
export declare class MailError extends Error {
|
|
12
|
+
readonly code: string | undefined;
|
|
13
|
+
readonly responseText: string;
|
|
14
|
+
constructor(message: string, code?: string | undefined, responseText?: string);
|
|
15
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Errors that come from the caller's arguments rather than from the server. */
|
|
2
|
+
export class ToolInputError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'ToolInputError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* An IMAP failure, carrying whatever the server said.
|
|
10
|
+
*
|
|
11
|
+
* `responseText` is upstream output and gets the same truncation treatment as
|
|
12
|
+
* any other remote string before it reaches the model.
|
|
13
|
+
*/
|
|
14
|
+
export class MailError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
responseText;
|
|
17
|
+
constructor(message, code = undefined, responseText = '') {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.responseText = responseText;
|
|
21
|
+
this.name = 'MailError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,MAAM,OAAO,cAAe,SAAQ,KAAK;IACvC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,OAAO,SAAU,SAAQ,KAAK;IAGvB;IACA;IAHX,YACE,OAAe,EACN,OAA2B,SAAS,EACpC,eAAuB,EAAE;QAElC,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,SAAI,GAAJ,IAAI,CAAgC;QACpC,iBAAY,GAAZ,YAAY,CAAa;QAGlC,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF"}
|
package/dist/imap.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { FetchMessageObject, FetchQueryObject, ListResponse, SearchObject, StatusObject } from 'imapflow';
|
|
2
|
+
import { type Config, type ImapConfig } from './config.js';
|
|
3
|
+
import { MailError } from './errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Everything this server needs from an IMAP connection.
|
|
6
|
+
*
|
|
7
|
+
* Narrowing imapflow to an interface is what makes the tools testable: the unit
|
|
8
|
+
* tests inject a fake through {@link ImapClientFactory} instead of standing up a
|
|
9
|
+
* real server, and the surface stays small enough that the fake cannot silently
|
|
10
|
+
* drift away from the real thing.
|
|
11
|
+
*/
|
|
12
|
+
export interface ImapConnection {
|
|
13
|
+
connect(): Promise<void>;
|
|
14
|
+
logout(): Promise<void>;
|
|
15
|
+
close(): void;
|
|
16
|
+
noop(): Promise<void>;
|
|
17
|
+
list(options?: {
|
|
18
|
+
statusQuery?: {
|
|
19
|
+
messages?: boolean;
|
|
20
|
+
unseen?: boolean;
|
|
21
|
+
uidNext?: boolean;
|
|
22
|
+
};
|
|
23
|
+
}): Promise<ListResponse[]>;
|
|
24
|
+
status(path: string, query: Record<string, boolean>): Promise<StatusObject>;
|
|
25
|
+
getMailboxLock(path: string, options?: {
|
|
26
|
+
readOnly?: boolean;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
path: string;
|
|
29
|
+
release(): void;
|
|
30
|
+
}>;
|
|
31
|
+
search(query: SearchObject, options?: {
|
|
32
|
+
uid?: boolean;
|
|
33
|
+
}): Promise<number[] | false>;
|
|
34
|
+
fetch(range: number[] | string, query: FetchQueryObject, options?: {
|
|
35
|
+
uid?: boolean;
|
|
36
|
+
}): AsyncIterableIterator<FetchMessageObject>;
|
|
37
|
+
download(range: string, part?: string, options?: {
|
|
38
|
+
uid?: boolean;
|
|
39
|
+
maxBytes?: number;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
meta: {
|
|
42
|
+
contentType: string;
|
|
43
|
+
charset?: string;
|
|
44
|
+
filename?: string;
|
|
45
|
+
};
|
|
46
|
+
content: NodeJS.ReadableStream;
|
|
47
|
+
}>;
|
|
48
|
+
messageFlagsAdd(range: number[], flags: string[], options?: {
|
|
49
|
+
uid?: boolean;
|
|
50
|
+
}): Promise<boolean>;
|
|
51
|
+
messageFlagsRemove(range: number[], flags: string[], options?: {
|
|
52
|
+
uid?: boolean;
|
|
53
|
+
}): Promise<boolean>;
|
|
54
|
+
messageMove(range: number[], destination: string, options?: {
|
|
55
|
+
uid?: boolean;
|
|
56
|
+
}): Promise<unknown>;
|
|
57
|
+
messageCopy(range: number[], destination: string, options?: {
|
|
58
|
+
uid?: boolean;
|
|
59
|
+
}): Promise<unknown>;
|
|
60
|
+
messageDelete(range: number[], options?: {
|
|
61
|
+
uid?: boolean;
|
|
62
|
+
}): Promise<boolean>;
|
|
63
|
+
append(path: string, content: string | Buffer, flags?: string[], date?: Date): Promise<unknown>;
|
|
64
|
+
mailboxCreate(path: string): Promise<unknown>;
|
|
65
|
+
mailboxRename(path: string, newPath: string): Promise<unknown>;
|
|
66
|
+
mailboxDelete(path: string): Promise<unknown>;
|
|
67
|
+
readonly capabilities: Map<string, boolean | number>;
|
|
68
|
+
readonly mailbox: false | {
|
|
69
|
+
path: string;
|
|
70
|
+
permanentFlags: Set<string>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export type ImapClientFactory = (config: ImapConfig) => ImapConnection;
|
|
74
|
+
export interface MailboxSummary {
|
|
75
|
+
path: string;
|
|
76
|
+
name: string;
|
|
77
|
+
delimiter: string;
|
|
78
|
+
specialUse: string | undefined;
|
|
79
|
+
subscribed: boolean;
|
|
80
|
+
selectable: boolean;
|
|
81
|
+
messages: number | undefined;
|
|
82
|
+
unseen: number | undefined;
|
|
83
|
+
uidNext: number | undefined;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Connection manager around a single IMAP account.
|
|
87
|
+
*
|
|
88
|
+
* Reads take the mailbox lock read-only and use BODY.PEEK throughout, so
|
|
89
|
+
* fetching a message never changes what the human sees as unread. The only
|
|
90
|
+
* exception is deliberate and explicit: {@link tagSeen}, which writes the
|
|
91
|
+
* server's own bookkeeping keyword.
|
|
92
|
+
*/
|
|
93
|
+
export declare class ImapClient {
|
|
94
|
+
private readonly config;
|
|
95
|
+
private readonly factory;
|
|
96
|
+
private connection;
|
|
97
|
+
private connecting;
|
|
98
|
+
constructor(config: Config, factory?: ImapClientFactory);
|
|
99
|
+
/** Credentials are checked per call, not at startup. */
|
|
100
|
+
private assertConfigured;
|
|
101
|
+
private connection_;
|
|
102
|
+
/**
|
|
103
|
+
* Runs `fn` with the mailbox open and the lock held.
|
|
104
|
+
*
|
|
105
|
+
* A dropped connection is retried exactly once: IMAP sessions are long-lived
|
|
106
|
+
* and idle ones get reaped by servers and NAT gateways alike, so the first
|
|
107
|
+
* call after a pause routinely fails for reasons that have nothing to do with
|
|
108
|
+
* the request.
|
|
109
|
+
*/
|
|
110
|
+
withMailbox<T>(mailbox: string | undefined, readOnly: boolean, fn: (client: ImapConnection, path: string) => Promise<T>): Promise<T>;
|
|
111
|
+
private run;
|
|
112
|
+
/** Runs `fn` without selecting a mailbox, for LIST/STATUS/CREATE style calls. */
|
|
113
|
+
withConnection<T>(fn: (client: ImapConnection) => Promise<T>): Promise<T>;
|
|
114
|
+
private forget;
|
|
115
|
+
listMailboxes(): Promise<MailboxSummary[]>;
|
|
116
|
+
/**
|
|
117
|
+
* Whether the account can store the bookkeeping keyword.
|
|
118
|
+
*
|
|
119
|
+
* `\*` in PERMANENTFLAGS means the server accepts arbitrary keywords. Some
|
|
120
|
+
* providers accept none at all, and there the new-mail tracking cannot work —
|
|
121
|
+
* better to say so than to tag silently into the void.
|
|
122
|
+
*/
|
|
123
|
+
keywordSupported(permanentFlags: Set<string>): boolean;
|
|
124
|
+
get seenKeyword(): string;
|
|
125
|
+
get defaultMailbox(): string;
|
|
126
|
+
get maxMessages(): number;
|
|
127
|
+
get user(): string | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Reads the threading headers of one message, for linking a draft into an
|
|
130
|
+
* existing conversation.
|
|
131
|
+
*
|
|
132
|
+
* Only the Message-ID and the References chain are taken; nothing the sender
|
|
133
|
+
* wrote as prose comes back from here.
|
|
134
|
+
*/
|
|
135
|
+
threadHeaders(mailbox: string | undefined, uid: number): Promise<{
|
|
136
|
+
messageId: string | undefined;
|
|
137
|
+
references: string[];
|
|
138
|
+
}>;
|
|
139
|
+
search(client: ImapConnection, query: SearchObject): Promise<number[]>;
|
|
140
|
+
/** Envelope-level fetch, newest UID first. */
|
|
141
|
+
fetchSummaries(client: ImapConnection, uids: number[]): Promise<FetchMessageObject[]>;
|
|
142
|
+
/**
|
|
143
|
+
* Adds the bookkeeping keyword to the given UIDs.
|
|
144
|
+
*
|
|
145
|
+
* This is the one write the server performs on its own initiative, and it
|
|
146
|
+
* stays available under the default IMAP_READ_ONLY: without it `list_new_messages`
|
|
147
|
+
* would return the same mail forever. It touches no flag a human interacts
|
|
148
|
+
* with — `\Seen` in particular is left alone.
|
|
149
|
+
*/
|
|
150
|
+
tagSeen(client: ImapConnection, uids: number[]): Promise<void>;
|
|
151
|
+
close(): Promise<void>;
|
|
152
|
+
}
|
|
153
|
+
/** Rejects a promise that outlives the command timeout. */
|
|
154
|
+
export declare function withTimeout<T>(promise: Promise<T>, command: string, ms?: number): Promise<T>;
|
|
155
|
+
/**
|
|
156
|
+
* Normalises whatever imapflow threw into a {@link MailError}.
|
|
157
|
+
*
|
|
158
|
+
* Only the response text is carried over, never the whole error object: it
|
|
159
|
+
* holds the command that was sent, and for a LOGIN that means the password.
|
|
160
|
+
*/
|
|
161
|
+
export declare function asMailError(error: unknown): MailError;
|