hypermail-mcp 0.7.10 → 0.7.12

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,9 +3,20 @@
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.12** — Hardened Outlook reply/forward draft formatting when
7
+ > Microsoft Graph labels generated thread history as HTML but returns
8
+ > plain/unstructured text. Such histories are now defensively normalized with
9
+ > escaped content and line breaks so quoted messages remain readable.
10
+ >
11
+ > **v0.7.11** — Fixed Outlook reply/forward drafts whose Graph-generated
12
+ > quoted bodies are plain text by escaping them and patching the draft as HTML,
13
+ > so newly composed HTML replies no longer render as raw text.
14
+ >
6
15
  > **v0.7.10** — Fixed an account-store race where Gmail/Outlook token
7
16
  > refreshes could overwrite `get_new_emails` checkpoints. Checkpoint updates now
8
- > preserve token data and merge delivered IDs at the same timestamp.
17
+ > preserve token data and merge delivered IDs at the same timestamp. `edit_draft`
18
+ > body edits now require exact selected-section replacement (`old_text` +
19
+ > `new_text`) so reply/forward history is preserved instead of overwritten.
9
20
  >
10
21
  > **v0.7.9** — Replaced server-side email watch/webhook/script delivery with
11
22
  > the pull-based `get_new_emails` tool. Agents schedule their own repeated calls
@@ -249,7 +260,7 @@ account store.
249
260
  | `move_email` | `account`, `id`, `destination` | Move to any folder by well-known name (`inbox`, `drafts`, etc.) or custom folder ID. |
250
261
  | `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. |
251
262
  | `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. |
252
- | `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. |
263
+ | `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. |
253
264
  | `send_draft` | `account`, `id` | Send an existing draft email by ID. Use with draft IDs returned by `draft_email` or `edit_draft`. |
254
265
  | `list_folders` | `account`, `parentFolderId?` | List available mail folders. Returns top-level folders by default, or children of `parentFolderId`. |
255
266
  | `create_folder` | `account`, `displayName`, `parentFolderId?` | Create a new mail folder under root (default) or the given parent. |
package/dist/cli.js CHANGED
@@ -14359,16 +14359,43 @@ function clampLimit(v, dflt, max) {
14359
14359
 
14360
14360
  // src/providers/outlook/write-ops.ts
14361
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
+ }
14368
+ function hasMeaningfulHtmlTag(html) {
14369
+ return /<\/?(?:p|div|br|blockquote|table|thead|tbody|tfoot|tr|td|th|ul|ol|li|a|span|font|b|strong|em|i|u|img|hr|pre)\b/i.test(html);
14370
+ }
14371
+ function wrapQuotedHistory(content) {
14372
+ return `<blockquote style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">${content}</blockquote>`;
14373
+ }
14374
+ function normalizeDraftBody(content, contentType) {
14375
+ if (isTextBody(contentType)) return textToHtml(content);
14376
+ if (contentType?.toLowerCase() !== "html") return content;
14377
+ const bodyMatch = /(<body\b[^>]*>)([\s\S]*?)(<\/body>)/i.exec(content);
14378
+ const inner = bodyMatch ? bodyMatch[2] ?? "" : content;
14379
+ if (inner.trim() === "" || hasMeaningfulHtmlTag(inner)) return content;
14380
+ const normalized = wrapQuotedHistory(textToHtml(inner));
14381
+ if (!bodyMatch) return normalized;
14382
+ const bodyOpen = bodyMatch[1] ?? "";
14383
+ const bodyClose = bodyMatch[3] ?? "";
14384
+ return content.slice(0, bodyMatch.index) + bodyOpen + normalized + bodyClose + content.slice(bodyMatch.index + bodyMatch[0].length);
14385
+ }
14362
14386
  async function buildDraftFromReference(client, createEndpoint, createPayload, converted) {
14363
14387
  const draft = await client.api(createEndpoint).post(createPayload);
14364
14388
  const draftMsg = await client.api(`/me/messages/${draft.id}`).select("body").get();
14365
- const draftBody = draftMsg.body?.content ?? "";
14366
- const draftContentType = draftMsg.body?.contentType ?? "HTML";
14389
+ const rawDraftBody = draftMsg.body?.content ?? "";
14390
+ const draftBody = normalizeDraftBody(
14391
+ rawDraftBody,
14392
+ draftMsg.body?.contentType
14393
+ );
14367
14394
  const spacer = '<div style="line-height:12px"><br></div>';
14368
14395
  const prepend = converted.body + spacer + THREAD_MARKER;
14369
14396
  const finalBody = draftBody.includes("<body") ? draftBody.replace(/(<body[^>]*>)/i, `$1${prepend}`) : prepend + draftBody;
14370
14397
  await client.api(`/me/messages/${draft.id}`).patch({
14371
- body: { contentType: draftContentType, content: finalBody }
14398
+ body: { contentType: "HTML", content: finalBody }
14372
14399
  });
14373
14400
  for (const att of converted.attachments) {
14374
14401
  await client.api(`/me/messages/${draft.id}/attachments`).post(att);
@@ -16840,30 +16867,21 @@ function buildStyleAttr(style) {
16840
16867
  if (style.fontColor) parts.push(`color: ${style.fontColor}`);
16841
16868
  return parts.join("; ");
16842
16869
  }
16843
- function findThreadBoundary(html) {
16844
- const markerIdx = html.indexOf(THREAD_MARKER);
16845
- if (markerIdx !== -1) {
16846
- const before = html.slice(0, markerIdx);
16847
- let answerEnd = before.lastIndexOf(
16848
- '<div style="line-height:12px"><br></div>'
16849
- );
16850
- if (answerEnd === -1) {
16851
- const re = /<div\s+style=["'][^"']*line-height\s*:\s*12px[^"']*["']\s*>\s*<br\s*\/?>\s*<\/div>/gi;
16852
- let m;
16853
- while ((m = re.exec(before)) !== null) answerEnd = m.index;
16854
- }
16855
- return { threadStart: markerIdx, answerEnd: Math.max(answerEnd, 0) };
16870
+ function applyExactTextEdit(content, oldText, newText) {
16871
+ if (oldText.length === 0) {
16872
+ throw new Error("old_text must not be empty");
16856
16873
  }
16857
- const spacerRe = /<div\s+style=["'][^"']*line-height\s*:\s*12px[^"']*["']\s*>\s*<br\s*\/?>\s*<\/div>/i;
16858
- const spacerMatch = spacerRe.exec(html);
16859
- if (spacerMatch) {
16860
- return { threadStart: spacerMatch.index, answerEnd: spacerMatch.index };
16874
+ const first = content.indexOf(oldText);
16875
+ if (first === -1) {
16876
+ throw new Error("old_text was not found in the current draft body");
16861
16877
  }
16862
- for (const re of [/id=["']?Signature["']?/i, /<blockquote/i]) {
16863
- const idx = html.search(re);
16864
- if (idx !== -1) return { threadStart: idx, answerEnd: idx };
16878
+ const second = content.indexOf(oldText, first + oldText.length);
16879
+ if (second !== -1) {
16880
+ throw new Error(
16881
+ "old_text matched multiple sections in the current draft body; provide a more specific selection"
16882
+ );
16865
16883
  }
16866
- return null;
16884
+ return content.slice(0, first) + newText + content.slice(first + oldText.length);
16867
16885
  }
16868
16886
  function shouldRegister(name, tools) {
16869
16887
  if (tools.enabledTools) {
@@ -17825,33 +17843,37 @@ function registerOrganizeTools(server, ctx) {
17825
17843
  import { z as z8 } from "zod";
17826
17844
  import { readFileSync } from "fs";
17827
17845
  import { basename, extname } from "path";
17846
+
17847
+ // src/tools/mime-types.ts
17848
+ var MIME_TYPES = {
17849
+ ".pdf": "application/pdf",
17850
+ ".png": "image/png",
17851
+ ".jpg": "image/jpeg",
17852
+ ".jpeg": "image/jpeg",
17853
+ ".gif": "image/gif",
17854
+ ".svg": "image/svg+xml",
17855
+ ".webp": "image/webp",
17856
+ ".txt": "text/plain",
17857
+ ".html": "text/html",
17858
+ ".css": "text/css",
17859
+ ".csv": "text/csv",
17860
+ ".json": "application/json",
17861
+ ".xml": "application/xml",
17862
+ ".zip": "application/zip",
17863
+ ".gz": "application/gzip",
17864
+ ".tar": "application/x-tar",
17865
+ ".doc": "application/msword",
17866
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17867
+ ".xls": "application/vnd.ms-excel",
17868
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17869
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
17870
+ ".mp3": "audio/mpeg",
17871
+ ".mp4": "video/mp4"
17872
+ };
17873
+
17874
+ // src/tools/compose.ts
17828
17875
  function registerComposeTools(server, ctx) {
17829
17876
  const { store, registry, tools } = ctx;
17830
- const MIME_TYPES = {
17831
- ".pdf": "application/pdf",
17832
- ".png": "image/png",
17833
- ".jpg": "image/jpeg",
17834
- ".jpeg": "image/jpeg",
17835
- ".gif": "image/gif",
17836
- ".svg": "image/svg+xml",
17837
- ".webp": "image/webp",
17838
- ".txt": "text/plain",
17839
- ".html": "text/html",
17840
- ".css": "text/css",
17841
- ".csv": "text/csv",
17842
- ".json": "application/json",
17843
- ".xml": "application/xml",
17844
- ".zip": "application/zip",
17845
- ".gz": "application/gzip",
17846
- ".tar": "application/x-tar",
17847
- ".doc": "application/msword",
17848
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17849
- ".xls": "application/vnd.ms-excel",
17850
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17851
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
17852
- ".mp3": "audio/mpeg",
17853
- ".mp4": "video/mp4"
17854
- };
17855
17877
  const sendEmailSchema = z8.object({
17856
17878
  account: z8.string().email(),
17857
17879
  to: z8.array(emailAddrSchema).min(1),
@@ -17987,12 +18009,20 @@ function registerComposeTools(server, ctx) {
17987
18009
  cc: z8.array(emailAddrSchema).optional(),
17988
18010
  bcc: z8.array(emailAddrSchema).optional(),
17989
18011
  subject: z8.string().optional(),
17990
- body: z8.string().optional(),
18012
+ old_text: z8.string().min(1).optional().describe(
18013
+ "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."
18014
+ ),
18015
+ new_text: z8.string().optional().describe(
18016
+ "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
18017
+ ),
18018
+ body: z8.string().optional().describe(
18019
+ "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
18020
+ ),
17991
18021
  format: z8.enum(["html", "markdown"]).optional().describe(
17992
- "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."
18022
+ "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."
17993
18023
  ),
17994
18024
  include_signature: z8.boolean().optional().describe(
17995
- "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."
18025
+ "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."
17996
18026
  ),
17997
18027
  new_attachments: z8.array(
17998
18028
  z8.object({
@@ -18017,7 +18047,7 @@ function registerComposeTools(server, ctx) {
18017
18047
  server.registerTool(
18018
18048
  "edit_draft",
18019
18049
  {
18020
- 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.",
18050
+ 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.",
18021
18051
  inputSchema: editDraftSchema,
18022
18052
  outputSchema: editDraftOutputSchema
18023
18053
  },
@@ -18025,43 +18055,57 @@ function registerComposeTools(server, ctx) {
18025
18055
  const a = args;
18026
18056
  try {
18027
18057
  const { provider, account } = registry.resolveByEmail(a.account);
18028
- if (a.include_signature && !account.signature) {
18058
+ const hasNewText = a.new_text !== void 0;
18059
+ const hasBodyAlias = a.body !== void 0;
18060
+ const hasOldText = a.old_text !== void 0;
18061
+ if (hasNewText && hasBodyAlias) {
18062
+ return fail("Provide only one of new_text or deprecated body, not both.");
18063
+ }
18064
+ if (hasBodyAlias && !hasOldText) {
18065
+ return fail(
18066
+ "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."
18067
+ );
18068
+ }
18069
+ if (hasNewText && !hasOldText) {
18070
+ return fail("new_text requires old_text so only the selected section is edited.");
18071
+ }
18072
+ if (hasOldText && !hasNewText && !hasBodyAlias) {
18073
+ return fail("old_text requires new_text with the replacement content.");
18074
+ }
18075
+ const replacementText = a.new_text ?? a.body;
18076
+ if (replacementText !== void 0 && a.include_signature && !account.signature) {
18029
18077
  return fail(
18030
18078
  "include_signature is true but no signature is configured for this account. Set up a signature first with set_account_settings."
18031
18079
  );
18032
18080
  }
18033
18081
  let bodyPayload;
18034
18082
  let isHtmlPayload;
18035
- if (a.body !== void 0) {
18083
+ if (replacementText !== void 0) {
18084
+ const existing = await provider.readEmail(account, a.id);
18085
+ const existingBody = existing.bodyHtml ?? existing.bodyText ?? "";
18036
18086
  const composed = composeBody({
18037
- body: a.body,
18087
+ body: replacementText,
18038
18088
  format: a.format ?? "html",
18039
18089
  signature: account.signature,
18040
18090
  style: account.style,
18041
18091
  includeSignature: !!a.include_signature
18042
18092
  });
18043
- bodyPayload = composed.body;
18093
+ bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
18044
18094
  isHtmlPayload = composed.isHtml;
18045
18095
  }
18046
- if (bodyPayload !== void 0) {
18047
- try {
18048
- const existing = await provider.readEmail(account, a.id);
18049
- const existingHtml = existing.bodyHtml ?? "";
18050
- const boundary = findThreadBoundary(existingHtml);
18051
- if (boundary !== null) {
18052
- bodyPayload = bodyPayload + existingHtml.slice(boundary.answerEnd);
18053
- }
18054
- } catch {
18055
- }
18096
+ const hasDraftUpdate = a.to !== void 0 || a.cc !== void 0 || a.bcc !== void 0 || a.subject !== void 0 || bodyPayload !== void 0;
18097
+ let currentId = a.id;
18098
+ if (hasDraftUpdate) {
18099
+ const res = await provider.updateDraft(account, currentId, {
18100
+ to: a.to,
18101
+ cc: a.cc,
18102
+ bcc: a.bcc,
18103
+ subject: a.subject,
18104
+ body: bodyPayload,
18105
+ isHtml: isHtmlPayload
18106
+ });
18107
+ currentId = res.id;
18056
18108
  }
18057
- const res = await provider.updateDraft(account, a.id, {
18058
- to: a.to,
18059
- cc: a.cc,
18060
- bcc: a.bcc,
18061
- subject: a.subject,
18062
- body: bodyPayload,
18063
- isHtml: isHtmlPayload
18064
- });
18065
18109
  const newAttachmentIds = [];
18066
18110
  if (a.new_attachments && a.new_attachments.length > 0) {
18067
18111
  for (const att of a.new_attachments) {
@@ -18070,11 +18114,12 @@ function registerComposeTools(server, ctx) {
18070
18114
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
18071
18115
  const attRes = await provider.addAttachmentToDraft(
18072
18116
  account,
18073
- res.id,
18117
+ currentId,
18074
18118
  att.name ?? basename(att.filePath),
18075
18119
  fileData.toString("base64"),
18076
18120
  contentType
18077
18121
  );
18122
+ currentId = attRes.id;
18078
18123
  newAttachmentIds.push(attRes.attachment.id);
18079
18124
  }
18080
18125
  }
@@ -18083,16 +18128,16 @@ function registerComposeTools(server, ctx) {
18083
18128
  for (const attId of a.remove_attachments) {
18084
18129
  await provider.removeAttachmentFromDraft(
18085
18130
  account,
18086
- res.id,
18131
+ currentId,
18087
18132
  attId
18088
18133
  );
18089
18134
  removedIds.push(attId);
18090
18135
  }
18091
18136
  }
18092
- const draft = await provider.readEmail(account, res.id);
18137
+ const draft = await provider.readEmail(account, currentId);
18093
18138
  const result = {
18094
18139
  edited: true,
18095
- id: res.id,
18140
+ id: currentId,
18096
18141
  draftHtml: draft.bodyHtml ?? ""
18097
18142
  };
18098
18143
  return ok(result, result);
@@ -18144,7 +18189,7 @@ function registerTools(server, opts) {
18144
18189
  // package.json
18145
18190
  var package_default = {
18146
18191
  name: "hypermail-mcp",
18147
- version: "0.7.10",
18192
+ version: "0.7.12",
18148
18193
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18149
18194
  type: "module",
18150
18195
  bin: {