@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.
package/README.md CHANGED
@@ -123,7 +123,7 @@ npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
123
123
  |------|-------------|
124
124
  | `email_list_accounts` | List all configured accounts with connection status |
125
125
  | `email_add_account` | Add a new IMAP or iCloud account (Gmail/Outlook require setup wizard) |
126
- | `email_remove_account` | Remove an account and its stored credentials |
126
+ | `email_remove_account` | Remove an account and its stored credentials; revokes the Google grant for Gmail, removes Outlook tokens from the local token cache, and reports the outcome |
127
127
  | `email_test_account` | Test connection to an account |
128
128
 
129
129
  ### Reading & Searching (6)
@@ -248,6 +248,21 @@ export EMAIL_MCP_KEY="your-strong-passphrase"
248
248
  When `EMAIL_MCP_KEY` is set, existing credential files are transparently
249
249
  re-encrypted with the passphrase the next time they are read.
250
250
 
251
+ The Outlook refresh token lives in the token cache of Microsoft's authentication
252
+ library (MSAL), `~/.email-mcp/msal-cache.enc`, encrypted with the same scheme and key
253
+ derivation as `credentials.enc`, so `EMAIL_MCP_KEY` protects both files. Versions before 1.8.0
254
+ kept this cache as plain JSON in `~/.email-mcp/msal-cache.json`; 1.8.0 encrypts it and
255
+ deletes the plain file the first time it reads it, without signing you out. Going back
256
+ to an older version afterwards means signing in to Outlook again.
257
+
258
+ On macOS and Linux, attachments saved with `email_save_attachment` are written
259
+ owner-only (`0600`), and folders email-mcp creates for them are `0700`. They are not
260
+ encrypted.
261
+
262
+ The OAuth sign-in callback started by `email-mcp-setup` listens on the loopback
263
+ addresses only (`127.0.0.1`, and `::1` when available), so nothing else on your network
264
+ can reach it.
265
+
251
266
  ## Support
252
267
 
253
268
  If this project is useful to you, consider supporting its development:
package/dist/index.js CHANGED
@@ -123,6 +123,14 @@ function decrypt(ciphertext, seeds) {
123
123
  }
124
124
  throw lastError ?? new Error("Unable to decrypt credentials");
125
125
  }
126
+ function encryptWithMachineKey(plaintext) {
127
+ return encrypt(plaintext, getSeedCandidates()[0]);
128
+ }
129
+ function decryptWithMachineKey(ciphertext) {
130
+ const seeds = getSeedCandidates();
131
+ const { plaintext, seedUsed } = decrypt(ciphertext, seeds);
132
+ return { plaintext, usedLegacyKey: seedUsed !== seeds[0] };
133
+ }
126
134
  var ALGORITHM, KEY_LENGTH, IV_LENGTH, SALT_LENGTH, PBKDF2_ITERATIONS, cachedMachineId, CredentialStore;
127
135
  var init_credential_store = __esm({
128
136
  "src/auth/credential-store.ts"() {
@@ -146,10 +154,9 @@ var init_credential_store = __esm({
146
154
  return { accounts: {} };
147
155
  }
148
156
  const raw = fs.readFileSync(this.filePath, "utf-8");
149
- const seeds = getSeedCandidates();
150
- const { plaintext, seedUsed } = decrypt(raw, seeds);
157
+ const { plaintext, usedLegacyKey } = decryptWithMachineKey(raw);
151
158
  const data = JSON.parse(plaintext);
152
- if (seedUsed !== seeds[0]) {
159
+ if (usedLegacyKey) {
153
160
  try {
154
161
  this.write(data);
155
162
  } catch {
@@ -159,7 +166,7 @@ var init_credential_store = __esm({
159
166
  }
160
167
  write(data) {
161
168
  const json2 = JSON.stringify(data);
162
- const encrypted = encrypt(json2, getSeedCandidates()[0]);
169
+ const encrypted = encryptWithMachineKey(json2);
163
170
  fs.writeFileSync(this.filePath, encrypted, { mode: 384 });
164
171
  }
165
172
  async save(creds) {
@@ -7683,7 +7690,41 @@ function resolveScopeMode(explicit) {
7683
7690
  if (explicit) return explicit;
7684
7691
  return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
7685
7692
  }
7686
- var GMAIL_SCOPES, GmailAuth;
7693
+ async function revokeGoogleGrant(token) {
7694
+ if (!token) {
7695
+ return {
7696
+ ok: false,
7697
+ detail: `No Google token was stored for this account, so nothing could be revoked. Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
7698
+ };
7699
+ }
7700
+ try {
7701
+ const res = await fetch(GOOGLE_REVOKE_ENDPOINT, {
7702
+ method: "POST",
7703
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
7704
+ body: new URLSearchParams({ token }).toString(),
7705
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
7706
+ });
7707
+ if (res.ok) {
7708
+ return { ok: true, detail: "Google revoked the grant: email-mcp no longer has access to this Gmail account." };
7709
+ }
7710
+ let reason = "";
7711
+ try {
7712
+ const body = await res.json();
7713
+ reason = [body.error, body.error_description].filter(Boolean).join(": ");
7714
+ } catch {
7715
+ }
7716
+ return {
7717
+ ok: false,
7718
+ 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}.`
7719
+ };
7720
+ } catch (err) {
7721
+ return {
7722
+ ok: false,
7723
+ detail: `Could not reach Google's revocation endpoint (${err?.message ?? String(err)}). Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
7724
+ };
7725
+ }
7726
+ }
7727
+ var GMAIL_SCOPES, GOOGLE_REVOKE_ENDPOINT, GOOGLE_PERMISSIONS_URL, REVOKE_TIMEOUT_MS, GmailAuth;
7687
7728
  var init_auth = __esm({
7688
7729
  "src/providers/gmail/auth.ts"() {
7689
7730
  "use strict";
@@ -7710,6 +7751,9 @@ var init_auth = __esm({
7710
7751
  "https://www.googleapis.com/auth/gmail.settings.basic"
7711
7752
  ]
7712
7753
  };
7754
+ GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
7755
+ GOOGLE_PERMISSIONS_URL = "https://myaccount.google.com/permissions";
7756
+ REVOKE_TIMEOUT_MS = 1e4;
7713
7757
  GmailAuth = class {
7714
7758
  clientId;
7715
7759
  clientSecret;
@@ -8391,31 +8435,82 @@ import {
8391
8435
  PublicClientApplication,
8392
8436
  CryptoProvider
8393
8437
  } from "@azure/msal-node";
8394
- function createFileCachePlugin(cacheFilePath) {
8438
+ function writeEncryptedCache(cacheFilePath, serialized) {
8439
+ const dir = path2.dirname(cacheFilePath);
8440
+ if (!fs2.existsSync(dir)) {
8441
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
8442
+ }
8443
+ const tmpPath = `${cacheFilePath}.${process.pid}.tmp`;
8444
+ fs2.writeFileSync(tmpPath, encryptWithMachineKey(serialized), { mode: 384 });
8445
+ fs2.chmodSync(tmpPath, 384);
8446
+ fs2.renameSync(tmpPath, cacheFilePath);
8447
+ }
8448
+ function loadSerializedCache(cacheFilePath, legacyPlaintextPath) {
8449
+ const hasEncrypted = fs2.existsSync(cacheFilePath);
8450
+ if (fs2.existsSync(legacyPlaintextPath)) {
8451
+ const plaintextIsNewer = !hasEncrypted || fs2.statSync(legacyPlaintextPath).mtimeMs > fs2.statSync(cacheFilePath).mtimeMs;
8452
+ if (plaintextIsNewer) {
8453
+ const serialized = fs2.readFileSync(legacyPlaintextPath, "utf-8");
8454
+ writeEncryptedCache(cacheFilePath, serialized);
8455
+ fs2.unlinkSync(legacyPlaintextPath);
8456
+ return serialized;
8457
+ }
8458
+ fs2.unlinkSync(legacyPlaintextPath);
8459
+ }
8460
+ if (!hasEncrypted) return null;
8461
+ const raw = fs2.readFileSync(cacheFilePath, "utf-8");
8462
+ let plaintext;
8463
+ let usedLegacyKey;
8464
+ try {
8465
+ ({ plaintext, usedLegacyKey } = decryptWithMachineKey(raw));
8466
+ } catch {
8467
+ throw new MsalCacheDecryptError(cacheFilePath);
8468
+ }
8469
+ if (usedLegacyKey) {
8470
+ try {
8471
+ writeEncryptedCache(cacheFilePath, plaintext);
8472
+ } catch {
8473
+ }
8474
+ }
8475
+ return plaintext;
8476
+ }
8477
+ function createEncryptedFileCachePlugin(cacheFilePath, legacyPlaintextPath = path2.join(path2.dirname(cacheFilePath), LEGACY_PLAINTEXT_CACHE_NAME)) {
8478
+ let loadFailed = false;
8395
8479
  return {
8396
8480
  async beforeCacheAccess(context) {
8397
- if (fs2.existsSync(cacheFilePath)) {
8398
- const data = fs2.readFileSync(cacheFilePath, "utf-8");
8399
- context.tokenCache.deserialize(data);
8481
+ loadFailed = false;
8482
+ try {
8483
+ const serialized = loadSerializedCache(cacheFilePath, legacyPlaintextPath);
8484
+ if (serialized !== null) {
8485
+ context.tokenCache.deserialize(serialized);
8486
+ }
8487
+ } catch (err) {
8488
+ loadFailed = true;
8489
+ throw err;
8400
8490
  }
8401
8491
  },
8402
8492
  async afterCacheAccess(context) {
8403
- if (context.cacheHasChanged) {
8404
- const dir = path2.dirname(cacheFilePath);
8405
- if (!fs2.existsSync(dir)) {
8406
- fs2.mkdirSync(dir, { recursive: true, mode: 448 });
8407
- }
8408
- fs2.writeFileSync(cacheFilePath, context.tokenCache.serialize(), { mode: 384 });
8409
- }
8493
+ if (loadFailed || !context.cacheHasChanged) return;
8494
+ writeEncryptedCache(cacheFilePath, context.tokenCache.serialize());
8410
8495
  }
8411
8496
  };
8412
8497
  }
8413
- var DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, OutlookAuth;
8498
+ var DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, LEGACY_PLAINTEXT_CACHE_NAME, MsalCacheDecryptError, OutlookAuth;
8414
8499
  var init_auth2 = __esm({
8415
8500
  "src/providers/outlook/auth.ts"() {
8416
8501
  "use strict";
8502
+ init_credential_store();
8417
8503
  DEFAULT_CACHE_DIR = path2.join(os2.homedir(), ".email-mcp");
8418
- DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.json");
8504
+ DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.enc");
8505
+ LEGACY_PLAINTEXT_CACHE_NAME = "msal-cache.json";
8506
+ MsalCacheDecryptError = class extends Error {
8507
+ constructor(cacheFilePath) {
8508
+ super(
8509
+ `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.`
8510
+ );
8511
+ this.name = "MsalCacheDecryptError";
8512
+ }
8513
+ };
8419
8514
  OutlookAuth = class _OutlookAuth {
8420
8515
  static AUTHORITY = "https://login.microsoftonline.com/consumers";
8421
8516
  // MailboxSettings.ReadWrite is required for the messageRules API (block
@@ -8431,7 +8526,7 @@ var init_auth2 = __esm({
8431
8526
  authority: _OutlookAuth.AUTHORITY
8432
8527
  },
8433
8528
  cache: {
8434
- cachePlugin: createFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
8529
+ cachePlugin: createEncryptedFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
8435
8530
  }
8436
8531
  });
8437
8532
  this.cryptoProvider = new CryptoProvider();
@@ -8485,6 +8580,27 @@ var init_auth2 = __esm({
8485
8580
  homeAccountId: result.account?.homeAccountId ?? homeAccountId
8486
8581
  };
8487
8582
  }
8583
+ /**
8584
+ * Removes one account (its refresh, access and ID tokens) from the MSAL
8585
+ * cache. Matches by home account id, or by username for credentials saved
8586
+ * before the home account id was recorded. Returns whether an entry was
8587
+ * found and removed.
8588
+ */
8589
+ async removeCachedAccount(match) {
8590
+ const cache = this.pca.getTokenCache();
8591
+ let account = null;
8592
+ if (match.homeAccountId) {
8593
+ account = await cache.getAccountByHomeId(match.homeAccountId);
8594
+ }
8595
+ if (!account && match.username) {
8596
+ const wanted = match.username.trim().toLowerCase();
8597
+ const all = await cache.getAllAccounts();
8598
+ account = all.find((a) => (a.username ?? "").trim().toLowerCase() === wanted) ?? null;
8599
+ }
8600
+ if (!account) return false;
8601
+ await cache.removeAccount(account);
8602
+ return true;
8603
+ }
8488
8604
  /**
8489
8605
  * @deprecated Use refreshTokenSilent() instead.
8490
8606
  * Kept for backward compatibility with credentials that have a stored refresh token.
@@ -8754,7 +8870,7 @@ var init_adapter3 = __esm({
8754
8870
  const result = await client.mailboxCreate(fullPath);
8755
8871
  return {
8756
8872
  id: result.path,
8757
- name: result.name || name,
8873
+ name,
8758
8874
  path: result.path,
8759
8875
  type: FolderType.Other
8760
8876
  };
@@ -8992,6 +9108,7 @@ var init_adapter3 = __esm({
8992
9108
  const uid = parseInt(id, 10);
8993
9109
  const msg = await client.fetchOne(String(uid), { source: true, uid: true, flags: true }, { uid: true });
8994
9110
  if (!msg) throw new Error(`Email ${id} not found`);
9111
+ if (!msg.source) throw new Error(`Email ${id} has no source`);
8995
9112
  const parsed = await simpleParser(msg.source);
8996
9113
  parsed.flags = msg.flags;
8997
9114
  return mapParsedEmail(parsed, targetFolder, this.accountId, msg.uid);
@@ -9016,6 +9133,7 @@ var init_adapter3 = __esm({
9016
9133
  if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
9017
9134
  const messages = [];
9018
9135
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
9136
+ if (!msg.source) continue;
9019
9137
  const parsed = await simpleParser(msg.source);
9020
9138
  parsed.flags = msg.flags;
9021
9139
  messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
@@ -9051,6 +9169,7 @@ var init_adapter3 = __esm({
9051
9169
  try {
9052
9170
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
9053
9171
  if (!msg) throw new Error(`Email ${emailId} not found`);
9172
+ if (!msg.source) throw new Error(`Email ${emailId} has no source`);
9054
9173
  const parsed = await simpleParser(msg.source);
9055
9174
  const attachment = (parsed.attachments || []).find(
9056
9175
  (att) => att.contentId === attachmentId || att.filename === attachmentId
@@ -9358,16 +9477,16 @@ var init_adapter4 = __esm({
9358
9477
  ICloudAdapter = class extends ImapAdapter {
9359
9478
  providerType = ProviderType.ICloud;
9360
9479
  async connect(credentials) {
9480
+ const passwordDefaults = {
9481
+ host: "imap.mail.me.com",
9482
+ port: 993,
9483
+ tls: true,
9484
+ smtpHost: "smtp.mail.me.com",
9485
+ smtpPort: 587
9486
+ };
9361
9487
  const withDefaults = {
9362
9488
  ...credentials,
9363
- password: {
9364
- host: "imap.mail.me.com",
9365
- port: 993,
9366
- tls: true,
9367
- smtpHost: "smtp.mail.me.com",
9368
- smtpPort: 587,
9369
- ...credentials.password
9370
- }
9489
+ password: { ...passwordDefaults, ...credentials.password }
9371
9490
  };
9372
9491
  return super.connect(withDefaults);
9373
9492
  }
@@ -9401,7 +9520,7 @@ function createProvider(provider) {
9401
9520
  throw new Error(`Unknown provider: ${provider}`);
9402
9521
  }
9403
9522
  }
9404
- var AccountManager;
9523
+ var MICROSOFT_CONSENT_URL, AccountManager;
9405
9524
  var init_account_manager = __esm({
9406
9525
  "src/account-manager.ts"() {
9407
9526
  "use strict";
@@ -9414,6 +9533,7 @@ var init_account_manager = __esm({
9414
9533
  init_adapter3();
9415
9534
  init_types();
9416
9535
  init_oauth_config();
9536
+ MICROSOFT_CONSENT_URL = "https://account.live.com/consent/Manage";
9417
9537
  AccountManager = class {
9418
9538
  store;
9419
9539
  providers = /* @__PURE__ */ new Map();
@@ -9468,9 +9588,55 @@ var init_account_manager = __esm({
9468
9588
  await this.store.save(creds);
9469
9589
  await this.connectAccount(creds.id);
9470
9590
  }
9591
+ /**
9592
+ * Removes an account locally, then cleans up at the provider as far as the
9593
+ * provider allows. Local removal always happens first and never depends on
9594
+ * the network; a failed provider step is reported in the result, not thrown.
9595
+ */
9471
9596
  async removeAccount(accountId) {
9597
+ const creds = await this.store.get(accountId);
9598
+ if (!creds) throw new Error(`Account ${accountId} not found`);
9472
9599
  await this.disconnectAccount(accountId);
9473
9600
  await this.store.remove(accountId);
9601
+ const result = {
9602
+ success: true,
9603
+ accountId,
9604
+ provider: creds.provider,
9605
+ email: creds.email,
9606
+ revocation: { status: "not_applicable", detail: "" }
9607
+ };
9608
+ if (creds.provider === ProviderType.Gmail) {
9609
+ const token = creds.oauth?.refresh_token || creds.oauth?.access_token || "";
9610
+ const { ok, detail } = await revokeGoogleGrant(token);
9611
+ result.revocation = { status: ok ? "revoked" : "failed", detail };
9612
+ } else if (creds.provider === ProviderType.Outlook) {
9613
+ result.tokenCache = await this.removeFromMsalCache(creds);
9614
+ result.revocation = {
9615
+ status: "local_only",
9616
+ 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}.`
9617
+ };
9618
+ } else {
9619
+ result.revocation = {
9620
+ status: "not_applicable",
9621
+ 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."
9622
+ };
9623
+ }
9624
+ return result;
9625
+ }
9626
+ async removeFromMsalCache(creds) {
9627
+ try {
9628
+ const outlookAuth = new OutlookAuth(OUTLOOK_CLIENT_ID);
9629
+ const removed = await outlookAuth.removeCachedAccount({
9630
+ homeAccountId: creds.oauth?.msal_home_account_id,
9631
+ username: creds.email
9632
+ });
9633
+ 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." };
9634
+ } catch (err) {
9635
+ return {
9636
+ status: "failed",
9637
+ detail: `The account's tokens could not be removed from the local Outlook token cache: ${err?.message ?? String(err)}`
9638
+ };
9639
+ }
9474
9640
  }
9475
9641
  async disconnectAccount(accountId) {
9476
9642
  const provider = this.providers.get(accountId);
@@ -9671,53 +9837,95 @@ var init_oauth_server = __esm({
9671
9837
  <p>An error occurred during authorization. Please try again.</p>
9672
9838
  </body>
9673
9839
  </html>`;
9674
- OAuthCallbackServer = class {
9675
- server = null;
9840
+ OAuthCallbackServer = class _OAuthCallbackServer {
9841
+ servers = [];
9676
9842
  resolveCode = null;
9677
9843
  rejectCode = null;
9678
9844
  timeoutMs;
9679
9845
  timeoutHandle = null;
9680
9846
  expectedState = null;
9847
+ static IPV4_LOOPBACK = "127.0.0.1";
9848
+ static IPV6_LOOPBACK = "::1";
9849
+ /** Attempts at finding a port that is free on both loopback addresses. */
9850
+ static MAX_PORT_ATTEMPTS = 5;
9681
9851
  constructor(timeoutMs = 12e4) {
9682
9852
  this.timeoutMs = timeoutMs;
9683
9853
  }
9684
- start() {
9854
+ handleRequest = (req, res) => {
9855
+ const url2 = new URL(req.url || "/", `http://localhost`);
9856
+ if (url2.pathname === "/callback" || url2.pathname === "/") {
9857
+ const code = url2.searchParams.get("code");
9858
+ const error48 = url2.searchParams.get("error");
9859
+ const state = url2.searchParams.get("state");
9860
+ if (code && this.expectedState !== null && state !== this.expectedState) {
9861
+ res.writeHead(400, { "Content-Type": "text/html" });
9862
+ res.end(ERROR_HTML);
9863
+ this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
9864
+ } else if (code) {
9865
+ res.writeHead(200, { "Content-Type": "text/html" });
9866
+ res.end(SUCCESS_HTML);
9867
+ this.resolveCode?.(code);
9868
+ } else {
9869
+ res.writeHead(400, { "Content-Type": "text/html" });
9870
+ res.end(ERROR_HTML);
9871
+ this.rejectCode?.(new Error(`OAuth error: ${error48 || "no code received"}`));
9872
+ }
9873
+ } else {
9874
+ res.writeHead(404);
9875
+ res.end("Not Found");
9876
+ }
9877
+ };
9878
+ listenOn(host, port) {
9685
9879
  return new Promise((resolve, reject) => {
9686
- this.server = http.createServer((req, res) => {
9687
- const url2 = new URL(req.url || "/", `http://localhost`);
9688
- if (url2.pathname === "/callback" || url2.pathname === "/") {
9689
- const code = url2.searchParams.get("code");
9690
- const error48 = url2.searchParams.get("error");
9691
- const state = url2.searchParams.get("state");
9692
- if (code && this.expectedState !== null && state !== this.expectedState) {
9693
- res.writeHead(400, { "Content-Type": "text/html" });
9694
- res.end(ERROR_HTML);
9695
- this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
9696
- } else if (code) {
9697
- res.writeHead(200, { "Content-Type": "text/html" });
9698
- res.end(SUCCESS_HTML);
9699
- this.resolveCode?.(code);
9700
- } else {
9701
- res.writeHead(400, { "Content-Type": "text/html" });
9702
- res.end(ERROR_HTML);
9703
- this.rejectCode?.(new Error(`OAuth error: ${error48 || "no code received"}`));
9704
- }
9705
- } else {
9706
- res.writeHead(404);
9707
- res.end("Not Found");
9708
- }
9709
- });
9710
- this.server.listen(0, () => {
9711
- const addr = this.server.address();
9712
- if (addr && typeof addr === "object") {
9713
- resolve(addr.port);
9714
- } else {
9715
- reject(new Error("Failed to get server port"));
9716
- }
9880
+ const server = http.createServer(this.handleRequest);
9881
+ const onError = (err) => {
9882
+ server.close();
9883
+ reject(err);
9884
+ };
9885
+ server.once("error", onError);
9886
+ server.listen(port, host, () => {
9887
+ server.off("error", onError);
9888
+ server.on("error", (err) => this.rejectCode?.(err));
9889
+ resolve(server);
9717
9890
  });
9718
- this.server.on("error", reject);
9719
9891
  });
9720
9892
  }
9893
+ /**
9894
+ * Starts listening on an OS-assigned port on 127.0.0.1 and, when available,
9895
+ * the same port on ::1. Resolves with the port.
9896
+ */
9897
+ async start() {
9898
+ let lastError;
9899
+ for (let attempt = 0; attempt < _OAuthCallbackServer.MAX_PORT_ATTEMPTS; attempt++) {
9900
+ const v4 = await this.listenOn(_OAuthCallbackServer.IPV4_LOOPBACK, 0);
9901
+ const addr = v4.address();
9902
+ if (!addr || typeof addr !== "object") {
9903
+ v4.close();
9904
+ throw new Error("Failed to get server port");
9905
+ }
9906
+ const port = addr.port;
9907
+ try {
9908
+ const v6 = await this.listenOn(_OAuthCallbackServer.IPV6_LOOPBACK, port);
9909
+ this.servers = [v4, v6];
9910
+ return port;
9911
+ } catch (err) {
9912
+ if (err?.code === "EADDRINUSE") {
9913
+ v4.close();
9914
+ lastError = err;
9915
+ continue;
9916
+ }
9917
+ this.servers = [v4];
9918
+ return port;
9919
+ }
9920
+ }
9921
+ throw new Error(
9922
+ `Could not find a port free on both loopback addresses: ${lastError?.message ?? "unknown error"}`
9923
+ );
9924
+ }
9925
+ /** The addresses the callback server is listening on (for diagnostics and tests). */
9926
+ boundAddresses() {
9927
+ return this.servers.map((s) => s.address()).filter((a) => !!a && typeof a === "object").map((a) => a.address);
9928
+ }
9721
9929
  /**
9722
9930
  * Resolve with the authorization code once the browser is redirected back.
9723
9931
  * Pass the `state` that was put into the authorization URL: callbacks whose
@@ -9745,10 +9953,10 @@ var init_oauth_server = __esm({
9745
9953
  clearTimeout(this.timeoutHandle);
9746
9954
  this.timeoutHandle = null;
9747
9955
  }
9748
- if (this.server) {
9749
- this.server.close();
9750
- this.server = null;
9956
+ for (const server of this.servers) {
9957
+ server.close();
9751
9958
  }
9959
+ this.servers = [];
9752
9960
  }
9753
9961
  };
9754
9962
  }
@@ -33327,14 +33535,14 @@ function registerAccountTools(server, accountManager) {
33327
33535
  );
33328
33536
  server.tool(
33329
33537
  "email_remove_account",
33330
- "Remove an email account",
33538
+ "Remove an email account: deletes its stored credentials, revokes the Google grant for Gmail accounts, and removes Outlook tokens from the local token cache. The result reports whether provider-side revocation succeeded.",
33331
33539
  {
33332
33540
  accountId: external_exports3.string().describe("ID of the account to remove")
33333
33541
  },
33334
33542
  async (args) => {
33335
33543
  try {
33336
- await accountManager.removeAccount(args.accountId);
33337
- return jsonResult({ success: true });
33544
+ const result = await accountManager.removeAccount(args.accountId);
33545
+ return jsonResult(result);
33338
33546
  } catch (error48) {
33339
33547
  return jsonResult({ error: error48.message });
33340
33548
  }
@@ -33518,8 +33726,9 @@ function registerReadingTools(server, accountManager) {
33518
33726
  const absPath = resolveDownloadPath(args.outputPath);
33519
33727
  const provider = await accountManager.getProvider(args.accountId);
33520
33728
  const { data, meta: meta3 } = await provider.getAttachment(args.emailId, args.attachmentId);
33521
- fs3.mkdirSync(path3.dirname(absPath), { recursive: true });
33522
- fs3.writeFileSync(absPath, Buffer.from(data));
33729
+ fs3.mkdirSync(path3.dirname(absPath), { recursive: true, mode: 448 });
33730
+ fs3.writeFileSync(absPath, Buffer.from(data), { mode: 384 });
33731
+ fs3.chmodSync(absPath, 384);
33523
33732
  return jsonResult2({
33524
33733
  path: absPath,
33525
33734
  bytes: data.length,
@@ -34242,11 +34451,12 @@ function registerModerationTools(server, accountManager) {
34242
34451
  }
34243
34452
 
34244
34453
  // src/server.ts
34454
+ var SERVER_VERSION = "1.8.0";
34245
34455
  async function createServer(accountManager) {
34246
34456
  const mgr = accountManager ?? new AccountManager();
34247
34457
  const server = new McpServer({
34248
34458
  name: "email-mcp",
34249
- version: "0.1.0"
34459
+ version: SERVER_VERSION
34250
34460
  });
34251
34461
  registerAccountTools(server, mgr);
34252
34462
  registerReadingTools(server, mgr);