@marlinjai/email-mcp 1.9.0 → 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 };
@@ -1488,6 +1519,16 @@ var GmailAdapter = class {
1488
1519
  }
1489
1520
  await this.deleteBlockRule(ruleId);
1490
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
+ }
1491
1532
  /**
1492
1533
  * Builds the base64url `raw` payload Gmail expects for send, draft create and
1493
1534
  * draft update: a full MIME message with a text part, an HTML part when one
@@ -1648,6 +1689,35 @@ function buildGraphFilter(query) {
1648
1689
  }
1649
1690
 
1650
1691
  // src/providers/outlook/adapter.ts
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
+ }
1651
1721
  var OutlookAdapter = class _OutlookAdapter {
1652
1722
  providerType = ProviderType.Outlook;
1653
1723
  client = null;
@@ -1787,31 +1857,125 @@ var OutlookAdapter = class _OutlookAdapter {
1787
1857
  meta: mapGraphAttachment(attachment)
1788
1858
  };
1789
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
+ }
1790
1919
  async sendEmail(params) {
1791
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);
1792
1934
  const message = this.buildGraphMessage(params);
1793
1935
  const payload = { message };
1794
1936
  if (params.attachments?.length) {
1795
- payload.message.attachments = params.attachments.map((att) => ({
1796
- "@odata.type": "#microsoft.graph.fileAttachment",
1797
- name: att.filename,
1798
- contentType: att.contentType,
1799
- contentBytes: att.content.toString("base64")
1800
- }));
1937
+ payload.message.attachments = params.attachments.map(toGraphAttachment);
1801
1938
  }
1939
+ assertOutlookRequestSize(payload);
1802
1940
  await client.api("/me/sendMail").post(payload);
1803
1941
  return { id: `sent-${Date.now()}` };
1804
1942
  }
1805
1943
  async createDraft(params) {
1806
1944
  const client = this.ensureClient();
1945
+ if (params.replyToGraphId) {
1946
+ return { id: await this.createReplyDraft(params, params.replyToGraphId) };
1947
+ }
1948
+ assertOutlookAttachmentSizes(params.attachments);
1807
1949
  const message = this.buildGraphMessage(params);
1950
+ if (params.attachments?.length) {
1951
+ message.attachments = params.attachments.map(toGraphAttachment);
1952
+ }
1953
+ assertOutlookRequestSize(message);
1808
1954
  const result = await client.api("/me/messages").post(message);
1809
1955
  return { id: result.id };
1810
1956
  }
1811
1957
  async updateDraft(draftId, params) {
1812
1958
  const client = this.ensureClient();
1959
+ assertOutlookAttachmentSizes(params.attachments);
1813
1960
  const message = this.buildGraphMessage(params);
1814
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
+ }
1815
1979
  return { id: draftId };
1816
1980
  }
1817
1981
  async listDrafts(limit, offset) {
@@ -2182,6 +2346,12 @@ function mapImapFolder(imapFolder) {
2182
2346
  unreadCount: imapFolder.status?.unseen
2183
2347
  };
2184
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
+ }
2185
2355
  function mapParsedEmail(parsed, folder, accountId, uid) {
2186
2356
  const mapContact = (addr) => ({
2187
2357
  name: addr.name || void 0,
@@ -2192,7 +2362,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2192
2362
  return addrs.value.map(mapContact);
2193
2363
  };
2194
2364
  const attachments = (parsed.attachments || []).map((att, i) => ({
2195
- id: att.contentId || `att-${i}`,
2365
+ id: imapAttachmentId(att, i),
2196
2366
  filename: att.filename || `attachment-${i}`,
2197
2367
  contentType: att.contentType || "application/octet-stream",
2198
2368
  size: att.size || 0
@@ -2221,7 +2391,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2221
2391
  flagged: parsed.flags?.has("\\Flagged") || false,
2222
2392
  draft: parsed.flags?.has("\\Draft") || false
2223
2393
  },
2224
- 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
2225
2395
  };
2226
2396
  }
2227
2397
 
@@ -2618,7 +2788,7 @@ var ImapAdapter = class {
2618
2788
  }
2619
2789
  async getEmail(id, folder) {
2620
2790
  const client = await this.ensureConnected();
2621
- const targetFolder = folder || "INBOX";
2791
+ const targetFolder = folder ? await this.resolveFolder(folder) : "INBOX";
2622
2792
  let lock;
2623
2793
  try {
2624
2794
  lock = await client.getMailboxLock(targetFolder);
@@ -2637,13 +2807,20 @@ var ImapAdapter = class {
2637
2807
  lock.release();
2638
2808
  }
2639
2809
  }
2640
- 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) {
2641
2817
  const client = await this.ensureConnected();
2818
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2642
2819
  let lock;
2643
2820
  try {
2644
- lock = await client.getMailboxLock("INBOX");
2821
+ lock = await client.getMailboxLock(folder);
2645
2822
  } catch (error) {
2646
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2823
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2647
2824
  }
2648
2825
  try {
2649
2826
  const searchResult = await client.search(
@@ -2651,13 +2828,13 @@ var ImapAdapter = class {
2651
2828
  { uid: true }
2652
2829
  );
2653
2830
  const uids = Array.isArray(searchResult) ? searchResult : [];
2654
- 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}"`);
2655
2832
  const messages = [];
2656
2833
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
2657
2834
  if (!msg.source) continue;
2658
2835
  const parsed = await simpleParser(msg.source);
2659
2836
  parsed.flags = msg.flags;
2660
- messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
2837
+ messages.push(mapParsedEmail(parsed, folder, this.accountId, msg.uid));
2661
2838
  }
2662
2839
  const participantMap = /* @__PURE__ */ new Map();
2663
2840
  for (const msg of messages) {
@@ -2679,27 +2856,29 @@ var ImapAdapter = class {
2679
2856
  lock.release();
2680
2857
  }
2681
2858
  }
2682
- async getAttachment(emailId, attachmentId) {
2859
+ async getAttachment(emailId, attachmentId, sourceFolder) {
2683
2860
  const client = await this.ensureConnected();
2861
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2684
2862
  let lock;
2685
2863
  try {
2686
- lock = await client.getMailboxLock("INBOX");
2864
+ lock = await client.getMailboxLock(folder);
2687
2865
  } catch (error) {
2688
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2866
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2689
2867
  }
2690
2868
  try {
2691
2869
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
2692
- if (!msg) throw new Error(`Email ${emailId} not found`);
2870
+ if (!msg) throw new Error(`Email ${emailId} not found in "${folder}"`);
2693
2871
  if (!msg.source) throw new Error(`Email ${emailId} has no source`);
2694
2872
  const parsed = await simpleParser(msg.source);
2695
- const attachment = (parsed.attachments || []).find(
2696
- (att) => att.contentId === attachmentId || att.filename === attachmentId
2697
- );
2698
- 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];
2699
2878
  return {
2700
2879
  data: attachment.content,
2701
2880
  meta: {
2702
- id: attachment.contentId || attachmentId,
2881
+ id: imapAttachmentId(attachment, index),
2703
2882
  filename: attachment.filename || "attachment",
2704
2883
  contentType: attachment.contentType || "application/octet-stream",
2705
2884
  size: attachment.size || 0
@@ -2736,18 +2915,34 @@ var ImapAdapter = class {
2736
2915
  * Drafts folder, and IMAP messages are immutable once appended. This
2737
2916
  * deletes the old revision and appends a new one, so the returned `id`
2738
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.
2739
2922
  */
2740
2923
  async updateDraft(draftId, params, sourceFolder) {
2741
2924
  const client = await this.ensureConnected();
2742
2925
  const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
2743
- const rawMessage = await this.buildDraftMessage(params);
2744
2926
  let lock;
2745
2927
  try {
2746
2928
  lock = await client.getMailboxLock(draftsFolder);
2747
2929
  } catch (error) {
2748
2930
  throw formatImapError(error, `Failed to open folder "${draftsFolder}"`);
2749
2931
  }
2932
+ let rawMessage;
2750
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);
2751
2946
  await client.messageDelete(draftId, { uid: true });
2752
2947
  } finally {
2753
2948
  lock.release();
@@ -3034,6 +3229,11 @@ var AccountManager = class {
3034
3229
  connected: this.providers.has(c.id)
3035
3230
  }));
3036
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
+ }
3037
3237
  async getProvider(accountId) {
3038
3238
  const existing = this.providers.get(accountId);
3039
3239
  if (existing) {