hypermail-mcp 0.6.0 → 0.6.1

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,10 @@
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.1** — Docker deployment (standalone Dockerfile with HEALTHCHECK),
7
+ > email notification bug fixes (ID-based dedup, pagination cap, dynamic
8
+ > re-scan), Node 22 base image, dropped docker-compose.
9
+ >
6
10
  > **v0.6.0** — Email watch notifications (polling-based), `signaturePath`
7
11
  > support in `set_account_settings` for loading signatures from files,
8
12
  > and a `check_notifications` tool for draining pending alerts.
@@ -72,6 +76,27 @@ hypermail-mcp --http --port 3000 --host 0.0.0.0
72
76
  When hosted you **must** set `HYPERMAIL_MCP_KEY` so the account file is
73
77
  reproducibly decryptable.
74
78
 
79
+ ### Docker
80
+
81
+ ```bash
82
+ # Build
83
+ docker build -t hypermail-mcp .
84
+
85
+ # Run
86
+ docker run -d \
87
+ --name hypermail-mcp \
88
+ -p 3000:3000 \
89
+ -e HYPERMAIL_MCP_KEY=<32-byte-key> \
90
+ -e MS_CLIENT_ID=<your-client-id> \
91
+ -e MS_TENANT_ID=<your-tenant-id> \
92
+ -v hypermail-data:/data \
93
+ hypermail-mcp
94
+ ```
95
+
96
+ The image runs the server in HTTP mode on port 3000 with a 30-second
97
+ HEALTHCHECK against `/mcp`. Data is persisted via a Docker volume at `/data`.
98
+ Pass `HYPERMAIL_AGENTS_CONFIG` and mount a config file for agent multi-tenancy.
99
+
75
100
  ### Development (HTTP mode + email watch)
76
101
 
77
102
  To test the email watch feature locally:
package/dist/cli.js CHANGED
@@ -3970,12 +3970,29 @@ var WatcherManager = class {
3970
3970
  running = false;
3971
3971
  /** Per-account inflight guards to prevent overlapping polls. */
3972
3972
  inflight = /* @__PURE__ */ new Map();
3973
+ /** Accounts with active polling timers (lowercased email). */
3974
+ tracked = /* @__PURE__ */ new Set();
3973
3975
  constructor(opts) {
3974
3976
  this.opts = opts;
3975
3977
  }
3976
3978
  start() {
3977
3979
  if (this.running) return;
3978
3980
  this.running = true;
3981
+ this.scanAccounts();
3982
+ const rescanTimer = setInterval(() => {
3983
+ if (!this.running) return;
3984
+ this.scanAccounts();
3985
+ }, this.opts.pollIntervalSeconds * 1e3);
3986
+ this.timers.push(rescanTimer);
3987
+ }
3988
+ stop() {
3989
+ this.running = false;
3990
+ for (const t of this.timers) clearInterval(t);
3991
+ this.timers = [];
3992
+ this.tracked.clear();
3993
+ }
3994
+ // ── internals ──
3995
+ scanAccounts() {
3979
3996
  let accounts = this.opts.store.listAccounts();
3980
3997
  if (this.opts.accountFilter) {
3981
3998
  const filter = new Set(this.opts.accountFilter.map((e) => e.toLowerCase()));
@@ -3985,13 +4002,10 @@ var WatcherManager = class {
3985
4002
  this.schedulePoll(account);
3986
4003
  }
3987
4004
  }
3988
- stop() {
3989
- this.running = false;
3990
- for (const t of this.timers) clearInterval(t);
3991
- this.timers = [];
3992
- }
3993
- // ── internals ──
3994
4005
  schedulePoll(account) {
4006
+ const key = account.email.toLowerCase();
4007
+ if (this.tracked.has(key)) return;
4008
+ this.tracked.add(key);
3995
4009
  this.pollAccount(account).catch(() => {
3996
4010
  });
3997
4011
  const timer = setInterval(() => {
@@ -4007,21 +4021,25 @@ var WatcherManager = class {
4007
4021
  this.inflight.set(key, true);
4008
4022
  try {
4009
4023
  const { provider } = this.opts.registry.resolveByEmail(account.email);
4010
- const lastSeen = account.lastSeenAt;
4024
+ const seenIds = new Set(account.lastSeenIds ?? []);
4025
+ const isFirstPoll = !account.lastSeenAt && !account.lastSeenIds?.length;
4011
4026
  const limit = 25;
4027
+ const MAX_PAGES = 5;
4012
4028
  let skip = 0;
4029
+ let pageCount = 0;
4013
4030
  const newEmails = [];
4014
- let newestTimestamp = lastSeen ?? "";
4031
+ let newestTimestamp = account.lastSeenAt ?? "";
4015
4032
  let hitBoundary = false;
4016
- while (true) {
4033
+ while (pageCount < MAX_PAGES) {
4017
4034
  const { items, hasMore } = await provider.listEmails(account, {
4018
4035
  folder: "inbox",
4019
4036
  limit,
4020
4037
  skip
4021
4038
  });
4039
+ pageCount++;
4022
4040
  for (const item of items) {
4023
4041
  if (!item.receivedAt) continue;
4024
- if (lastSeen && item.receivedAt <= lastSeen) {
4042
+ if (seenIds.has(item.id)) {
4025
4043
  hitBoundary = true;
4026
4044
  break;
4027
4045
  }
@@ -4033,11 +4051,7 @@ var WatcherManager = class {
4033
4051
  if (hitBoundary || !hasMore) break;
4034
4052
  skip += limit;
4035
4053
  }
4036
- if (!lastSeen) {
4037
- if (newEmails.length > 0) {
4038
- newestTimestamp = newEmails[0].receivedAt;
4039
- }
4040
- } else if (newEmails.length > 0) {
4054
+ if (!isFirstPoll && newEmails.length > 0) {
4041
4055
  this.enqueue({
4042
4056
  type: "new_emails",
4043
4057
  account: account.email,
@@ -4045,11 +4059,27 @@ var WatcherManager = class {
4045
4059
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4046
4060
  });
4047
4061
  }
4048
- if (newestTimestamp !== (lastSeen ?? "")) {
4062
+ if (isFirstPoll && newEmails.length === 0 && !newestTimestamp) {
4063
+ newestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
4064
+ }
4065
+ const newIds = newEmails.map((e) => e.id);
4066
+ const updatedLastSeenIds = [
4067
+ ...newIds,
4068
+ ...account.lastSeenIds ?? []
4069
+ ].slice(0, 200);
4070
+ try {
4049
4071
  await this.opts.store.upsertAccount({
4050
4072
  ...account,
4051
- lastSeenAt: newestTimestamp || void 0
4073
+ lastSeenAt: newestTimestamp || void 0,
4074
+ lastSeenIds: updatedLastSeenIds
4052
4075
  });
4076
+ } catch (storeErr) {
4077
+ console.error(
4078
+ "[hypermail-mcp] failed to persist poll state for",
4079
+ account.email,
4080
+ ":",
4081
+ storeErr instanceof Error ? storeErr.message : String(storeErr)
4082
+ );
4053
4083
  }
4054
4084
  } catch (err) {
4055
4085
  this.enqueue({
@@ -4186,7 +4216,8 @@ async function startServer(opts) {
4186
4216
  const store = await AccountStore.open({ dataDir: config.dataDir });
4187
4217
  const registry = buildRegistry({ store, providers: config.providers });
4188
4218
  const tools = resolveTools(config);
4189
- const notificationBuffer = config.http.enabled ? [] : void 0;
4219
+ const watchEnabled = config.http.enabled && config.watch?.enabled !== false;
4220
+ const notificationBuffer = watchEnabled ? [] : void 0;
4190
4221
  let agentStoreForFactory;
4191
4222
  const createServer = (agentContext = null) => {
4192
4223
  const s = new McpServer(
@@ -4211,18 +4242,30 @@ async function startServer(opts) {
4211
4242
  );
4212
4243
  }
4213
4244
  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);
4245
+ const accountFilter = agentStoreForFactory ? (() => {
4246
+ const all = /* @__PURE__ */ new Set();
4247
+ for (const agent of agentStoreForFactory.listAgents()) {
4248
+ for (const email of agent.accounts) {
4249
+ all.add(email.toLowerCase());
4221
4250
  }
4222
- },
4223
- buffer: notificationBuffer
4224
- });
4225
- watcher.start();
4251
+ }
4252
+ return all.size > 0 ? [...all] : void 0;
4253
+ })() : void 0;
4254
+ if (watchEnabled) {
4255
+ const watcher = new WatcherManager({
4256
+ registry,
4257
+ store,
4258
+ pollIntervalSeconds: config.watch?.pollIntervalSeconds ?? 60,
4259
+ accountFilter,
4260
+ onNotification: (notification) => {
4261
+ for (const fn of notifyTargets) {
4262
+ fn(notification);
4263
+ }
4264
+ },
4265
+ buffer: notificationBuffer
4266
+ });
4267
+ watcher.start();
4268
+ }
4226
4269
  await startHttp(
4227
4270
  createServer,
4228
4271
  config.http.host,