@marlinjai/email-mcp 1.9.0 → 1.10.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.
@@ -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,53 @@ 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 what the caller does not
1116
+ * name would be lost: a reply draft would fall out of its thread and a
1117
+ * draft's files would be dropped. So when the caller names no thread, the
1118
+ * thread and the reply headers of the stored draft are carried over, and
1119
+ * when the caller names no attachments, the stored draft's files are. A
1120
+ * given list (an empty one included) replaces the files.
1121
+ */
1122
+ async updateDraft(draftId, update) {
1106
1123
  const gmail = this.ensureConnected();
1124
+ let params = update;
1125
+ const keepThread = update.threadId === void 0;
1126
+ const keepFiles = update.attachments === void 0;
1127
+ if (keepThread || keepFiles) {
1128
+ const existing = await gmail.users.drafts.get({
1129
+ userId: "me",
1130
+ id: draftId,
1131
+ // 'metadata' is ids, labels and headers, no body (drafts.get has no
1132
+ // way to ask for single headers, so all of them come back). The parts
1133
+ // that list the files only come with 'full'.
1134
+ format: keepFiles ? "full" : "metadata"
1135
+ });
1136
+ const message = existing.data.message;
1137
+ if (keepThread) {
1138
+ const header = (name) => (message?.payload?.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || void 0;
1139
+ const inReplyTo = update.inReplyTo ?? header("in-reply-to");
1140
+ const references = update.references ?? header("references")?.split(/\s+/).filter(Boolean);
1141
+ if (message?.threadId && inReplyTo) {
1142
+ params = { ...params, threadId: message.threadId, inReplyTo, references: references?.length ? references : [inReplyTo] };
1143
+ }
1144
+ }
1145
+ if (keepFiles && message?.id) {
1146
+ const stored = await this.storedAttachments(message.id, message.payload);
1147
+ if (stored.length) params = { ...params, attachments: stored };
1148
+ }
1149
+ }
1107
1150
  const raw = await this.buildRaw(params);
1108
1151
  const res = await gmail.users.drafts.update({
1109
1152
  userId: "me",
1110
1153
  id: draftId,
1111
1154
  requestBody: {
1112
- message: { raw }
1155
+ message: { raw, ...this.threadFor(params) }
1113
1156
  }
1114
1157
  });
1115
1158
  return { id: res.data.id || draftId };
@@ -1488,6 +1531,51 @@ var GmailAdapter = class {
1488
1531
  }
1489
1532
  await this.deleteBlockRule(ruleId);
1490
1533
  }
1534
+ /**
1535
+ * Gmail files a message into an existing thread only when the threadId
1536
+ * comes with In-Reply-To and References headers and a subject (which has to
1537
+ * match the thread's; the reply tool builds "Re: <original subject>"). A
1538
+ * threadId without them is left out, and the message starts its own thread.
1539
+ */
1540
+ threadFor(params) {
1541
+ const belongs = params.threadId && params.inReplyTo && params.references?.length && params.subject.trim();
1542
+ return belongs ? { threadId: params.threadId } : {};
1543
+ }
1544
+ /**
1545
+ * The files of a stored message, with their bytes: every part that has a
1546
+ * file name, at any depth. Large files are referenced by an attachment id
1547
+ * and fetched; small ones can come inline in the part. A fetch that fails
1548
+ * is not swallowed: a draft update built on it would drop that file.
1549
+ */
1550
+ async storedAttachments(messageId, payload) {
1551
+ const gmail = this.ensureConnected();
1552
+ const files = [];
1553
+ const html = (extractBody(payload).html || "").toLowerCase();
1554
+ const visit = async (part) => {
1555
+ if (!part) return;
1556
+ const partHeader = (name) => (part.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || "";
1557
+ const contentId = partHeader("content-id").trim().replace(/^<|>$/g, "").toLowerCase();
1558
+ const embedded = /^\s*inline/i.test(partHeader("content-disposition")) && contentId !== "" && html.includes(`cid:${contentId}`);
1559
+ if (part.filename && !embedded) {
1560
+ let data = part.body?.data;
1561
+ if (!data && part.body?.attachmentId) {
1562
+ const res = await gmail.users.messages.attachments.get({ userId: "me", messageId, id: part.body.attachmentId });
1563
+ data = res.data.data;
1564
+ }
1565
+ if (data === void 0 || data === null) {
1566
+ throw new Error(`Could not read the draft's attachment ${part.filename}; the draft was not changed.`);
1567
+ }
1568
+ files.push({
1569
+ filename: part.filename,
1570
+ content: Buffer.from(data, "base64url"),
1571
+ contentType: part.mimeType || "application/octet-stream"
1572
+ });
1573
+ }
1574
+ for (const child of part.parts || []) await visit(child);
1575
+ };
1576
+ await visit(payload);
1577
+ return files;
1578
+ }
1491
1579
  /**
1492
1580
  * Builds the base64url `raw` payload Gmail expects for send, draft create and
1493
1581
  * draft update: a full MIME message with a text part, an HTML part when one
@@ -1648,6 +1736,35 @@ function buildGraphFilter(query) {
1648
1736
  }
1649
1737
 
1650
1738
  // src/providers/outlook/adapter.ts
1739
+ var toGraphAttachment = (att) => ({
1740
+ "@odata.type": "#microsoft.graph.fileAttachment",
1741
+ name: att.filename,
1742
+ contentType: att.contentType,
1743
+ contentBytes: att.content.toString("base64")
1744
+ });
1745
+ var OUTLOOK_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
1746
+ var OUTLOOK_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
1747
+ var UPLOAD_SESSION_HINT = "Larger files need an upload session, which email-mcp does not implement for Outlook yet.";
1748
+ function megabytes(bytes) {
1749
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1750
+ }
1751
+ function assertOutlookAttachmentSizes(attachments) {
1752
+ if (!attachments?.length) return;
1753
+ const limit = megabytes(OUTLOOK_MAX_ATTACHMENT_BYTES);
1754
+ for (const att of attachments) {
1755
+ if (att.content.length > OUTLOOK_MAX_ATTACHMENT_BYTES) {
1756
+ throw new Error(`Outlook accepts attachments up to ${limit} through email-mcp; ${att.filename} is ${megabytes(att.content.length)}. ${UPLOAD_SESSION_HINT}`);
1757
+ }
1758
+ }
1759
+ }
1760
+ function assertOutlookRequestSize(body) {
1761
+ const bytes = Buffer.byteLength(JSON.stringify(body));
1762
+ if (bytes > OUTLOOK_MAX_REQUEST_BYTES) {
1763
+ throw new Error(
1764
+ `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}`
1765
+ );
1766
+ }
1767
+ }
1651
1768
  var OutlookAdapter = class _OutlookAdapter {
1652
1769
  providerType = ProviderType.Outlook;
1653
1770
  client = null;
@@ -1787,31 +1904,125 @@ var OutlookAdapter = class _OutlookAdapter {
1787
1904
  meta: mapGraphAttachment(attachment)
1788
1905
  };
1789
1906
  }
1907
+ /**
1908
+ * Graph only threads a reply (conversationId, subject, quoted history)
1909
+ * through the createReply flow: POST /me/messages and /me/sendMail always
1910
+ * start a new conversation whatever In-Reply-To says. Build the reply draft,
1911
+ * overlay our subject, body, recipients and attachments, and return its id.
1912
+ * Note the body replaces Graph's pre-filled quoted history.
1913
+ *
1914
+ * Each attachment goes up in a request of its own, so the sizes are checked
1915
+ * per file, and before createReply so that a file that is too big leaves no
1916
+ * draft behind. A draft that cannot be filled is deleted again. (A draft
1917
+ * whose send failed is kept, see `sendEmail`.)
1918
+ */
1919
+ async createReplyDraft(params, replyToId) {
1920
+ const client = this.ensureClient();
1921
+ assertOutlookAttachmentSizes(params.attachments);
1922
+ const draft = await client.api(`/me/messages/${encodeURIComponent(replyToId)}/createReply`).post({});
1923
+ try {
1924
+ await this.fillReplyDraft(draft.id, params);
1925
+ } catch (error) {
1926
+ throw await this.discardDraft(draft.id, error);
1927
+ }
1928
+ return draft.id;
1929
+ }
1930
+ async fillReplyDraft(draftId, params) {
1931
+ const client = this.ensureClient();
1932
+ const toGraphRecipient = (c) => ({
1933
+ emailAddress: { name: c.name, address: c.email }
1934
+ });
1935
+ const patch = {
1936
+ // Always the caller's subject: Graph pre-fills its own "RE: ..." one.
1937
+ subject: params.subject,
1938
+ body: { contentType: params.body.html ? "html" : "text", content: params.body.html || params.body.text || "" }
1939
+ };
1940
+ if (params.to?.length) patch.toRecipients = params.to.map(toGraphRecipient);
1941
+ if (params.cc !== void 0) patch.ccRecipients = params.cc.map(toGraphRecipient);
1942
+ if (params.bcc !== void 0) patch.bccRecipients = params.bcc.map(toGraphRecipient);
1943
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(patch);
1944
+ for (const att of params.attachments ?? []) {
1945
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post(toGraphAttachment(att));
1946
+ }
1947
+ }
1948
+ /**
1949
+ * Deletes a reply draft that could not be filled, so a failed reply leaves
1950
+ * nothing half-built in Drafts. Returns the error to throw: the original
1951
+ * one, or, when the delete fails as well, the original message with a note
1952
+ * that the draft is still there.
1953
+ */
1954
+ async discardDraft(draftId, cause) {
1955
+ try {
1956
+ await this.ensureClient().api(`/me/messages/${encodeURIComponent(draftId)}`).delete();
1957
+ return cause;
1958
+ } catch (deleteError) {
1959
+ const message = cause instanceof Error ? cause.message : String(cause);
1960
+ return new Error(
1961
+ `${message} (the reply draft ${draftId} could not be removed and was left in Drafts: ${deleteError?.message ?? deleteError})`,
1962
+ { cause }
1963
+ );
1964
+ }
1965
+ }
1790
1966
  async sendEmail(params) {
1791
1967
  const client = this.ensureClient();
1968
+ if (params.replyToGraphId) {
1969
+ const draftId = await this.createReplyDraft(params, params.replyToGraphId);
1970
+ try {
1971
+ await client.api(`/me/messages/${encodeURIComponent(draftId)}/send`).post({});
1972
+ } catch (error) {
1973
+ throw new Error(
1974
+ `${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)`,
1975
+ { cause: error }
1976
+ );
1977
+ }
1978
+ return { id: draftId };
1979
+ }
1980
+ assertOutlookAttachmentSizes(params.attachments);
1792
1981
  const message = this.buildGraphMessage(params);
1793
1982
  const payload = { message };
1794
1983
  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
- }));
1984
+ payload.message.attachments = params.attachments.map(toGraphAttachment);
1801
1985
  }
1986
+ assertOutlookRequestSize(payload);
1802
1987
  await client.api("/me/sendMail").post(payload);
1803
1988
  return { id: `sent-${Date.now()}` };
1804
1989
  }
1805
1990
  async createDraft(params) {
1806
1991
  const client = this.ensureClient();
1992
+ if (params.replyToGraphId) {
1993
+ return { id: await this.createReplyDraft(params, params.replyToGraphId) };
1994
+ }
1995
+ assertOutlookAttachmentSizes(params.attachments);
1807
1996
  const message = this.buildGraphMessage(params);
1997
+ if (params.attachments?.length) {
1998
+ message.attachments = params.attachments.map(toGraphAttachment);
1999
+ }
2000
+ assertOutlookRequestSize(message);
1808
2001
  const result = await client.api("/me/messages").post(message);
1809
2002
  return { id: result.id };
1810
2003
  }
1811
2004
  async updateDraft(draftId, params) {
1812
2005
  const client = this.ensureClient();
2006
+ assertOutlookAttachmentSizes(params.attachments);
1813
2007
  const message = this.buildGraphMessage(params);
1814
2008
  await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(message);
2009
+ if (params.attachments) {
2010
+ const attachmentsPath = `/me/messages/${encodeURIComponent(draftId)}/attachments`;
2011
+ const previousIds = [];
2012
+ let page = await client.api(attachmentsPath).select("id").get();
2013
+ for (; ; ) {
2014
+ previousIds.push(...(page.value || []).map((att) => att.id));
2015
+ const next = page["@odata.nextLink"];
2016
+ if (!next) break;
2017
+ page = await client.api(next).get();
2018
+ }
2019
+ for (const att of params.attachments) {
2020
+ await client.api(attachmentsPath).post(toGraphAttachment(att));
2021
+ }
2022
+ for (const id of previousIds) {
2023
+ await client.api(`${attachmentsPath}/${encodeURIComponent(id)}`).delete();
2024
+ }
2025
+ }
1815
2026
  return { id: draftId };
1816
2027
  }
1817
2028
  async listDrafts(limit, offset) {
@@ -2182,6 +2393,12 @@ function mapImapFolder(imapFolder) {
2182
2393
  unreadCount: imapFolder.status?.unseen
2183
2394
  };
2184
2395
  }
2396
+ function imapAttachmentId(att, index) {
2397
+ return att.contentId || `att-${index}`;
2398
+ }
2399
+ function headerValue(line) {
2400
+ return line.slice(line.indexOf(":") + 1).replace(/\r?\n[ \t]+/g, " ").trim();
2401
+ }
2185
2402
  function mapParsedEmail(parsed, folder, accountId, uid) {
2186
2403
  const mapContact = (addr) => ({
2187
2404
  name: addr.name || void 0,
@@ -2192,7 +2409,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2192
2409
  return addrs.value.map(mapContact);
2193
2410
  };
2194
2411
  const attachments = (parsed.attachments || []).map((att, i) => ({
2195
- id: att.contentId || `att-${i}`,
2412
+ id: imapAttachmentId(att, i),
2196
2413
  filename: att.filename || `attachment-${i}`,
2197
2414
  contentType: att.contentType || "application/octet-stream",
2198
2415
  size: att.size || 0
@@ -2221,7 +2438,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
2221
2438
  flagged: parsed.flags?.has("\\Flagged") || false,
2222
2439
  draft: parsed.flags?.has("\\Draft") || false
2223
2440
  },
2224
- headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, h.line])) : void 0
2441
+ headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, headerValue(h.line)])) : void 0
2225
2442
  };
2226
2443
  }
2227
2444
 
@@ -2618,7 +2835,7 @@ var ImapAdapter = class {
2618
2835
  }
2619
2836
  async getEmail(id, folder) {
2620
2837
  const client = await this.ensureConnected();
2621
- const targetFolder = folder || "INBOX";
2838
+ const targetFolder = folder ? await this.resolveFolder(folder) : "INBOX";
2622
2839
  let lock;
2623
2840
  try {
2624
2841
  lock = await client.getMailboxLock(targetFolder);
@@ -2637,13 +2854,20 @@ var ImapAdapter = class {
2637
2854
  lock.release();
2638
2855
  }
2639
2856
  }
2640
- async getThread(threadId) {
2857
+ /**
2858
+ * IMAP has no thread object: the thread is the set of messages in ONE
2859
+ * folder whose Message-ID, References or In-Reply-To name `threadId`.
2860
+ * Messages of the same conversation in other folders (the own replies in
2861
+ * Sent, for example) are not part of the result.
2862
+ */
2863
+ async getThread(threadId, sourceFolder) {
2641
2864
  const client = await this.ensureConnected();
2865
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2642
2866
  let lock;
2643
2867
  try {
2644
- lock = await client.getMailboxLock("INBOX");
2868
+ lock = await client.getMailboxLock(folder);
2645
2869
  } catch (error) {
2646
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2870
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2647
2871
  }
2648
2872
  try {
2649
2873
  const searchResult = await client.search(
@@ -2651,13 +2875,13 @@ var ImapAdapter = class {
2651
2875
  { uid: true }
2652
2876
  );
2653
2877
  const uids = Array.isArray(searchResult) ? searchResult : [];
2654
- if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
2878
+ if (uids.length === 0) throw new Error(`Thread ${threadId} not found in "${folder}"`);
2655
2879
  const messages = [];
2656
2880
  for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
2657
2881
  if (!msg.source) continue;
2658
2882
  const parsed = await simpleParser(msg.source);
2659
2883
  parsed.flags = msg.flags;
2660
- messages.push(mapParsedEmail(parsed, "INBOX", this.accountId, msg.uid));
2884
+ messages.push(mapParsedEmail(parsed, folder, this.accountId, msg.uid));
2661
2885
  }
2662
2886
  const participantMap = /* @__PURE__ */ new Map();
2663
2887
  for (const msg of messages) {
@@ -2679,27 +2903,29 @@ var ImapAdapter = class {
2679
2903
  lock.release();
2680
2904
  }
2681
2905
  }
2682
- async getAttachment(emailId, attachmentId) {
2906
+ async getAttachment(emailId, attachmentId, sourceFolder) {
2683
2907
  const client = await this.ensureConnected();
2908
+ const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
2684
2909
  let lock;
2685
2910
  try {
2686
- lock = await client.getMailboxLock("INBOX");
2911
+ lock = await client.getMailboxLock(folder);
2687
2912
  } catch (error) {
2688
- throw formatImapError(error, 'Failed to open folder "INBOX"');
2913
+ throw formatImapError(error, `Failed to open folder "${folder}"`);
2689
2914
  }
2690
2915
  try {
2691
2916
  const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
2692
- if (!msg) throw new Error(`Email ${emailId} not found`);
2917
+ if (!msg) throw new Error(`Email ${emailId} not found in "${folder}"`);
2693
2918
  if (!msg.source) throw new Error(`Email ${emailId} has no source`);
2694
2919
  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}`);
2920
+ const attachments = parsed.attachments || [];
2921
+ let index = attachments.findIndex((att, i) => imapAttachmentId(att, i) === attachmentId);
2922
+ if (index === -1) index = attachments.findIndex((att) => att.filename === attachmentId);
2923
+ if (index === -1) throw new Error(`Attachment ${attachmentId} not found in email ${emailId}`);
2924
+ const attachment = attachments[index];
2699
2925
  return {
2700
2926
  data: attachment.content,
2701
2927
  meta: {
2702
- id: attachment.contentId || attachmentId,
2928
+ id: imapAttachmentId(attachment, index),
2703
2929
  filename: attachment.filename || "attachment",
2704
2930
  contentType: attachment.contentType || "application/octet-stream",
2705
2931
  size: attachment.size || 0
@@ -2736,18 +2962,53 @@ var ImapAdapter = class {
2736
2962
  * Drafts folder, and IMAP messages are immutable once appended. This
2737
2963
  * deletes the old revision and appends a new one, so the returned `id`
2738
2964
  * is a NEW uid; the old id no longer resolves after this call.
2965
+ *
2966
+ * A reply draft keeps its place in the thread: when the caller passes no
2967
+ * `inReplyTo`, the In-Reply-To and References headers of the old revision
2968
+ * are copied into the new one. The same goes for files: when the caller
2969
+ * passes no `attachments`, the old revision's attachments are copied over
2970
+ * (a given list, an empty one included, replaces them). Images embedded in
2971
+ * the old HTML body are not files and are not copied: the new body replaces
2972
+ * the old one.
2739
2973
  */
2740
2974
  async updateDraft(draftId, params, sourceFolder) {
2741
2975
  const client = await this.ensureConnected();
2742
2976
  const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
2743
- const rawMessage = await this.buildDraftMessage(params);
2744
2977
  let lock;
2745
2978
  try {
2746
2979
  lock = await client.getMailboxLock(draftsFolder);
2747
2980
  } catch (error) {
2748
2981
  throw formatImapError(error, `Failed to open folder "${draftsFolder}"`);
2749
2982
  }
2983
+ let rawMessage;
2750
2984
  try {
2985
+ let next = params;
2986
+ const keepThread = params.inReplyTo === void 0;
2987
+ const keepFiles = params.attachments === void 0;
2988
+ if (keepThread || keepFiles) {
2989
+ const old = await client.fetchOne(String(draftId), { source: true, uid: true }, { uid: true });
2990
+ if (old && old.source) {
2991
+ const parsed = await simpleParser(old.source);
2992
+ if (keepThread && parsed.inReplyTo) {
2993
+ const references = [].concat(parsed.references ?? []);
2994
+ next = { ...next, inReplyTo: parsed.inReplyTo, references: params.references ?? references };
2995
+ }
2996
+ if (keepFiles) {
2997
+ const html = (parsed.html || "").toLowerCase();
2998
+ const files = (parsed.attachments || []).filter((att) => {
2999
+ if (!att.related) return true;
3000
+ const cid = String(att.cid || att.contentId || "").replace(/^<|>$/g, "").toLowerCase();
3001
+ return !cid || !html.includes(`cid:${cid}`);
3002
+ }).map((att, i) => ({
3003
+ filename: att.filename || `attachment-${i + 1}`,
3004
+ content: att.content,
3005
+ contentType: att.contentType || "application/octet-stream"
3006
+ }));
3007
+ if (files.length) next = { ...next, attachments: files };
3008
+ }
3009
+ }
3010
+ }
3011
+ rawMessage = await this.buildDraftMessage(next);
2751
3012
  await client.messageDelete(draftId, { uid: true });
2752
3013
  } finally {
2753
3014
  lock.release();
@@ -3034,6 +3295,11 @@ var AccountManager = class {
3034
3295
  connected: this.providers.has(c.id)
3035
3296
  }));
3036
3297
  }
3298
+ /** The account's own address, or undefined for an unknown account id. */
3299
+ async getAccountEmail(accountId) {
3300
+ const creds = this.credentials.get(accountId) ?? await this.store.get(accountId);
3301
+ return creds?.email;
3302
+ }
3037
3303
  async getProvider(accountId) {
3038
3304
  const existing = this.providers.get(accountId);
3039
3305
  if (existing) {