@lengelhard/imap-email-mcp 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * IMAP session helpers for imap-email-mcp.
3
+ *
4
+ * - SessionManager keeps one authenticated IMAP connection open and hands it
5
+ * out as a lease, one tool call at a time. A lease's end() releases the lock;
6
+ * it never logs out. The socket is closed after a period of inactivity.
7
+ * - fetchMessages() fetches an explicit UID list without the search-then-fetch
8
+ * pattern of imap-simple (which fetched every match and could hang if a
9
+ * message vanished between SEARCH and FETCH).
10
+ * - queryUids() returns matching UIDs newest first, using server-side SORT when
11
+ * available and a UID sort otherwise.
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ import imaps from 'imap-simple';
17
+
18
+ const DEFAULT_IDLE_CLOSE_MS = 5 * 60 * 1000;
19
+ const DEFAULT_LEASE_TIMEOUT_MS = 5 * 60 * 1000;
20
+
21
+ function withKeepalive(config) {
22
+ // NOOP-based keepalive is more predictable than IDLE behind proxies.
23
+ return {
24
+ ...config,
25
+ imap: {
26
+ keepalive: { interval: 60000, idleInterval: 300000, forceNoop: true },
27
+ ...config.imap
28
+ }
29
+ };
30
+ }
31
+
32
+ export class SessionManager {
33
+ constructor(config, { idleCloseMs = DEFAULT_IDLE_CLOSE_MS, leaseTimeoutMs = DEFAULT_LEASE_TIMEOUT_MS, label = 'tools', log = console.error } = {}) {
34
+ this.config = withKeepalive(config);
35
+ this.idleCloseMs = idleCloseMs;
36
+ this.leaseTimeoutMs = leaseTimeoutMs;
37
+ this.label = label;
38
+ this.log = log;
39
+ this.conn = null;
40
+ this.connecting = null;
41
+ this.tail = Promise.resolve();
42
+ this.idleTimer = null;
43
+ }
44
+
45
+ _isUsable(conn) {
46
+ return !!conn && conn.imap && conn.imap.state === 'authenticated';
47
+ }
48
+
49
+ _drop(conn, reason) {
50
+ if (!conn) return;
51
+ if (this.conn === conn) this.conn = null;
52
+ if (conn._mcpDropped) return;
53
+ conn._mcpDropped = true;
54
+ if (reason) this.log(`[imap:${this.label}] dropping connection: ${reason}`);
55
+ try {
56
+ // Call node-imap directly. imap-simple's end() sets a flag that makes
57
+ // its own error handler throw on errors without a code.
58
+ conn.imap.end();
59
+ } catch {
60
+ try { conn.imap.destroy(); } catch { /* ignore */ }
61
+ }
62
+ }
63
+
64
+ async _connect() {
65
+ if (this._isUsable(this.conn)) return this.conn;
66
+ if (!this.connecting) {
67
+ this.connecting = (async () => {
68
+ const conn = await imaps.connect(this.config);
69
+ // Without an 'error' listener an idle socket error would crash the process.
70
+ conn.on('error', (err) => this._drop(conn, `error: ${err && err.message}`));
71
+ conn.imap.once('close', () => { if (this.conn === conn) this.conn = null; });
72
+ conn.imap.once('end', () => { if (this.conn === conn) this.conn = null; });
73
+ this.conn = conn;
74
+ return conn;
75
+ })().finally(() => { this.connecting = null; });
76
+ }
77
+ return this.connecting;
78
+ }
79
+
80
+ _armIdleTimer() {
81
+ clearTimeout(this.idleTimer);
82
+ this.idleTimer = setTimeout(() => {
83
+ if (this.conn) this._drop(this.conn, null);
84
+ }, this.idleCloseMs);
85
+ if (this.idleTimer.unref) this.idleTimer.unref();
86
+ }
87
+
88
+ /**
89
+ * Acquire the shared connection. Resolves to a proxy whose end() releases the
90
+ * lease. Every call site must call end() in a finally block.
91
+ */
92
+ acquire() {
93
+ let releaseLock;
94
+ const lock = new Promise((resolve) => { releaseLock = resolve; });
95
+ const previous = this.tail;
96
+ this.tail = previous.then(() => lock);
97
+
98
+ return previous.then(async () => {
99
+ clearTimeout(this.idleTimer);
100
+ let conn;
101
+ try {
102
+ conn = await this._connect();
103
+ } catch (err) {
104
+ releaseLock();
105
+ this._armIdleTimer();
106
+ throw err;
107
+ }
108
+
109
+ let released = false;
110
+ const release = () => {
111
+ if (released) return;
112
+ released = true;
113
+ clearTimeout(watchdog);
114
+ if (!this._isUsable(conn)) this._drop(conn, 'unusable after call');
115
+ this._armIdleTimer();
116
+ releaseLock();
117
+ };
118
+
119
+ // A hung command must not block every later tool call.
120
+ const watchdog = setTimeout(() => {
121
+ this._drop(conn, `lease exceeded ${Math.round(this.leaseTimeoutMs / 1000)}s`);
122
+ release();
123
+ }, this.leaseTimeoutMs);
124
+ if (watchdog.unref) watchdog.unref();
125
+
126
+ return new Proxy(conn, {
127
+ get(target, prop) {
128
+ if (prop === 'end') return release;
129
+ const value = target[prop];
130
+ return typeof value === 'function' ? value.bind(target) : value;
131
+ }
132
+ });
133
+ });
134
+ }
135
+
136
+ close() {
137
+ clearTimeout(this.idleTimer);
138
+ if (this.conn) this._drop(this.conn, null);
139
+ }
140
+ }
141
+
142
+ /** Open a mailbox. readOnly uses EXAMINE, which never changes flags. */
143
+ export function openBox(connection, name, readOnly = false) {
144
+ return new Promise((resolve, reject) => {
145
+ try {
146
+ connection.imap.openBox(name, readOnly, (err, box) => (err ? reject(err) : resolve(box)));
147
+ } catch (err) {
148
+ reject(err);
149
+ }
150
+ });
151
+ }
152
+
153
+ export function serverSupports(connection, cap) {
154
+ try { return !!connection.imap.serverSupports(cap); } catch { return false; }
155
+ }
156
+
157
+ /**
158
+ * Return matching UIDs, newest first.
159
+ * order 'arrival' uses the server's internal date (delivery time, preserved on
160
+ * moves). order 'date' uses the Date header.
161
+ */
162
+ export function queryUids(connection, criteria, { order = 'arrival' } = {}) {
163
+ const imap = connection.imap;
164
+ const crit = criteria && criteria.length ? criteria : ['ALL'];
165
+ const key = order === 'date' ? '-DATE' : '-ARRIVAL';
166
+ return new Promise((resolve, reject) => {
167
+ const done = (err, uids) => (err ? reject(err) : resolve((uids || []).map(Number)));
168
+ try {
169
+ if (serverSupports(connection, 'SORT')) {
170
+ imap.sort([key], crit, done);
171
+ } else {
172
+ imap.search(crit, (err, uids) => {
173
+ if (err) return reject(err);
174
+ resolve((uids || []).map(Number).sort((a, b) => b - a));
175
+ });
176
+ }
177
+ } catch (err) {
178
+ reject(err);
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Fetch an explicit list of UIDs. Resolves to a Map uid -> { attributes, parts }
185
+ * where parts maps the body section name to a Buffer. Messages that no longer
186
+ * exist are simply absent from the result.
187
+ */
188
+ export function fetchMessages(connection, uids, options) {
189
+ return new Promise((resolve, reject) => {
190
+ const out = new Map();
191
+ if (!uids || !uids.length) return resolve(out);
192
+ let f;
193
+ try {
194
+ f = connection.imap.fetch(uids, options);
195
+ } catch (err) {
196
+ return reject(err);
197
+ }
198
+ f.on('message', (msg) => {
199
+ const rec = { attributes: null, parts: {} };
200
+ msg.on('body', (stream, info) => {
201
+ const chunks = [];
202
+ stream.on('data', (c) => chunks.push(c));
203
+ stream.once('end', () => { rec.parts[info.which] = Buffer.concat(chunks); });
204
+ });
205
+ msg.once('attributes', (attrs) => { rec.attributes = attrs; });
206
+ msg.once('end', () => {
207
+ if (rec.attributes && rec.attributes.uid != null) out.set(Number(rec.attributes.uid), rec);
208
+ });
209
+ });
210
+ f.once('error', reject);
211
+ f.once('end', () => resolve(out));
212
+ });
213
+ }
214
+
215
+ /** Find the header buffer in a fetched record and parse it into { name: [values] }. */
216
+ export function headerOf(rec, parseHeader) {
217
+ const key = Object.keys(rec.parts).find((k) => k.toUpperCase().includes('HEADER'));
218
+ if (!key) return {};
219
+ return parseHeader(rec.parts[key].toString('utf8'));
220
+ }
221
+
222
+ /**
223
+ * Parse YYYY-MM-DD as a local calendar date. new Date('2026-09-01') is UTC
224
+ * midnight, which node-imap would render as the previous day west of UTC.
225
+ */
226
+ export function localDate(value) {
227
+ if (value instanceof Date) return value;
228
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value).trim());
229
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
230
+ const d = new Date(value);
231
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid date: ${value} (use YYYY-MM-DD)`);
232
+ return d;
233
+ }
234
+
235
+ /** Build node-imap search criteria from tool arguments shared by list and search tools. */
236
+ export function buildCriteria(args = {}) {
237
+ const c = [];
238
+ if (args.subject) c.push(['SUBJECT', args.subject]);
239
+ if (args.from) c.push(['FROM', args.from]);
240
+ if (args.to) c.push(['TO', args.to]);
241
+ if (args.cc) c.push(['CC', args.cc]);
242
+ if (args.body) c.push(['BODY', args.body]);
243
+ if (args.text) c.push(['TEXT', args.text]);
244
+ if (args.since_date) c.push(['SINCE', localDate(args.since_date)]);
245
+ if (args.before_date) c.push(['BEFORE', localDate(args.before_date)]);
246
+ if (args.unseen_only) c.push('UNSEEN');
247
+ if (args.seen_only) c.push('SEEN');
248
+ if (args.flagged_only) c.push('FLAGGED');
249
+ if (args.unflagged_only) c.push('UNFLAGGED');
250
+ return c.length ? c : ['ALL'];
251
+ }