@marlinjai/email-mcp 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,11 +62,19 @@ No configuration needed — the setup wizard handles everything using built-in O
62
62
  ```bash
63
63
  npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
64
64
  # Select "Gmail" when prompted
65
+ # Choose "Full" or "Restricted" permission scope when asked
65
66
  # A browser window opens for Google authorization
66
67
  # Grant the requested permissions and return to the terminal
67
68
  ```
68
69
 
69
- > **Note:** If you prefer to use your own OAuth app, create a Desktop OAuth 2.0 Client in the [Google Cloud Console](https://console.cloud.google.com/) with the Gmail API enabled.
70
+ The wizard asks which Gmail permission scope to authorize:
71
+
72
+ - **Full** (default) — everything below, plus immediate, Trash-bypassing permanent deletion (`https://mail.google.com/`, Gmail's maximum-permission scope).
73
+ - **Restricted** — read, send, label, archive, and move-to-trash (`gmail.modify` + `gmail.settings.basic`), but no permanent deletion. Every tool in this server works identically under Restricted except an explicit `permanent: true` delete, which fails with a Gmail API error instead of succeeding.
74
+
75
+ Pass `--scope full` or `--scope restricted` to skip the prompt, or set `EMAIL_MCP_GMAIL_SCOPE=restricted` in the environment the wizard runs in.
76
+
77
+ > **Note:** If you prefer to use your own OAuth app instead of the shared one this package ships with, create a Desktop OAuth 2.0 Client in the [Google Cloud Console](https://console.cloud.google.com/) with the Gmail API enabled, then set `EMAIL_MCP_GMAIL_CLIENT_ID` and `EMAIL_MCP_GMAIL_CLIENT_SECRET` in the environment before running the setup wizard (and in the MCP server's environment, since re-authentication uses the same variables). This gives you your own token lifecycle, independent of the publisher's Cloud project, and sidesteps Google's unverified-app warning and 100-test-user cap for your own account(s) once you add yourself as a test user on your own app.
70
78
 
71
79
  ### Outlook
72
80
 
@@ -79,7 +87,7 @@ npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
79
87
  # Sign in and grant the requested permissions
80
88
  ```
81
89
 
82
- > **Note:** If you prefer to use your own OAuth app, register one in the [Azure Portal](https://portal.azure.com/) with `Mail.ReadWrite`, `Mail.Send`, `MailboxSettings.ReadWrite` (needed for `email_create_block_rule`), and `offline_access` permissions.
90
+ > **Note:** If you prefer to use your own OAuth app, register one in the [Azure Portal](https://portal.azure.com/) with `Mail.ReadWrite`, `Mail.Send`, `MailboxSettings.ReadWrite` (needed for `email_create_block_rule`), and `offline_access` permissions, then set `EMAIL_MCP_OUTLOOK_CLIENT_ID` in the environment before running the setup wizard.
83
91
 
84
92
  ### iCloud
85
93
 
@@ -105,7 +113,7 @@ npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
105
113
  # Optionally enter SMTP host and port for sending
106
114
  ```
107
115
 
108
- ## Available Tools (31)
116
+ ## Available Tools (32)
109
117
 
110
118
  ### Account Management (4)
111
119
 
@@ -127,7 +135,7 @@ npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
127
135
  | `email_get_attachment` | Download a specific attachment by ID (returns base64 data) |
128
136
  | `email_save_attachment` | Download an attachment directly to disk, returning metadata only — avoids the token cost of round-tripping large files as base64. `outputPath` is relative to a fixed downloads directory (`~/.email-mcp/downloads`, override with `EMAIL_MCP_DOWNLOADS_DIR`) and cannot escape it |
129
137
 
130
- ### Sending & Drafts (5)
138
+ ### Sending & Drafts (6)
131
139
 
132
140
  | Tool | Description |
133
141
  |------|-------------|
@@ -135,6 +143,7 @@ npx -y -p @marlinjai/email-mcp@latest email-mcp-setup
135
143
  | `email_reply` | Reply to an email (supports reply-all, preserves threading) |
136
144
  | `email_forward` | Forward an email to new recipients |
137
145
  | `email_draft_create` | Save a draft without sending |
146
+ | `email_draft_update` | Update an existing draft in place. On Gmail/Outlook the draft id is unchanged; on iCloud/generic IMAP there's no in-place update (IMAP messages are immutable), so the old draft is deleted and a new one appended — the returned id is a **new** id, always use it going forward |
138
147
  | `email_draft_list` | List all drafts |
139
148
 
140
149
  ### Organization (8)
package/dist/index.js CHANGED
@@ -7182,11 +7182,12 @@ var init_adapter = __esm({
7182
7182
  const labels = res.data.labels || [];
7183
7183
  return labels.map(mapGmailLabel);
7184
7184
  }
7185
- async createFolder(name) {
7185
+ async createFolder(name, parentPath) {
7186
7186
  const gmail = this.ensureConnected();
7187
+ const fullName = parentPath ? `${parentPath}/${name}` : name;
7187
7188
  const res = await gmail.users.labels.create({
7188
7189
  userId: "me",
7189
- requestBody: { name }
7190
+ requestBody: { name: fullName }
7190
7191
  });
7191
7192
  return mapGmailLabel(res.data);
7192
7193
  }
@@ -7303,6 +7304,18 @@ var init_adapter = __esm({
7303
7304
  });
7304
7305
  return { id: res.data.id || "" };
7305
7306
  }
7307
+ async updateDraft(draftId, params) {
7308
+ const gmail = this.ensureConnected();
7309
+ const raw = this.buildRfc2822(params);
7310
+ const res = await gmail.users.drafts.update({
7311
+ userId: "me",
7312
+ id: draftId,
7313
+ requestBody: {
7314
+ message: { raw }
7315
+ }
7316
+ });
7317
+ return { id: res.data.id || draftId };
7318
+ }
7306
7319
  async listDrafts(limit, _offset) {
7307
7320
  const gmail = this.ensureConnected();
7308
7321
  const res = await gmail.users.drafts.list({
@@ -7466,6 +7479,34 @@ var init_adapter = __esm({
7466
7479
  }
7467
7480
  return result;
7468
7481
  }
7482
+ async batchLabel(emailIds, addLabels, removeLabels) {
7483
+ const gmail = this.ensureConnected();
7484
+ const result = { succeeded: [], failed: [] };
7485
+ const addLabelIds = addLabels ?? [];
7486
+ const removeLabelIds = removeLabels ?? [];
7487
+ if (addLabelIds.length === 0 && removeLabelIds.length === 0) {
7488
+ result.succeeded = [...emailIds];
7489
+ return result;
7490
+ }
7491
+ try {
7492
+ await gmail.users.messages.batchModify({
7493
+ userId: "me",
7494
+ requestBody: { ids: emailIds, addLabelIds, removeLabelIds }
7495
+ });
7496
+ result.succeeded = [...emailIds];
7497
+ } catch (error48) {
7498
+ for (const id of emailIds) {
7499
+ try {
7500
+ if (addLabelIds.length > 0) await this.addLabels(id, addLabelIds);
7501
+ if (removeLabelIds.length > 0) await this.removeLabels(id, removeLabelIds);
7502
+ result.succeeded.push(id);
7503
+ } catch (e) {
7504
+ result.failed.push({ id, error: e.message });
7505
+ }
7506
+ }
7507
+ }
7508
+ return result;
7509
+ }
7469
7510
  async getRawMessage(emailId, _sourceFolder) {
7470
7511
  const gmail = this.ensureConnected();
7471
7512
  const res = await gmail.users.messages.get({
@@ -7626,18 +7667,37 @@ var init_adapter = __esm({
7626
7667
  // src/providers/gmail/auth.ts
7627
7668
  import { OAuth2Client as OAuth2Client2 } from "google-auth-library";
7628
7669
  import crypto2 from "node:crypto";
7670
+ function resolveScopeMode(explicit) {
7671
+ if (explicit) return explicit;
7672
+ return process.env.EMAIL_MCP_GMAIL_SCOPE?.toLowerCase() === "restricted" ? "restricted" : "full";
7673
+ }
7629
7674
  var GMAIL_SCOPES, GmailAuth;
7630
7675
  var init_auth = __esm({
7631
7676
  "src/providers/gmail/auth.ts"() {
7632
7677
  "use strict";
7633
- GMAIL_SCOPES = [
7634
- "https://mail.google.com/",
7635
- "https://www.googleapis.com/auth/gmail.modify",
7636
- // Mailbox content access (above) does NOT cover the Settings API —
7637
- // Google treats them as separate permission domains. Needed for
7638
- // users.settings.filters.* (email_create_block_rule/list/delete).
7639
- "https://www.googleapis.com/auth/gmail.settings.basic"
7640
- ];
7678
+ GMAIL_SCOPES = {
7679
+ full: [
7680
+ // Gmail's maximum-permission scope. Superset of gmail.modify below;
7681
+ // the only capability it adds is immediate, trash-bypassing permanent
7682
+ // deletion (messages.delete / batchDelete with permanent: true).
7683
+ "https://mail.google.com/",
7684
+ "https://www.googleapis.com/auth/gmail.modify",
7685
+ // Mailbox content access (above) does NOT cover the Settings API —
7686
+ // Google treats them as separate permission domains. Needed for
7687
+ // users.settings.filters.* (email_create_block_rule/list/delete).
7688
+ "https://www.googleapis.com/auth/gmail.settings.basic"
7689
+ ],
7690
+ // Drops https://mail.google.com/ so an AI-triggered permanent delete
7691
+ // can't happen: gmail.modify already covers every other read/write
7692
+ // operation this server performs (read, send, label, archive, trash,
7693
+ // drafts, filters). Choosing this mode means deleteEmail(..., permanent:
7694
+ // true) and batchDelete(..., permanent: true) will fail with a Gmail API
7695
+ // 403 instead of succeeding — everything else behaves identically.
7696
+ restricted: [
7697
+ "https://www.googleapis.com/auth/gmail.modify",
7698
+ "https://www.googleapis.com/auth/gmail.settings.basic"
7699
+ ]
7700
+ };
7641
7701
  GmailAuth = class {
7642
7702
  clientId;
7643
7703
  clientSecret;
@@ -7647,13 +7707,13 @@ var init_auth = __esm({
7647
7707
  this.clientSecret = clientSecret;
7648
7708
  this.oauth2Client = new OAuth2Client2(clientId, clientSecret, redirectUri);
7649
7709
  }
7650
- getAuthUrl(redirectUri) {
7710
+ getAuthUrl(redirectUri, scopeMode) {
7651
7711
  const codeVerifier = crypto2.randomBytes(32).toString("base64url");
7652
7712
  const codeChallenge = crypto2.createHash("sha256").update(codeVerifier).digest("base64url");
7653
7713
  this.oauth2Client = new OAuth2Client2(this.clientId, this.clientSecret, redirectUri);
7654
7714
  const url2 = this.oauth2Client.generateAuthUrl({
7655
7715
  access_type: "offline",
7656
- scope: GMAIL_SCOPES,
7716
+ scope: GMAIL_SCOPES[resolveScopeMode(scopeMode)],
7657
7717
  prompt: "consent",
7658
7718
  code_challenge: codeChallenge,
7659
7719
  code_challenge_method: "S256"
@@ -8010,6 +8070,12 @@ var init_adapter2 = __esm({
8010
8070
  const result = await client.api("/me/messages").post(message);
8011
8071
  return { id: result.id };
8012
8072
  }
8073
+ async updateDraft(draftId, params) {
8074
+ const client = this.ensureClient();
8075
+ const message = this.buildGraphMessage(params);
8076
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(message);
8077
+ return { id: draftId };
8078
+ }
8013
8079
  async listDrafts(limit, offset) {
8014
8080
  const client = this.ensureClient();
8015
8081
  let request = client.api("/me/mailFolders/drafts/messages");
@@ -8992,8 +9058,7 @@ var init_adapter3 = __esm({
8992
9058
  const messageId = await sendViaSmtp(transport, this.email, params);
8993
9059
  return { id: messageId };
8994
9060
  }
8995
- async createDraft(params) {
8996
- const client = await this.ensureConnected();
9061
+ buildDraftRfc2822(params) {
8997
9062
  const lines = [];
8998
9063
  lines.push(`From: ${this.email}`);
8999
9064
  lines.push(`To: ${params.to.map((c) => c.name ? `"${c.name}" <${c.email}>` : c.email).join(", ")}`);
@@ -9008,12 +9073,41 @@ var init_adapter3 = __esm({
9008
9073
  lines.push("Content-Type: text/plain; charset=utf-8");
9009
9074
  lines.push("");
9010
9075
  lines.push(params.body.text || "");
9011
- const rawMessage = lines.join("\r\n");
9076
+ return lines.join("\r\n");
9077
+ }
9078
+ async createDraft(params) {
9079
+ const client = await this.ensureConnected();
9080
+ const rawMessage = this.buildDraftRfc2822(params);
9012
9081
  const draftsFolder = await this.resolveFolder("Drafts");
9013
9082
  const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
9014
9083
  if (!result) throw new Error("Failed to append draft");
9015
9084
  return { id: String(result.uid || result) };
9016
9085
  }
9086
+ /**
9087
+ * IMAP has no in-place message update — a draft is just a message in the
9088
+ * Drafts folder, and IMAP messages are immutable once appended. This
9089
+ * deletes the old revision and appends a new one, so the returned `id`
9090
+ * is a NEW uid; the old id no longer resolves after this call.
9091
+ */
9092
+ async updateDraft(draftId, params, sourceFolder) {
9093
+ const client = await this.ensureConnected();
9094
+ const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
9095
+ let lock;
9096
+ try {
9097
+ lock = await client.getMailboxLock(draftsFolder);
9098
+ } catch (error48) {
9099
+ throw formatImapError(error48, `Failed to open folder "${draftsFolder}"`);
9100
+ }
9101
+ try {
9102
+ await client.messageDelete(draftId, { uid: true });
9103
+ } finally {
9104
+ lock.release();
9105
+ }
9106
+ const rawMessage = this.buildDraftRfc2822(params);
9107
+ const result = await client.append(draftsFolder, rawMessage, ["\\Draft", "\\Seen"]);
9108
+ if (!result) throw new Error("Failed to append updated draft");
9109
+ return { id: String(result.uid || result) };
9110
+ }
9017
9111
  async listDrafts(limit, offset) {
9018
9112
  return this.search({
9019
9113
  folder: "Drafts",
@@ -9269,9 +9363,9 @@ var GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, OUTLOOK_CLIENT_ID;
9269
9363
  var init_oauth_config = __esm({
9270
9364
  "src/oauth-config.ts"() {
9271
9365
  "use strict";
9272
- GMAIL_CLIENT_ID = "1062266096576-lu2ukplhc3lcm0bvheml5i18urptdp4t.apps.googleusercontent.com";
9273
- GMAIL_CLIENT_SECRET = "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";
9274
- OUTLOOK_CLIENT_ID = "76d32225-ef10-4b49-951c-c80ae906595d";
9366
+ GMAIL_CLIENT_ID = process.env.EMAIL_MCP_GMAIL_CLIENT_ID || "1062266096576-lu2ukplhc3lcm0bvheml5i18urptdp4t.apps.googleusercontent.com";
9367
+ GMAIL_CLIENT_SECRET = process.env.EMAIL_MCP_GMAIL_CLIENT_SECRET || "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";
9368
+ OUTLOOK_CLIENT_ID = process.env.EMAIL_MCP_OUTLOOK_CLIENT_ID || "76d32225-ef10-4b49-951c-c80ae906595d";
9275
9369
  }
9276
9370
  });
9277
9371
 
@@ -9677,14 +9771,30 @@ async function testAndSave(creds) {
9677
9771
  async function promptAccountName(defaultName) {
9678
9772
  return askText("Account name:", defaultName);
9679
9773
  }
9680
- async function setupGmail() {
9774
+ async function setupGmail(scopeModeFlag) {
9681
9775
  console.log("\n--- Gmail Setup ---");
9776
+ if (process.env.EMAIL_MCP_GMAIL_CLIENT_ID) {
9777
+ console.log("Using your own Google OAuth Client ID (EMAIL_MCP_GMAIL_CLIENT_ID).");
9778
+ }
9682
9779
  console.log("A browser window will open for Google authorization.\n");
9780
+ let scopeMode = scopeModeFlag;
9781
+ if (!scopeMode) {
9782
+ scopeMode = await askChoice("Gmail permission scope:", [
9783
+ {
9784
+ name: "Full (default) \u2014 includes permanent delete, bypassing Trash",
9785
+ value: "full"
9786
+ },
9787
+ {
9788
+ name: "Restricted \u2014 read/send/label/archive/trash only, no permanent delete",
9789
+ value: "restricted"
9790
+ }
9791
+ ]);
9792
+ }
9683
9793
  const server = new OAuthCallbackServer();
9684
9794
  const port = await server.start();
9685
9795
  const redirectUri = `http://localhost:${port}/callback`;
9686
9796
  const gmailAuth = new GmailAuth(GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, redirectUri);
9687
- const { url: url2, codeVerifier } = gmailAuth.getAuthUrl(redirectUri);
9797
+ const { url: url2, codeVerifier } = gmailAuth.getAuthUrl(redirectUri, scopeMode);
9688
9798
  console.log("\nOpening browser for Google authorization...");
9689
9799
  console.log(`If the browser does not open, visit:
9690
9800
  ${url2}
@@ -9883,6 +9993,17 @@ async function main() {
9883
9993
  await removeAccount(id);
9884
9994
  return;
9885
9995
  }
9996
+ const scopeIdx = args.indexOf("--scope");
9997
+ let scopeModeFlag;
9998
+ if (scopeIdx !== -1) {
9999
+ const value = args[scopeIdx + 1];
10000
+ if (value === "full" || value === "restricted") {
10001
+ scopeModeFlag = value;
10002
+ } else {
10003
+ console.error("Usage: --scope <full|restricted>");
10004
+ process.exit(1);
10005
+ }
10006
+ }
9886
10007
  console.log("@marlinjai/email-mcp Account Setup\n");
9887
10008
  let addMore = true;
9888
10009
  while (addMore) {
@@ -9894,7 +10015,7 @@ async function main() {
9894
10015
  ]);
9895
10016
  switch (provider) {
9896
10017
  case ProviderType.Gmail:
9897
- await setupGmail();
10018
+ await setupGmail(scopeModeFlag);
9898
10019
  break;
9899
10020
  case ProviderType.Outlook:
9900
10021
  await setupOutlook();
@@ -33538,6 +33659,32 @@ function registerSendingTools(server, accountManager) {
33538
33659
  }
33539
33660
  }
33540
33661
  );
33662
+ server.tool(
33663
+ "email_draft_update",
33664
+ "Update an existing email draft in place (to/subject/body). On Gmail and Outlook the draft id is unchanged. On iCloud/generic IMAP there is no in-place update \u2014 the old draft is deleted and a new one appended \u2014 so the returned id is a NEW id and the old one no longer resolves; always use the id this call returns, not the one you passed in.",
33665
+ {
33666
+ accountId: external_exports3.string(),
33667
+ draftId: external_exports3.string(),
33668
+ to: external_exports3.array(ContactSchema),
33669
+ subject: external_exports3.string(),
33670
+ body: BodySchema,
33671
+ sourceFolder: external_exports3.string().optional().describe("Source folder (required for IMAP/iCloud when the draft is not in the default Drafts folder)")
33672
+ },
33673
+ async (args) => {
33674
+ try {
33675
+ const provider = await accountManager.getProvider(args.accountId);
33676
+ const params = {
33677
+ to: args.to,
33678
+ subject: args.subject,
33679
+ body: args.body
33680
+ };
33681
+ const result = await provider.updateDraft(args.draftId, params, args.sourceFolder);
33682
+ return jsonResult3(result);
33683
+ } catch (error48) {
33684
+ return jsonResult3({ error: error48.message });
33685
+ }
33686
+ }
33687
+ );
33541
33688
  server.tool(
33542
33689
  "email_draft_list",
33543
33690
  "List email drafts",
@@ -33725,6 +33872,45 @@ function registerOrganizingTools(server, accountManager) {
33725
33872
  }
33726
33873
  }
33727
33874
  );
33875
+ server.tool(
33876
+ "email_batch_label",
33877
+ "Add or remove labels on multiple emails at once (Gmail only). Much faster than individual email_label calls.",
33878
+ {
33879
+ accountId: external_exports3.string(),
33880
+ emailIds: external_exports3.array(external_exports3.string()).min(1).describe("Array of email IDs to label"),
33881
+ addLabels: external_exports3.array(external_exports3.string()).optional(),
33882
+ removeLabels: external_exports3.array(external_exports3.string()).optional()
33883
+ },
33884
+ async (args) => {
33885
+ try {
33886
+ const provider = await accountManager.getProvider(args.accountId);
33887
+ if (!provider.addLabels || !provider.removeLabels) {
33888
+ return jsonResult4({
33889
+ success: false,
33890
+ error: "email_batch_label is only supported on Gmail accounts",
33891
+ supportedProviders: ["gmail"]
33892
+ });
33893
+ }
33894
+ if (provider.batchLabel) {
33895
+ const result2 = await provider.batchLabel(args.emailIds, args.addLabels, args.removeLabels);
33896
+ return jsonResult4({ success: true, ...result2 });
33897
+ }
33898
+ const result = { succeeded: [], failed: [] };
33899
+ for (const id of args.emailIds) {
33900
+ try {
33901
+ if (args.addLabels && args.addLabels.length > 0) await provider.addLabels(id, args.addLabels);
33902
+ if (args.removeLabels && args.removeLabels.length > 0) await provider.removeLabels(id, args.removeLabels);
33903
+ result.succeeded.push(id);
33904
+ } catch (e) {
33905
+ result.failed.push({ id, error: e.message });
33906
+ }
33907
+ }
33908
+ return jsonResult4({ success: true, ...result });
33909
+ } catch (error48) {
33910
+ return jsonResult4({ success: false, error: error48.message });
33911
+ }
33912
+ }
33913
+ );
33728
33914
  server.tool(
33729
33915
  "email_transfer",
33730
33916
  "Move or copy emails BETWEEN accounts, preserving the original message intact (sender, date, threading) by transferring the raw MIME. Fetches each message from the source account and imports it into the target account. With deleteAfter=true the source copy is sent to trash only after a confirmed import, making this a safe cross-account move.",