@marlinjai/email-mcp 1.7.1 → 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) {
@@ -175,6 +182,18 @@ var init_credential_store = __esm({
175
182
  const data = this.read();
176
183
  return Object.values(data.accounts);
177
184
  }
185
+ /**
186
+ * The account already configured for this mailbox, if any. Provider plus
187
+ * address identifies a mailbox; the address comparison is case-insensitive
188
+ * because providers report it in varying case.
189
+ */
190
+ async findByProviderAndEmail(provider, email3) {
191
+ const wanted = email3.trim().toLowerCase();
192
+ const data = this.read();
193
+ return Object.values(data.accounts).find(
194
+ (a) => a.provider === provider && a.email.trim().toLowerCase() === wanted
195
+ ) ?? null;
196
+ }
178
197
  async remove(id) {
179
198
  const data = this.read();
180
199
  delete data.accounts[id];
@@ -7671,7 +7690,41 @@ function resolveScopeMode(explicit) {
7671
7690
  if (explicit) return explicit;
7672
7691
  return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
7673
7692
  }
7674
- 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;
7675
7728
  var init_auth = __esm({
7676
7729
  "src/providers/gmail/auth.ts"() {
7677
7730
  "use strict";
@@ -7698,6 +7751,9 @@ var init_auth = __esm({
7698
7751
  "https://www.googleapis.com/auth/gmail.settings.basic"
7699
7752
  ]
7700
7753
  };
7754
+ GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
7755
+ GOOGLE_PERMISSIONS_URL = "https://myaccount.google.com/permissions";
7756
+ REVOKE_TIMEOUT_MS = 1e4;
7701
7757
  GmailAuth = class {
7702
7758
  clientId;
7703
7759
  clientSecret;
@@ -8379,31 +8435,82 @@ import {
8379
8435
  PublicClientApplication,
8380
8436
  CryptoProvider
8381
8437
  } from "@azure/msal-node";
8382
- 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;
8383
8479
  return {
8384
8480
  async beforeCacheAccess(context) {
8385
- if (fs2.existsSync(cacheFilePath)) {
8386
- const data = fs2.readFileSync(cacheFilePath, "utf-8");
8387
- 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;
8388
8490
  }
8389
8491
  },
8390
8492
  async afterCacheAccess(context) {
8391
- if (context.cacheHasChanged) {
8392
- const dir = path2.dirname(cacheFilePath);
8393
- if (!fs2.existsSync(dir)) {
8394
- fs2.mkdirSync(dir, { recursive: true, mode: 448 });
8395
- }
8396
- fs2.writeFileSync(cacheFilePath, context.tokenCache.serialize(), { mode: 384 });
8397
- }
8493
+ if (loadFailed || !context.cacheHasChanged) return;
8494
+ writeEncryptedCache(cacheFilePath, context.tokenCache.serialize());
8398
8495
  }
8399
8496
  };
8400
8497
  }
8401
- var DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, OutlookAuth;
8498
+ var DEFAULT_CACHE_DIR, DEFAULT_CACHE_FILE, LEGACY_PLAINTEXT_CACHE_NAME, MsalCacheDecryptError, OutlookAuth;
8402
8499
  var init_auth2 = __esm({
8403
8500
  "src/providers/outlook/auth.ts"() {
8404
8501
  "use strict";
8502
+ init_credential_store();
8405
8503
  DEFAULT_CACHE_DIR = path2.join(os2.homedir(), ".email-mcp");
8406
- 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
+ };
8407
8514
  OutlookAuth = class _OutlookAuth {
8408
8515
  static AUTHORITY = "https://login.microsoftonline.com/consumers";
8409
8516
  // MailboxSettings.ReadWrite is required for the messageRules API (block
@@ -8419,7 +8526,7 @@ var init_auth2 = __esm({
8419
8526
  authority: _OutlookAuth.AUTHORITY
8420
8527
  },
8421
8528
  cache: {
8422
- cachePlugin: createFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
8529
+ cachePlugin: createEncryptedFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
8423
8530
  }
8424
8531
  });
8425
8532
  this.cryptoProvider = new CryptoProvider();
@@ -8473,6 +8580,27 @@ var init_auth2 = __esm({
8473
8580
  homeAccountId: result.account?.homeAccountId ?? homeAccountId
8474
8581
  };
8475
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
+ }
8476
8604
  /**
8477
8605
  * @deprecated Use refreshTokenSilent() instead.
8478
8606
  * Kept for backward compatibility with credentials that have a stored refresh token.
@@ -8742,7 +8870,7 @@ var init_adapter3 = __esm({
8742
8870
  const result = await client.mailboxCreate(fullPath);
8743
8871
  return {
8744
8872
  id: result.path,
8745
- name: result.name || name,
8873
+ name,
8746
8874
  path: result.path,
8747
8875
  type: FolderType.Other
8748
8876
  };
@@ -8980,6 +9108,7 @@ var init_adapter3 = __esm({
8980
9108
  const uid = parseInt(id, 10);
8981
9109
  const msg = await client.fetchOne(String(uid), { source: true, uid: true, flags: true }, { uid: true });
8982
9110
  if (!msg) throw new Error(`Email ${id} not found`);
9111
+ if (!msg.source) throw new Error(`Email ${id} has no source`);
8983
9112
  const parsed = await simpleParser(msg.source);
8984
9113
  parsed.flags = msg.flags;
8985
9114
  return mapParsedEmail(parsed, targetFolder, this.accountId, msg.uid);
@@ -9004,6 +9133,7 @@ var init_adapter3 = __esm({
9004
9133
  if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
9005
9134
  const messages = [];
9006
9135
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
9136
+ if (!msg.source) continue;
9007
9137
  const parsed = await simpleParser(msg.source);
9008
9138
  parsed.flags = msg.flags;
9009
9139
  messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
@@ -9039,6 +9169,7 @@ var init_adapter3 = __esm({
9039
9169
  try {
9040
9170
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
9041
9171
  if (!msg) throw new Error(`Email ${emailId} not found`);
9172
+ if (!msg.source) throw new Error(`Email ${emailId} has no source`);
9042
9173
  const parsed = await simpleParser(msg.source);
9043
9174
  const attachment = (parsed.attachments || []).find(
9044
9175
  (att) => att.contentId === attachmentId || att.filename === attachmentId
@@ -9346,16 +9477,16 @@ var init_adapter4 = __esm({
9346
9477
  ICloudAdapter = class extends ImapAdapter {
9347
9478
  providerType = ProviderType.ICloud;
9348
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
+ };
9349
9487
  const withDefaults = {
9350
9488
  ...credentials,
9351
- password: {
9352
- host: "imap.mail.me.com",
9353
- port: 993,
9354
- tls: true,
9355
- smtpHost: "smtp.mail.me.com",
9356
- smtpPort: 587,
9357
- ...credentials.password
9358
- }
9489
+ password: { ...passwordDefaults, ...credentials.password }
9359
9490
  };
9360
9491
  return super.connect(withDefaults);
9361
9492
  }
@@ -9389,7 +9520,7 @@ function createProvider(provider) {
9389
9520
  throw new Error(`Unknown provider: ${provider}`);
9390
9521
  }
9391
9522
  }
9392
- var AccountManager;
9523
+ var MICROSOFT_CONSENT_URL, AccountManager;
9393
9524
  var init_account_manager = __esm({
9394
9525
  "src/account-manager.ts"() {
9395
9526
  "use strict";
@@ -9402,6 +9533,7 @@ var init_account_manager = __esm({
9402
9533
  init_adapter3();
9403
9534
  init_types();
9404
9535
  init_oauth_config();
9536
+ MICROSOFT_CONSENT_URL = "https://account.live.com/consent/Manage";
9405
9537
  AccountManager = class {
9406
9538
  store;
9407
9539
  providers = /* @__PURE__ */ new Map();
@@ -9456,9 +9588,55 @@ var init_account_manager = __esm({
9456
9588
  await this.store.save(creds);
9457
9589
  await this.connectAccount(creds.id);
9458
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
+ */
9459
9596
  async removeAccount(accountId) {
9597
+ const creds = await this.store.get(accountId);
9598
+ if (!creds) throw new Error(`Account ${accountId} not found`);
9460
9599
  await this.disconnectAccount(accountId);
9461
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
+ }
9462
9640
  }
9463
9641
  async disconnectAccount(accountId) {
9464
9642
  const provider = this.providers.get(accountId);
@@ -9659,53 +9837,95 @@ var init_oauth_server = __esm({
9659
9837
  <p>An error occurred during authorization. Please try again.</p>
9660
9838
  </body>
9661
9839
  </html>`;
9662
- OAuthCallbackServer = class {
9663
- server = null;
9840
+ OAuthCallbackServer = class _OAuthCallbackServer {
9841
+ servers = [];
9664
9842
  resolveCode = null;
9665
9843
  rejectCode = null;
9666
9844
  timeoutMs;
9667
9845
  timeoutHandle = null;
9668
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;
9669
9851
  constructor(timeoutMs = 12e4) {
9670
9852
  this.timeoutMs = timeoutMs;
9671
9853
  }
9672
- 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) {
9673
9879
  return new Promise((resolve, reject) => {
9674
- this.server = http.createServer((req, res) => {
9675
- const url2 = new URL(req.url || "/", `http://localhost`);
9676
- if (url2.pathname === "/callback" || url2.pathname === "/") {
9677
- const code = url2.searchParams.get("code");
9678
- const error48 = url2.searchParams.get("error");
9679
- const state = url2.searchParams.get("state");
9680
- if (code && this.expectedState !== null && state !== this.expectedState) {
9681
- res.writeHead(400, { "Content-Type": "text/html" });
9682
- res.end(ERROR_HTML);
9683
- this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
9684
- } else if (code) {
9685
- res.writeHead(200, { "Content-Type": "text/html" });
9686
- res.end(SUCCESS_HTML);
9687
- this.resolveCode?.(code);
9688
- } else {
9689
- res.writeHead(400, { "Content-Type": "text/html" });
9690
- res.end(ERROR_HTML);
9691
- this.rejectCode?.(new Error(`OAuth error: ${error48 || "no code received"}`));
9692
- }
9693
- } else {
9694
- res.writeHead(404);
9695
- res.end("Not Found");
9696
- }
9697
- });
9698
- this.server.listen(0, () => {
9699
- const addr = this.server.address();
9700
- if (addr && typeof addr === "object") {
9701
- resolve(addr.port);
9702
- } else {
9703
- reject(new Error("Failed to get server port"));
9704
- }
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);
9705
9890
  });
9706
- this.server.on("error", reject);
9707
9891
  });
9708
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
+ }
9709
9929
  /**
9710
9930
  * Resolve with the authorization code once the browser is redirected back.
9711
9931
  * Pass the `state` that was put into the authorization URL: callbacks whose
@@ -9733,10 +9953,10 @@ var init_oauth_server = __esm({
9733
9953
  clearTimeout(this.timeoutHandle);
9734
9954
  this.timeoutHandle = null;
9735
9955
  }
9736
- if (this.server) {
9737
- this.server.close();
9738
- this.server = null;
9956
+ for (const server of this.servers) {
9957
+ server.close();
9739
9958
  }
9959
+ this.servers = [];
9740
9960
  }
9741
9961
  };
9742
9962
  }
@@ -9788,6 +10008,14 @@ async function testAndSave(creds) {
9788
10008
  async function promptAccountName(defaultName) {
9789
10009
  return askText("Account name:", defaultName);
9790
10010
  }
10011
+ async function resolveAccountIdentity(provider, email3, defaultName) {
10012
+ const existing = await store.findByProviderAndEmail(provider, email3);
10013
+ if (existing) {
10014
+ console.log(`An account for ${email3} already exists ("${existing.name}"), updating it in place.`);
10015
+ return { id: existing.id, name: await promptAccountName(existing.name) };
10016
+ }
10017
+ return { id: crypto5.randomUUID(), name: await promptAccountName(defaultName) };
10018
+ }
9791
10019
  async function setupGmail(scopeModeFlag) {
9792
10020
  console.log("\n--- Gmail Setup ---");
9793
10021
  if (process.env.EMAIL_MCP_GMAIL_CLIENT_ID) {
@@ -9834,8 +10062,7 @@ ${url2}
9834
10062
  const email3 = profile.emailAddress ?? "";
9835
10063
  if (!email3) throw new Error("Could not retrieve Gmail address from profile");
9836
10064
  console.log(`Authenticated as ${email3}`);
9837
- const name = await promptAccountName("Gmail");
9838
- const id = crypto5.randomUUID();
10065
+ const { id, name } = await resolveAccountIdentity(ProviderType.Gmail, email3.trim(), "Gmail");
9839
10066
  const creds = {
9840
10067
  id,
9841
10068
  name,
@@ -9872,8 +10099,7 @@ ${url2}
9872
10099
  const email3 = account?.username ?? "";
9873
10100
  if (!email3) throw new Error("Could not retrieve email from Microsoft account");
9874
10101
  console.log(`Authenticated as ${email3}`);
9875
- const name = await promptAccountName("Outlook");
9876
- const id = crypto5.randomUUID();
10102
+ const { id, name } = await resolveAccountIdentity(ProviderType.Outlook, email3.trim(), "Outlook");
9877
10103
  const creds = {
9878
10104
  id,
9879
10105
  name,
@@ -9904,8 +10130,7 @@ async function setupICloud() {
9904
10130
  console.log(" Password is required");
9905
10131
  password = await askPassword("App-specific password:");
9906
10132
  }
9907
- const name = await promptAccountName("iCloud");
9908
- const id = crypto5.randomUUID();
10133
+ const { id, name } = await resolveAccountIdentity(ProviderType.ICloud, email3.trim(), "iCloud");
9909
10134
  const creds = {
9910
10135
  id,
9911
10136
  name,
@@ -9945,8 +10170,7 @@ async function setupIMAP() {
9945
10170
  const smtpPortStr = await askValidated("SMTP port:", "587", portValidator);
9946
10171
  smtpPort = parseInt(smtpPortStr, 10);
9947
10172
  }
9948
- const name = await promptAccountName("IMAP");
9949
- const id = crypto5.randomUUID();
10173
+ const { id, name } = await resolveAccountIdentity(ProviderType.IMAP, email3.trim(), "IMAP");
9950
10174
  const creds = {
9951
10175
  id,
9952
10176
  name,
@@ -33311,14 +33535,14 @@ function registerAccountTools(server, accountManager) {
33311
33535
  );
33312
33536
  server.tool(
33313
33537
  "email_remove_account",
33314
- "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.",
33315
33539
  {
33316
33540
  accountId: external_exports3.string().describe("ID of the account to remove")
33317
33541
  },
33318
33542
  async (args) => {
33319
33543
  try {
33320
- await accountManager.removeAccount(args.accountId);
33321
- return jsonResult({ success: true });
33544
+ const result = await accountManager.removeAccount(args.accountId);
33545
+ return jsonResult(result);
33322
33546
  } catch (error48) {
33323
33547
  return jsonResult({ error: error48.message });
33324
33548
  }
@@ -33502,8 +33726,9 @@ function registerReadingTools(server, accountManager) {
33502
33726
  const absPath = resolveDownloadPath(args.outputPath);
33503
33727
  const provider = await accountManager.getProvider(args.accountId);
33504
33728
  const { data, meta: meta3 } = await provider.getAttachment(args.emailId, args.attachmentId);
33505
- fs3.mkdirSync(path3.dirname(absPath), { recursive: true });
33506
- 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);
33507
33732
  return jsonResult2({
33508
33733
  path: absPath,
33509
33734
  bytes: data.length,
@@ -34226,11 +34451,12 @@ function registerModerationTools(server, accountManager) {
34226
34451
  }
34227
34452
 
34228
34453
  // src/server.ts
34454
+ var SERVER_VERSION = "1.8.0";
34229
34455
  async function createServer(accountManager) {
34230
34456
  const mgr = accountManager ?? new AccountManager();
34231
34457
  const server = new McpServer({
34232
34458
  name: "email-mcp",
34233
- version: "0.1.0"
34459
+ version: SERVER_VERSION
34234
34460
  });
34235
34461
  registerAccountTools(server, mgr);
34236
34462
  registerReadingTools(server, mgr);