@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/dist/setup/wizard.js
CHANGED
|
@@ -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
|
|
220
|
-
const { plaintext, seedUsed } = decrypt(raw, seeds);
|
|
227
|
+
const { plaintext, usedLegacyKey } = decryptWithMachineKey(raw);
|
|
221
228
|
const data = JSON.parse(plaintext);
|
|
222
|
-
if (
|
|
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 =
|
|
239
|
+
const encrypted = encryptWithMachineKey(json);
|
|
233
240
|
fs.writeFileSync(this.filePath, encrypted, { mode: 384 });
|
|
234
241
|
}
|
|
235
242
|
async save(creds) {
|
|
@@ -245,6 +252,18 @@ var CredentialStore = class {
|
|
|
245
252
|
const data = this.read();
|
|
246
253
|
return Object.values(data.accounts);
|
|
247
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* The account already configured for this mailbox, if any. Provider plus
|
|
257
|
+
* address identifies a mailbox; the address comparison is case-insensitive
|
|
258
|
+
* because providers report it in varying case.
|
|
259
|
+
*/
|
|
260
|
+
async findByProviderAndEmail(provider, email) {
|
|
261
|
+
const wanted = email.trim().toLowerCase();
|
|
262
|
+
const data = this.read();
|
|
263
|
+
return Object.values(data.accounts).find(
|
|
264
|
+
(a) => a.provider === provider && a.email.trim().toLowerCase() === wanted
|
|
265
|
+
) ?? null;
|
|
266
|
+
}
|
|
248
267
|
async remove(id) {
|
|
249
268
|
const data = this.read();
|
|
250
269
|
delete data.accounts[id];
|
|
@@ -270,53 +289,95 @@ var ERROR_HTML = `<!DOCTYPE html>
|
|
|
270
289
|
<p>An error occurred during authorization. Please try again.</p>
|
|
271
290
|
</body>
|
|
272
291
|
</html>`;
|
|
273
|
-
var OAuthCallbackServer = class {
|
|
274
|
-
|
|
292
|
+
var OAuthCallbackServer = class _OAuthCallbackServer {
|
|
293
|
+
servers = [];
|
|
275
294
|
resolveCode = null;
|
|
276
295
|
rejectCode = null;
|
|
277
296
|
timeoutMs;
|
|
278
297
|
timeoutHandle = null;
|
|
279
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;
|
|
280
303
|
constructor(timeoutMs = 12e4) {
|
|
281
304
|
this.timeoutMs = timeoutMs;
|
|
282
305
|
}
|
|
283
|
-
|
|
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) {
|
|
284
331
|
return new Promise((resolve, reject) => {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
} else if (code) {
|
|
296
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
297
|
-
res.end(SUCCESS_HTML);
|
|
298
|
-
this.resolveCode?.(code);
|
|
299
|
-
} else {
|
|
300
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
301
|
-
res.end(ERROR_HTML);
|
|
302
|
-
this.rejectCode?.(new Error(`OAuth error: ${error || "no code received"}`));
|
|
303
|
-
}
|
|
304
|
-
} else {
|
|
305
|
-
res.writeHead(404);
|
|
306
|
-
res.end("Not Found");
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
this.server.listen(0, () => {
|
|
310
|
-
const addr = this.server.address();
|
|
311
|
-
if (addr && typeof addr === "object") {
|
|
312
|
-
resolve(addr.port);
|
|
313
|
-
} else {
|
|
314
|
-
reject(new Error("Failed to get server port"));
|
|
315
|
-
}
|
|
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);
|
|
316
342
|
});
|
|
317
|
-
this.server.on("error", reject);
|
|
318
343
|
});
|
|
319
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
|
+
}
|
|
320
381
|
/**
|
|
321
382
|
* Resolve with the authorization code once the browser is redirected back.
|
|
322
383
|
* Pass the `state` that was put into the authorization URL: callbacks whose
|
|
@@ -344,10 +405,10 @@ var OAuthCallbackServer = class {
|
|
|
344
405
|
clearTimeout(this.timeoutHandle);
|
|
345
406
|
this.timeoutHandle = null;
|
|
346
407
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
this.server = null;
|
|
408
|
+
for (const server of this.servers) {
|
|
409
|
+
server.close();
|
|
350
410
|
}
|
|
411
|
+
this.servers = [];
|
|
351
412
|
}
|
|
352
413
|
};
|
|
353
414
|
|
|
@@ -381,6 +442,43 @@ function resolveScopeMode(explicit) {
|
|
|
381
442
|
if (explicit) return explicit;
|
|
382
443
|
return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
|
|
383
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
|
+
}
|
|
384
482
|
var GmailAuth = class {
|
|
385
483
|
clientId;
|
|
386
484
|
clientSecret;
|
|
@@ -444,23 +542,73 @@ import {
|
|
|
444
542
|
CryptoProvider
|
|
445
543
|
} from "@azure/msal-node";
|
|
446
544
|
var DEFAULT_CACHE_DIR = path2.join(os2.homedir(), ".email-mcp");
|
|
447
|
-
var DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.
|
|
448
|
-
|
|
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;
|
|
449
596
|
return {
|
|
450
597
|
async beforeCacheAccess(context) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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;
|
|
454
607
|
}
|
|
455
608
|
},
|
|
456
609
|
async afterCacheAccess(context) {
|
|
457
|
-
if (context.cacheHasChanged)
|
|
458
|
-
|
|
459
|
-
if (!fs2.existsSync(dir)) {
|
|
460
|
-
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
461
|
-
}
|
|
462
|
-
fs2.writeFileSync(cacheFilePath, context.tokenCache.serialize(), { mode: 384 });
|
|
463
|
-
}
|
|
610
|
+
if (loadFailed || !context.cacheHasChanged) return;
|
|
611
|
+
writeEncryptedCache(cacheFilePath, context.tokenCache.serialize());
|
|
464
612
|
}
|
|
465
613
|
};
|
|
466
614
|
}
|
|
@@ -479,7 +627,7 @@ var OutlookAuth = class _OutlookAuth {
|
|
|
479
627
|
authority: _OutlookAuth.AUTHORITY
|
|
480
628
|
},
|
|
481
629
|
cache: {
|
|
482
|
-
cachePlugin:
|
|
630
|
+
cachePlugin: createEncryptedFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
|
|
483
631
|
}
|
|
484
632
|
});
|
|
485
633
|
this.cryptoProvider = new CryptoProvider();
|
|
@@ -533,6 +681,27 @@ var OutlookAuth = class _OutlookAuth {
|
|
|
533
681
|
homeAccountId: result.account?.homeAccountId ?? homeAccountId
|
|
534
682
|
};
|
|
535
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
|
+
}
|
|
536
705
|
/**
|
|
537
706
|
* @deprecated Use refreshTokenSilent() instead.
|
|
538
707
|
* Kept for backward compatibility with credentials that have a stored refresh token.
|
|
@@ -2079,7 +2248,7 @@ var ImapAdapter = class {
|
|
|
2079
2248
|
const result = await client.mailboxCreate(fullPath);
|
|
2080
2249
|
return {
|
|
2081
2250
|
id: result.path,
|
|
2082
|
-
name
|
|
2251
|
+
name,
|
|
2083
2252
|
path: result.path,
|
|
2084
2253
|
type: FolderType.Other
|
|
2085
2254
|
};
|
|
@@ -2317,6 +2486,7 @@ var ImapAdapter = class {
|
|
|
2317
2486
|
const uid = parseInt(id, 10);
|
|
2318
2487
|
const msg = await client.fetchOne(String(uid), { source: true, uid: true, flags: true }, { uid: true });
|
|
2319
2488
|
if (!msg) throw new Error(`Email ${id} not found`);
|
|
2489
|
+
if (!msg.source) throw new Error(`Email ${id} has no source`);
|
|
2320
2490
|
const parsed = await simpleParser(msg.source);
|
|
2321
2491
|
parsed.flags = msg.flags;
|
|
2322
2492
|
return mapParsedEmail(parsed, targetFolder, this.accountId, msg.uid);
|
|
@@ -2341,6 +2511,7 @@ var ImapAdapter = class {
|
|
|
2341
2511
|
if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
|
|
2342
2512
|
const messages = [];
|
|
2343
2513
|
for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
|
|
2514
|
+
if (!msg.source) continue;
|
|
2344
2515
|
const parsed = await simpleParser(msg.source);
|
|
2345
2516
|
parsed.flags = msg.flags;
|
|
2346
2517
|
messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
|
|
@@ -2376,6 +2547,7 @@ var ImapAdapter = class {
|
|
|
2376
2547
|
try {
|
|
2377
2548
|
const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
|
|
2378
2549
|
if (!msg) throw new Error(`Email ${emailId} not found`);
|
|
2550
|
+
if (!msg.source) throw new Error(`Email ${emailId} has no source`);
|
|
2379
2551
|
const parsed = await simpleParser(msg.source);
|
|
2380
2552
|
const attachment = (parsed.attachments || []).find(
|
|
2381
2553
|
(att) => att.contentId === attachmentId || att.filename === attachmentId
|
|
@@ -2675,16 +2847,16 @@ var ImapAdapter = class {
|
|
|
2675
2847
|
var ICloudAdapter = class extends ImapAdapter {
|
|
2676
2848
|
providerType = ProviderType.ICloud;
|
|
2677
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
|
+
};
|
|
2678
2857
|
const withDefaults = {
|
|
2679
2858
|
...credentials,
|
|
2680
|
-
password: {
|
|
2681
|
-
host: "imap.mail.me.com",
|
|
2682
|
-
port: 993,
|
|
2683
|
-
tls: true,
|
|
2684
|
-
smtpHost: "smtp.mail.me.com",
|
|
2685
|
-
smtpPort: 587,
|
|
2686
|
-
...credentials.password
|
|
2687
|
-
}
|
|
2859
|
+
password: { ...passwordDefaults, ...credentials.password }
|
|
2688
2860
|
};
|
|
2689
2861
|
return super.connect(withDefaults);
|
|
2690
2862
|
}
|
|
@@ -2710,6 +2882,7 @@ function createProvider(provider) {
|
|
|
2710
2882
|
throw new Error(`Unknown provider: ${provider}`);
|
|
2711
2883
|
}
|
|
2712
2884
|
}
|
|
2885
|
+
var MICROSOFT_CONSENT_URL = "https://account.live.com/consent/Manage";
|
|
2713
2886
|
var AccountManager = class {
|
|
2714
2887
|
store;
|
|
2715
2888
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -2764,9 +2937,55 @@ var AccountManager = class {
|
|
|
2764
2937
|
await this.store.save(creds);
|
|
2765
2938
|
await this.connectAccount(creds.id);
|
|
2766
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
|
+
*/
|
|
2767
2945
|
async removeAccount(accountId) {
|
|
2946
|
+
const creds = await this.store.get(accountId);
|
|
2947
|
+
if (!creds) throw new Error(`Account ${accountId} not found`);
|
|
2768
2948
|
await this.disconnectAccount(accountId);
|
|
2769
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
|
+
}
|
|
2770
2989
|
}
|
|
2771
2990
|
async disconnectAccount(accountId) {
|
|
2772
2991
|
const provider = this.providers.get(accountId);
|
|
@@ -2881,6 +3100,14 @@ async function testAndSave(creds) {
|
|
|
2881
3100
|
async function promptAccountName(defaultName) {
|
|
2882
3101
|
return askText("Account name:", defaultName);
|
|
2883
3102
|
}
|
|
3103
|
+
async function resolveAccountIdentity(provider, email, defaultName) {
|
|
3104
|
+
const existing = await store.findByProviderAndEmail(provider, email);
|
|
3105
|
+
if (existing) {
|
|
3106
|
+
console.log(`An account for ${email} already exists ("${existing.name}"), updating it in place.`);
|
|
3107
|
+
return { id: existing.id, name: await promptAccountName(existing.name) };
|
|
3108
|
+
}
|
|
3109
|
+
return { id: crypto4.randomUUID(), name: await promptAccountName(defaultName) };
|
|
3110
|
+
}
|
|
2884
3111
|
async function setupGmail(scopeModeFlag) {
|
|
2885
3112
|
console.log("\n--- Gmail Setup ---");
|
|
2886
3113
|
if (process.env.EMAIL_MCP_GMAIL_CLIENT_ID) {
|
|
@@ -2927,8 +3154,7 @@ ${url}
|
|
|
2927
3154
|
const email = profile.emailAddress ?? "";
|
|
2928
3155
|
if (!email) throw new Error("Could not retrieve Gmail address from profile");
|
|
2929
3156
|
console.log(`Authenticated as ${email}`);
|
|
2930
|
-
const name = await
|
|
2931
|
-
const id = crypto4.randomUUID();
|
|
3157
|
+
const { id, name } = await resolveAccountIdentity(ProviderType.Gmail, email.trim(), "Gmail");
|
|
2932
3158
|
const creds = {
|
|
2933
3159
|
id,
|
|
2934
3160
|
name,
|
|
@@ -2965,8 +3191,7 @@ ${url}
|
|
|
2965
3191
|
const email = account?.username ?? "";
|
|
2966
3192
|
if (!email) throw new Error("Could not retrieve email from Microsoft account");
|
|
2967
3193
|
console.log(`Authenticated as ${email}`);
|
|
2968
|
-
const name = await
|
|
2969
|
-
const id = crypto4.randomUUID();
|
|
3194
|
+
const { id, name } = await resolveAccountIdentity(ProviderType.Outlook, email.trim(), "Outlook");
|
|
2970
3195
|
const creds = {
|
|
2971
3196
|
id,
|
|
2972
3197
|
name,
|
|
@@ -2997,8 +3222,7 @@ async function setupICloud() {
|
|
|
2997
3222
|
console.log(" Password is required");
|
|
2998
3223
|
password = await askPassword("App-specific password:");
|
|
2999
3224
|
}
|
|
3000
|
-
const name = await
|
|
3001
|
-
const id = crypto4.randomUUID();
|
|
3225
|
+
const { id, name } = await resolveAccountIdentity(ProviderType.ICloud, email.trim(), "iCloud");
|
|
3002
3226
|
const creds = {
|
|
3003
3227
|
id,
|
|
3004
3228
|
name,
|
|
@@ -3038,8 +3262,7 @@ async function setupIMAP() {
|
|
|
3038
3262
|
const smtpPortStr = await askValidated("SMTP port:", "587", portValidator);
|
|
3039
3263
|
smtpPort = parseInt(smtpPortStr, 10);
|
|
3040
3264
|
}
|
|
3041
|
-
const name = await
|
|
3042
|
-
const id = crypto4.randomUUID();
|
|
3265
|
+
const { id, name } = await resolveAccountIdentity(ProviderType.IMAP, email.trim(), "IMAP");
|
|
3043
3266
|
const creds = {
|
|
3044
3267
|
id,
|
|
3045
3268
|
name,
|