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 +8 -2
- package/dist/cli.js +104 -80
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).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
|
|
14366
|
-
const
|
|
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:
|
|
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
|
|
16844
|
-
|
|
16845
|
-
|
|
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
|
|
16858
|
-
|
|
16859
|
-
|
|
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
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
18047
|
-
|
|
18048
|
-
|
|
18049
|
-
|
|
18050
|
-
|
|
18051
|
-
|
|
18052
|
-
|
|
18053
|
-
|
|
18054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18110
|
+
currentId,
|
|
18087
18111
|
attId
|
|
18088
18112
|
);
|
|
18089
18113
|
removedIds.push(attId);
|
|
18090
18114
|
}
|
|
18091
18115
|
}
|
|
18092
|
-
const draft = await provider.readEmail(account,
|
|
18116
|
+
const draft = await provider.readEmail(account, currentId);
|
|
18093
18117
|
const result = {
|
|
18094
18118
|
edited: true,
|
|
18095
|
-
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.
|
|
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: {
|