hypermail-mcp 0.7.17 → 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,19 @@
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
+ >
13
+ > **v0.7.18** — `search_emails` can now omit `account` to search all
14
+ > registered accounts in parallel, returning account-annotated results plus
15
+ > per-account errors without discarding successful matches. All-account
16
+ > `get_new_emails` polling also collects and hydrates per-account batches in
17
+ > parallel while preserving the global oldest-first limit.
18
+ >
6
19
  > **v0.7.17** — Hardened Outlook message IDs returned by `list_emails` and
7
20
  > `search_emails`: Outlook reads now request Microsoft Graph immutable IDs,
8
21
  > `search_emails` probes results and marks stale/not-found messages with
@@ -289,10 +302,10 @@ account store.
289
302
  | `get_account_settings` | `account` | Get signature (HTML) and style preferences for an account. |
290
303
  | `set_account_settings` | `account`, `signature?`, `signaturePath?`, `style?` | Set signature HTML (inline or via file path) and font preferences. |
291
304
  | `remove_account` | `email` | Deletes tokens for the account. |
292
- | `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. |
293
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. |
294
- | `search_emails` | `account`, `query`, `limit?` | KQL on Outlook. |
295
- | `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. |
296
309
  | `read_attachment` | `account`, `messageId`, `attachmentId` | Download an attachment to a temporary file and return its path. |
297
310
  | `archive_email` | `account`, `id` | Move a message to the Archive folder. |
298
311
  | `trash_email` | `account`, `id` | Move a message to Deleted Items (trash). |
@@ -319,6 +332,8 @@ tool on their own schedule, for example every 30–60 seconds.
319
332
  - `account` is optional. When omitted, the tool checks all registered accounts.
320
333
  - `limit` defaults to `10`. In all-account mode, the limit is a global total
321
334
  across accounts, selected by oldest `receivedAt` first.
335
+ - All-account polling performs per-account candidate collection and hydration in
336
+ parallel, then returns the combined batch in oldest-first order.
322
337
  - First use for an account initializes its checkpoint to the newest inbox email
323
338
  and returns no emails for that account.
324
339
  - Later calls return emails not previously returned by this tool, oldest first.
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;
14928
14949
  } catch (err) {
14929
- if (isStaleMessageIdError(err)) return false;
14930
- throw 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;
14957
+ } catch (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
  }
@@ -17839,20 +17913,36 @@ function registerNewEmailTool(server, ctx) {
17839
17913
  const collected = [];
17840
17914
  const providersByEmail = /* @__PURE__ */ new Map();
17841
17915
  const accountsByEmail = /* @__PURE__ */ new Map();
17842
- for (const stored of accounts) {
17843
- try {
17844
- const { provider, account } = registry.resolveByEmail(stored.email);
17845
- const result = await collectCandidatesForAccount(store, provider, account, logger);
17846
- providersByEmail.set(result.account.email, provider);
17847
- accountsByEmail.set(result.account.email, result.account);
17848
- collected.push(...result.candidates);
17849
- } catch (err) {
17850
- logger.debug("get-new-emails", "accountError", {
17851
- account: stored.email,
17852
- message: errMsg(err)
17853
- });
17854
- errors.push({ account: stored.email, message: errMsg(err) });
17916
+ const collectedByAccount = await Promise.all(
17917
+ accounts.map(async (stored) => {
17918
+ try {
17919
+ const { provider, account } = registry.resolveByEmail(stored.email);
17920
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17921
+ return {
17922
+ status: "ok",
17923
+ account: result.account,
17924
+ provider,
17925
+ candidates: result.candidates
17926
+ };
17927
+ } catch (err) {
17928
+ const message = errMsg(err);
17929
+ logger.debug("get-new-emails", "accountError", { account: stored.email, message });
17930
+ return {
17931
+ status: "error",
17932
+ account: stored.email,
17933
+ message
17934
+ };
17935
+ }
17936
+ })
17937
+ );
17938
+ for (const result of collectedByAccount) {
17939
+ if (result.status === "error") {
17940
+ errors.push({ account: result.account, message: result.message });
17941
+ continue;
17855
17942
  }
17943
+ providersByEmail.set(result.account.email, result.provider);
17944
+ accountsByEmail.set(result.account.email, result.account);
17945
+ collected.push(...result.candidates);
17856
17946
  }
17857
17947
  const selected = oldestCandidatesFirst(collected).slice(0, limit);
17858
17948
  logger.debug("get-new-emails", "selected", {
@@ -17871,27 +17961,33 @@ function registerNewEmailTool(server, ctx) {
17871
17961
  items.push(candidate);
17872
17962
  byAccount.set(candidate.account.email, items);
17873
17963
  }
17874
- for (const [email, accountCandidates] of byAccount) {
17875
- const provider = providersByEmail.get(email);
17876
- const account = accountsByEmail.get(email);
17877
- if (!provider || !account) continue;
17878
- try {
17879
- emails.push(
17880
- ...await hydrateAndAdvance(
17881
- store,
17882
- provider,
17883
- account,
17884
- accountCandidates,
17885
- logger
17886
- )
17887
- );
17888
- } catch (err) {
17889
- logger.debug("get-new-emails", "accountError", {
17890
- account: email,
17891
- message: errMsg(err)
17892
- });
17893
- errors.push({ account: email, message: errMsg(err) });
17964
+ const hydratedByAccount = await Promise.all(
17965
+ [...byAccount].map(async ([email, accountCandidates]) => {
17966
+ const provider = providersByEmail.get(email);
17967
+ const account = accountsByEmail.get(email);
17968
+ if (!provider || !account) return { status: "ok", emails: [] };
17969
+ try {
17970
+ return {
17971
+ status: "ok",
17972
+ emails: await hydrateAndAdvance(store, provider, account, accountCandidates, logger)
17973
+ };
17974
+ } catch (err) {
17975
+ const message = errMsg(err);
17976
+ logger.debug("get-new-emails", "accountError", { account: email, message });
17977
+ return {
17978
+ status: "error",
17979
+ account: email,
17980
+ message
17981
+ };
17982
+ }
17983
+ })
17984
+ );
17985
+ for (const result of hydratedByAccount) {
17986
+ if (result.status === "error") {
17987
+ errors.push({ account: result.account, message: result.message });
17988
+ continue;
17894
17989
  }
17990
+ emails.push(...result.emails);
17895
17991
  }
17896
17992
  }
17897
17993
  const orderedEmails = emails.sort(compareNewEmailOutputOldestFirst);
@@ -18082,10 +18178,15 @@ function registerBrowseTools(server, ctx) {
18082
18178
  skip: z5.number(),
18083
18179
  hasMore: z5.boolean()
18084
18180
  });
18181
+ const searchEmailSummaryOutputSchema = emailSummaryOutputSchema.extend({
18182
+ account: z5.string()
18183
+ });
18085
18184
  const searchEmailsOutputSchema = z5.object({
18086
18185
  account: z5.string(),
18087
18186
  count: z5.number(),
18088
- items: z5.array(emailSummaryOutputSchema)
18187
+ items: z5.array(searchEmailSummaryOutputSchema),
18188
+ accounts: z5.array(z5.string()).optional(),
18189
+ errors: z5.array(z5.object({ account: z5.string(), message: z5.string() })).optional()
18089
18190
  });
18090
18191
  if (shouldRegister("list_emails", tools)) {
18091
18192
  server.registerTool(
@@ -18129,29 +18230,67 @@ function registerBrowseTools(server, ctx) {
18129
18230
  server.registerTool(
18130
18231
  "search_emails",
18131
18232
  {
18132
- description: "Search emails by free-text query (KQL on Outlook). Returns lightweight summaries.",
18233
+ description: "Search emails by free-text query (KQL on Outlook). Returns lightweight summaries. Pass `account` to search one account, or omit it to search all registered accounts in parallel.",
18133
18234
  inputSchema: z5.object({
18134
- account: z5.string().email(),
18235
+ account: z5.string().email().optional(),
18135
18236
  query: z5.string().min(1),
18136
18237
  limit: z5.number().int().positive().max(100).optional()
18137
18238
  }),
18138
18239
  outputSchema: searchEmailsOutputSchema
18139
18240
  },
18140
18241
  async (args) => {
18141
- try {
18142
- const { provider, account } = registry.resolveByEmail(args.account);
18143
- const items = await provider.searchEmails(account, args.query, {
18144
- limit: args.limit
18145
- });
18146
- const data = {
18147
- account: account.email,
18148
- count: items.length,
18149
- items
18150
- };
18151
- return ok(data, data);
18152
- } catch (err) {
18153
- return fail(errMsg(err));
18242
+ if (args.account) {
18243
+ try {
18244
+ const { provider, account } = registry.resolveByEmail(args.account);
18245
+ const items2 = await provider.searchEmails(account, args.query, {
18246
+ limit: args.limit
18247
+ });
18248
+ const data2 = {
18249
+ account: account.email,
18250
+ count: items2.length,
18251
+ items: items2.map((item) => ({ ...item, account: account.email })),
18252
+ errors: []
18253
+ };
18254
+ return ok(data2, data2);
18255
+ } catch (err) {
18256
+ return fail(errMsg(err));
18257
+ }
18258
+ }
18259
+ const accounts = store.listAccounts();
18260
+ if (accounts.length === 0) {
18261
+ return fail("no accounts registered. Call add_account first.");
18154
18262
  }
18263
+ const results = await Promise.all(
18264
+ accounts.map(async (stored) => {
18265
+ try {
18266
+ const { provider, account } = registry.resolveByEmail(stored.email);
18267
+ const items2 = await provider.searchEmails(account, args.query, {
18268
+ limit: args.limit
18269
+ });
18270
+ return {
18271
+ account: account.email,
18272
+ items: items2.map((item) => ({ ...item, account: account.email }))
18273
+ };
18274
+ } catch (err) {
18275
+ return {
18276
+ account: stored.email,
18277
+ error: errMsg(err)
18278
+ };
18279
+ }
18280
+ })
18281
+ );
18282
+ const items = results.flatMap((result) => result.items ?? []);
18283
+ const errors = results.filter(
18284
+ (result) => "error" in result
18285
+ ).map((result) => ({ account: result.account, message: result.error }));
18286
+ const data = {
18287
+ account: "all",
18288
+ accounts: accounts.map((account) => account.email),
18289
+ count: items.length,
18290
+ items,
18291
+ errors
18292
+ };
18293
+ return ok(data, data);
18155
18294
  }
18156
18295
  );
18157
18296
  }
@@ -18999,7 +19138,7 @@ function registerTools(server, opts) {
18999
19138
  // package.json
19000
19139
  var package_default = {
19001
19140
  name: "hypermail-mcp",
19002
- version: "0.7.17",
19141
+ version: "0.7.19",
19003
19142
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
19004
19143
  type: "module",
19005
19144
  bin: {