hypermail-mcp 0.6.0 → 0.6.2

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,14 @@
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.6.2** — Version source-of-truth fix: `version.ts` now imports directly
7
+ > from `package.json` instead of hardcoding, preventing version drift between
8
+ > the two files.
9
+ >
10
+ > **v0.6.1** — Docker deployment (standalone Dockerfile with HEALTHCHECK),
11
+ > email notification bug fixes (ID-based dedup, pagination cap, dynamic
12
+ > re-scan), Node 22 base image, dropped docker-compose.
13
+ >
6
14
  > **v0.6.0** — Email watch notifications (polling-based), `signaturePath`
7
15
  > support in `set_account_settings` for loading signatures from files,
8
16
  > and a `check_notifications` tool for draining pending alerts.
@@ -72,6 +80,27 @@ hypermail-mcp --http --port 3000 --host 0.0.0.0
72
80
  When hosted you **must** set `HYPERMAIL_MCP_KEY` so the account file is
73
81
  reproducibly decryptable.
74
82
 
83
+ ### Docker
84
+
85
+ ```bash
86
+ # Build
87
+ docker build -t hypermail-mcp .
88
+
89
+ # Run
90
+ docker run -d \
91
+ --name hypermail-mcp \
92
+ -p 3000:3000 \
93
+ -e HYPERMAIL_MCP_KEY=<32-byte-key> \
94
+ -e MS_CLIENT_ID=<your-client-id> \
95
+ -e MS_TENANT_ID=<your-tenant-id> \
96
+ -v hypermail-data:/data \
97
+ hypermail-mcp
98
+ ```
99
+
100
+ The image runs the server in HTTP mode on port 3000 with a 30-second
101
+ HEALTHCHECK against `/mcp`. Data is persisted via a Docker volume at `/data`.
102
+ Pass `HYPERMAIL_AGENTS_CONFIG` and mount a config file for agent multi-tenancy.
103
+
75
104
  ### Development (HTTP mode + email watch)
76
105
 
77
106
  To test the email watch feature locally:
package/dist/cli.js CHANGED
@@ -3819,8 +3819,80 @@ function registerTools(server, opts) {
3819
3819
  }
3820
3820
  }
3821
3821
 
3822
+ // package.json
3823
+ var package_default = {
3824
+ name: "hypermail-mcp",
3825
+ version: "0.6.2",
3826
+ description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
3827
+ type: "module",
3828
+ bin: {
3829
+ "hypermail-mcp": "dist/cli.js"
3830
+ },
3831
+ main: "dist/cli.js",
3832
+ files: [
3833
+ "dist",
3834
+ "README.md",
3835
+ "LICENSE"
3836
+ ],
3837
+ scripts: {
3838
+ build: "tsup",
3839
+ dev: "tsup --watch",
3840
+ "dev:http": "tsup && node dist/cli.js --http --config hypermail-config.http.json",
3841
+ start: "node dist/cli.js",
3842
+ typecheck: "tsc --noEmit",
3843
+ test: "vitest run",
3844
+ "test:watch": "vitest",
3845
+ prepublishOnly: "pnpm build && pnpm test"
3846
+ },
3847
+ engines: {
3848
+ node: ">=20"
3849
+ },
3850
+ keywords: [
3851
+ "mcp",
3852
+ "model-context-protocol",
3853
+ "email",
3854
+ "outlook",
3855
+ "microsoft-graph",
3856
+ "imap"
3857
+ ],
3858
+ license: "MIT",
3859
+ repository: {
3860
+ type: "git",
3861
+ url: "git+https://github.com/mateotiedra/hypermail-mcp.git"
3862
+ },
3863
+ bugs: {
3864
+ url: "https://github.com/mateotiedra/hypermail-mcp/issues"
3865
+ },
3866
+ dependencies: {
3867
+ "@azure/msal-node": "^2.16.2",
3868
+ "@microsoft/microsoft-graph-client": "^3.0.7",
3869
+ "@modelcontextprotocol/sdk": "^1.0.4",
3870
+ "google-auth-library": "^9.15.1",
3871
+ googleapis: "^144.0.0",
3872
+ imapflow: "^1.3.3",
3873
+ "isomorphic-fetch": "^3.0.0",
3874
+ "js-yaml": "^4.2.0",
3875
+ marked: "^18.0.4",
3876
+ nodemailer: "^8.0.8",
3877
+ turndown: "^7.2.4",
3878
+ zod: "^4.4.3"
3879
+ },
3880
+ optionalDependencies: {
3881
+ keytar: "^7.9.0"
3882
+ },
3883
+ devDependencies: {
3884
+ "@types/isomorphic-fetch": "^0.0.39",
3885
+ "@types/js-yaml": "^4.0.9",
3886
+ "@types/node": "^22.10.2",
3887
+ "@types/nodemailer": "^8.0.0",
3888
+ tsup: "^8.3.5",
3889
+ typescript: "^5.7.2",
3890
+ vitest: "^2.1.8"
3891
+ }
3892
+ };
3893
+
3822
3894
  // src/version.ts
3823
- var VERSION = "0.4.1";
3895
+ var VERSION = package_default.version;
3824
3896
 
3825
3897
  // src/config.ts
3826
3898
  import { readFileSync } from "fs";
@@ -3970,12 +4042,29 @@ var WatcherManager = class {
3970
4042
  running = false;
3971
4043
  /** Per-account inflight guards to prevent overlapping polls. */
3972
4044
  inflight = /* @__PURE__ */ new Map();
4045
+ /** Accounts with active polling timers (lowercased email). */
4046
+ tracked = /* @__PURE__ */ new Set();
3973
4047
  constructor(opts) {
3974
4048
  this.opts = opts;
3975
4049
  }
3976
4050
  start() {
3977
4051
  if (this.running) return;
3978
4052
  this.running = true;
4053
+ this.scanAccounts();
4054
+ const rescanTimer = setInterval(() => {
4055
+ if (!this.running) return;
4056
+ this.scanAccounts();
4057
+ }, this.opts.pollIntervalSeconds * 1e3);
4058
+ this.timers.push(rescanTimer);
4059
+ }
4060
+ stop() {
4061
+ this.running = false;
4062
+ for (const t of this.timers) clearInterval(t);
4063
+ this.timers = [];
4064
+ this.tracked.clear();
4065
+ }
4066
+ // ── internals ──
4067
+ scanAccounts() {
3979
4068
  let accounts = this.opts.store.listAccounts();
3980
4069
  if (this.opts.accountFilter) {
3981
4070
  const filter = new Set(this.opts.accountFilter.map((e) => e.toLowerCase()));
@@ -3985,13 +4074,10 @@ var WatcherManager = class {
3985
4074
  this.schedulePoll(account);
3986
4075
  }
3987
4076
  }
3988
- stop() {
3989
- this.running = false;
3990
- for (const t of this.timers) clearInterval(t);
3991
- this.timers = [];
3992
- }
3993
- // ── internals ──
3994
4077
  schedulePoll(account) {
4078
+ const key = account.email.toLowerCase();
4079
+ if (this.tracked.has(key)) return;
4080
+ this.tracked.add(key);
3995
4081
  this.pollAccount(account).catch(() => {
3996
4082
  });
3997
4083
  const timer = setInterval(() => {
@@ -4007,21 +4093,25 @@ var WatcherManager = class {
4007
4093
  this.inflight.set(key, true);
4008
4094
  try {
4009
4095
  const { provider } = this.opts.registry.resolveByEmail(account.email);
4010
- const lastSeen = account.lastSeenAt;
4096
+ const seenIds = new Set(account.lastSeenIds ?? []);
4097
+ const isFirstPoll = !account.lastSeenAt && !account.lastSeenIds?.length;
4011
4098
  const limit = 25;
4099
+ const MAX_PAGES = 5;
4012
4100
  let skip = 0;
4101
+ let pageCount = 0;
4013
4102
  const newEmails = [];
4014
- let newestTimestamp = lastSeen ?? "";
4103
+ let newestTimestamp = account.lastSeenAt ?? "";
4015
4104
  let hitBoundary = false;
4016
- while (true) {
4105
+ while (pageCount < MAX_PAGES) {
4017
4106
  const { items, hasMore } = await provider.listEmails(account, {
4018
4107
  folder: "inbox",
4019
4108
  limit,
4020
4109
  skip
4021
4110
  });
4111
+ pageCount++;
4022
4112
  for (const item of items) {
4023
4113
  if (!item.receivedAt) continue;
4024
- if (lastSeen && item.receivedAt <= lastSeen) {
4114
+ if (seenIds.has(item.id)) {
4025
4115
  hitBoundary = true;
4026
4116
  break;
4027
4117
  }
@@ -4033,11 +4123,7 @@ var WatcherManager = class {
4033
4123
  if (hitBoundary || !hasMore) break;
4034
4124
  skip += limit;
4035
4125
  }
4036
- if (!lastSeen) {
4037
- if (newEmails.length > 0) {
4038
- newestTimestamp = newEmails[0].receivedAt;
4039
- }
4040
- } else if (newEmails.length > 0) {
4126
+ if (!isFirstPoll && newEmails.length > 0) {
4041
4127
  this.enqueue({
4042
4128
  type: "new_emails",
4043
4129
  account: account.email,
@@ -4045,11 +4131,27 @@ var WatcherManager = class {
4045
4131
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4046
4132
  });
4047
4133
  }
4048
- if (newestTimestamp !== (lastSeen ?? "")) {
4134
+ if (isFirstPoll && newEmails.length === 0 && !newestTimestamp) {
4135
+ newestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
4136
+ }
4137
+ const newIds = newEmails.map((e) => e.id);
4138
+ const updatedLastSeenIds = [
4139
+ ...newIds,
4140
+ ...account.lastSeenIds ?? []
4141
+ ].slice(0, 200);
4142
+ try {
4049
4143
  await this.opts.store.upsertAccount({
4050
4144
  ...account,
4051
- lastSeenAt: newestTimestamp || void 0
4145
+ lastSeenAt: newestTimestamp || void 0,
4146
+ lastSeenIds: updatedLastSeenIds
4052
4147
  });
4148
+ } catch (storeErr) {
4149
+ console.error(
4150
+ "[hypermail-mcp] failed to persist poll state for",
4151
+ account.email,
4152
+ ":",
4153
+ storeErr instanceof Error ? storeErr.message : String(storeErr)
4154
+ );
4053
4155
  }
4054
4156
  } catch (err) {
4055
4157
  this.enqueue({
@@ -4186,7 +4288,8 @@ async function startServer(opts) {
4186
4288
  const store = await AccountStore.open({ dataDir: config.dataDir });
4187
4289
  const registry = buildRegistry({ store, providers: config.providers });
4188
4290
  const tools = resolveTools(config);
4189
- const notificationBuffer = config.http.enabled ? [] : void 0;
4291
+ const watchEnabled = config.http.enabled && config.watch?.enabled !== false;
4292
+ const notificationBuffer = watchEnabled ? [] : void 0;
4190
4293
  let agentStoreForFactory;
4191
4294
  const createServer = (agentContext = null) => {
4192
4295
  const s = new McpServer(
@@ -4211,18 +4314,30 @@ async function startServer(opts) {
4211
4314
  );
4212
4315
  }
4213
4316
  const notifyTargets = /* @__PURE__ */ new Set();
4214
- const watcher = new WatcherManager({
4215
- registry,
4216
- store,
4217
- pollIntervalSeconds: config.watch?.pollIntervalSeconds ?? 60,
4218
- onNotification: (notification) => {
4219
- for (const fn of notifyTargets) {
4220
- fn(notification);
4317
+ const accountFilter = agentStoreForFactory ? (() => {
4318
+ const all = /* @__PURE__ */ new Set();
4319
+ for (const agent of agentStoreForFactory.listAgents()) {
4320
+ for (const email of agent.accounts) {
4321
+ all.add(email.toLowerCase());
4221
4322
  }
4222
- },
4223
- buffer: notificationBuffer
4224
- });
4225
- watcher.start();
4323
+ }
4324
+ return all.size > 0 ? [...all] : void 0;
4325
+ })() : void 0;
4326
+ if (watchEnabled) {
4327
+ const watcher = new WatcherManager({
4328
+ registry,
4329
+ store,
4330
+ pollIntervalSeconds: config.watch?.pollIntervalSeconds ?? 60,
4331
+ accountFilter,
4332
+ onNotification: (notification) => {
4333
+ for (const fn of notifyTargets) {
4334
+ fn(notification);
4335
+ }
4336
+ },
4337
+ buffer: notificationBuffer
4338
+ });
4339
+ watcher.start();
4340
+ }
4226
4341
  await startHttp(
4227
4342
  createServer,
4228
4343
  config.http.host,