hypermail-mcp 0.7.10 → 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,9 +3,15 @@
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
+ >
6
10
  > **v0.7.10** — Fixed an account-store race where Gmail/Outlook token
7
11
  > refreshes could overwrite `get_new_emails` checkpoints. Checkpoint updates now
8
- > preserve token data and merge delivered IDs at the same timestamp.
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.
9
15
  >
10
16
  > **v0.7.9** — Replaced server-side email watch/webhook/script delivery with
11
17
  > the pull-based `get_new_emails` tool. Agents schedule their own repeated calls
@@ -249,7 +255,7 @@ account store.
249
255
  | `move_email` | `account`, `id`, `destination` | Move to any folder by well-known name (`inbox`, `drafts`, etc.) or custom folder ID. |
250
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. |
251
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. |
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. |
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. |
253
259
  | `send_draft` | `account`, `id` | Send an existing draft email by ID. Use with draft IDs returned by `draft_email` or `edit_draft`. |
254
260
  | `list_folders` | `account`, `parentFolderId?` | List available mail folders. Returns top-level folders by default, or children of `parentFolderId`. |
255
261
  | `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,22 @@ 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
+ }
14362
14368
  async function buildDraftFromReference(client, createEndpoint, createPayload, converted) {
14363
14369
  const draft = await client.api(createEndpoint).post(createPayload);
14364
14370
  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";
14371
+ const rawDraftBody = draftMsg.body?.content ?? "";
14372
+ const draftBody = isTextBody(draftMsg.body?.contentType) ? textToHtml(rawDraftBody) : rawDraftBody;
14367
14373
  const spacer = '<div style="line-height:12px"><br></div>';
14368
14374
  const prepend = converted.body + spacer + THREAD_MARKER;
14369
14375
  const finalBody = draftBody.includes("<body") ? draftBody.replace(/(<body[^>]*>)/i, `$1${prepend}`) : prepend + draftBody;
14370
14376
  await client.api(`/me/messages/${draft.id}`).patch({
14371
- body: { contentType: draftContentType, content: finalBody }
14377
+ body: { contentType: "HTML", content: finalBody }
14372
14378
  });
14373
14379
  for (const att of converted.attachments) {
14374
14380
  await client.api(`/me/messages/${draft.id}/attachments`).post(att);
@@ -16840,30 +16846,21 @@ function buildStyleAttr(style) {
16840
16846
  if (style.fontColor) parts.push(`color: ${style.fontColor}`);
16841
16847
  return parts.join("; ");
16842
16848
  }
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) };
16849
+ function applyExactTextEdit(content, oldText, newText) {
16850
+ if (oldText.length === 0) {
16851
+ throw new Error("old_text must not be empty");
16856
16852
  }
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 };
16853
+ const first = content.indexOf(oldText);
16854
+ if (first === -1) {
16855
+ throw new Error("old_text was not found in the current draft body");
16861
16856
  }
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 };
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
+ );
16865
16862
  }
16866
- return null;
16863
+ return content.slice(0, first) + newText + content.slice(first + oldText.length);
16867
16864
  }
16868
16865
  function shouldRegister(name, tools) {
16869
16866
  if (tools.enabledTools) {
@@ -17825,33 +17822,37 @@ function registerOrganizeTools(server, ctx) {
17825
17822
  import { z as z8 } from "zod";
17826
17823
  import { readFileSync } from "fs";
17827
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
17828
17854
  function registerComposeTools(server, ctx) {
17829
17855
  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
17856
  const sendEmailSchema = z8.object({
17856
17857
  account: z8.string().email(),
17857
17858
  to: z8.array(emailAddrSchema).min(1),
@@ -17987,12 +17988,20 @@ function registerComposeTools(server, ctx) {
17987
17988
  cc: z8.array(emailAddrSchema).optional(),
17988
17989
  bcc: z8.array(emailAddrSchema).optional(),
17989
17990
  subject: z8.string().optional(),
17990
- 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
+ ),
17991
18000
  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."
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."
17993
18002
  ),
17994
18003
  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."
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."
17996
18005
  ),
17997
18006
  new_attachments: z8.array(
17998
18007
  z8.object({
@@ -18017,7 +18026,7 @@ function registerComposeTools(server, ctx) {
18017
18026
  server.registerTool(
18018
18027
  "edit_draft",
18019
18028
  {
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.",
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.",
18021
18030
  inputSchema: editDraftSchema,
18022
18031
  outputSchema: editDraftOutputSchema
18023
18032
  },
@@ -18025,43 +18034,57 @@ function registerComposeTools(server, ctx) {
18025
18034
  const a = args;
18026
18035
  try {
18027
18036
  const { provider, account } = registry.resolveByEmail(a.account);
18028
- 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) {
18029
18056
  return fail(
18030
18057
  "include_signature is true but no signature is configured for this account. Set up a signature first with set_account_settings."
18031
18058
  );
18032
18059
  }
18033
18060
  let bodyPayload;
18034
18061
  let isHtmlPayload;
18035
- 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 ?? "";
18036
18065
  const composed = composeBody({
18037
- body: a.body,
18066
+ body: replacementText,
18038
18067
  format: a.format ?? "html",
18039
18068
  signature: account.signature,
18040
18069
  style: account.style,
18041
18070
  includeSignature: !!a.include_signature
18042
18071
  });
18043
- bodyPayload = composed.body;
18072
+ bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
18044
18073
  isHtmlPayload = composed.isHtml;
18045
18074
  }
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
- }
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;
18056
18087
  }
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
18088
  const newAttachmentIds = [];
18066
18089
  if (a.new_attachments && a.new_attachments.length > 0) {
18067
18090
  for (const att of a.new_attachments) {
@@ -18070,11 +18093,12 @@ function registerComposeTools(server, ctx) {
18070
18093
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
18071
18094
  const attRes = await provider.addAttachmentToDraft(
18072
18095
  account,
18073
- res.id,
18096
+ currentId,
18074
18097
  att.name ?? basename(att.filePath),
18075
18098
  fileData.toString("base64"),
18076
18099
  contentType
18077
18100
  );
18101
+ currentId = attRes.id;
18078
18102
  newAttachmentIds.push(attRes.attachment.id);
18079
18103
  }
18080
18104
  }
@@ -18083,16 +18107,16 @@ function registerComposeTools(server, ctx) {
18083
18107
  for (const attId of a.remove_attachments) {
18084
18108
  await provider.removeAttachmentFromDraft(
18085
18109
  account,
18086
- res.id,
18110
+ currentId,
18087
18111
  attId
18088
18112
  );
18089
18113
  removedIds.push(attId);
18090
18114
  }
18091
18115
  }
18092
- const draft = await provider.readEmail(account, res.id);
18116
+ const draft = await provider.readEmail(account, currentId);
18093
18117
  const result = {
18094
18118
  edited: true,
18095
- id: res.id,
18119
+ id: currentId,
18096
18120
  draftHtml: draft.bodyHtml ?? ""
18097
18121
  };
18098
18122
  return ok(result, result);
@@ -18144,7 +18168,7 @@ function registerTools(server, opts) {
18144
18168
  // package.json
18145
18169
  var package_default = {
18146
18170
  name: "hypermail-mcp",
18147
- version: "0.7.10",
18171
+ version: "0.7.11",
18148
18172
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18149
18173
  type: "module",
18150
18174
  bin: {