@marlinjai/email-mcp 1.7.2 → 1.8.1
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 +365 -123
- package/dist/index.js.map +3 -3
- package/dist/setup/wizard.js +345 -114
- package/dist/setup/wizard.js.map +4 -4
- package/package.json +5 -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) {
|
|
@@ -282,53 +289,95 @@ var ERROR_HTML = `<!DOCTYPE html>
|
|
|
282
289
|
<p>An error occurred during authorization. Please try again.</p>
|
|
283
290
|
</body>
|
|
284
291
|
</html>`;
|
|
285
|
-
var OAuthCallbackServer = class {
|
|
286
|
-
|
|
292
|
+
var OAuthCallbackServer = class _OAuthCallbackServer {
|
|
293
|
+
servers = [];
|
|
287
294
|
resolveCode = null;
|
|
288
295
|
rejectCode = null;
|
|
289
296
|
timeoutMs;
|
|
290
297
|
timeoutHandle = null;
|
|
291
298
|
expectedState = null;
|
|
299
|
+
static IPV4_LOOPBACK = "127.0.0.1";
|
|
300
|
+
static IPV6_LOOPBACK = "::1";
|
|
301
|
+
/** Attempts at finding a port that is free on both loopback addresses. */
|
|
302
|
+
static MAX_PORT_ATTEMPTS = 5;
|
|
292
303
|
constructor(timeoutMs = 12e4) {
|
|
293
304
|
this.timeoutMs = timeoutMs;
|
|
294
305
|
}
|
|
295
|
-
|
|
306
|
+
handleRequest = (req, res) => {
|
|
307
|
+
const url = new URL(req.url || "/", `http://localhost`);
|
|
308
|
+
if (url.pathname === "/callback" || url.pathname === "/") {
|
|
309
|
+
const code = url.searchParams.get("code");
|
|
310
|
+
const error = url.searchParams.get("error");
|
|
311
|
+
const state = url.searchParams.get("state");
|
|
312
|
+
if (code && this.expectedState !== null && state !== this.expectedState) {
|
|
313
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
314
|
+
res.end(ERROR_HTML);
|
|
315
|
+
this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
|
|
316
|
+
} else if (code) {
|
|
317
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
318
|
+
res.end(SUCCESS_HTML);
|
|
319
|
+
this.resolveCode?.(code);
|
|
320
|
+
} else {
|
|
321
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
322
|
+
res.end(ERROR_HTML);
|
|
323
|
+
this.rejectCode?.(new Error(`OAuth error: ${error || "no code received"}`));
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
res.writeHead(404);
|
|
327
|
+
res.end("Not Found");
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
listenOn(host, port) {
|
|
296
331
|
return new Promise((resolve, reject) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
} else if (code) {
|
|
308
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
309
|
-
res.end(SUCCESS_HTML);
|
|
310
|
-
this.resolveCode?.(code);
|
|
311
|
-
} else {
|
|
312
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
313
|
-
res.end(ERROR_HTML);
|
|
314
|
-
this.rejectCode?.(new Error(`OAuth error: ${error || "no code received"}`));
|
|
315
|
-
}
|
|
316
|
-
} else {
|
|
317
|
-
res.writeHead(404);
|
|
318
|
-
res.end("Not Found");
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
this.server.listen(0, () => {
|
|
322
|
-
const addr = this.server.address();
|
|
323
|
-
if (addr && typeof addr === "object") {
|
|
324
|
-
resolve(addr.port);
|
|
325
|
-
} else {
|
|
326
|
-
reject(new Error("Failed to get server port"));
|
|
327
|
-
}
|
|
332
|
+
const server = http.createServer(this.handleRequest);
|
|
333
|
+
const onError = (err) => {
|
|
334
|
+
server.close();
|
|
335
|
+
reject(err);
|
|
336
|
+
};
|
|
337
|
+
server.once("error", onError);
|
|
338
|
+
server.listen(port, host, () => {
|
|
339
|
+
server.off("error", onError);
|
|
340
|
+
server.on("error", (err) => this.rejectCode?.(err));
|
|
341
|
+
resolve(server);
|
|
328
342
|
});
|
|
329
|
-
this.server.on("error", reject);
|
|
330
343
|
});
|
|
331
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Starts listening on an OS-assigned port on 127.0.0.1 and, when available,
|
|
347
|
+
* the same port on ::1. Resolves with the port.
|
|
348
|
+
*/
|
|
349
|
+
async start() {
|
|
350
|
+
let lastError;
|
|
351
|
+
for (let attempt = 0; attempt < _OAuthCallbackServer.MAX_PORT_ATTEMPTS; attempt++) {
|
|
352
|
+
const v4 = await this.listenOn(_OAuthCallbackServer.IPV4_LOOPBACK, 0);
|
|
353
|
+
const addr = v4.address();
|
|
354
|
+
if (!addr || typeof addr !== "object") {
|
|
355
|
+
v4.close();
|
|
356
|
+
throw new Error("Failed to get server port");
|
|
357
|
+
}
|
|
358
|
+
const port = addr.port;
|
|
359
|
+
try {
|
|
360
|
+
const v6 = await this.listenOn(_OAuthCallbackServer.IPV6_LOOPBACK, port);
|
|
361
|
+
this.servers = [v4, v6];
|
|
362
|
+
return port;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (err?.code === "EADDRINUSE") {
|
|
365
|
+
v4.close();
|
|
366
|
+
lastError = err;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
this.servers = [v4];
|
|
370
|
+
return port;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Could not find a port free on both loopback addresses: ${lastError?.message ?? "unknown error"}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
/** The addresses the callback server is listening on (for diagnostics and tests). */
|
|
378
|
+
boundAddresses() {
|
|
379
|
+
return this.servers.map((s) => s.address()).filter((a) => !!a && typeof a === "object").map((a) => a.address);
|
|
380
|
+
}
|
|
332
381
|
/**
|
|
333
382
|
* Resolve with the authorization code once the browser is redirected back.
|
|
334
383
|
* Pass the `state` that was put into the authorization URL: callbacks whose
|
|
@@ -356,10 +405,10 @@ var OAuthCallbackServer = class {
|
|
|
356
405
|
clearTimeout(this.timeoutHandle);
|
|
357
406
|
this.timeoutHandle = null;
|
|
358
407
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.server = null;
|
|
408
|
+
for (const server of this.servers) {
|
|
409
|
+
server.close();
|
|
362
410
|
}
|
|
411
|
+
this.servers = [];
|
|
363
412
|
}
|
|
364
413
|
};
|
|
365
414
|
|
|
@@ -393,6 +442,43 @@ function resolveScopeMode(explicit) {
|
|
|
393
442
|
if (explicit) return explicit;
|
|
394
443
|
return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
|
|
395
444
|
}
|
|
445
|
+
var GOOGLE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
|
|
446
|
+
var GOOGLE_PERMISSIONS_URL = "https://myaccount.google.com/permissions";
|
|
447
|
+
var REVOKE_TIMEOUT_MS = 1e4;
|
|
448
|
+
async function revokeGoogleGrant(token) {
|
|
449
|
+
if (!token) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
detail: `No Google token was stored for this account, so nothing could be revoked. Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const res = await fetch(GOOGLE_REVOKE_ENDPOINT, {
|
|
457
|
+
method: "POST",
|
|
458
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
459
|
+
body: new URLSearchParams({ token }).toString(),
|
|
460
|
+
signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
|
|
461
|
+
});
|
|
462
|
+
if (res.ok) {
|
|
463
|
+
return { ok: true, detail: "Google revoked the grant: email-mcp no longer has access to this Gmail account." };
|
|
464
|
+
}
|
|
465
|
+
let reason = "";
|
|
466
|
+
try {
|
|
467
|
+
const body = await res.json();
|
|
468
|
+
reason = [body.error, body.error_description].filter(Boolean).join(": ");
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
detail: `Google refused the revocation (HTTP ${res.status}${reason ? `, ${reason}` : ""}). The grant may already be revoked or expired. Check and remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
|
|
474
|
+
};
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
detail: `Could not reach Google's revocation endpoint (${err?.message ?? String(err)}). Remove email-mcp manually at ${GOOGLE_PERMISSIONS_URL}.`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
396
482
|
var GmailAuth = class {
|
|
397
483
|
clientId;
|
|
398
484
|
clientSecret;
|
|
@@ -456,23 +542,73 @@ import {
|
|
|
456
542
|
CryptoProvider
|
|
457
543
|
} from "@azure/msal-node";
|
|
458
544
|
var DEFAULT_CACHE_DIR = path2.join(os2.homedir(), ".email-mcp");
|
|
459
|
-
var DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.
|
|
460
|
-
|
|
545
|
+
var DEFAULT_CACHE_FILE = path2.join(DEFAULT_CACHE_DIR, "msal-cache.enc");
|
|
546
|
+
var LEGACY_PLAINTEXT_CACHE_NAME = "msal-cache.json";
|
|
547
|
+
var MsalCacheDecryptError = class extends Error {
|
|
548
|
+
constructor(cacheFilePath) {
|
|
549
|
+
super(
|
|
550
|
+
`The Outlook token cache ${cacheFilePath} could not be decrypted: it was modified, is corrupt, or was written with a different key (EMAIL_MCP_KEY or another machine). Delete that file and re-run email-mcp-setup to sign in to Outlook again.`
|
|
551
|
+
);
|
|
552
|
+
this.name = "MsalCacheDecryptError";
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
function writeEncryptedCache(cacheFilePath, serialized) {
|
|
556
|
+
const dir = path2.dirname(cacheFilePath);
|
|
557
|
+
if (!fs2.existsSync(dir)) {
|
|
558
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
559
|
+
}
|
|
560
|
+
const tmpPath = `${cacheFilePath}.${process.pid}.tmp`;
|
|
561
|
+
fs2.writeFileSync(tmpPath, encryptWithMachineKey(serialized), { mode: 384 });
|
|
562
|
+
fs2.chmodSync(tmpPath, 384);
|
|
563
|
+
fs2.renameSync(tmpPath, cacheFilePath);
|
|
564
|
+
}
|
|
565
|
+
function loadSerializedCache(cacheFilePath, legacyPlaintextPath) {
|
|
566
|
+
const hasEncrypted = fs2.existsSync(cacheFilePath);
|
|
567
|
+
if (fs2.existsSync(legacyPlaintextPath)) {
|
|
568
|
+
const plaintextIsNewer = !hasEncrypted || fs2.statSync(legacyPlaintextPath).mtimeMs > fs2.statSync(cacheFilePath).mtimeMs;
|
|
569
|
+
if (plaintextIsNewer) {
|
|
570
|
+
const serialized = fs2.readFileSync(legacyPlaintextPath, "utf-8");
|
|
571
|
+
writeEncryptedCache(cacheFilePath, serialized);
|
|
572
|
+
fs2.unlinkSync(legacyPlaintextPath);
|
|
573
|
+
return serialized;
|
|
574
|
+
}
|
|
575
|
+
fs2.unlinkSync(legacyPlaintextPath);
|
|
576
|
+
}
|
|
577
|
+
if (!hasEncrypted) return null;
|
|
578
|
+
const raw = fs2.readFileSync(cacheFilePath, "utf-8");
|
|
579
|
+
let plaintext;
|
|
580
|
+
let usedLegacyKey;
|
|
581
|
+
try {
|
|
582
|
+
({ plaintext, usedLegacyKey } = decryptWithMachineKey(raw));
|
|
583
|
+
} catch {
|
|
584
|
+
throw new MsalCacheDecryptError(cacheFilePath);
|
|
585
|
+
}
|
|
586
|
+
if (usedLegacyKey) {
|
|
587
|
+
try {
|
|
588
|
+
writeEncryptedCache(cacheFilePath, plaintext);
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return plaintext;
|
|
593
|
+
}
|
|
594
|
+
function createEncryptedFileCachePlugin(cacheFilePath, legacyPlaintextPath = path2.join(path2.dirname(cacheFilePath), LEGACY_PLAINTEXT_CACHE_NAME)) {
|
|
595
|
+
let loadFailed = false;
|
|
461
596
|
return {
|
|
462
597
|
async beforeCacheAccess(context) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
598
|
+
loadFailed = false;
|
|
599
|
+
try {
|
|
600
|
+
const serialized = loadSerializedCache(cacheFilePath, legacyPlaintextPath);
|
|
601
|
+
if (serialized !== null) {
|
|
602
|
+
context.tokenCache.deserialize(serialized);
|
|
603
|
+
}
|
|
604
|
+
} catch (err) {
|
|
605
|
+
loadFailed = true;
|
|
606
|
+
throw err;
|
|
466
607
|
}
|
|
467
608
|
},
|
|
468
609
|
async afterCacheAccess(context) {
|
|
469
|
-
if (context.cacheHasChanged)
|
|
470
|
-
|
|
471
|
-
if (!fs2.existsSync(dir)) {
|
|
472
|
-
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
473
|
-
}
|
|
474
|
-
fs2.writeFileSync(cacheFilePath, context.tokenCache.serialize(), { mode: 384 });
|
|
475
|
-
}
|
|
610
|
+
if (loadFailed || !context.cacheHasChanged) return;
|
|
611
|
+
writeEncryptedCache(cacheFilePath, context.tokenCache.serialize());
|
|
476
612
|
}
|
|
477
613
|
};
|
|
478
614
|
}
|
|
@@ -491,7 +627,7 @@ var OutlookAuth = class _OutlookAuth {
|
|
|
491
627
|
authority: _OutlookAuth.AUTHORITY
|
|
492
628
|
},
|
|
493
629
|
cache: {
|
|
494
|
-
cachePlugin:
|
|
630
|
+
cachePlugin: createEncryptedFileCachePlugin(cacheFilePath ?? DEFAULT_CACHE_FILE)
|
|
495
631
|
}
|
|
496
632
|
});
|
|
497
633
|
this.cryptoProvider = new CryptoProvider();
|
|
@@ -545,6 +681,27 @@ var OutlookAuth = class _OutlookAuth {
|
|
|
545
681
|
homeAccountId: result.account?.homeAccountId ?? homeAccountId
|
|
546
682
|
};
|
|
547
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Removes one account (its refresh, access and ID tokens) from the MSAL
|
|
686
|
+
* cache. Matches by home account id, or by username for credentials saved
|
|
687
|
+
* before the home account id was recorded. Returns whether an entry was
|
|
688
|
+
* found and removed.
|
|
689
|
+
*/
|
|
690
|
+
async removeCachedAccount(match) {
|
|
691
|
+
const cache = this.pca.getTokenCache();
|
|
692
|
+
let account = null;
|
|
693
|
+
if (match.homeAccountId) {
|
|
694
|
+
account = await cache.getAccountByHomeId(match.homeAccountId);
|
|
695
|
+
}
|
|
696
|
+
if (!account && match.username) {
|
|
697
|
+
const wanted = match.username.trim().toLowerCase();
|
|
698
|
+
const all = await cache.getAllAccounts();
|
|
699
|
+
account = all.find((a) => (a.username ?? "").trim().toLowerCase() === wanted) ?? null;
|
|
700
|
+
}
|
|
701
|
+
if (!account) return false;
|
|
702
|
+
await cache.removeAccount(account);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
548
705
|
/**
|
|
549
706
|
* @deprecated Use refreshTokenSilent() instead.
|
|
550
707
|
* Kept for backward compatibility with credentials that have a stored refresh token.
|
|
@@ -735,6 +892,53 @@ function buildGmailQuery(query) {
|
|
|
735
892
|
return parts.join(" ");
|
|
736
893
|
}
|
|
737
894
|
|
|
895
|
+
// src/providers/mime.ts
|
|
896
|
+
import MailComposer from "nodemailer/lib/mail-composer/index.js";
|
|
897
|
+
import { convert } from "html-to-text";
|
|
898
|
+
function htmlToText(html) {
|
|
899
|
+
return convert(html, {
|
|
900
|
+
wordwrap: false,
|
|
901
|
+
selectors: [
|
|
902
|
+
{ selector: "img", format: "skip" },
|
|
903
|
+
{ selector: "a", options: { hideLinkHrefIfSameAsText: true } }
|
|
904
|
+
]
|
|
905
|
+
}).trim();
|
|
906
|
+
}
|
|
907
|
+
function normalizeBody(body) {
|
|
908
|
+
const html = body.html ? body.html : void 0;
|
|
909
|
+
if (body.text) return { text: body.text, html };
|
|
910
|
+
if (html) return { text: htmlToText(html), html };
|
|
911
|
+
return { text: "" };
|
|
912
|
+
}
|
|
913
|
+
function toAddress(contact) {
|
|
914
|
+
return contact.name ? { name: contact.name, address: contact.email } : contact.email;
|
|
915
|
+
}
|
|
916
|
+
function toAddressList(contacts) {
|
|
917
|
+
return contacts?.length ? contacts.map(toAddress) : void 0;
|
|
918
|
+
}
|
|
919
|
+
async function buildMimeMessage(from, params) {
|
|
920
|
+
const { text, html } = normalizeBody(params.body);
|
|
921
|
+
const mail = {
|
|
922
|
+
from,
|
|
923
|
+
to: toAddressList(params.to),
|
|
924
|
+
cc: toAddressList(params.cc),
|
|
925
|
+
bcc: toAddressList(params.bcc),
|
|
926
|
+
subject: params.subject,
|
|
927
|
+
text,
|
|
928
|
+
html,
|
|
929
|
+
inReplyTo: params.inReplyTo,
|
|
930
|
+
references: params.references?.length ? params.references : void 0,
|
|
931
|
+
attachments: params.attachments?.map((a) => ({
|
|
932
|
+
filename: a.filename,
|
|
933
|
+
content: a.content,
|
|
934
|
+
contentType: a.contentType
|
|
935
|
+
}))
|
|
936
|
+
};
|
|
937
|
+
const node = new MailComposer(mail).compile();
|
|
938
|
+
node.keepBcc = true;
|
|
939
|
+
return node.build();
|
|
940
|
+
}
|
|
941
|
+
|
|
738
942
|
// src/providers/gmail/adapter.ts
|
|
739
943
|
var GmailAdapter = class {
|
|
740
944
|
providerType = ProviderType.Gmail;
|
|
@@ -877,7 +1081,7 @@ var GmailAdapter = class {
|
|
|
877
1081
|
}
|
|
878
1082
|
async sendEmail(params) {
|
|
879
1083
|
const gmail = this.ensureConnected();
|
|
880
|
-
const raw = this.
|
|
1084
|
+
const raw = await this.buildRaw(params);
|
|
881
1085
|
const res = await gmail.users.messages.send({
|
|
882
1086
|
userId: "me",
|
|
883
1087
|
requestBody: { raw }
|
|
@@ -889,7 +1093,7 @@ var GmailAdapter = class {
|
|
|
889
1093
|
}
|
|
890
1094
|
async createDraft(params) {
|
|
891
1095
|
const gmail = this.ensureConnected();
|
|
892
|
-
const raw = this.
|
|
1096
|
+
const raw = await this.buildRaw(params);
|
|
893
1097
|
const res = await gmail.users.drafts.create({
|
|
894
1098
|
userId: "me",
|
|
895
1099
|
requestBody: {
|
|
@@ -900,7 +1104,7 @@ var GmailAdapter = class {
|
|
|
900
1104
|
}
|
|
901
1105
|
async updateDraft(draftId, params) {
|
|
902
1106
|
const gmail = this.ensureConnected();
|
|
903
|
-
const raw = this.
|
|
1107
|
+
const raw = await this.buildRaw(params);
|
|
904
1108
|
const res = await gmail.users.drafts.update({
|
|
905
1109
|
userId: "me",
|
|
906
1110
|
id: draftId,
|
|
@@ -1229,30 +1433,15 @@ var GmailAdapter = class {
|
|
|
1229
1433
|
const gmail = this.ensureConnected();
|
|
1230
1434
|
await gmail.users.settings.filters.delete({ userId: "me", id: ruleId });
|
|
1231
1435
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if (params.bcc?.length) {
|
|
1242
|
-
lines.push(`Bcc: ${params.bcc.map((c) => c.email).join(", ")}`);
|
|
1243
|
-
}
|
|
1244
|
-
lines.push(`Subject: ${params.subject}`);
|
|
1245
|
-
if (params.inReplyTo) {
|
|
1246
|
-
lines.push(`In-Reply-To: ${params.inReplyTo}`);
|
|
1247
|
-
}
|
|
1248
|
-
if (params.references?.length) {
|
|
1249
|
-
lines.push(`References: ${params.references.join(" ")}`);
|
|
1250
|
-
}
|
|
1251
|
-
lines.push("MIME-Version: 1.0");
|
|
1252
|
-
lines.push("Content-Type: text/plain; charset=UTF-8");
|
|
1253
|
-
lines.push("");
|
|
1254
|
-
lines.push(params.body.text || params.body.html || "");
|
|
1255
|
-
return Buffer.from(lines.join("\r\n")).toString("base64url");
|
|
1436
|
+
/**
|
|
1437
|
+
* Builds the base64url `raw` payload Gmail expects for send, draft create and
|
|
1438
|
+
* draft update: a full MIME message with a text part, an HTML part when one
|
|
1439
|
+
* was given (or a text part derived from HTML-only input), encoded headers
|
|
1440
|
+
* and any attachments. See `buildMimeMessage`.
|
|
1441
|
+
*/
|
|
1442
|
+
async buildRaw(params) {
|
|
1443
|
+
const message = await buildMimeMessage(this.email, params);
|
|
1444
|
+
return message.toString("base64url");
|
|
1256
1445
|
}
|
|
1257
1446
|
};
|
|
1258
1447
|
|
|
@@ -1937,14 +2126,15 @@ function createSmtpTransport(email, creds) {
|
|
|
1937
2126
|
});
|
|
1938
2127
|
}
|
|
1939
2128
|
async function sendViaSmtp(transport, from, params) {
|
|
2129
|
+
const { text, html } = normalizeBody(params.body);
|
|
1940
2130
|
const result = await transport.sendMail({
|
|
1941
2131
|
from,
|
|
1942
|
-
to: params.to.map(
|
|
1943
|
-
cc: params.cc?.
|
|
1944
|
-
bcc: params.bcc?.
|
|
2132
|
+
to: params.to.map(toAddress),
|
|
2133
|
+
cc: params.cc?.length ? params.cc.map(toAddress) : void 0,
|
|
2134
|
+
bcc: params.bcc?.length ? params.bcc.map(toAddress) : void 0,
|
|
1945
2135
|
subject: params.subject,
|
|
1946
|
-
text
|
|
1947
|
-
html
|
|
2136
|
+
text,
|
|
2137
|
+
html,
|
|
1948
2138
|
inReplyTo: params.inReplyTo,
|
|
1949
2139
|
references: params.references?.join(" "),
|
|
1950
2140
|
attachments: params.attachments?.map((a) => ({
|
|
@@ -2091,7 +2281,7 @@ var ImapAdapter = class {
|
|
|
2091
2281
|
const result = await client.mailboxCreate(fullPath);
|
|
2092
2282
|
return {
|
|
2093
2283
|
id: result.path,
|
|
2094
|
-
name
|
|
2284
|
+
name,
|
|
2095
2285
|
path: result.path,
|
|
2096
2286
|
type: FolderType.Other
|
|
2097
2287
|
};
|
|
@@ -2329,6 +2519,7 @@ var ImapAdapter = class {
|
|
|
2329
2519
|
const uid = parseInt(id, 10);
|
|
2330
2520
|
const msg = await client.fetchOne(String(uid), { source: true, uid: true, flags: true }, { uid: true });
|
|
2331
2521
|
if (!msg) throw new Error(`Email ${id} not found`);
|
|
2522
|
+
if (!msg.source) throw new Error(`Email ${id} has no source`);
|
|
2332
2523
|
const parsed = await simpleParser(msg.source);
|
|
2333
2524
|
parsed.flags = msg.flags;
|
|
2334
2525
|
return mapParsedEmail(parsed, targetFolder, this.accountId, msg.uid);
|
|
@@ -2353,6 +2544,7 @@ var ImapAdapter = class {
|
|
|
2353
2544
|
if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
|
|
2354
2545
|
const messages = [];
|
|
2355
2546
|
for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
|
|
2547
|
+
if (!msg.source) continue;
|
|
2356
2548
|
const parsed = await simpleParser(msg.source);
|
|
2357
2549
|
parsed.flags = msg.flags;
|
|
2358
2550
|
messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
|
|
@@ -2388,6 +2580,7 @@ var ImapAdapter = class {
|
|
|
2388
2580
|
try {
|
|
2389
2581
|
const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
|
|
2390
2582
|
if (!msg) throw new Error(`Email ${emailId} not found`);
|
|
2583
|
+
if (!msg.source) throw new Error(`Email ${emailId} has no source`);
|
|
2391
2584
|
const parsed = await simpleParser(msg.source);
|
|
2392
2585
|
const attachment = (parsed.attachments || []).find(
|
|
2393
2586
|
(att) => att.contentId === attachmentId || att.filename === attachmentId
|
|
@@ -2412,26 +2605,17 @@ var ImapAdapter = class {
|
|
|
2412
2605
|
const messageId = await sendViaSmtp(transport, this.email, params);
|
|
2413
2606
|
return { id: messageId };
|
|
2414
2607
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
lines.push(`Subject: ${params.subject}`);
|
|
2423
|
-
lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
2424
|
-
if (params.inReplyTo) lines.push(`In-Reply-To: ${params.inReplyTo}`);
|
|
2425
|
-
if (params.references?.length) lines.push(`References: ${params.references.join(" ")}`);
|
|
2426
|
-
lines.push("MIME-Version: 1.0");
|
|
2427
|
-
lines.push("Content-Type: text/plain; charset=utf-8");
|
|
2428
|
-
lines.push("");
|
|
2429
|
-
lines.push(params.body.text || "");
|
|
2430
|
-
return lines.join("\r\n");
|
|
2608
|
+
/**
|
|
2609
|
+
* Builds the full MIME message a draft is stored as: text and HTML parts,
|
|
2610
|
+
* Bcc kept so the draft remembers its recipients, encoded headers and any
|
|
2611
|
+
* attachments. See `buildMimeMessage`.
|
|
2612
|
+
*/
|
|
2613
|
+
buildDraftMessage(params) {
|
|
2614
|
+
return buildMimeMessage(this.email, params);
|
|
2431
2615
|
}
|
|
2432
2616
|
async createDraft(params) {
|
|
2433
2617
|
const client = await this.ensureConnected();
|
|
2434
|
-
const rawMessage = this.
|
|
2618
|
+
const rawMessage = await this.buildDraftMessage(params);
|
|
2435
2619
|
const draftsFolder = await this.resolveFolder("Drafts");
|
|
2436
2620
|
const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
|
|
2437
2621
|
if (!result) throw new Error("Failed to append draft");
|
|
@@ -2446,6 +2630,7 @@ var ImapAdapter = class {
|
|
|
2446
2630
|
async updateDraft(draftId, params, sourceFolder) {
|
|
2447
2631
|
const client = await this.ensureConnected();
|
|
2448
2632
|
const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
|
|
2633
|
+
const rawMessage = await this.buildDraftMessage(params);
|
|
2449
2634
|
let lock;
|
|
2450
2635
|
try {
|
|
2451
2636
|
lock = await client.getMailboxLock(draftsFolder);
|
|
@@ -2457,7 +2642,6 @@ var ImapAdapter = class {
|
|
|
2457
2642
|
} finally {
|
|
2458
2643
|
lock.release();
|
|
2459
2644
|
}
|
|
2460
|
-
const rawMessage = this.buildDraftRfc2822(params);
|
|
2461
2645
|
const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
|
|
2462
2646
|
if (!result) throw new Error("Failed to append updated draft");
|
|
2463
2647
|
return { id: String(result.uid || result) };
|
|
@@ -2687,16 +2871,16 @@ var ImapAdapter = class {
|
|
|
2687
2871
|
var ICloudAdapter = class extends ImapAdapter {
|
|
2688
2872
|
providerType = ProviderType.ICloud;
|
|
2689
2873
|
async connect(credentials) {
|
|
2874
|
+
const passwordDefaults = {
|
|
2875
|
+
host: "imap.mail.me.com",
|
|
2876
|
+
port: 993,
|
|
2877
|
+
tls: true,
|
|
2878
|
+
smtpHost: "smtp.mail.me.com",
|
|
2879
|
+
smtpPort: 587
|
|
2880
|
+
};
|
|
2690
2881
|
const withDefaults = {
|
|
2691
2882
|
...credentials,
|
|
2692
|
-
password: {
|
|
2693
|
-
host: "imap.mail.me.com",
|
|
2694
|
-
port: 993,
|
|
2695
|
-
tls: true,
|
|
2696
|
-
smtpHost: "smtp.mail.me.com",
|
|
2697
|
-
smtpPort: 587,
|
|
2698
|
-
...credentials.password
|
|
2699
|
-
}
|
|
2883
|
+
password: { ...passwordDefaults, ...credentials.password }
|
|
2700
2884
|
};
|
|
2701
2885
|
return super.connect(withDefaults);
|
|
2702
2886
|
}
|
|
@@ -2722,6 +2906,7 @@ function createProvider(provider) {
|
|
|
2722
2906
|
throw new Error(`Unknown provider: ${provider}`);
|
|
2723
2907
|
}
|
|
2724
2908
|
}
|
|
2909
|
+
var MICROSOFT_CONSENT_URL = "https://account.live.com/consent/Manage";
|
|
2725
2910
|
var AccountManager = class {
|
|
2726
2911
|
store;
|
|
2727
2912
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -2776,9 +2961,55 @@ var AccountManager = class {
|
|
|
2776
2961
|
await this.store.save(creds);
|
|
2777
2962
|
await this.connectAccount(creds.id);
|
|
2778
2963
|
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Removes an account locally, then cleans up at the provider as far as the
|
|
2966
|
+
* provider allows. Local removal always happens first and never depends on
|
|
2967
|
+
* the network; a failed provider step is reported in the result, not thrown.
|
|
2968
|
+
*/
|
|
2779
2969
|
async removeAccount(accountId) {
|
|
2970
|
+
const creds = await this.store.get(accountId);
|
|
2971
|
+
if (!creds) throw new Error(`Account ${accountId} not found`);
|
|
2780
2972
|
await this.disconnectAccount(accountId);
|
|
2781
2973
|
await this.store.remove(accountId);
|
|
2974
|
+
const result = {
|
|
2975
|
+
success: true,
|
|
2976
|
+
accountId,
|
|
2977
|
+
provider: creds.provider,
|
|
2978
|
+
email: creds.email,
|
|
2979
|
+
revocation: { status: "not_applicable", detail: "" }
|
|
2980
|
+
};
|
|
2981
|
+
if (creds.provider === ProviderType.Gmail) {
|
|
2982
|
+
const token = creds.oauth?.refresh_token || creds.oauth?.access_token || "";
|
|
2983
|
+
const { ok, detail } = await revokeGoogleGrant(token);
|
|
2984
|
+
result.revocation = { status: ok ? "revoked" : "failed", detail };
|
|
2985
|
+
} else if (creds.provider === ProviderType.Outlook) {
|
|
2986
|
+
result.tokenCache = await this.removeFromMsalCache(creds);
|
|
2987
|
+
result.revocation = {
|
|
2988
|
+
status: "local_only",
|
|
2989
|
+
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}.`
|
|
2990
|
+
};
|
|
2991
|
+
} else {
|
|
2992
|
+
result.revocation = {
|
|
2993
|
+
status: "not_applicable",
|
|
2994
|
+
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."
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
return result;
|
|
2998
|
+
}
|
|
2999
|
+
async removeFromMsalCache(creds) {
|
|
3000
|
+
try {
|
|
3001
|
+
const outlookAuth = new OutlookAuth(OUTLOOK_CLIENT_ID);
|
|
3002
|
+
const removed = await outlookAuth.removeCachedAccount({
|
|
3003
|
+
homeAccountId: creds.oauth?.msal_home_account_id,
|
|
3004
|
+
username: creds.email
|
|
3005
|
+
});
|
|
3006
|
+
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." };
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
return {
|
|
3009
|
+
status: "failed",
|
|
3010
|
+
detail: `The account's tokens could not be removed from the local Outlook token cache: ${err?.message ?? String(err)}`
|
|
3011
|
+
};
|
|
3012
|
+
}
|
|
2782
3013
|
}
|
|
2783
3014
|
async disconnectAccount(accountId) {
|
|
2784
3015
|
const provider = this.providers.get(accountId);
|