hypermail-mcp 0.7.9 → 0.7.11

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
@@ -3,6 +3,16 @@
3
3
  A **Model Context Protocol** server that lets an agent operate any of the user's
4
4
  inboxes through a single, unified tool surface.
5
5
 
6
+ > **v0.7.11** — Fixed Outlook reply/forward drafts whose Graph-generated
7
+ > quoted bodies are plain text by escaping them and patching the draft as HTML,
8
+ > so newly composed HTML replies no longer render as raw text.
9
+ >
10
+ > **v0.7.10** — Fixed an account-store race where Gmail/Outlook token
11
+ > refreshes could overwrite `get_new_emails` checkpoints. Checkpoint updates now
12
+ > preserve token data and merge delivered IDs at the same timestamp. `edit_draft`
13
+ > body edits now require exact selected-section replacement (`old_text` +
14
+ > `new_text`) so reply/forward history is preserved instead of overwritten.
15
+ >
6
16
  > **v0.7.9** — Replaced server-side email watch/webhook/script delivery with
7
17
  > the pull-based `get_new_emails` tool. Agents schedule their own repeated calls
8
18
  > and fetch bounded batches of new inbox email.
@@ -245,7 +255,7 @@ account store.
245
255
  | `move_email` | `account`, `id`, `destination` | Move to any folder by well-known name (`inbox`, `drafts`, etc.) or custom folder ID. |
246
256
  | `send_email` | `account`, `to[]`, `cc?`, `bcc?`, `subject`, `body`, `format`, `include_signature`, `inReplyTo`, `replyAll?`, `forwardMessageId?`, `attachments?` | Send an email. `format` (`"html"` or `"markdown"`) controls body format — Markdown is converted to HTML via `marked`. Appends signature when `include_signature` is true. `inReplyTo` sends as threaded reply; `forwardMessageId` sends as forward. `inReplyTo` is required — set to `false` for new emails. `attachments` is an optional array of `{filePath, name?}` — files are read from disk and encoded automatically. |
247
257
  | `draft_email` | `account`, `to[]`, `cc?`, `bcc?`, `subject`, `body`, `format`, `include_signature`, `inReplyTo`, `replyAll?`, `forwardMessageId?`, `attachments?` | Save as draft instead of sending. Same params as `send_email` including `attachments`. Returns the draft message ID and HTML body (`draftHtml`). `inReplyTo` is required — set to `false` for new emails. |
248
- | `edit_draft` | `account`, `id`, `to?`, `cc?`, `bcc?`, `subject?`, `body?`, `format?`, `include_signature?`, `new_attachments?`, `remove_attachments?` | Edit an existing draft by ID. Only provided fields are updated. `new_attachments` adds files (`{filePath, name?}[]`); `remove_attachments` removes by attachment ID (`string[]`). Returns the updated draft ID, HTML body (`draftHtml`), and attachment metadata. |
258
+ | `edit_draft` | `account`, `id`, `to?`, `cc?`, `bcc?`, `subject?`, `old_text?`, `new_text?`, `body?`, `format?`, `include_signature?`, `new_attachments?`, `remove_attachments?` | Edit an existing draft by ID. Body edits require exact selected-section replacement: copy `old_text` from the current draft HTML (`draftHtml` or `read_email` with `format: "html"`) and provide `new_text`; the match must occur exactly once, and unselected content such as reply/forward history is preserved. Deprecated `body` is only an alias for `new_text` when `old_text` is also provided; body-only full replacement is rejected. `new_attachments` adds files (`{filePath, name?}[]`); `remove_attachments` removes by attachment ID (`string[]`). Returns the updated draft ID, HTML body (`draftHtml`), and attachment metadata. |
249
259
  | `send_draft` | `account`, `id` | Send an existing draft email by ID. Use with draft IDs returned by `draft_email` or `edit_draft`. |
250
260
  | `list_folders` | `account`, `parentFolderId?` | List available mail folders. Returns top-level folders by default, or children of `parentFolderId`. |
251
261
  | `create_folder` | `account`, `displayName`, `parentFolderId?` | Create a new mail folder under root (default) or the given parent. |
package/dist/cli.js CHANGED
@@ -13965,6 +13965,7 @@ var AccountStore = class _AccountStore {
13965
13965
  filePath;
13966
13966
  key;
13967
13967
  data;
13968
+ writeLocks = /* @__PURE__ */ new Map();
13968
13969
  static async open(opts = {}) {
13969
13970
  const dataDir = resolveDataDir(opts.dataDir);
13970
13971
  await fs2.mkdir(dataDir, { recursive: true, mode: 448 });
@@ -13992,21 +13993,79 @@ var AccountStore = class _AccountStore {
13992
13993
  return rec ? { ...rec } : void 0;
13993
13994
  }
13994
13995
  async upsertAccount(rec) {
13995
- const norm = rec.email.trim().toLowerCase();
13996
- const next = { ...rec, email: norm };
13997
- const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
13998
- if (idx >= 0) this.data.accounts[idx] = next;
13999
- else this.data.accounts.push(next);
14000
- await this.flush();
14001
- return { ...next };
13996
+ return this.runSerial(rec.email, async () => {
13997
+ const norm = rec.email.trim().toLowerCase();
13998
+ const next = { ...rec, email: norm };
13999
+ const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14000
+ if (idx >= 0) this.data.accounts[idx] = next;
14001
+ else this.data.accounts.push(next);
14002
+ await this.flush();
14003
+ return { ...next };
14004
+ });
14005
+ }
14006
+ async updateTokens(email, tokens) {
14007
+ return this.runSerial(email, async () => {
14008
+ const norm = email.trim().toLowerCase();
14009
+ const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14010
+ if (idx < 0) return void 0;
14011
+ const current = this.data.accounts[idx];
14012
+ const next = { ...current, tokens };
14013
+ this.data.accounts[idx] = next;
14014
+ await this.flush();
14015
+ return { ...next };
14016
+ });
14017
+ }
14018
+ async updateNewEmailCheckpoint(email, checkpoint) {
14019
+ return this.runSerial(email, async () => {
14020
+ const norm = email.trim().toLowerCase();
14021
+ const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14022
+ if (idx < 0) return void 0;
14023
+ const current = this.data.accounts[idx];
14024
+ const currentCheckpoint = current.newEmailCheckpoint;
14025
+ const mergedCheckpoint = currentCheckpoint?.receivedAt === checkpoint.receivedAt ? {
14026
+ receivedAt: checkpoint.receivedAt,
14027
+ deliveredIdsAtReceivedAt: [
14028
+ .../* @__PURE__ */ new Set([
14029
+ ...currentCheckpoint.deliveredIdsAtReceivedAt ?? [],
14030
+ ...checkpoint.deliveredIdsAtReceivedAt ?? []
14031
+ ])
14032
+ ]
14033
+ } : checkpoint;
14034
+ const next = {
14035
+ ...current,
14036
+ newEmailCheckpoint: mergedCheckpoint
14037
+ };
14038
+ this.data.accounts[idx] = next;
14039
+ await this.flush();
14040
+ return { ...next };
14041
+ });
14002
14042
  }
14003
14043
  async removeAccount(email) {
14044
+ return this.runSerial(email, async () => {
14045
+ const norm = email.trim().toLowerCase();
14046
+ const before = this.data.accounts.length;
14047
+ this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
14048
+ if (this.data.accounts.length === before) return false;
14049
+ await this.flush();
14050
+ return true;
14051
+ });
14052
+ }
14053
+ async runSerial(email, task) {
14004
14054
  const norm = email.trim().toLowerCase();
14005
- const before = this.data.accounts.length;
14006
- this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
14007
- if (this.data.accounts.length === before) return false;
14008
- await this.flush();
14009
- return true;
14055
+ const previous = this.writeLocks.get(norm) ?? Promise.resolve();
14056
+ const run = previous.catch(() => void 0).then(task);
14057
+ const lock = run.then(
14058
+ () => void 0,
14059
+ () => void 0
14060
+ );
14061
+ this.writeLocks.set(norm, lock);
14062
+ try {
14063
+ return await run;
14064
+ } finally {
14065
+ if (this.writeLocks.get(norm) === lock) {
14066
+ this.writeLocks.delete(norm);
14067
+ }
14068
+ }
14010
14069
  }
14011
14070
  async flush() {
14012
14071
  const buf = encrypt(this.data, this.key);
@@ -14203,10 +14262,10 @@ var OutlookClientFactory = class {
14203
14262
  this.tenantId
14204
14263
  );
14205
14264
  if (nextTokens.msalCache !== tokens.msalCache) {
14206
- store.upsertAccount({
14207
- ...fresh,
14208
- tokens: nextTokens
14209
- }).catch(() => {
14265
+ store.updateTokens(
14266
+ account.email,
14267
+ nextTokens
14268
+ ).catch(() => {
14210
14269
  });
14211
14270
  }
14212
14271
  return accessToken;
@@ -14300,16 +14359,22 @@ function clampLimit(v, dflt, max) {
14300
14359
 
14301
14360
  // src/providers/outlook/write-ops.ts
14302
14361
  var THREAD_MARKER = "<!-- hypermail-thread-boundary -->";
14362
+ function isTextBody(contentType) {
14363
+ return contentType?.toLowerCase() === "text";
14364
+ }
14365
+ function textToHtml(text) {
14366
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/\r\n|\r|\n/g, "<br>");
14367
+ }
14303
14368
  async function buildDraftFromReference(client, createEndpoint, createPayload, converted) {
14304
14369
  const draft = await client.api(createEndpoint).post(createPayload);
14305
14370
  const draftMsg = await client.api(`/me/messages/${draft.id}`).select("body").get();
14306
- const draftBody = draftMsg.body?.content ?? "";
14307
- const draftContentType = draftMsg.body?.contentType ?? "HTML";
14371
+ const rawDraftBody = draftMsg.body?.content ?? "";
14372
+ const draftBody = isTextBody(draftMsg.body?.contentType) ? textToHtml(rawDraftBody) : rawDraftBody;
14308
14373
  const spacer = '<div style="line-height:12px"><br></div>';
14309
14374
  const prepend = converted.body + spacer + THREAD_MARKER;
14310
14375
  const finalBody = draftBody.includes("<body") ? draftBody.replace(/(<body[^>]*>)/i, `$1${prepend}`) : prepend + draftBody;
14311
14376
  await client.api(`/me/messages/${draft.id}`).patch({
14312
- body: { contentType: draftContentType, content: finalBody }
14377
+ body: { contentType: "HTML", content: finalBody }
14313
14378
  });
14314
14379
  for (const att of converted.attachments) {
14315
14380
  await client.api(`/me/messages/${draft.id}/attachments`).post(att);
@@ -15826,10 +15891,10 @@ var GmailClientFactory = class {
15826
15891
  expiryDate: updated.expiry_date ?? currentTokens.expiryDate,
15827
15892
  scopes: updated.scope ? updated.scope.split(" ") : currentTokens.scopes
15828
15893
  };
15829
- await store.upsertAccount({
15830
- ...fresh,
15831
- tokens: nextTokens
15832
- }).catch(() => {
15894
+ await store.updateTokens(
15895
+ account.email,
15896
+ nextTokens
15897
+ ).catch(() => {
15833
15898
  });
15834
15899
  });
15835
15900
  persistLocks.set(key, chain);
@@ -16781,30 +16846,21 @@ function buildStyleAttr(style) {
16781
16846
  if (style.fontColor) parts.push(`color: ${style.fontColor}`);
16782
16847
  return parts.join("; ");
16783
16848
  }
16784
- function findThreadBoundary(html) {
16785
- const markerIdx = html.indexOf(THREAD_MARKER);
16786
- if (markerIdx !== -1) {
16787
- const before = html.slice(0, markerIdx);
16788
- let answerEnd = before.lastIndexOf(
16789
- '<div style="line-height:12px"><br></div>'
16790
- );
16791
- if (answerEnd === -1) {
16792
- const re = /<div\s+style=["'][^"']*line-height\s*:\s*12px[^"']*["']\s*>\s*<br\s*\/?>\s*<\/div>/gi;
16793
- let m;
16794
- while ((m = re.exec(before)) !== null) answerEnd = m.index;
16795
- }
16796
- return { threadStart: markerIdx, answerEnd: Math.max(answerEnd, 0) };
16849
+ function applyExactTextEdit(content, oldText, newText) {
16850
+ if (oldText.length === 0) {
16851
+ throw new Error("old_text must not be empty");
16797
16852
  }
16798
- const spacerRe = /<div\s+style=["'][^"']*line-height\s*:\s*12px[^"']*["']\s*>\s*<br\s*\/?>\s*<\/div>/i;
16799
- const spacerMatch = spacerRe.exec(html);
16800
- if (spacerMatch) {
16801
- return { threadStart: spacerMatch.index, answerEnd: spacerMatch.index };
16853
+ const first = content.indexOf(oldText);
16854
+ if (first === -1) {
16855
+ throw new Error("old_text was not found in the current draft body");
16802
16856
  }
16803
- for (const re of [/id=["']?Signature["']?/i, /<blockquote/i]) {
16804
- const idx = html.search(re);
16805
- if (idx !== -1) return { threadStart: idx, answerEnd: idx };
16857
+ const second = content.indexOf(oldText, first + oldText.length);
16858
+ if (second !== -1) {
16859
+ throw new Error(
16860
+ "old_text matched multiple sections in the current draft body; provide a more specific selection"
16861
+ );
16806
16862
  }
16807
- return null;
16863
+ return content.slice(0, first) + newText + content.slice(first + oldText.length);
16808
16864
  }
16809
16865
  function shouldRegister(name, tools) {
16810
16866
  if (tools.enabledTools) {
@@ -17222,9 +17278,9 @@ async function initializeCheckpoint(store, provider, account) {
17222
17278
  const first = items[0];
17223
17279
  const receivedAt = first ? effectiveReceivedAt(first.receivedAt) : (/* @__PURE__ */ new Date()).toISOString();
17224
17280
  const deliveredIdsAtReceivedAt = items.filter((item) => effectiveReceivedAt(item.receivedAt) === receivedAt).map((item) => item.id);
17225
- await store.upsertAccount({
17226
- ...account,
17227
- newEmailCheckpoint: { receivedAt, deliveredIdsAtReceivedAt }
17281
+ await store.updateNewEmailCheckpoint(account.email, {
17282
+ receivedAt,
17283
+ deliveredIdsAtReceivedAt
17228
17284
  });
17229
17285
  }
17230
17286
  async function hydrateAndAdvance(store, provider, account, selected) {
@@ -17264,24 +17320,11 @@ async function advanceCheckpoint(store, account, selected) {
17264
17320
  const ordered = oldestCandidatesFirst(selected);
17265
17321
  const newest = ordered[ordered.length - 1];
17266
17322
  if (!newest) return;
17267
- const previous = normalizeCheckpoint(account.newEmailCheckpoint);
17268
17323
  const newestTimestamp = newest.timestamp;
17269
17324
  const idsAtNewest = ordered.filter((candidate) => candidate.timestamp === newestTimestamp).map((candidate) => candidate.summary.id);
17270
- let deliveredIdsAtReceivedAt = idsAtNewest;
17271
- if (previous?.receivedAt === newestTimestamp) {
17272
- deliveredIdsAtReceivedAt = [
17273
- .../* @__PURE__ */ new Set([
17274
- ...previous.deliveredIdsAtReceivedAt ?? [],
17275
- ...idsAtNewest
17276
- ])
17277
- ];
17278
- }
17279
- await store.upsertAccount({
17280
- ...account,
17281
- newEmailCheckpoint: {
17282
- receivedAt: newestTimestamp,
17283
- deliveredIdsAtReceivedAt
17284
- }
17325
+ await store.updateNewEmailCheckpoint(account.email, {
17326
+ receivedAt: newestTimestamp,
17327
+ deliveredIdsAtReceivedAt: idsAtNewest
17285
17328
  });
17286
17329
  }
17287
17330
  function normalizeCheckpoint(checkpoint) {
@@ -17779,33 +17822,37 @@ function registerOrganizeTools(server, ctx) {
17779
17822
  import { z as z8 } from "zod";
17780
17823
  import { readFileSync } from "fs";
17781
17824
  import { basename, extname } from "path";
17825
+
17826
+ // src/tools/mime-types.ts
17827
+ var MIME_TYPES = {
17828
+ ".pdf": "application/pdf",
17829
+ ".png": "image/png",
17830
+ ".jpg": "image/jpeg",
17831
+ ".jpeg": "image/jpeg",
17832
+ ".gif": "image/gif",
17833
+ ".svg": "image/svg+xml",
17834
+ ".webp": "image/webp",
17835
+ ".txt": "text/plain",
17836
+ ".html": "text/html",
17837
+ ".css": "text/css",
17838
+ ".csv": "text/csv",
17839
+ ".json": "application/json",
17840
+ ".xml": "application/xml",
17841
+ ".zip": "application/zip",
17842
+ ".gz": "application/gzip",
17843
+ ".tar": "application/x-tar",
17844
+ ".doc": "application/msword",
17845
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17846
+ ".xls": "application/vnd.ms-excel",
17847
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17848
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
17849
+ ".mp3": "audio/mpeg",
17850
+ ".mp4": "video/mp4"
17851
+ };
17852
+
17853
+ // src/tools/compose.ts
17782
17854
  function registerComposeTools(server, ctx) {
17783
17855
  const { store, registry, tools } = ctx;
17784
- const MIME_TYPES = {
17785
- ".pdf": "application/pdf",
17786
- ".png": "image/png",
17787
- ".jpg": "image/jpeg",
17788
- ".jpeg": "image/jpeg",
17789
- ".gif": "image/gif",
17790
- ".svg": "image/svg+xml",
17791
- ".webp": "image/webp",
17792
- ".txt": "text/plain",
17793
- ".html": "text/html",
17794
- ".css": "text/css",
17795
- ".csv": "text/csv",
17796
- ".json": "application/json",
17797
- ".xml": "application/xml",
17798
- ".zip": "application/zip",
17799
- ".gz": "application/gzip",
17800
- ".tar": "application/x-tar",
17801
- ".doc": "application/msword",
17802
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17803
- ".xls": "application/vnd.ms-excel",
17804
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17805
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
17806
- ".mp3": "audio/mpeg",
17807
- ".mp4": "video/mp4"
17808
- };
17809
17856
  const sendEmailSchema = z8.object({
17810
17857
  account: z8.string().email(),
17811
17858
  to: z8.array(emailAddrSchema).min(1),
@@ -17941,12 +17988,20 @@ function registerComposeTools(server, ctx) {
17941
17988
  cc: z8.array(emailAddrSchema).optional(),
17942
17989
  bcc: z8.array(emailAddrSchema).optional(),
17943
17990
  subject: z8.string().optional(),
17944
- body: z8.string().optional(),
17991
+ old_text: z8.string().min(1).optional().describe(
17992
+ "Exact current HTML section to replace in the draft body. Copy this from `draftHtml` or from `read_email` with format='html'. Must match exactly once; unselected content is preserved."
17993
+ ),
17994
+ new_text: z8.string().optional().describe(
17995
+ "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
17996
+ ),
17997
+ body: z8.string().optional().describe(
17998
+ "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
17999
+ ),
17945
18000
  format: z8.enum(["html", "markdown"]).optional().describe(
17946
- "Body format. Only meaningful when `body` is also provided. 'html' sends the body as-is (must be valid HTML). 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
18001
+ "Replacement format. Only meaningful when `new_text` or deprecated `body` is also provided. 'html' inserts the replacement as-is (must be valid HTML). 'markdown' converts the replacement from Markdown to HTML."
17947
18002
  ),
17948
18003
  include_signature: z8.boolean().optional().describe(
17949
- "Whether to re-apply the account's saved HTML signature to the body. If true, don't include a signature in the body param. Only meaningful when `body` is also provided. Returns an error if true but no signature is configured for this account."
18004
+ "Whether to append the account's saved HTML signature to the replacement section. If true, don't include a signature in `new_text`/`body`. Only meaningful when replacement content is provided. Returns an error if true but no signature is configured for this account."
17950
18005
  ),
17951
18006
  new_attachments: z8.array(
17952
18007
  z8.object({
@@ -17971,7 +18026,7 @@ function registerComposeTools(server, ctx) {
17971
18026
  server.registerTool(
17972
18027
  "edit_draft",
17973
18028
  {
17974
- description: "Edit an existing draft email by ID. Only the fields you provide are updated \u2014 unmentioned fields stay unchanged. When `body` is provided and `include_signature` is true, the account's signature is re-applied. Returns the draft ID and the draft's updated HTML body content (`draftHtml`). Before sending, inspect `draftHtml` to verify the draft looks correct. Does not support changing `inReplyTo` or `forwardMessageId` \u2014 those are set at creation time via `draft_email`. Disabled in --read-only mode.",
18029
+ description: "Edit an existing draft email by ID. Only the fields you provide are updated \u2014 unmentioned fields stay unchanged. Body edits work like an exact text edit: provide `old_text` copied from the current draft HTML and `new_text` to replace that exact section. The match must occur exactly once, and all unselected content \u2014 including reply/forward history \u2014 is preserved. Deprecated `body` is accepted only as an alias for `new_text` when `old_text` is also provided. Returns the draft ID and the draft's updated HTML body content (`draftHtml`). Before sending, inspect `draftHtml` to verify the draft looks correct. Does not support changing `inReplyTo` or `forwardMessageId` \u2014 those are set at creation time via `draft_email`. Disabled in --read-only mode.",
17975
18030
  inputSchema: editDraftSchema,
17976
18031
  outputSchema: editDraftOutputSchema
17977
18032
  },
@@ -17979,43 +18034,57 @@ function registerComposeTools(server, ctx) {
17979
18034
  const a = args;
17980
18035
  try {
17981
18036
  const { provider, account } = registry.resolveByEmail(a.account);
17982
- if (a.include_signature && !account.signature) {
18037
+ const hasNewText = a.new_text !== void 0;
18038
+ const hasBodyAlias = a.body !== void 0;
18039
+ const hasOldText = a.old_text !== void 0;
18040
+ if (hasNewText && hasBodyAlias) {
18041
+ return fail("Provide only one of new_text or deprecated body, not both.");
18042
+ }
18043
+ if (hasBodyAlias && !hasOldText) {
18044
+ return fail(
18045
+ "Body-only full replacement is no longer supported. Provide old_text copied from the current draft HTML and use body as the replacement, or use new_text."
18046
+ );
18047
+ }
18048
+ if (hasNewText && !hasOldText) {
18049
+ return fail("new_text requires old_text so only the selected section is edited.");
18050
+ }
18051
+ if (hasOldText && !hasNewText && !hasBodyAlias) {
18052
+ return fail("old_text requires new_text with the replacement content.");
18053
+ }
18054
+ const replacementText = a.new_text ?? a.body;
18055
+ if (replacementText !== void 0 && a.include_signature && !account.signature) {
17983
18056
  return fail(
17984
18057
  "include_signature is true but no signature is configured for this account. Set up a signature first with set_account_settings."
17985
18058
  );
17986
18059
  }
17987
18060
  let bodyPayload;
17988
18061
  let isHtmlPayload;
17989
- if (a.body !== void 0) {
18062
+ if (replacementText !== void 0) {
18063
+ const existing = await provider.readEmail(account, a.id);
18064
+ const existingBody = existing.bodyHtml ?? existing.bodyText ?? "";
17990
18065
  const composed = composeBody({
17991
- body: a.body,
18066
+ body: replacementText,
17992
18067
  format: a.format ?? "html",
17993
18068
  signature: account.signature,
17994
18069
  style: account.style,
17995
18070
  includeSignature: !!a.include_signature
17996
18071
  });
17997
- bodyPayload = composed.body;
18072
+ bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
17998
18073
  isHtmlPayload = composed.isHtml;
17999
18074
  }
18000
- if (bodyPayload !== void 0) {
18001
- try {
18002
- const existing = await provider.readEmail(account, a.id);
18003
- const existingHtml = existing.bodyHtml ?? "";
18004
- const boundary = findThreadBoundary(existingHtml);
18005
- if (boundary !== null) {
18006
- bodyPayload = bodyPayload + existingHtml.slice(boundary.answerEnd);
18007
- }
18008
- } catch {
18009
- }
18075
+ const hasDraftUpdate = a.to !== void 0 || a.cc !== void 0 || a.bcc !== void 0 || a.subject !== void 0 || bodyPayload !== void 0;
18076
+ let currentId = a.id;
18077
+ if (hasDraftUpdate) {
18078
+ const res = await provider.updateDraft(account, currentId, {
18079
+ to: a.to,
18080
+ cc: a.cc,
18081
+ bcc: a.bcc,
18082
+ subject: a.subject,
18083
+ body: bodyPayload,
18084
+ isHtml: isHtmlPayload
18085
+ });
18086
+ currentId = res.id;
18010
18087
  }
18011
- const res = await provider.updateDraft(account, a.id, {
18012
- to: a.to,
18013
- cc: a.cc,
18014
- bcc: a.bcc,
18015
- subject: a.subject,
18016
- body: bodyPayload,
18017
- isHtml: isHtmlPayload
18018
- });
18019
18088
  const newAttachmentIds = [];
18020
18089
  if (a.new_attachments && a.new_attachments.length > 0) {
18021
18090
  for (const att of a.new_attachments) {
@@ -18024,11 +18093,12 @@ function registerComposeTools(server, ctx) {
18024
18093
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
18025
18094
  const attRes = await provider.addAttachmentToDraft(
18026
18095
  account,
18027
- res.id,
18096
+ currentId,
18028
18097
  att.name ?? basename(att.filePath),
18029
18098
  fileData.toString("base64"),
18030
18099
  contentType
18031
18100
  );
18101
+ currentId = attRes.id;
18032
18102
  newAttachmentIds.push(attRes.attachment.id);
18033
18103
  }
18034
18104
  }
@@ -18037,16 +18107,16 @@ function registerComposeTools(server, ctx) {
18037
18107
  for (const attId of a.remove_attachments) {
18038
18108
  await provider.removeAttachmentFromDraft(
18039
18109
  account,
18040
- res.id,
18110
+ currentId,
18041
18111
  attId
18042
18112
  );
18043
18113
  removedIds.push(attId);
18044
18114
  }
18045
18115
  }
18046
- const draft = await provider.readEmail(account, res.id);
18116
+ const draft = await provider.readEmail(account, currentId);
18047
18117
  const result = {
18048
18118
  edited: true,
18049
- id: res.id,
18119
+ id: currentId,
18050
18120
  draftHtml: draft.bodyHtml ?? ""
18051
18121
  };
18052
18122
  return ok(result, result);
@@ -18098,7 +18168,7 @@ function registerTools(server, opts) {
18098
18168
  // package.json
18099
18169
  var package_default = {
18100
18170
  name: "hypermail-mcp",
18101
- version: "0.7.9",
18171
+ version: "0.7.11",
18102
18172
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18103
18173
  type: "module",
18104
18174
  bin: {