@marlinjai/email-mcp 1.8.1 → 1.10.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.
@@ -830,6 +830,14 @@ function extractAttachments(payload) {
830
830
  }
831
831
  return attachments;
832
832
  }
833
+ function threadingHeaders(headers) {
834
+ const picked = {};
835
+ for (const name of ["Message-ID", "In-Reply-To", "References"]) {
836
+ const value = getHeader(headers, name);
837
+ if (value) picked[name.toLowerCase()] = value;
838
+ }
839
+ return Object.keys(picked).length > 0 ? picked : void 0;
840
+ }
833
841
  function mapGmailMessage(message, accountId) {
834
842
  const headers = message.payload?.headers || [];
835
843
  const labelIds = message.labelIds || [];
@@ -864,6 +872,7 @@ function mapGmailMessage(message, accountId) {
864
872
  snippet: message.snippet,
865
873
  attachments,
866
874
  labels: labelIds,
875
+ headers: threadingHeaders(headers),
867
876
  flags: {
868
877
  read: !labelIds.includes("UNREAD"),
869
878
  starred: labelIds.includes("STARRED"),
@@ -1084,7 +1093,7 @@ var GmailAdapter = class {
1084
1093
  const raw = await this.buildRaw(params);
1085
1094
  const res = await gmail.users.messages.send({
1086
1095
  userId: "me",
1087
- requestBody: { raw }
1096
+ requestBody: { raw, ...this.threadFor(params) }
1088
1097
  });
1089
1098
  return {
1090
1099
  id: res.data.id || "",
@@ -1097,19 +1106,41 @@ var GmailAdapter = class {
1097
1106
  const res = await gmail.users.drafts.create({
1098
1107
  userId: "me",
1099
1108
  requestBody: {
1100
- message: { raw }
1109
+ message: { raw, ...this.threadFor(params) }
1101
1110
  }
1102
1111
  });
1103
1112
  return { id: res.data.id || "" };
1104
1113
  }
1105
- async updateDraft(draftId, params) {
1114
+ /**
1115
+ * drafts.update replaces the whole message, so a reply draft would fall out
1116
+ * of its thread. When the caller names no thread, the thread and the reply
1117
+ * headers of the stored draft are carried over into the new message.
1118
+ */
1119
+ async updateDraft(draftId, update) {
1106
1120
  const gmail = this.ensureConnected();
1121
+ let params = update;
1122
+ if (update.threadId === void 0) {
1123
+ const existing = await gmail.users.drafts.get({
1124
+ userId: "me",
1125
+ id: draftId,
1126
+ // Ids, labels and headers, no body. drafts.get has no way to ask for
1127
+ // single headers (messages.get does), so all of them come back.
1128
+ format: "metadata"
1129
+ });
1130
+ const message = existing.data.message;
1131
+ const header = (name) => (message?.payload?.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || void 0;
1132
+ const inReplyTo = update.inReplyTo ?? header("in-reply-to");
1133
+ const references = update.references ?? header("references")?.split(/\s+/).filter(Boolean);
1134
+ if (message?.threadId && inReplyTo) {
1135
+ params = { ...update, threadId: message.threadId, inReplyTo, references: references?.length ? references : [inReplyTo] };
1136
+ }
1137
+ }
1107
1138
  const raw = await this.buildRaw(params);
1108
1139
  const res = await gmail.users.drafts.update({
1109
1140
  userId: "me",
1110
1141
  id: draftId,
1111
1142
  requestBody: {
1112
- message: { raw }
1143
+ message: { raw, ...this.threadFor(params) }
1113
1144
  }
1114
1145
  });
1115
1146
  return { id: res.data.id || draftId };
@@ -1377,23 +1408,36 @@ var GmailAdapter = class {
1377
1408
  requestBody: { addLabelIds: ["SPAM"], removeLabelIds: ["INBOX"] }
1378
1409
  });
1379
1410
  }
1380
- async createBlockRule(rule) {
1381
- const gmail = this.ensureConnected();
1411
+ filterCriteria(matchType, value) {
1382
1412
  const criteria = {};
1383
- switch (rule.matchType) {
1413
+ switch (matchType) {
1384
1414
  case "senderDomain":
1385
- criteria.from = `@${rule.value}`;
1415
+ criteria.from = `@${value}`;
1386
1416
  break;
1387
1417
  case "senderAddress":
1388
- criteria.from = rule.value;
1418
+ criteria.from = value;
1389
1419
  break;
1390
1420
  case "subjectContains":
1391
- criteria.subject = rule.value;
1421
+ criteria.subject = value;
1392
1422
  break;
1393
1423
  case "headerContains":
1394
- criteria.query = rule.value;
1424
+ criteria.query = value;
1395
1425
  break;
1396
1426
  }
1427
+ return criteria;
1428
+ }
1429
+ /** The reverse of filterCriteria, for listing. A best-effort reading of filters made elsewhere. */
1430
+ readCriteria(criteria) {
1431
+ if (typeof criteria?.from === "string" && criteria.from.startsWith("@")) {
1432
+ return { matchType: "senderDomain", value: criteria.from.slice(1) };
1433
+ }
1434
+ if (criteria?.from) return { matchType: "senderAddress", value: criteria.from };
1435
+ if (criteria?.subject) return { matchType: "subjectContains", value: criteria.subject };
1436
+ return { matchType: "headerContains", value: criteria?.query || "" };
1437
+ }
1438
+ async createBlockRule(rule) {
1439
+ const gmail = this.ensureConnected();
1440
+ const criteria = this.filterCriteria(rule.matchType, rule.value);
1397
1441
  const action = rule.action === "delete" ? { addLabelIds: ["TRASH"] } : { removeLabelIds: ["INBOX"] };
1398
1442
  const res = await gmail.users.settings.filters.create({
1399
1443
  userId: "me",
@@ -1404,25 +1448,11 @@ var GmailAdapter = class {
1404
1448
  async listBlockRules() {
1405
1449
  const gmail = this.ensureConnected();
1406
1450
  const res = await gmail.users.settings.filters.list({ userId: "me" });
1407
- return (res.data.filter || []).map((f) => {
1408
- const criteria = f.criteria || {};
1451
+ return (res.data.filter || []).filter((f) => !f.action?.forward).map((f) => {
1409
1452
  const addLabelIds = f.action?.addLabelIds || [];
1410
- let matchType = "headerContains";
1411
- let value = criteria.query || "";
1412
- if (typeof criteria.from === "string" && criteria.from.startsWith("@")) {
1413
- matchType = "senderDomain";
1414
- value = criteria.from.slice(1);
1415
- } else if (criteria.from) {
1416
- matchType = "senderAddress";
1417
- value = criteria.from;
1418
- } else if (criteria.subject) {
1419
- matchType = "subjectContains";
1420
- value = criteria.subject;
1421
- }
1422
1453
  return {
1423
1454
  id: f.id || "",
1424
- matchType,
1425
- value,
1455
+ ...this.readCriteria(f.criteria),
1426
1456
  action: addLabelIds.includes("TRASH") ? "delete" : "moveToJunk",
1427
1457
  createdAt: ""
1428
1458
  // Gmail's filter API does not expose a creation timestamp
@@ -1433,6 +1463,72 @@ var GmailAdapter = class {
1433
1463
  const gmail = this.ensureConnected();
1434
1464
  await gmail.users.settings.filters.delete({ userId: "me", id: ruleId });
1435
1465
  }
1466
+ /**
1467
+ * Gmail only forwards to an address the user has added and confirmed under
1468
+ * Settings, "Forwarding and POP/IMAP". Adding one through the API needs a
1469
+ * scope this app does not request (gmail.settings.sharing), so a missing or
1470
+ * unconfirmed address is answered with the manual step, before Gmail's own
1471
+ * less helpful error.
1472
+ */
1473
+ async createForwardRule(rule) {
1474
+ const gmail = this.ensureConnected();
1475
+ const target = rule.forwardTo.trim().toLowerCase();
1476
+ const addresses = await gmail.users.settings.forwardingAddresses.list({ userId: "me" });
1477
+ const known = (addresses.data.forwardingAddresses || []).find(
1478
+ (a) => (a.forwardingEmail || "").toLowerCase() === target
1479
+ );
1480
+ if (!known) {
1481
+ throw new Error(
1482
+ `${rule.forwardTo} is not a forwarding address of this Gmail account yet. Add it once in Gmail under Settings, "Forwarding and POP/IMAP", "Add a forwarding address", confirm the email Google sends to it, then run this again.`
1483
+ );
1484
+ }
1485
+ if (known.verificationStatus !== "accepted") {
1486
+ throw new Error(
1487
+ `${rule.forwardTo} was added to this Gmail account but not confirmed yet. Open the confirmation email Google sent to that address, confirm it, then run this again.`
1488
+ );
1489
+ }
1490
+ const existing = (await this.listForwardRules()).find(
1491
+ (r) => r.matchType === rule.matchType && r.value.toLowerCase() === rule.value.toLowerCase() && r.forwardTo.toLowerCase() === target
1492
+ );
1493
+ if (existing) return { id: existing.id, alreadyExisted: true };
1494
+ const action = { forward: known.forwardingEmail };
1495
+ if (!rule.keepInInbox) action.removeLabelIds = ["INBOX"];
1496
+ const res = await gmail.users.settings.filters.create({
1497
+ userId: "me",
1498
+ requestBody: { criteria: this.filterCriteria(rule.matchType, rule.value), action }
1499
+ });
1500
+ return { id: res.data.id || "" };
1501
+ }
1502
+ /** Every filter that forwards, including ones made by hand in Gmail. */
1503
+ async listForwardRules() {
1504
+ const gmail = this.ensureConnected();
1505
+ const res = await gmail.users.settings.filters.list({ userId: "me" });
1506
+ return (res.data.filter || []).filter((f) => f.action?.forward).map((f) => ({
1507
+ id: f.id || "",
1508
+ ...this.readCriteria(f.criteria),
1509
+ forwardTo: f.action.forward,
1510
+ keepInInbox: !(f.action.removeLabelIds || []).includes("INBOX"),
1511
+ createdAt: ""
1512
+ // Gmail's filter API does not expose a creation timestamp
1513
+ }));
1514
+ }
1515
+ async deleteForwardRule(ruleId) {
1516
+ const rules = await this.listForwardRules();
1517
+ if (!rules.some((r) => r.id === ruleId)) {
1518
+ throw new Error(`No forwarding rule with id ${ruleId} on this account. Use email_list_forward_rules to see the ids.`);
1519
+ }
1520
+ await this.deleteBlockRule(ruleId);
1521
+ }
1522
+ /**
1523
+ * Gmail files a message into an existing thread only when the threadId
1524
+ * comes with In-Reply-To and References headers and a subject (which has to
1525
+ * match the thread's; the reply tool builds "Re: <original subject>"). A
1526
+ * threadId without them is left out, and the message starts its own thread.
1527
+ */
1528
+ threadFor(params) {
1529
+ const belongs = params.threadId && params.inReplyTo && params.references?.length && params.subject.trim();
1530
+ return belongs ? { threadId: params.threadId } : {};
1531
+ }
1436
1532
  /**
1437
1533
  * Builds the base64url `raw` payload Gmail expects for send, draft create and
1438
1534
  * draft update: a full MIME message with a text part, an HTML part when one
@@ -1593,7 +1689,36 @@ function buildGraphFilter(query) {
1593
1689
  }
1594
1690
 
1595
1691
  // src/providers/outlook/adapter.ts
1596
- var OutlookAdapter = class {
1692
+ var toGraphAttachment = (att) => ({
1693
+ "@odata.type": "#microsoft.graph.fileAttachment",
1694
+ name: att.filename,
1695
+ contentType: att.contentType,
1696
+ contentBytes: att.content.toString("base64")
1697
+ });
1698
+ var OUTLOOK_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
1699
+ var OUTLOOK_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
1700
+ var UPLOAD_SESSION_HINT = "Larger files need an upload session, which email-mcp does not implement for Outlook yet.";
1701
+ function megabytes(bytes) {
1702
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1703
+ }
1704
+ function assertOutlookAttachmentSizes(attachments) {
1705
+ if (!attachments?.length) return;
1706
+ const limit = megabytes(OUTLOOK_MAX_ATTACHMENT_BYTES);
1707
+ for (const att of attachments) {
1708
+ if (att.content.length > OUTLOOK_MAX_ATTACHMENT_BYTES) {
1709
+ throw new Error(`Outlook accepts attachments up to ${limit} through email-mcp; ${att.filename} is ${megabytes(att.content.length)}. ${UPLOAD_SESSION_HINT}`);
1710
+ }
1711
+ }
1712
+ }
1713
+ function assertOutlookRequestSize(body) {
1714
+ const bytes = Buffer.byteLength(JSON.stringify(body));
1715
+ if (bytes > OUTLOOK_MAX_REQUEST_BYTES) {
1716
+ throw new Error(
1717
+ `Outlook accepts requests up to ${megabytes(OUTLOOK_MAX_REQUEST_BYTES)} through email-mcp; this message is ${megabytes(bytes)} once its attachments are base64-encoded. ${UPLOAD_SESSION_HINT}`
1718
+ );
1719
+ }
1720
+ }
1721
+ var OutlookAdapter = class _OutlookAdapter {
1597
1722
  providerType = ProviderType.Outlook;
1598
1723
  client = null;
1599
1724
  accountId = "";
@@ -1732,31 +1857,125 @@ var OutlookAdapter = class {
1732
1857
  meta: mapGraphAttachment(attachment)
1733
1858
  };
1734
1859
  }
1860
+ /**
1861
+ * Graph only threads a reply (conversationId, subject, quoted history)
1862
+ * through the createReply flow: POST /me/messages and /me/sendMail always
1863
+ * start a new conversation whatever In-Reply-To says. Build the reply draft,
1864
+ * overlay our subject, body, recipients and attachments, and return its id.
1865
+ * Note the body replaces Graph's pre-filled quoted history.
1866
+ *
1867
+ * Each attachment goes up in a request of its own, so the sizes are checked
1868
+ * per file, and before createReply so that a file that is too big leaves no
1869
+ * draft behind. A draft that cannot be filled is deleted again. (A draft
1870
+ * whose send failed is kept, see `sendEmail`.)
1871
+ */
1872
+ async createReplyDraft(params, replyToId) {
1873
+ const client = this.ensureClient();
1874
+ assertOutlookAttachmentSizes(params.attachments);
1875
+ const draft = await client.api(`/me/messages/${encodeURIComponent(replyToId)}/createReply`).post({});
1876
+ try {
1877
+ await this.fillReplyDraft(draft.id, params);
1878
+ } catch (error) {
1879
+ throw await this.discardDraft(draft.id, error);
1880
+ }
1881
+ return draft.id;
1882
+ }
1883
+ async fillReplyDraft(draftId, params) {
1884
+ const client = this.ensureClient();
1885
+ const toGraphRecipient = (c) => ({
1886
+ emailAddress: { name: c.name, address: c.email }
1887
+ });
1888
+ const patch = {
1889
+ // Always the caller's subject: Graph pre-fills its own "RE: ..." one.
1890
+ subject: params.subject,
1891
+ body: { contentType: params.body.html ? "html" : "text", content: params.body.html || params.body.text || "" }
1892
+ };
1893
+ if (params.to?.length) patch.toRecipients = params.to.map(toGraphRecipient);
1894
+ if (params.cc !== void 0) patch.ccRecipients = params.cc.map(toGraphRecipient);
1895
+ if (params.bcc !== void 0) patch.bccRecipients = params.bcc.map(toGraphRecipient);
1896
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(patch);
1897
+ for (const att of params.attachments ?? []) {
1898
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post(toGraphAttachment(att));
1899
+ }
1900
+ }
1901
+ /**
1902
+ * Deletes a reply draft that could not be filled, so a failed reply leaves
1903
+ * nothing half-built in Drafts. Returns the error to throw: the original
1904
+ * one, or, when the delete fails as well, the original message with a note
1905
+ * that the draft is still there.
1906
+ */
1907
+ async discardDraft(draftId, cause) {
1908
+ try {
1909
+ await this.ensureClient().api(`/me/messages/${encodeURIComponent(draftId)}`).delete();
1910
+ return cause;
1911
+ } catch (deleteError) {
1912
+ const message = cause instanceof Error ? cause.message : String(cause);
1913
+ return new Error(
1914
+ `${message} (the reply draft ${draftId} could not be removed and was left in Drafts: ${deleteError?.message ?? deleteError})`,
1915
+ { cause }
1916
+ );
1917
+ }
1918
+ }
1735
1919
  async sendEmail(params) {
1736
1920
  const client = this.ensureClient();
1921
+ if (params.replyToGraphId) {
1922
+ const draftId = await this.createReplyDraft(params, params.replyToGraphId);
1923
+ try {
1924
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}/send`).post({});
1925
+ } catch (error) {
1926
+ throw new Error(
1927
+ `${error?.message ?? error} (the reply was saved as draft ${draftId} in Drafts and was not confirmed as sent; check Sent Items before sending it again)`,
1928
+ { cause: error }
1929
+ );
1930
+ }
1931
+ return { id: draftId };
1932
+ }
1933
+ assertOutlookAttachmentSizes(params.attachments);
1737
1934
  const message = this.buildGraphMessage(params);
1738
1935
  const payload = { message };
1739
1936
  if (params.attachments?.length) {
1740
- payload.message.attachments = params.attachments.map((att) => ({
1741
- "@odata.type": "#microsoft.graph.fileAttachment",
1742
- name: att.filename,
1743
- contentType: att.contentType,
1744
- contentBytes: att.content.toString("base64")
1745
- }));
1937
+ payload.message.attachments = params.attachments.map(toGraphAttachment);
1746
1938
  }
1939
+ assertOutlookRequestSize(payload);
1747
1940
  await client.api("/me/sendMail").post(payload);
1748
1941
  return { id: `sent-${Date.now()}` };
1749
1942
  }
1750
1943
  async createDraft(params) {
1751
1944
  const client = this.ensureClient();
1945
+ if (params.replyToGraphId) {
1946
+ return { id: await this.createReplyDraft(params, params.replyToGraphId) };
1947
+ }
1948
+ assertOutlookAttachmentSizes(params.attachments);
1752
1949
  const message = this.buildGraphMessage(params);
1950
+ if (params.attachments?.length) {
1951
+ message.attachments = params.attachments.map(toGraphAttachment);
1952
+ }
1953
+ assertOutlookRequestSize(message);
1753
1954
  const result = await client.api("/me/messages").post(message);
1754
1955
  return { id: result.id };
1755
1956
  }
1756
1957
  async updateDraft(draftId, params) {
1757
1958
  const client = this.ensureClient();
1959
+ assertOutlookAttachmentSizes(params.attachments);
1758
1960
  const message = this.buildGraphMessage(params);
1759
1961
  await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(message);
1962
+ if (params.attachments) {
1963
+ const attachmentsPath = `/me/messages/${encodeURIComponent(draftId)}/attachments`;
1964
+ const previousIds = [];
1965
+ let page = await client.api(attachmentsPath).select("id").get();
1966
+ for (; ; ) {
1967
+ previousIds.push(...(page.value || []).map((att) => att.id));
1968
+ const next = page["@odata.nextLink"];
1969
+ if (!next) break;
1970
+ page = await client.api(next).get();
1971
+ }
1972
+ for (const att of params.attachments) {
1973
+ await client.api(attachmentsPath).post(toGraphAttachment(att));
1974
+ }
1975
+ for (const id of previousIds) {
1976
+ await client.api(`${attachmentsPath}/${encodeURIComponent(id)}`).delete();
1977
+ }
1978
+ }
1760
1979
  return { id: draftId };
1761
1980
  }
1762
1981
  async listDrafts(limit, offset) {
@@ -1961,28 +2180,53 @@ var OutlookAdapter = class {
1961
2180
  * before this scope was added need to re-run the setup wizard to
1962
2181
  * re-consent — old tokens will 403 on this call until then.
1963
2182
  */
1964
- async createBlockRule(rule) {
1965
- const client = this.ensureClient();
2183
+ ruleConditions(matchType, value) {
1966
2184
  const conditions = {};
1967
- switch (rule.matchType) {
2185
+ switch (matchType) {
1968
2186
  case "senderDomain":
1969
2187
  case "senderAddress":
1970
- conditions.senderContains = [rule.value];
2188
+ conditions.senderContains = [value];
1971
2189
  break;
1972
2190
  case "subjectContains":
1973
- conditions.subjectContains = [rule.value];
2191
+ conditions.subjectContains = [value];
1974
2192
  break;
1975
2193
  case "headerContains":
1976
- conditions.headerContains = [rule.value];
2194
+ conditions.headerContains = [value];
1977
2195
  break;
1978
2196
  }
2197
+ return conditions;
2198
+ }
2199
+ // Graph doesn't tag which of our four matchTypes a rule came from:
2200
+ // this reconstruction is a best-effort heuristic for listing/auditing,
2201
+ // not a guaranteed round-trip of what was originally requested.
2202
+ readConditions(conditions) {
2203
+ if (conditions?.senderContains?.length) {
2204
+ const value = conditions.senderContains[0];
2205
+ return { matchType: value.includes("@") ? "senderAddress" : "senderDomain", value };
2206
+ }
2207
+ if (conditions?.subjectContains?.length) return { matchType: "subjectContains", value: conditions.subjectContains[0] };
2208
+ if (conditions?.headerContains?.length) return { matchType: "headerContains", value: conditions.headerContains[0] };
2209
+ return { matchType: "headerContains", value: "" };
2210
+ }
2211
+ /** Addresses a rule sends mail on to, whether by "forward" or "redirect". */
2212
+ static forwardTargets(actions) {
2213
+ return [...actions?.forwardTo || [], ...actions?.redirectTo || [], ...actions?.forwardAsAttachmentTo || []].map((r) => r?.emailAddress?.address).filter((a) => typeof a === "string" && a.length > 0);
2214
+ }
2215
+ async listMessageRules() {
2216
+ const client = this.ensureClient();
2217
+ const response = await client.api("/me/mailFolders/inbox/messageRules").get();
2218
+ return response.value || [];
2219
+ }
2220
+ async createBlockRule(rule) {
2221
+ const client = this.ensureClient();
2222
+ const conditions = this.ruleConditions(rule.matchType, rule.value);
1979
2223
  const actions = { stopProcessingRules: true };
1980
2224
  if (rule.action === "delete") {
1981
2225
  actions.delete = true;
1982
2226
  } else {
1983
2227
  actions.moveToFolder = await this.resolveFolder("junkemail");
1984
2228
  }
1985
- const existing = await this.listBlockRules();
2229
+ const existing = await this.listMessageRules();
1986
2230
  const result = await client.api("/me/mailFolders/inbox/messageRules").post({
1987
2231
  displayName: `email-mcp: ${rule.matchType} "${rule.value}"`.slice(0, 255),
1988
2232
  sequence: existing.length + 1,
@@ -1993,36 +2237,66 @@ var OutlookAdapter = class {
1993
2237
  return { id: result.id };
1994
2238
  }
1995
2239
  async listBlockRules() {
1996
- const client = this.ensureClient();
1997
- const response = await client.api("/me/mailFolders/inbox/messageRules").get();
1998
- return (response.value || []).map((r) => {
1999
- const conditions = r.conditions || {};
2000
- let matchType = "headerContains";
2001
- let value = "";
2002
- if (conditions.senderContains?.length) {
2003
- value = conditions.senderContains[0];
2004
- matchType = value.includes("@") ? "senderAddress" : "senderDomain";
2005
- } else if (conditions.subjectContains?.length) {
2006
- matchType = "subjectContains";
2007
- value = conditions.subjectContains[0];
2008
- } else if (conditions.headerContains?.length) {
2009
- matchType = "headerContains";
2010
- value = conditions.headerContains[0];
2011
- }
2012
- return {
2013
- id: r.id,
2014
- matchType,
2015
- value,
2016
- action: r.actions?.delete ? "delete" : "moveToJunk",
2017
- createdAt: ""
2018
- // Graph's messageRule resource has no creation timestamp field
2019
- };
2020
- });
2240
+ return (await this.listMessageRules()).filter((r) => _OutlookAdapter.forwardTargets(r.actions).length === 0).map((r) => ({
2241
+ id: r.id,
2242
+ ...this.readConditions(r.conditions),
2243
+ action: r.actions?.delete ? "delete" : "moveToJunk",
2244
+ createdAt: ""
2245
+ // Graph's messageRule resource has no creation timestamp field
2246
+ }));
2021
2247
  }
2022
2248
  async deleteBlockRule(ruleId) {
2023
2249
  const client = this.ensureClient();
2024
2250
  await client.api(`/me/mailFolders/inbox/messageRules/${encodeURIComponent(ruleId)}`).delete();
2025
2251
  }
2252
+ /**
2253
+ * Outlook forwards to any address without a confirmation step, so the only
2254
+ * check on the target is the allowlist in the tool layer. Needs the same
2255
+ * MailboxSettings.ReadWrite scope as block rules.
2256
+ */
2257
+ async createForwardRule(rule) {
2258
+ const client = this.ensureClient();
2259
+ const forwardTo = rule.forwardTo.trim();
2260
+ const target = forwardTo.toLowerCase();
2261
+ const all = await this.listMessageRules();
2262
+ const existing = all.find((r) => {
2263
+ const read = this.readConditions(r.conditions);
2264
+ const sameMatch = read.value.toLowerCase() === rule.value.toLowerCase() && (read.matchType === rule.matchType || read.matchType.startsWith("sender") && rule.matchType.startsWith("sender"));
2265
+ return sameMatch && _OutlookAdapter.forwardTargets(r.actions).some((a) => a.toLowerCase() === target);
2266
+ });
2267
+ if (existing) return { id: existing.id, alreadyExisted: true };
2268
+ const actions = { forwardTo: [{ emailAddress: { address: forwardTo } }] };
2269
+ if (!rule.keepInInbox) {
2270
+ actions.moveToFolder = await this.resolveFolder("archive");
2271
+ actions.stopProcessingRules = true;
2272
+ }
2273
+ const result = await client.api("/me/mailFolders/inbox/messageRules").post({
2274
+ displayName: `email-mcp: forward ${rule.matchType} "${rule.value}" to ${forwardTo}`.slice(0, 255),
2275
+ sequence: all.length + 1,
2276
+ isEnabled: true,
2277
+ conditions: this.ruleConditions(rule.matchType, rule.value),
2278
+ actions
2279
+ });
2280
+ return { id: result.id };
2281
+ }
2282
+ /** Every inbox rule that forwards or redirects, including ones made by hand in Outlook. */
2283
+ async listForwardRules() {
2284
+ return (await this.listMessageRules()).filter((r) => _OutlookAdapter.forwardTargets(r.actions).length > 0).map((r) => ({
2285
+ id: r.id,
2286
+ ...this.readConditions(r.conditions),
2287
+ forwardTo: _OutlookAdapter.forwardTargets(r.actions).join(", "),
2288
+ keepInInbox: !r.actions?.moveToFolder && !r.actions?.delete,
2289
+ createdAt: ""
2290
+ // Graph's messageRule resource has no creation timestamp field
2291
+ }));
2292
+ }
2293
+ async deleteForwardRule(ruleId) {
2294
+ const rules = await this.listForwardRules();
2295
+ if (!rules.some((r) => r.id === ruleId)) {
2296
+ throw new Error(`No forwarding rule with id ${ruleId} on this account. Use email_list_forward_rules to see the ids.`);
2297
+ }
2298
+ await this.deleteBlockRule(ruleId);
2299
+ }
2026
2300
  async getCategories() {
2027
2301
  const client = this.ensureClient();
2028
2302
  const response = await client.api("/me/outlook/masterCategories").get();
@@ -2072,6 +2346,12 @@ function mapImapFolder(imapFolder) {
2072
2346
  unreadCount: imapFolder.status?.unseen
2073
2347
  };
2074
2348
  }
2349
+ function imapAttachmentId(att, index) {
2350
+ return att.contentId || `att-${index}`;
2351
+ }
2352
+ function headerValue(line) {
2353
+ return line.slice(line.indexOf(":") + 1).replace(/\r?\n[ \t]+/g, " ").trim();
2354
+ }
2075
2355
  function mapParsedEmail(parsed, folder, accountId, uid) {
2076
2356
  const mapContact = (addr) => ({
2077
2357
  name: addr.name || void 0,
@@ -2082,7 +2362,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2082
2362
  return addrs.value.map(mapContact);
2083
2363
  };
2084
2364
  const attachments = (parsed.attachments || []).map((att, i) => ({
2085
- id: att.contentId || `att-${i}`,
2365
+ id: imapAttachmentId(att, i),
2086
2366
  filename: att.filename || `attachment-${i}`,
2087
2367
  contentType: att.contentType || "application/octet-stream",
2088
2368
  size: att.size || 0
@@ -2111,7 +2391,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2111
2391
  flagged: parsed.flags?.has("\\Flagged") || false,
2112
2392
  draft: parsed.flags?.has("\\Draft") || false
2113
2393
  },
2114
- headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, h.line])) : void 0
2394
+ headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, headerValue(h.line)])) : void 0
2115
2395
  };
2116
2396
  }
2117
2397
 
@@ -2508,7 +2788,7 @@ var ImapAdapter = class {
2508
2788
  }
2509
2789
  async getEmail(id, folder) {
2510
2790
  const client = await this.ensureConnected();
2511
- const targetFolder = folder || "INBOX";
2791
+ const targetFolder = folder ? await this.resolveFolder(folder) : "INBOX";
2512
2792
  let lock;
2513
2793
  try {
2514
2794
  lock = await client.getMailboxLock(targetFolder);
@@ -2527,13 +2807,20 @@ var ImapAdapter = class {
2527
2807
  lock.release();
2528
2808
  }
2529
2809
  }
2530
- async getThread(threadId) {
2810
+ /**
2811
+ * IMAP has no thread object: the thread is the set of messages in ONE
2812
+ * folder whose Message-ID, References or In-Reply-To name `threadId`.
2813
+ * Messages of the same conversation in other folders (the own replies in
2814
+ * Sent, for example) are not part of the result.
2815
+ */
2816
+ async getThread(threadId, sourceFolder) {
2531
2817
  const client = await this.ensureConnected();
2818
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2532
2819
  let lock;
2533
2820
  try {
2534
- lock = await client.getMailboxLock("INBOX");
2821
+ lock = await client.getMailboxLock(folder);
2535
2822
  } catch (error) {
2536
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2823
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2537
2824
  }
2538
2825
  try {
2539
2826
  const searchResult = await client.search(
@@ -2541,13 +2828,13 @@ var ImapAdapter = class {
2541
2828
  { uid: true }
2542
2829
  );
2543
2830
  const uids = Array.isArray(searchResult) ? searchResult : [];
2544
- if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
2831
+ if (uids.length === 0) throw new Error(`Thread ${threadId} not found in "${folder}"`);
2545
2832
  const messages = [];
2546
2833
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
2547
2834
  if (!msg.source) continue;
2548
2835
  const parsed = await simpleParser(msg.source);
2549
2836
  parsed.flags = msg.flags;
2550
- messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
2837
+ messages.push(mapParsedEmail(parsed, folder, this.accountId, msg.uid));
2551
2838
  }
2552
2839
  const participantMap = /* @__PURE__ */ new Map();
2553
2840
  for (const msg of messages) {
@@ -2569,27 +2856,29 @@ var ImapAdapter = class {
2569
2856
  lock.release();
2570
2857
  }
2571
2858
  }
2572
- async getAttachment(emailId, attachmentId) {
2859
+ async getAttachment(emailId, attachmentId, sourceFolder) {
2573
2860
  const client = await this.ensureConnected();
2861
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2574
2862
  let lock;
2575
2863
  try {
2576
- lock = await client.getMailboxLock("INBOX");
2864
+ lock = await client.getMailboxLock(folder);
2577
2865
  } catch (error) {
2578
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2866
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2579
2867
  }
2580
2868
  try {
2581
2869
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
2582
- if (!msg) throw new Error(`Email ${emailId} not found`);
2870
+ if (!msg) throw new Error(`Email ${emailId} not found in "${folder}"`);
2583
2871
  if (!msg.source) throw new Error(`Email ${emailId} has no source`);
2584
2872
  const parsed = await simpleParser(msg.source);
2585
- const attachment = (parsed.attachments || []).find(
2586
- (att) => att.contentId === attachmentId || att.filename === attachmentId
2587
- );
2588
- if (!attachment) throw new Error(`Attachment ${attachmentId} not found in email ${emailId}`);
2873
+ const attachments = parsed.attachments || [];
2874
+ let index = attachments.findIndex((att, i) => imapAttachmentId(att, i) === attachmentId);
2875
+ if (index === -1) index = attachments.findIndex((att) => att.filename === attachmentId);
2876
+ if (index === -1) throw new Error(`Attachment ${attachmentId} not found in email ${emailId}`);
2877
+ const attachment = attachments[index];
2589
2878
  return {
2590
2879
  data: attachment.content,
2591
2880
  meta: {
2592
- id: attachment.contentId || attachmentId,
2881
+ id: imapAttachmentId(attachment, index),
2593
2882
  filename: attachment.filename || "attachment",
2594
2883
  contentType: attachment.contentType || "application/octet-stream",
2595
2884
  size: attachment.size || 0
@@ -2626,18 +2915,34 @@ var ImapAdapter = class {
2626
2915
  * Drafts folder, and IMAP messages are immutable once appended. This
2627
2916
  * deletes the old revision and appends a new one, so the returned `id`
2628
2917
  * is a NEW uid; the old id no longer resolves after this call.
2918
+ *
2919
+ * A reply draft keeps its place in the thread: when the caller passes no
2920
+ * `inReplyTo`, the In-Reply-To and References headers of the old revision
2921
+ * are copied into the new one.
2629
2922
  */
2630
2923
  async updateDraft(draftId, params, sourceFolder) {
2631
2924
  const client = await this.ensureConnected();
2632
2925
  const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
2633
- const rawMessage = await this.buildDraftMessage(params);
2634
2926
  let lock;
2635
2927
  try {
2636
2928
  lock = await client.getMailboxLock(draftsFolder);
2637
2929
  } catch (error) {
2638
2930
  throw formatImapError(error, `Failed to open folder "${draftsFolder}"`);
2639
2931
  }
2932
+ let rawMessage;
2640
2933
  try {
2934
+ let next = params;
2935
+ if (params.inReplyTo === void 0) {
2936
+ const old = await client.fetchOne(String(draftId), { source: true, uid: true }, { uid: true });
2937
+ if (old && old.source) {
2938
+ const parsed = await simpleParser(old.source);
2939
+ const references = [].concat(parsed.references ?? []);
2940
+ if (parsed.inReplyTo) {
2941
+ next = { ...params, inReplyTo: parsed.inReplyTo, references: params.references ?? references };
2942
+ }
2943
+ }
2944
+ }
2945
+ rawMessage = await this.buildDraftMessage(next);
2641
2946
  await client.messageDelete(draftId, { uid: true });
2642
2947
  } finally {
2643
2948
  lock.release();
@@ -2924,6 +3229,11 @@ var AccountManager = class {
2924
3229
  connected: this.providers.has(c.id)
2925
3230
  }));
2926
3231
  }
3232
+ /** The account's own address, or undefined for an unknown account id. */
3233
+ async getAccountEmail(accountId) {
3234
+ const creds = this.credentials.get(accountId) ?? await this.store.get(accountId);
3235
+ return creds?.email;
3236
+ }
2927
3237
  async getProvider(accountId) {
2928
3238
  const existing = this.providers.get(accountId);
2929
3239
  if (existing) {
@@ -3390,6 +3700,9 @@ async function main() {
3390
3700
  addMore = await askConfirm("Would you like to add another email account?", false);
3391
3701
  }
3392
3702
  console.log("\nSetup complete! You can now use the email tools in Claude Code.");
3703
+ console.log(
3704
+ "\nemail-mcp is free and open source. Google requires a yearly independent security\naudit (675 US dollars) for the Gmail sign-in it ships. If email-mcp is useful to you,\nyou can help cover it: https://github.com/sponsors/marlinjai"
3705
+ );
3393
3706
  }
3394
3707
  main().catch((err) => {
3395
3708
  console.error(`