hypermail-mcp 0.7.18 → 0.7.19

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,13 @@
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.19** — Hardened Outlook ID handling when Microsoft Graph `$search`
7
+ > returns IDs that `/me/messages/{id}` rejects as malformed: `search_emails`
8
+ > now returns a translated readable immutable ID when available, and
9
+ > `read_email` retries malformed/stale IDs through `translateExchangeIds`
10
+ > before failing. `list_emails` also falls back from localized folder display
11
+ > names such as `Éléments envoyés` to the matching Graph folder ID.
12
+ >
6
13
  > **v0.7.18** — `search_emails` can now omit `account` to search all
7
14
  > registered accounts in parallel, returning account-annotated results plus
8
15
  > per-account errors without discarding successful matches. All-account
@@ -295,10 +302,10 @@ account store.
295
302
  | `get_account_settings` | `account` | Get signature (HTML) and style preferences for an account. |
296
303
  | `set_account_settings` | `account`, `signature?`, `signaturePath?`, `style?` | Set signature HTML (inline or via file path) and font preferences. |
297
304
  | `remove_account` | `email` | Deletes tokens for the account. |
298
- | `list_emails` | `account`, `folder?`, `limit?`, `unreadOnly?`, `skip?` | Defaults: folder=`inbox`, limit=25. Supports pagination via `skip` — response includes `hasMore`. |
305
+ | `list_emails` | `account`, `folder?`, `limit?`, `unreadOnly?`, `skip?` | Defaults: folder=`inbox`, limit=25. Supports pagination via `skip` — response includes `hasMore`. Outlook accepts well-known folder names, folder IDs, and falls back from localized display names to matching folder IDs. |
299
306
  | `get_new_emails` | `account?`, `limit?` | Pull new inbox emails not previously returned by this tool. `limit` defaults to 10 and is global when `account` is omitted. Returns full markdown bodies with attachment metadata; bodies may be truncated. |
300
- | `search_emails` | `account?`, `query`, `limit?` | Search one account when `account` is provided, or omit it to search all registered accounts in parallel. Returns account-annotated summaries and partial per-account errors. Uses KQL on Outlook. |
301
- | `read_email` | `account`, `id`, `format?` | Returns full body + recipients + attachment metadata. `format`: `markdown` (default), `html`, or `text`. |
307
+ | `search_emails` | `account?`, `query`, `limit?` | Search one account when `account` is provided, or omit it to search all registered accounts in parallel. Returns account-annotated summaries and partial per-account errors. Uses KQL on Outlook, and normalizes malformed Outlook search-result IDs to readable immutable IDs when possible. |
308
+ | `read_email` | `account`, `id`, `format?` | Returns full body + recipients + attachment metadata. `format`: `markdown` (default), `html`, or `text`. Outlook retries malformed/stale IDs through Graph ID translation before failing. |
302
309
  | `read_attachment` | `account`, `messageId`, `attachmentId` | Download an attachment to a temporary file and return its path. |
303
310
  | `archive_email` | `account`, `id` | Move a message to the Archive folder. |
304
311
  | `trash_email` | `account`, `id` | Move a message to Deleted Items (trash). |
package/dist/cli.js CHANGED
@@ -14870,6 +14870,10 @@ var FULL_MESSAGE_SELECT = [
14870
14870
  "hasAttachments",
14871
14871
  "body"
14872
14872
  ].join(",");
14873
+ var EXCHANGE_ID_TRANSLATION_SOURCE_TYPES = [
14874
+ "ewsId",
14875
+ "restId"
14876
+ ];
14873
14877
  function graphErrorFields(err) {
14874
14878
  if (typeof err !== "object" || err === null) {
14875
14879
  return [String(err)];
@@ -14910,6 +14914,23 @@ function isStaleMessageIdError(err) {
14910
14914
  return normalized.includes("erroritemnotfound") || normalized.includes("invalidid") || normalized.includes("object was not found in the store") || normalized.includes("not found in the store");
14911
14915
  });
14912
14916
  }
14917
+ async function translateMessageIdToImmutable(client, id) {
14918
+ for (const sourceIdType of EXCHANGE_ID_TRANSLATION_SOURCE_TYPES) {
14919
+ try {
14920
+ const res = await client.api("/me/translateExchangeIds").post({
14921
+ inputIds: [id],
14922
+ sourceIdType,
14923
+ targetIdType: "restImmutableEntryId"
14924
+ });
14925
+ const targetId = res.value?.find((item) => item.targetId)?.targetId;
14926
+ if (typeof targetId === "string" && targetId !== "" && targetId !== id) {
14927
+ return targetId;
14928
+ }
14929
+ } catch {
14930
+ }
14931
+ }
14932
+ return void 0;
14933
+ }
14913
14934
  async function probeMessage(client, id, useImmutableIds) {
14914
14935
  let req = client.api(`/me/messages/${encodeURIComponent(id)}`).select("id");
14915
14936
  if (useImmutableIds) req = req.header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER);
@@ -14918,27 +14939,58 @@ async function probeMessage(client, id, useImmutableIds) {
14918
14939
  async function probeSearchResult(client, id) {
14919
14940
  try {
14920
14941
  await probeMessage(client, id, true);
14921
- return true;
14942
+ return id;
14922
14943
  } catch (err) {
14923
14944
  if (!isStaleMessageIdError(err)) throw err;
14924
14945
  }
14925
14946
  try {
14926
14947
  await probeMessage(client, id, false);
14927
- return true;
14948
+ return id;
14949
+ } catch (err) {
14950
+ if (!isStaleMessageIdError(err)) throw err;
14951
+ }
14952
+ const translatedId = await translateMessageIdToImmutable(client, id);
14953
+ if (!translatedId) return void 0;
14954
+ try {
14955
+ await probeMessage(client, translatedId, true);
14956
+ return translatedId;
14928
14957
  } catch (err) {
14929
- if (isStaleMessageIdError(err)) return false;
14930
- throw err;
14958
+ if (!isStaleMessageIdError(err)) throw err;
14959
+ return void 0;
14931
14960
  }
14932
14961
  }
14962
+ async function getMessageById(client, id, useImmutableIds) {
14963
+ let req = client.api(`/me/messages/${encodeURIComponent(id)}`).select(FULL_MESSAGE_SELECT);
14964
+ if (useImmutableIds) req = req.header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER);
14965
+ return await req.get();
14966
+ }
14933
14967
  async function getMessage(client, id) {
14968
+ let lastStaleError;
14934
14969
  try {
14935
- const message2 = await client.api(`/me/messages/${encodeURIComponent(id)}`).header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER).select(FULL_MESSAGE_SELECT).get();
14936
- return { message: message2, useImmutableIds: true };
14970
+ return { message: await getMessageById(client, id, true), useImmutableIds: true };
14937
14971
  } catch (err) {
14938
14972
  if (!isStaleMessageIdError(err)) throw err;
14973
+ lastStaleError = err;
14939
14974
  }
14940
- const message = await client.api(`/me/messages/${encodeURIComponent(id)}`).select(FULL_MESSAGE_SELECT).get();
14941
- return { message, useImmutableIds: false };
14975
+ try {
14976
+ return { message: await getMessageById(client, id, false), useImmutableIds: false };
14977
+ } catch (err) {
14978
+ if (!isStaleMessageIdError(err)) throw err;
14979
+ lastStaleError = err;
14980
+ }
14981
+ const translatedId = await translateMessageIdToImmutable(client, id);
14982
+ if (translatedId) {
14983
+ try {
14984
+ return {
14985
+ message: await getMessageById(client, translatedId, true),
14986
+ useImmutableIds: true
14987
+ };
14988
+ } catch (err) {
14989
+ if (!isStaleMessageIdError(err)) throw err;
14990
+ lastStaleError = err;
14991
+ }
14992
+ }
14993
+ throw lastStaleError ?? new Error("Message not found");
14942
14994
  }
14943
14995
  async function listAttachments(client, messageId, useImmutableIds) {
14944
14996
  let req = client.api(`/me/messages/${encodeURIComponent(messageId)}/attachments`).select("id,name,contentType,size");
@@ -14956,14 +15008,36 @@ async function getAttachmentValue(client, messageId, attachmentId, useImmutableI
14956
15008
  if (useImmutableIds) req = req.header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER);
14957
15009
  return await req.get();
14958
15010
  }
15011
+ async function listMessagePage(client, folder, limit, skip, filterParts) {
15012
+ let req = client.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`).header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER).top(limit).skip(skip).select(MESSAGE_SELECT).orderby("receivedDateTime DESC");
15013
+ if (filterParts.length > 0) req = req.filter(filterParts.join(" and "));
15014
+ return await req.get();
15015
+ }
15016
+ function normalizeFolderDisplayName(value) {
15017
+ return value.trim().normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase();
15018
+ }
15019
+ async function resolveFolderIdByDisplayName(client, displayName) {
15020
+ const wanted = normalizeFolderDisplayName(displayName);
15021
+ if (!wanted) return void 0;
15022
+ const res = await client.api("/me/mailFolders").top(100).select("id,displayName").get();
15023
+ return (res.value ?? []).find(
15024
+ (folder) => normalizeFolderDisplayName(folder.displayName) === wanted
15025
+ )?.id;
15026
+ }
14959
15027
  async function listEmails(client, account, opts) {
14960
15028
  const limit = clampLimit(opts.limit, 25, 100);
14961
15029
  const folder = opts.folder ?? "inbox";
14962
15030
  const filterParts = [];
14963
15031
  if (opts.unreadOnly) filterParts.push("isRead eq false");
14964
- let req = client.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`).header("Prefer", OUTLOOK_IMMUTABLE_ID_PREFER).top(limit).skip(opts.skip ?? 0).select(MESSAGE_SELECT).orderby("receivedDateTime DESC");
14965
- if (filterParts.length > 0) req = req.filter(filterParts.join(" and "));
14966
- const res = await req.get();
15032
+ let res;
15033
+ try {
15034
+ res = await listMessagePage(client, folder, limit, opts.skip ?? 0, filterParts);
15035
+ } catch (err) {
15036
+ if (!isStaleMessageIdError(err)) throw err;
15037
+ const resolvedFolderId = await resolveFolderIdByDisplayName(client, folder);
15038
+ if (!resolvedFolderId || resolvedFolderId === folder) throw err;
15039
+ res = await listMessagePage(client, resolvedFolderId, limit, opts.skip ?? 0, filterParts);
15040
+ }
14967
15041
  return {
14968
15042
  items: res.value.map((m) => mapSummary(m, folder)),
14969
15043
  hasMore: !!res["@odata.nextLink"]
@@ -14975,8 +15049,8 @@ async function searchEmails(client, account, query, opts) {
14975
15049
  const summaries = res.value.map((m) => mapSummary(m));
14976
15050
  return Promise.all(
14977
15051
  summaries.map(async (summary) => {
14978
- const readable = await probeSearchResult(client, summary.id);
14979
- return readable ? summary : { ...summary, stale: true };
15052
+ const readableId = await probeSearchResult(client, summary.id);
15053
+ return readableId ? { ...summary, id: readableId } : { ...summary, stale: true };
14980
15054
  })
14981
15055
  );
14982
15056
  }
@@ -19064,7 +19138,7 @@ function registerTools(server, opts) {
19064
19138
  // package.json
19065
19139
  var package_default = {
19066
19140
  name: "hypermail-mcp",
19067
- version: "0.7.18",
19141
+ version: "0.7.19",
19068
19142
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
19069
19143
  type: "module",
19070
19144
  bin: {