@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 +16 -1
- package/dist/index.js +307 -81
- package/dist/index.js.map +3 -3
- package/dist/setup/wizard.js +295 -72
- package/dist/setup/wizard.js.map +3 -3
- package/package.json +3 -1
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
|
|
150
|
-
const { plaintext, seedUsed } = decrypt(raw, seeds);
|
|
157
|
+
const { plaintext, usedLegacyKey } = decryptWithMachineKey(raw);
|
|
151
158
|
const data = JSON.parse(plaintext);
|
|
152
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
|
|
9681
|
-
|
|
9682
|
-
|
|
9683
|
-
|
|
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
|
-
|
|
9737
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
34459
|
+
version: SERVER_VERSION
|
|
34234
34460
|
});
|
|
34235
34461
|
registerAccountTools(server, mgr);
|
|
34236
34462
|
registerReadingTools(server, mgr);
|