@marlinjai/email-mcp 1.5.0 → 1.7.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
 
4
4
  // src/setup/wizard.ts
5
- import crypto3 from "node:crypto";
5
+ import crypto4 from "node:crypto";
6
6
  import { exec } from "node:child_process";
7
7
 
8
8
  // src/setup/prompts.ts
@@ -276,6 +276,7 @@ var OAuthCallbackServer = class {
276
276
  rejectCode = null;
277
277
  timeoutMs;
278
278
  timeoutHandle = null;
279
+ expectedState = null;
279
280
  constructor(timeoutMs = 12e4) {
280
281
  this.timeoutMs = timeoutMs;
281
282
  }
@@ -286,7 +287,12 @@ var OAuthCallbackServer = class {
286
287
  if (url.pathname === "/callback" || url.pathname === "/") {
287
288
  const code = url.searchParams.get("code");
288
289
  const error = url.searchParams.get("error");
289
- if (code) {
290
+ const state = url.searchParams.get("state");
291
+ if (code && this.expectedState !== null && state !== this.expectedState) {
292
+ res.writeHead(400, { "Content-Type": "text/html" });
293
+ res.end(ERROR_HTML);
294
+ this.rejectCode?.(new Error("OAuth error: state mismatch, callback did not come from the authorization request we started"));
295
+ } else if (code) {
290
296
  res.writeHead(200, { "Content-Type": "text/html" });
291
297
  res.end(SUCCESS_HTML);
292
298
  this.resolveCode?.(code);
@@ -311,7 +317,13 @@ var OAuthCallbackServer = class {
311
317
  this.server.on("error", reject);
312
318
  });
313
319
  }
314
- waitForCode() {
320
+ /**
321
+ * Resolve with the authorization code once the browser is redirected back.
322
+ * Pass the `state` that was put into the authorization URL: callbacks whose
323
+ * state does not match are rejected instead of resolved (CSRF protection).
324
+ */
325
+ waitForCode(expectedState) {
326
+ this.expectedState = expectedState ?? null;
315
327
  return new Promise((resolve, reject) => {
316
328
  this.resolveCode = (code) => {
317
329
  if (this.timeoutHandle) clearTimeout(this.timeoutHandle);
@@ -342,14 +354,33 @@ var OAuthCallbackServer = class {
342
354
  // src/providers/gmail/auth.ts
343
355
  import { OAuth2Client } from "google-auth-library";
344
356
  import crypto2 from "node:crypto";
345
- var GMAIL_SCOPES = [
346
- "https://mail.google.com/",
347
- "https://www.googleapis.com/auth/gmail.modify",
348
- // Mailbox content access (above) does NOT cover the Settings API —
349
- // Google treats them as separate permission domains. Needed for
350
- // users.settings.filters.* (email_create_block_rule/list/delete).
351
- "https://www.googleapis.com/auth/gmail.settings.basic"
352
- ];
357
+ var GMAIL_SCOPES = {
358
+ full: [
359
+ // Gmail's maximum-permission scope. Superset of gmail.modify below;
360
+ // the only capability it adds is immediate, trash-bypassing permanent
361
+ // deletion (messages.delete / batchDelete with permanent: true).
362
+ "https://mail.google.com/",
363
+ "https://www.googleapis.com/auth/gmail.modify",
364
+ // Mailbox content access (above) does NOT cover the Settings API —
365
+ // Google treats them as separate permission domains. Needed for
366
+ // users.settings.filters.* (email_create_block_rule/list/delete).
367
+ "https://www.googleapis.com/auth/gmail.settings.basic"
368
+ ],
369
+ // Drops https://mail.google.com/ so an AI-triggered permanent delete
370
+ // can't happen: gmail.modify already covers every other read/write
371
+ // operation this server performs (read, send, label, archive, trash,
372
+ // drafts, filters). Choosing this mode means deleteEmail(..., permanent:
373
+ // true) and batchDelete(..., permanent: true) will fail with a Gmail API
374
+ // 403 instead of succeeding — everything else behaves identically.
375
+ restricted: [
376
+ "https://www.googleapis.com/auth/gmail.modify",
377
+ "https://www.googleapis.com/auth/gmail.settings.basic"
378
+ ]
379
+ };
380
+ function resolveScopeMode(explicit) {
381
+ if (explicit) return explicit;
382
+ return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
383
+ }
353
384
  var GmailAuth = class {
354
385
  clientId;
355
386
  clientSecret;
@@ -359,18 +390,20 @@ var GmailAuth = class {
359
390
  this.clientSecret = clientSecret;
360
391
  this.oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUri);
361
392
  }
362
- getAuthUrl(redirectUri) {
393
+ getAuthUrl(redirectUri, scopeMode) {
363
394
  const codeVerifier = crypto2.randomBytes(32).toString("base64url");
364
395
  const codeChallenge = crypto2.createHash("sha256").update(codeVerifier).digest("base64url");
396
+ const state = crypto2.randomBytes(16).toString("base64url");
365
397
  this.oauth2Client = new OAuth2Client(this.clientId, this.clientSecret, redirectUri);
366
398
  const url = this.oauth2Client.generateAuthUrl({
367
399
  access_type: "offline",
368
- scope: GMAIL_SCOPES,
400
+ scope: GMAIL_SCOPES[resolveScopeMode(scopeMode)],
369
401
  prompt: "consent",
370
402
  code_challenge: codeChallenge,
371
- code_challenge_method: "S256"
403
+ code_challenge_method: "S256",
404
+ state
372
405
  });
373
- return { url, codeVerifier };
406
+ return { url, codeVerifier, state };
374
407
  }
375
408
  async exchangeCode(code, codeVerifier) {
376
409
  const { tokens } = await this.oauth2Client.getToken({ code, codeVerifier });
@@ -402,6 +435,7 @@ var GmailAuth = class {
402
435
  };
403
436
 
404
437
  // src/providers/outlook/auth.ts
438
+ import crypto3 from "node:crypto";
405
439
  import fs2 from "node:fs";
406
440
  import path2 from "node:path";
407
441
  import os2 from "node:os";
@@ -452,13 +486,15 @@ var OutlookAuth = class _OutlookAuth {
452
486
  }
453
487
  async getAuthUrl(redirectUri) {
454
488
  const { verifier, challenge } = await this.cryptoProvider.generatePkceCodes();
489
+ const state = crypto3.randomBytes(16).toString("base64url");
455
490
  const url = await this.pca.getAuthCodeUrl({
456
491
  scopes: _OutlookAuth.SCOPES,
457
492
  redirectUri,
458
493
  codeChallenge: challenge,
459
- codeChallengeMethod: "S256"
494
+ codeChallengeMethod: "S256",
495
+ state
460
496
  });
461
- return { url, codeVerifier: verifier };
497
+ return { url, codeVerifier: verifier, state };
462
498
  }
463
499
  async exchangeCode(code, codeVerifier, redirectUri) {
464
500
  const result = await this.pca.acquireTokenByCode({
@@ -728,11 +764,12 @@ var GmailAdapter = class {
728
764
  const labels = res.data.labels || [];
729
765
  return labels.map(mapGmailLabel);
730
766
  }
731
- async createFolder(name) {
767
+ async createFolder(name, parentPath) {
732
768
  const gmail = this.ensureConnected();
769
+ const fullName = parentPath ? `${parentPath}/${name}` : name;
733
770
  const res = await gmail.users.labels.create({
734
771
  userId: "me",
735
- requestBody: { name }
772
+ requestBody: { name: fullName }
736
773
  });
737
774
  return mapGmailLabel(res.data);
738
775
  }
@@ -849,6 +886,18 @@ var GmailAdapter = class {
849
886
  });
850
887
  return { id: res.data.id || "" };
851
888
  }
889
+ async updateDraft(draftId, params) {
890
+ const gmail = this.ensureConnected();
891
+ const raw = this.buildRfc2822(params);
892
+ const res = await gmail.users.drafts.update({
893
+ userId: "me",
894
+ id: draftId,
895
+ requestBody: {
896
+ message: { raw }
897
+ }
898
+ });
899
+ return { id: res.data.id || draftId };
900
+ }
852
901
  async listDrafts(limit, _offset) {
853
902
  const gmail = this.ensureConnected();
854
903
  const res = await gmail.users.drafts.list({
@@ -1012,6 +1061,34 @@ var GmailAdapter = class {
1012
1061
  }
1013
1062
  return result;
1014
1063
  }
1064
+ async batchLabel(emailIds, addLabels, removeLabels) {
1065
+ const gmail = this.ensureConnected();
1066
+ const result = { succeeded: [], failed: [] };
1067
+ const addLabelIds = addLabels ?? [];
1068
+ const removeLabelIds = removeLabels ?? [];
1069
+ if (addLabelIds.length === 0 && removeLabelIds.length === 0) {
1070
+ result.succeeded = [...emailIds];
1071
+ return result;
1072
+ }
1073
+ try {
1074
+ await gmail.users.messages.batchModify({
1075
+ userId: "me",
1076
+ requestBody: { ids: emailIds, addLabelIds, removeLabelIds }
1077
+ });
1078
+ result.succeeded = [...emailIds];
1079
+ } catch (error) {
1080
+ for (const id of emailIds) {
1081
+ try {
1082
+ if (addLabelIds.length > 0) await this.addLabels(id, addLabelIds);
1083
+ if (removeLabelIds.length > 0) await this.removeLabels(id, removeLabelIds);
1084
+ result.succeeded.push(id);
1085
+ } catch (e) {
1086
+ result.failed.push({ id, error: e.message });
1087
+ }
1088
+ }
1089
+ }
1090
+ return result;
1091
+ }
1015
1092
  async getRawMessage(emailId, _sourceFolder) {
1016
1093
  const gmail = this.ensureConnected();
1017
1094
  const res = await gmail.users.messages.get({
@@ -1475,6 +1552,12 @@ var OutlookAdapter = class {
1475
1552
  const result = await client.api("/me/messages").post(message);
1476
1553
  return { id: result.id };
1477
1554
  }
1555
+ async updateDraft(draftId, params) {
1556
+ const client = this.ensureClient();
1557
+ const message = this.buildGraphMessage(params);
1558
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(message);
1559
+ return { id: draftId };
1560
+ }
1478
1561
  async listDrafts(limit, offset) {
1479
1562
  const client = this.ensureClient();
1480
1563
  let request = client.api("/me/mailFolders/drafts/messages");
@@ -2317,8 +2400,7 @@ var ImapAdapter = class {
2317
2400
  const messageId = await sendViaSmtp(transport, this.email, params);
2318
2401
  return { id: messageId };
2319
2402
  }
2320
- async createDraft(params) {
2321
- const client = await this.ensureConnected();
2403
+ buildDraftRfc2822(params) {
2322
2404
  const lines = [];
2323
2405
  lines.push(`From: ${this.email}`);
2324
2406
  lines.push(`To: ${params.to.map((c) => c.name ? `"${c.name}" <${c.email}>` : c.email).join(", ")}`);
@@ -2333,12 +2415,41 @@ var ImapAdapter = class {
2333
2415
  lines.push("Content-Type: text/plain; charset=utf-8");
2334
2416
  lines.push("");
2335
2417
  lines.push(params.body.text || "");
2336
- const rawMessage = lines.join("\r\n");
2418
+ return lines.join("\r\n");
2419
+ }
2420
+ async createDraft(params) {
2421
+ const client = await this.ensureConnected();
2422
+ const rawMessage = this.buildDraftRfc2822(params);
2337
2423
  const draftsFolder = await this.resolveFolder("Drafts");
2338
2424
  const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
2339
2425
  if (!result) throw new Error("Failed to append draft");
2340
2426
  return { id: String(result.uid || result) };
2341
2427
  }
2428
+ /**
2429
+ * IMAP has no in-place message update — a draft is just a message in the
2430
+ * Drafts folder, and IMAP messages are immutable once appended. This
2431
+ * deletes the old revision and appends a new one, so the returned `id`
2432
+ * is a NEW uid; the old id no longer resolves after this call.
2433
+ */
2434
+ async updateDraft(draftId, params, sourceFolder) {
2435
+ const client = await this.ensureConnected();
2436
+ const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
2437
+ let lock;
2438
+ try {
2439
+ lock = await client.getMailboxLock(draftsFolder);
2440
+ } catch (error) {
2441
+ throw formatImapError(error, `Failed to open folder "${draftsFolder}"`);
2442
+ }
2443
+ try {
2444
+ await client.messageDelete(draftId, { uid: true });
2445
+ } finally {
2446
+ lock.release();
2447
+ }
2448
+ const rawMessage = this.buildDraftRfc2822(params);
2449
+ const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
2450
+ if (!result) throw new Error("Failed to append updated draft");
2451
+ return { id: String(result.uid || result) };
2452
+ }
2342
2453
  async listDrafts(limit, offset) {
2343
2454
  return this.search({
2344
2455
  folder: "Drafts",
@@ -2580,9 +2691,9 @@ var ICloudAdapter = class extends ImapAdapter {
2580
2691
  };
2581
2692
 
2582
2693
  // src/oauth-config.ts
2583
- var GMAIL_CLIENT_ID = "1062266096576-lu2ukplhc3lcm0bvheml5i18urptdp4t.apps.googleusercontent.com";
2584
- var GMAIL_CLIENT_SECRET = "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";
2585
- var OUTLOOK_CLIENT_ID = "76d32225-ef10-4b49-951c-c80ae906595d";
2694
+ var GMAIL_CLIENT_ID = process.env.EMAIL_MCP_GMAIL_CLIENT_ID || "1062266096576-lu2ukplhc3lcm0bvheml5i18urptdp4t.apps.googleusercontent.com";
2695
+ var GMAIL_CLIENT_SECRET = process.env.EMAIL_MCP_GMAIL_CLIENT_SECRET || "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";
2696
+ var OUTLOOK_CLIENT_ID = process.env.EMAIL_MCP_OUTLOOK_CLIENT_ID || "76d32225-ef10-4b49-951c-c80ae906595d";
2586
2697
 
2587
2698
  // src/account-manager.ts
2588
2699
  function createProvider(provider) {
@@ -2770,14 +2881,30 @@ async function testAndSave(creds) {
2770
2881
  async function promptAccountName(defaultName) {
2771
2882
  return askText("Account name:", defaultName);
2772
2883
  }
2773
- async function setupGmail() {
2884
+ async function setupGmail(scopeModeFlag) {
2774
2885
  console.log("\n--- Gmail Setup ---");
2886
+ if (process.env.EMAIL_MCP_GMAIL_CLIENT_ID) {
2887
+ console.log("Using your own Google OAuth Client ID (EMAIL_MCP_GMAIL_CLIENT_ID).");
2888
+ }
2775
2889
  console.log("A browser window will open for Google authorization.\n");
2890
+ let scopeMode = scopeModeFlag;
2891
+ if (!scopeMode) {
2892
+ scopeMode = await askChoice("Gmail permission scope:", [
2893
+ {
2894
+ name: "Full (default) \u2014 includes permanent delete, bypassing Trash",
2895
+ value: "full"
2896
+ },
2897
+ {
2898
+ name: "Restricted \u2014 read/send/label/archive/trash only, no permanent delete",
2899
+ value: "restricted"
2900
+ }
2901
+ ]);
2902
+ }
2776
2903
  const server = new OAuthCallbackServer();
2777
2904
  const port = await server.start();
2778
2905
  const redirectUri = `http://localhost:${port}/callback`;
2779
2906
  const gmailAuth = new GmailAuth(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, redirectUri);
2780
- const { url, codeVerifier } = gmailAuth.getAuthUrl(redirectUri);
2907
+ const { url, codeVerifier, state } = gmailAuth.getAuthUrl(redirectUri, scopeMode);
2781
2908
  console.log("\nOpening browser for Google authorization...");
2782
2909
  console.log(`If the browser does not open, visit:
2783
2910
  ${url}
@@ -2785,7 +2912,7 @@ ${url}
2785
2912
  openBrowser(url);
2786
2913
  let code;
2787
2914
  try {
2788
- code = await server.waitForCode();
2915
+ code = await server.waitForCode(state);
2789
2916
  } catch (err) {
2790
2917
  server.shutdown();
2791
2918
  throw new Error(`OAuth flow failed: ${err.message}`);
@@ -2801,7 +2928,7 @@ ${url}
2801
2928
  if (!email) throw new Error("Could not retrieve Gmail address from profile");
2802
2929
  console.log(`Authenticated as ${email}`);
2803
2930
  const name = await promptAccountName("Gmail");
2804
- const id = crypto3.randomUUID();
2931
+ const id = crypto4.randomUUID();
2805
2932
  const creds = {
2806
2933
  id,
2807
2934
  name,
@@ -2818,7 +2945,7 @@ async function setupOutlook() {
2818
2945
  const port = await server.start();
2819
2946
  const redirectUri = `http://localhost:${port}`;
2820
2947
  const outlookAuth = new OutlookAuth(OUTLOOK_CLIENT_ID);
2821
- const { url, codeVerifier } = await outlookAuth.getAuthUrl(redirectUri);
2948
+ const { url, codeVerifier, state } = await outlookAuth.getAuthUrl(redirectUri);
2822
2949
  console.log("\nOpening browser for Microsoft authorization...");
2823
2950
  console.log(`If the browser does not open, visit:
2824
2951
  ${url}
@@ -2826,7 +2953,7 @@ ${url}
2826
2953
  openBrowser(url);
2827
2954
  let code;
2828
2955
  try {
2829
- code = await server.waitForCode();
2956
+ code = await server.waitForCode(state);
2830
2957
  } catch (err) {
2831
2958
  server.shutdown();
2832
2959
  throw new Error(`OAuth flow failed: ${err.message}`);
@@ -2839,7 +2966,7 @@ ${url}
2839
2966
  if (!email) throw new Error("Could not retrieve email from Microsoft account");
2840
2967
  console.log(`Authenticated as ${email}`);
2841
2968
  const name = await promptAccountName("Outlook");
2842
- const id = crypto3.randomUUID();
2969
+ const id = crypto4.randomUUID();
2843
2970
  const creds = {
2844
2971
  id,
2845
2972
  name,
@@ -2871,7 +2998,7 @@ async function setupICloud() {
2871
2998
  password = await askPassword("App-specific password:");
2872
2999
  }
2873
3000
  const name = await promptAccountName("iCloud");
2874
- const id = crypto3.randomUUID();
3001
+ const id = crypto4.randomUUID();
2875
3002
  const creds = {
2876
3003
  id,
2877
3004
  name,
@@ -2912,7 +3039,7 @@ async function setupIMAP() {
2912
3039
  smtpPort = parseInt(smtpPortStr, 10);
2913
3040
  }
2914
3041
  const name = await promptAccountName("IMAP");
2915
- const id = crypto3.randomUUID();
3042
+ const id = crypto4.randomUUID();
2916
3043
  const creds = {
2917
3044
  id,
2918
3045
  name,
@@ -2976,6 +3103,17 @@ async function main() {
2976
3103
  await removeAccount(id);
2977
3104
  return;
2978
3105
  }
3106
+ const scopeIdx = args.indexOf("--scope");
3107
+ let scopeModeFlag;
3108
+ if (scopeIdx !== -1) {
3109
+ const value = args[scopeIdx + 1];
3110
+ if (value === "full" || value === "restricted") {
3111
+ scopeModeFlag = value;
3112
+ } else {
3113
+ console.error("Usage: --scope <full|restricted>");
3114
+ process.exit(1);
3115
+ }
3116
+ }
2979
3117
  console.log("@marlinjai/email-mcp Account Setup\n");
2980
3118
  let addMore = true;
2981
3119
  while (addMore) {
@@ -2987,7 +3125,7 @@ async function main() {
2987
3125
  ]);
2988
3126
  switch (provider) {
2989
3127
  case ProviderType.Gmail:
2990
- await setupGmail();
3128
+ await setupGmail(scopeModeFlag);
2991
3129
  break;
2992
3130
  case ProviderType.Outlook:
2993
3131
  await setupOutlook();