@marlinjai/email-mcp 1.10.0 → 1.11.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.
@@ -922,6 +922,17 @@ function normalizeBody(body) {
922
922
  function toAddress(contact) {
923
923
  return contact.name ? { name: contact.name, address: contact.email } : contact.email;
924
924
  }
925
+ var PROVIDER_LABELS = /* @__PURE__ */ new Set(["gmail", "outlook", "icloud", "imap"]);
926
+ function fromAddress(email, displayName, accountName) {
927
+ const own = displayName?.trim();
928
+ if (own) return { name: own, address: email };
929
+ const fallback = accountName?.trim();
930
+ const label = (name) => PROVIDER_LABELS.has(name) || name === email.toLowerCase() || name === email.toLowerCase().split("@")[0];
931
+ if (fallback && !label(fallback.toLowerCase())) {
932
+ return { name: fallback, address: email };
933
+ }
934
+ return email;
935
+ }
925
936
  function toAddressList(contacts) {
926
937
  return contacts?.length ? contacts.map(toAddress) : void 0;
927
938
  }
@@ -954,12 +965,14 @@ var GmailAdapter = class {
954
965
  gmail = null;
955
966
  accountId = "";
956
967
  email = "";
968
+ accountName = "";
957
969
  async connect(credentials) {
958
970
  if (!credentials.oauth) {
959
971
  throw new Error("Gmail adapter requires OAuth credentials");
960
972
  }
961
973
  this.accountId = credentials.id;
962
974
  this.email = credentials.email;
975
+ this.accountName = credentials.name;
963
976
  const auth = new OAuth2Client2();
964
977
  auth.setCredentials({
965
978
  access_token: credentials.oauth.access_token,
@@ -1112,27 +1125,39 @@ var GmailAdapter = class {
1112
1125
  return { id: res.data.id || "" };
1113
1126
  }
1114
1127
  /**
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.
1128
+ * drafts.update replaces the whole message, so what the caller does not
1129
+ * name would be lost: a reply draft would fall out of its thread and a
1130
+ * draft's files would be dropped. So when the caller names no thread, the
1131
+ * thread and the reply headers of the stored draft are carried over, and
1132
+ * when the caller names no attachments, the stored draft's files are. A
1133
+ * given list (an empty one included) replaces the files.
1118
1134
  */
1119
1135
  async updateDraft(draftId, update) {
1120
1136
  const gmail = this.ensureConnected();
1121
1137
  let params = update;
1122
- if (update.threadId === void 0) {
1138
+ const keepThread = update.threadId === void 0;
1139
+ const keepFiles = update.attachments === void 0;
1140
+ if (keepThread || keepFiles) {
1123
1141
  const existing = await gmail.users.drafts.get({
1124
1142
  userId: "me",
1125
1143
  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"
1144
+ // 'metadata' is ids, labels and headers, no body (drafts.get has no
1145
+ // way to ask for single headers, so all of them come back). The parts
1146
+ // that list the files only come with 'full'.
1147
+ format: keepFiles ? "full" : "metadata"
1129
1148
  });
1130
1149
  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] };
1150
+ if (keepThread) {
1151
+ const header = (name) => (message?.payload?.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || void 0;
1152
+ const inReplyTo = update.inReplyTo ?? header("in-reply-to");
1153
+ const references = update.references ?? header("references")?.split(/\s+/).filter(Boolean);
1154
+ if (message?.threadId && inReplyTo) {
1155
+ params = { ...params, threadId: message.threadId, inReplyTo, references: references?.length ? references : [inReplyTo] };
1156
+ }
1157
+ }
1158
+ if (keepFiles && message?.id) {
1159
+ const stored = await this.storedAttachments(message.id, message.payload);
1160
+ if (stored.length) params = { ...params, attachments: stored };
1136
1161
  }
1137
1162
  }
1138
1163
  const raw = await this.buildRaw(params);
@@ -1145,6 +1170,43 @@ var GmailAdapter = class {
1145
1170
  });
1146
1171
  return { id: res.data.id || draftId };
1147
1172
  }
1173
+ /**
1174
+ * The signature Gmail's own editor would add: the one of the send-as
1175
+ * address the From header names (`this.email`, see buildRaw), or the
1176
+ * default send-as when that address is not listed. Read on every call, so
1177
+ * a signature changed in Gmail's settings applies to the next mail.
1178
+ */
1179
+ async getSignature() {
1180
+ const entries = await this.sendAsList();
1181
+ const entry = this.ownSendAs(entries) || entries.find((e) => e.isDefault);
1182
+ if (!entry) throw new Error(`Gmail lists no send-as address for ${this.email}`);
1183
+ const html = (entry.signature || "").trim();
1184
+ return { sendAs: entry.sendAsEmail || this.email, html: html || null };
1185
+ }
1186
+ async sendAsList() {
1187
+ const gmail = this.ensureConnected();
1188
+ const res = await gmail.users.settings.sendAs.list({ userId: "me" });
1189
+ return res.data.sendAs || [];
1190
+ }
1191
+ ownSendAs(entries) {
1192
+ const own = this.email.toLowerCase();
1193
+ return entries.find((e) => (e.sendAsEmail || "").toLowerCase() === own);
1194
+ }
1195
+ /**
1196
+ * The From header: the account's address with the display name Gmail
1197
+ * keeps for it as a send-as address. When the settings cannot be read the
1198
+ * mail still goes out, named after the account (see `fromAddress`); the
1199
+ * signature outcome in the tool result already reports that failure.
1200
+ */
1201
+ async from() {
1202
+ let displayName;
1203
+ try {
1204
+ displayName = this.ownSendAs(await this.sendAsList())?.displayName || void 0;
1205
+ } catch {
1206
+ displayName = void 0;
1207
+ }
1208
+ return fromAddress(this.email, displayName, this.accountName);
1209
+ }
1148
1210
  async listDrafts(limit, _offset) {
1149
1211
  const gmail = this.ensureConnected();
1150
1212
  const res = await gmail.users.drafts.list({
@@ -1529,6 +1591,41 @@ var GmailAdapter = class {
1529
1591
  const belongs = params.threadId && params.inReplyTo && params.references?.length && params.subject.trim();
1530
1592
  return belongs ? { threadId: params.threadId } : {};
1531
1593
  }
1594
+ /**
1595
+ * The files of a stored message, with their bytes: every part that has a
1596
+ * file name, at any depth. Large files are referenced by an attachment id
1597
+ * and fetched; small ones can come inline in the part. A fetch that fails
1598
+ * is not swallowed: a draft update built on it would drop that file.
1599
+ */
1600
+ async storedAttachments(messageId, payload) {
1601
+ const gmail = this.ensureConnected();
1602
+ const files = [];
1603
+ const html = (extractBody(payload).html || "").toLowerCase();
1604
+ const visit = async (part) => {
1605
+ if (!part) return;
1606
+ const partHeader = (name) => (part.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || "";
1607
+ const contentId = partHeader("content-id").trim().replace(/^<|>$/g, "").toLowerCase();
1608
+ const embedded = /^\s*inline/i.test(partHeader("content-disposition")) && contentId !== "" && html.includes(`cid:${contentId}`);
1609
+ if (part.filename && !embedded) {
1610
+ let data = part.body?.data;
1611
+ if (!data && part.body?.attachmentId) {
1612
+ const res = await gmail.users.messages.attachments.get({ userId: "me", messageId, id: part.body.attachmentId });
1613
+ data = res.data.data;
1614
+ }
1615
+ if (data === void 0 || data === null) {
1616
+ throw new Error(`Could not read the draft's attachment ${part.filename}; the draft was not changed.`);
1617
+ }
1618
+ files.push({
1619
+ filename: part.filename,
1620
+ content: Buffer.from(data, "base64url"),
1621
+ contentType: part.mimeType || "application/octet-stream"
1622
+ });
1623
+ }
1624
+ for (const child of part.parts || []) await visit(child);
1625
+ };
1626
+ await visit(payload);
1627
+ return files;
1628
+ }
1532
1629
  /**
1533
1630
  * Builds the base64url `raw` payload Gmail expects for send, draft create and
1534
1631
  * draft update: a full MIME message with a text part, an HTML part when one
@@ -1536,7 +1633,7 @@ var GmailAdapter = class {
1536
1633
  * and any attachments. See `buildMimeMessage`.
1537
1634
  */
1538
1635
  async buildRaw(params) {
1539
- const message = await buildMimeMessage(this.email, params);
1636
+ const message = await buildMimeMessage(await this.from(), params);
1540
1637
  return message.toString("base64url");
1541
1638
  }
1542
1639
  };
@@ -2451,6 +2548,7 @@ var ImapAdapter = class {
2451
2548
  client = null;
2452
2549
  accountId = "";
2453
2550
  email = "";
2551
+ accountName = "";
2454
2552
  passwordCreds = null;
2455
2553
  connectingPromise = null;
2456
2554
  async connect(credentials) {
@@ -2459,6 +2557,7 @@ var ImapAdapter = class {
2459
2557
  }
2460
2558
  this.accountId = credentials.id;
2461
2559
  this.email = credentials.email;
2560
+ this.accountName = credentials.name;
2462
2561
  this.passwordCreds = credentials.password;
2463
2562
  await this.ensureConnected();
2464
2563
  }
@@ -2891,7 +2990,7 @@ var ImapAdapter = class {
2891
2990
  async sendEmail(params) {
2892
2991
  if (!this.passwordCreds) throw new Error("No credentials available");
2893
2992
  const transport = createSmtpTransport(this.email, this.passwordCreds);
2894
- const messageId = await sendViaSmtp(transport, this.email, params);
2993
+ const messageId = await sendViaSmtp(transport, this.from(), params);
2895
2994
  return { id: messageId };
2896
2995
  }
2897
2996
  /**
@@ -2900,7 +2999,12 @@ var ImapAdapter = class {
2900
2999
  * attachments. See `buildMimeMessage`.
2901
3000
  */
2902
3001
  buildDraftMessage(params) {
2903
- return buildMimeMessage(this.email, params);
3002
+ return buildMimeMessage(this.from(), params);
3003
+ }
3004
+ /** IMAP keeps no display name on the server, so the account's name is the
3005
+ * only one there is (see `fromAddress`). */
3006
+ from() {
3007
+ return fromAddress(this.email, void 0, this.accountName);
2904
3008
  }
2905
3009
  async createDraft(params) {
2906
3010
  const client = await this.ensureConnected();
@@ -2918,7 +3022,11 @@ var ImapAdapter = class {
2918
3022
  *
2919
3023
  * A reply draft keeps its place in the thread: when the caller passes no
2920
3024
  * `inReplyTo`, the In-Reply-To and References headers of the old revision
2921
- * are copied into the new one.
3025
+ * are copied into the new one. The same goes for files: when the caller
3026
+ * passes no `attachments`, the old revision's attachments are copied over
3027
+ * (a given list, an empty one included, replaces them). Images embedded in
3028
+ * the old HTML body are not files and are not copied: the new body replaces
3029
+ * the old one.
2922
3030
  */
2923
3031
  async updateDraft(draftId, params, sourceFolder) {
2924
3032
  const client = await this.ensureConnected();
@@ -2932,13 +3040,28 @@ var ImapAdapter = class {
2932
3040
  let rawMessage;
2933
3041
  try {
2934
3042
  let next = params;
2935
- if (params.inReplyTo === void 0) {
3043
+ const keepThread = params.inReplyTo === void 0;
3044
+ const keepFiles = params.attachments === void 0;
3045
+ if (keepThread || keepFiles) {
2936
3046
  const old = await client.fetchOne(String(draftId), { source: true, uid: true }, { uid: true });
2937
3047
  if (old && old.source) {
2938
3048
  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 };
3049
+ if (keepThread && parsed.inReplyTo) {
3050
+ const references = [].concat(parsed.references ?? []);
3051
+ next = { ...next, inReplyTo: parsed.inReplyTo, references: params.references ?? references };
3052
+ }
3053
+ if (keepFiles) {
3054
+ const html = (parsed.html || "").toLowerCase();
3055
+ const files = (parsed.attachments || []).filter((att) => {
3056
+ if (!att.related) return true;
3057
+ const cid = String(att.cid || att.contentId || "").replace(/^<|>$/g, "").toLowerCase();
3058
+ return !cid || !html.includes(`cid:${cid}`);
3059
+ }).map((att, i) => ({
3060
+ filename: att.filename || `attachment-${i + 1}`,
3061
+ content: att.content,
3062
+ contentType: att.contentType || "application/octet-stream"
3063
+ }));
3064
+ if (files.length) next = { ...next, attachments: files };
2942
3065
  }
2943
3066
  }
2944
3067
  }