@marlinjai/email-mcp 1.7.2 → 1.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.
@@ -202,6 +202,14 @@ function decrypt(ciphertext, seeds) {
202
202
  }
203
203
  throw lastError ?? new Error("Unable to decrypt credentials");
204
204
  }
205
+ function encryptWithMachineKey(plaintext) {
206
+ return encrypt(plaintext, getSeedCandidates()[0]);
207
+ }
208
+ function decryptWithMachineKey(ciphertext) {
209
+ const seeds = getSeedCandidates();
210
+ const { plaintext, seedUsed } = decrypt(ciphertext, seeds);
211
+ return { plaintext, usedLegacyKey: seedUsed !== seeds[0] };
212
+ }
205
213
  var CredentialStore = class {
206
214
  filePath;
207
215
  constructor(dir) {
@@ -216,10 +224,9 @@ var CredentialStore = class {
216
224
  return { accounts: {} };
217
225
  }
218
226
  const raw = fs.readFileSync(this.filePath, "utf-8");
219
- const seeds = getSeedCandidates();
220
- const { plaintext, seedUsed } = decrypt(raw, seeds);
227
+ const { plaintext, usedLegacyKey } = decryptWithMachineKey(raw);
221
228
  const data = JSON.parse(plaintext);
222
- if (seedUsed !== seeds[0]) {
229
+ if (usedLegacyKey) {
223
230
  try {
224
231
  this.write(data);
225
232
  } catch {
@@ -229,7 +236,7 @@ var CredentialStore = class {
229
236
  }
230
237
  write(data) {
231
238
  const json = JSON.stringify(data);
232
- const encrypted = encrypt(json, getSeedCandidates()[0]);
239
+ const encrypted = encryptWithMachineKey(json);
233
240
  fs.writeFileSync(this.filePath, encrypted, { mode: 384 });
234
241
  }
235
242
  async save(creds) {
@@ -282,53 +289,95 @@ var ERROR_HTML = `<!DOCTYPE html>
282
289
  <p>An error occurred during authorization. Please try again.</p>
283
290
  </body>
284
291
  </html>`;
285
- var OAuthCallbackServer = class {
286
- server = null;
292
+ var OAuthCallbackServer = class _OAuthCallbackServer {
293
+ servers = [];
287
294
  resolveCode = null;
288
295
  rejectCode = null;
289
296
  timeoutMs;
290
297
  timeoutHandle = null;
291
298
  expectedState = null;
299
+ static IPV4_LOOPBACK = "127.0.0.1";
300
+ static IPV6_LOOPBACK = "::1";
301
+ /** Attempts at finding a port that is free on both loopback addresses. */
302
+ static MAX_PORT_ATTEMPTS = 5;
292
303
  constructor(timeoutMs = 12e4) {
293
304
  this.timeoutMs = timeoutMs;
294
305
  }
295
- start() {
306
+ handleRequest = (req, res) => {
307
+ const url = new URL(req.url || "/", `http://localhost`);
308
+ if (url.pathname === "/callback" || url.pathname === "/") {
309
+ const code = url.searchParams.get("code");
310
+ const error = url.searchParams.get("error");
311
+ const state = url.searchParams.get("state");
312
+ if (code && this.expectedState !== null && state !== this.expectedState) {
313
+ res.writeHead(400, { "Content-Type": "text/html" });
314
+ res.end(ERROR_HTML);
315
+ this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
316
+ } else if (code) {
317
+ res.writeHead(200, { "Content-Type": "text/html" });
318
+ res.end(SUCCESS_HTML);
319
+ this.resolveCode?.(code);
320
+ } else {
321
+ res.writeHead(400, { "Content-Type": "text/html" });
322
+ res.end(ERROR_HTML);
323
+ this.rejectCode?.(new Error(`OAuth error: ${error || "no code received"}`));
324
+ }
325
+ } else {
326
+ res.writeHead(404);
327
+ res.end("Not Found");
328
+ }
329
+ };
330
+ listenOn(host, port) {
296
331
  return new Promise((resolve, reject) => {
297
- this.server = http.createServer((req, res) => {
298
- const url = new URL(req.url || "/", `http://localhost`);
299
- if (url.pathname === "/callback" || url.pathname === "/") {
300
- const code = url.searchParams.get("code");
301
- const error = url.searchParams.get("error");
302
- const state = url.searchParams.get("state");
303
- if (code && this.expectedState !== null && state !== this.expectedState) {
304
- res.writeHead(400, { "Content-Type": "text/html" });
305
- res.end(ERROR_HTML);
306
- this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
307
- } else if (code) {
308
- res.writeHead(200, { "Content-Type": "text/html" });
309
- res.end(SUCCESS_HTML);
310
- this.resolveCode?.(code);
311
- } else {
312
- res.writeHead(400, { "Content-Type": "text/html" });
313
- res.end(ERROR_HTML);
314
- this.rejectCode?.(new Error(`OAuth error: ${error || "no code received"}`));
315
- }
316
- } else {
317
- res.writeHead(404);
318
- res.end("Not Found");
319
- }
320
- });
321
- this.server.listen(0, () => {
322
- const addr = this.server.address();
323
- if (addr && typeof addr === "object") {
324
- resolve(addr.port);
325
- } else {
326
- reject(new Error("Failed to get server port"));
327
- }
332
+ const server = http.createServer(this.handleRequest);
333
+ const onError = (err) => {
334
+ server.close();
335
+ reject(err);
336
+ };
337
+ server.once("error", onError);
338
+ server.listen(port, host, () => {
339
+ server.off("error", onError);
340
+ server.on("error", (err) => this.rejectCode?.(err));
341
+ resolve(server);
328
342
  });
329
- this.server.on("error", reject);
330
343
  });
331
344
  }
345
+ /**
346
+ * Starts listening on an OS-assigned port on 127.0.0.1 and, when available,
347
+ * the same port on ::1. Resolves with the port.
348
+ */
349
+ async start() {
350
+ let lastError;
351
+ for (let attempt = 0; attempt < _OAuthCallbackServer.MAX_PORT_ATTEMPTS; attempt++) {
352
+ const v4 = await this.listenOn(_OAuthCallbackServer.IPV4_LOOPBACK, 0);
353
+ const addr = v4.address();
354
+ if (!addr || typeof addr !== "object") {
355
+ v4.close();
356
+ throw new Error("Failed to get server port");
357
+ }
358
+ const port = addr.port;
359
+ try {
360
+ const v6 = await this.listenOn(_OAuthCallbackServer.IPV6_LOOPBACK, port);
361
+ this.servers = [v4, v6];
362
+ return port;
363
+ } catch (err) {
364
+ if (err?.code === "EADDRINUSE") {
365
+ v4.close();
366
+ lastError = err;
367
+ continue;
368
+ }
369
+ this.servers = [v4];
370
+ return port;
371
+ }
372
+ }
373
+ throw new Error(
374
+ `Could not find a port free on both loopback addresses: ${lastError?.message ?? "unknown error"}`
375
+ );
376
+ }
377
+ /** The addresses the callback server is listening on (for diagnostics and tests). */
378
+ boundAddresses() {
379
+ return this.servers.map((s) => s.address()).filter((a) => !!a && typeof a === "object").map((a) => a.address);
380
+ }
332
381
  /**
333
382
  * Resolve with the authorization code once the browser is redirected back.
334
383
  * Pass the `state` that was put into the authorization URL: callbacks whose
@@ -356,10 +405,10 @@ var OAuthCallbackServer = class {
356
405
  clearTimeout(this.timeoutHandle);
357
406
  this.timeoutHandle = null;
358
407
  }
359
- if (this.server) {
360
- this.server.close();
361
- this.server = null;
408
+ for (const server of this.servers) {
409
+ server.close();
362
410
  }
411
+ this.servers = [];
363
412
  }
364
413
  };
365
414
 
@@ -393,6 +442,43 @@ function resolveScopeMode(explicit) {
393
442
  if (explicit) return explicit;
394
443
  return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
395
444
  }
445
+ var GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
446
+ var GOOGLE_PERMISSIONS_URL = "https://myaccount.google.com/permissions";
447
+ var REVOKE_TIMEOUT_MS = 1e4;
448
+ async function revokeGoogleGrant(token) {
449
+ if (!token) {
450
+ return {
451
+ ok: false,
452
+ detail: `No Google token was stored for this account, so nothing could be revoked. Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
453
+ };
454
+ }
455
+ try {
456
+ const res = await fetch(GOOGLE_REVOKE_ENDPOINT, {
457
+ method: "POST",
458
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
459
+ body: new URLSearchParams({ token }).toString(),
460
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
461
+ });
462
+ if (res.ok) {
463
+ return { ok: true, detail: "Google revoked the grant: email-mcp no longer has access to this Gmail account." };
464
+ }
465
+ let reason = "";
466
+ try {
467
+ const body = await res.json();
468
+ reason = [body.error, body.error_description].filter(Boolean).join(": ");
469
+ } catch {
470
+ }
471
+ return {
472
+ ok: false,
473
+ detail: `Google refused the revocation (HTTP ${res.status}${reason ? `, ${reason}` : ""}). The grant may already be revoked or expired. Check and remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
474
+ };
475
+ } catch (err) {
476
+ return {
477
+ ok: false,
478
+ detail: `Could not reach Google's revocation endpoint (${err?.message ?? String(err)}). Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
479
+ };
480
+ }
481
+ }
396
482
  var GmailAuth = class {
397
483
  clientId;
398
484
  clientSecret;
@@ -456,23 +542,73 @@ import {
456
542
  CryptoProvider
457
543
  } from "@azure/msal-node";
458
544
  var DEFAULT_CACHE_DIR = path2.join(os2.homedir(), ".email-mcp");
459
- var DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.json");
460
- function createFileCachePlugin(cacheFilePath) {
545
+ var DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.enc");
546
+ var LEGACY_PLAINTEXT_CACHE_NAME = "msal-cache.json";
547
+ var MsalCacheDecryptError = class extends Error {
548
+ constructor(cacheFilePath) {
549
+ super(
550
+ `The Outlook token cache ${cacheFilePath} could not be decrypted: it was modified, is corrupt, or was written with a different key (EMAIL_MCP_KEY or another machine). Delete that file and re-run email-mcp-setup to sign in to Outlook again.`
551
+ );
552
+ this.name = "MsalCacheDecryptError";
553
+ }
554
+ };
555
+ function writeEncryptedCache(cacheFilePath, serialized) {
556
+ const dir = path2.dirname(cacheFilePath);
557
+ if (!fs2.existsSync(dir)) {
558
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
559
+ }
560
+ const tmpPath = `${cacheFilePath}.${process.pid}.tmp`;
561
+ fs2.writeFileSync(tmpPath, encryptWithMachineKey(serialized), { mode: 384 });
562
+ fs2.chmodSync(tmpPath, 384);
563
+ fs2.renameSync(tmpPath, cacheFilePath);
564
+ }
565
+ function loadSerializedCache(cacheFilePath, legacyPlaintextPath) {
566
+ const hasEncrypted = fs2.existsSync(cacheFilePath);
567
+ if (fs2.existsSync(legacyPlaintextPath)) {
568
+ const plaintextIsNewer = !hasEncrypted || fs2.statSync(legacyPlaintextPath).mtimeMs > fs2.statSync(cacheFilePath).mtimeMs;
569
+ if (plaintextIsNewer) {
570
+ const serialized = fs2.readFileSync(legacyPlaintextPath, "utf-8");
571
+ writeEncryptedCache(cacheFilePath, serialized);
572
+ fs2.unlinkSync(legacyPlaintextPath);
573
+ return serialized;
574
+ }
575
+ fs2.unlinkSync(legacyPlaintextPath);
576
+ }
577
+ if (!hasEncrypted) return null;
578
+ const raw = fs2.readFileSync(cacheFilePath, "utf-8");
579
+ let plaintext;
580
+ let usedLegacyKey;
581
+ try {
582
+ ({ plaintext, usedLegacyKey } = decryptWithMachineKey(raw));
583
+ } catch {
584
+ throw new MsalCacheDecryptError(cacheFilePath);
585
+ }
586
+ if (usedLegacyKey) {
587
+ try {
588
+ writeEncryptedCache(cacheFilePath, plaintext);
589
+ } catch {
590
+ }
591
+ }
592
+ return plaintext;
593
+ }
594
+ function createEncryptedFileCachePlugin(cacheFilePath, legacyPlaintextPath = path2.join(path2.dirname(cacheFilePath), LEGACY_PLAINTEXT_CACHE_NAME)) {
595
+ let loadFailed = false;
461
596
  return {
462
597
  async beforeCacheAccess(context) {
463
- if (fs2.existsSync(cacheFilePath)) {
464
- const data = fs2.readFileSync(cacheFilePath, "utf-8");
465
- context.tokenCache.deserialize(data);
598
+ loadFailed = false;
599
+ try {
600
+ const serialized = loadSerializedCache(cacheFilePath, legacyPlaintextPath);
601
+ if (serialized !== null) {
602
+ context.tokenCache.deserialize(serialized);
603
+ }
604
+ } catch (err) {
605
+ loadFailed = true;
606
+ throw err;
466
607
  }
467
608
  },
468
609
  async afterCacheAccess(context) {
469
- if (context.cacheHasChanged) {
470
- const dir = path2.dirname(cacheFilePath);
471
- if (!fs2.existsSync(dir)) {
472
- fs2.mkdirSync(dir, { recursive: true, mode: 448 });
473
- }
474
- fs2.writeFileSync(cacheFilePath, context.tokenCache.serialize(), { mode: 384 });
475
- }
610
+ if (loadFailed || !context.cacheHasChanged) return;
611
+ writeEncryptedCache(cacheFilePath, context.tokenCache.serialize());
476
612
  }
477
613
  };
478
614
  }
@@ -491,7 +627,7 @@ var OutlookAuth = class _OutlookAuth {
491
627
  authority: _OutlookAuth.AUTHORITY
492
628
  },
493
629
  cache: {
494
- cachePlugin: createFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
630
+ cachePlugin: createEncryptedFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
495
631
  }
496
632
  });
497
633
  this.cryptoProvider = new CryptoProvider();
@@ -545,6 +681,27 @@ var OutlookAuth = class _OutlookAuth {
545
681
  homeAccountId: result.account?.homeAccountId ?? homeAccountId
546
682
  };
547
683
  }
684
+ /**
685
+ * Removes one account (its refresh, access and ID tokens) from the MSAL
686
+ * cache. Matches by home account id, or by username for credentials saved
687
+ * before the home account id was recorded. Returns whether an entry was
688
+ * found and removed.
689
+ */
690
+ async removeCachedAccount(match) {
691
+ const cache = this.pca.getTokenCache();
692
+ let account = null;
693
+ if (match.homeAccountId) {
694
+ account = await cache.getAccountByHomeId(match.homeAccountId);
695
+ }
696
+ if (!account && match.username) {
697
+ const wanted = match.username.trim().toLowerCase();
698
+ const all = await cache.getAllAccounts();
699
+ account = all.find((a) => (a.username ?? "").trim().toLowerCase() === wanted) ?? null;
700
+ }
701
+ if (!account) return false;
702
+ await cache.removeAccount(account);
703
+ return true;
704
+ }
548
705
  /**
549
706
  * @deprecated Use refreshTokenSilent() instead.
550
707
  * Kept for backward compatibility with credentials that have a stored refresh token.
@@ -2091,7 +2248,7 @@ var ImapAdapter = class {
2091
2248
  const result = await client.mailboxCreate(fullPath);
2092
2249
  return {
2093
2250
  id: result.path,
2094
- name: result.name || name,
2251
+ name,
2095
2252
  path: result.path,
2096
2253
  type: FolderType.Other
2097
2254
  };
@@ -2329,6 +2486,7 @@ var ImapAdapter = class {
2329
2486
  const uid = parseInt(id, 10);
2330
2487
  const msg = await client.fetchOne(String(uid), { source: true, uid: true, flags: true }, { uid: true });
2331
2488
  if (!msg) throw new Error(`Email ${id} not found`);
2489
+ if (!msg.source) throw new Error(`Email ${id} has no source`);
2332
2490
  const parsed = await simpleParser(msg.source);
2333
2491
  parsed.flags = msg.flags;
2334
2492
  return mapParsedEmail(parsed, targetFolder, this.accountId, msg.uid);
@@ -2353,6 +2511,7 @@ var ImapAdapter = class {
2353
2511
  if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
2354
2512
  const messages = [];
2355
2513
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
2514
+ if (!msg.source) continue;
2356
2515
  const parsed = await simpleParser(msg.source);
2357
2516
  parsed.flags = msg.flags;
2358
2517
  messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
@@ -2388,6 +2547,7 @@ var ImapAdapter = class {
2388
2547
  try {
2389
2548
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
2390
2549
  if (!msg) throw new Error(`Email ${emailId} not found`);
2550
+ if (!msg.source) throw new Error(`Email ${emailId} has no source`);
2391
2551
  const parsed = await simpleParser(msg.source);
2392
2552
  const attachment = (parsed.attachments || []).find(
2393
2553
  (att) => att.contentId === attachmentId || att.filename === attachmentId
@@ -2687,16 +2847,16 @@ var ImapAdapter = class {
2687
2847
  var ICloudAdapter = class extends ImapAdapter {
2688
2848
  providerType = ProviderType.ICloud;
2689
2849
  async connect(credentials) {
2850
+ const passwordDefaults = {
2851
+ host: "imap.mail.me.com",
2852
+ port: 993,
2853
+ tls: true,
2854
+ smtpHost: "smtp.mail.me.com",
2855
+ smtpPort: 587
2856
+ };
2690
2857
  const withDefaults = {
2691
2858
  ...credentials,
2692
- password: {
2693
- host: "imap.mail.me.com",
2694
- port: 993,
2695
- tls: true,
2696
- smtpHost: "smtp.mail.me.com",
2697
- smtpPort: 587,
2698
- ...credentials.password
2699
- }
2859
+ password: { ...passwordDefaults, ...credentials.password }
2700
2860
  };
2701
2861
  return super.connect(withDefaults);
2702
2862
  }
@@ -2722,6 +2882,7 @@ function createProvider(provider) {
2722
2882
  throw new Error(`Unknown provider: ${provider}`);
2723
2883
  }
2724
2884
  }
2885
+ var MICROSOFT_CONSENT_URL = "https://account.live.com/consent/Manage";
2725
2886
  var AccountManager = class {
2726
2887
  store;
2727
2888
  providers = /* @__PURE__ */ new Map();
@@ -2776,9 +2937,55 @@ var AccountManager = class {
2776
2937
  await this.store.save(creds);
2777
2938
  await this.connectAccount(creds.id);
2778
2939
  }
2940
+ /**
2941
+ * Removes an account locally, then cleans up at the provider as far as the
2942
+ * provider allows. Local removal always happens first and never depends on
2943
+ * the network; a failed provider step is reported in the result, not thrown.
2944
+ */
2779
2945
  async removeAccount(accountId) {
2946
+ const creds = await this.store.get(accountId);
2947
+ if (!creds) throw new Error(`Account ${accountId} not found`);
2780
2948
  await this.disconnectAccount(accountId);
2781
2949
  await this.store.remove(accountId);
2950
+ const result = {
2951
+ success: true,
2952
+ accountId,
2953
+ provider: creds.provider,
2954
+ email: creds.email,
2955
+ revocation: { status: "not_applicable", detail: "" }
2956
+ };
2957
+ if (creds.provider === ProviderType.Gmail) {
2958
+ const token = creds.oauth?.refresh_token || creds.oauth?.access_token || "";
2959
+ const { ok, detail } = await revokeGoogleGrant(token);
2960
+ result.revocation = { status: ok ? "revoked" : "failed", detail };
2961
+ } else if (creds.provider === ProviderType.Outlook) {
2962
+ result.tokenCache = await this.removeFromMsalCache(creds);
2963
+ result.revocation = {
2964
+ status: "local_only",
2965
+ detail: `Microsoft offers no endpoint to revoke a refresh token for a personal Microsoft account, so the grant still exists at Microsoft until it expires. To cancel it now, remove email-mcp at ${MICROSOFT_CONSENT_URL}.`
2966
+ };
2967
+ } else {
2968
+ result.revocation = {
2969
+ status: "not_applicable",
2970
+ detail: "This account used a password, not an OAuth grant, so there is nothing to revoke. The password itself stays valid at your provider; if it was an app-specific password (for example from Apple), delete it there if you no longer need it."
2971
+ };
2972
+ }
2973
+ return result;
2974
+ }
2975
+ async removeFromMsalCache(creds) {
2976
+ try {
2977
+ const outlookAuth = new OutlookAuth(OUTLOOK_CLIENT_ID);
2978
+ const removed = await outlookAuth.removeCachedAccount({
2979
+ homeAccountId: creds.oauth?.msal_home_account_id,
2980
+ username: creds.email
2981
+ });
2982
+ return removed ? { status: "removed", detail: "The account's refresh and access tokens were removed from the local Outlook token cache." } : { status: "not_found", detail: "The local Outlook token cache held no tokens for this account." };
2983
+ } catch (err) {
2984
+ return {
2985
+ status: "failed",
2986
+ detail: `The account's tokens could not be removed from the local Outlook token cache: ${err?.message ?? String(err)}`
2987
+ };
2988
+ }
2782
2989
  }
2783
2990
  async disconnectAccount(accountId) {
2784
2991
  const provider = this.providers.get(accountId);