hypermail-mcp 0.6.3 → 0.7.0

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,11 @@
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.0** — Email watch mode: background poll loop detects new inbox
7
+ > messages and POSTs them to a configurable webhook URL (e.g. Mastra). Opt-in —
8
+ > disabled by default, enabled via `HYPERMAIL_WATCH_ENABLED=true` or config.
9
+ > Works in both stdio and HTTP transport modes.
10
+ >
6
11
  > **v0.6.3** — Unify stdio and HTTP modes into a single feature set. Removed
7
12
  > email watch (inbox polling, SSE push, notification buffer), agent
8
13
  > multi-tenancy (`agents.yaml`, `x-api-key` auth, per-agent allowlists), and
@@ -149,6 +154,14 @@ looks for it in the same directory as `cli.js`.
149
154
  },
150
155
  "providers": {
151
156
  "outlook": { "clientId": "...", "tenantId": "..." }
157
+ },
158
+ "watch": {
159
+ "enabled": true,
160
+ "pollIntervalSeconds": 10,
161
+ "webhook": {
162
+ "url": "http://your-agent:3000/api/email-webhook",
163
+ "retry": { "maxAttempts": 5, "baseDelayMs": 1000 }
164
+ }
152
165
  }
153
166
  }
154
167
  ```
@@ -190,6 +203,49 @@ account store.
190
203
  | `mark_read` | `account`, `id` | Mark a message as read. Disabled under `--read-only`. |
191
204
  | `mark_unread` | `account`, `id` | Mark a message as unread. Disabled under `--read-only`. |
192
205
 
206
+ ## Email Watch
207
+
208
+ When enabled, hypermail-mcp runs a background poll loop that scans inboxes for
209
+ new messages and POSTs each one to a configurable webhook URL. Intended for
210
+ push-based email triage — downstream agents (e.g. Mastra) receive full email
211
+ content without polling.
212
+
213
+ ```jsonc
214
+ {
215
+ "watch": {
216
+ "enabled": true,
217
+ "pollIntervalSeconds": 10,
218
+ "webhook": {
219
+ "url": "http://localhost:3000/api/email-webhook",
220
+ "retry": { "maxAttempts": 5, "baseDelayMs": 1000 }
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ | Setting | Default | Notes |
227
+ | --- | --- | --- |
228
+ | `watch.enabled` | `false` | Toggle via config or `HYPERMAIL_WATCH_ENABLED=true` env var |
229
+ | `watch.pollIntervalSeconds` | `10` | Min 10s, max 3600s |
230
+ | `watch.webhook.url` | — | Endpoint that receives `POST` with `EmailFull` JSON |
231
+ | `watch.webhook.retry.maxAttempts` | `5` | Max delivery attempts (1–10) |
232
+ | `watch.webhook.retry.baseDelayMs` | `1000` | Base backoff delay (× 2^attempt) |
233
+
234
+ **Behavior:**
235
+ - Polls **all accounts** in the store, **inbox only**.
236
+ - Detects new emails via `lastSeenIds` (capped at 200) stored in the encrypted
237
+ account file — no duplicate emits across restarts.
238
+ - One `POST` per email (full body: subject, sender, text, HTML, attachments
239
+ metadata, thread ID via `EmailFull`).
240
+ - Delivery uses exponential backoff (`baseDelay × 2^attempt`). Retries on
241
+ non-2xx responses and connection errors. Logs and moves on after
242
+ `maxAttempts` exhausted — never blocks the poll loop.
243
+ - Works in both **stdio** and **HTTP** transport modes — the poll interval
244
+ fires normally alongside MCP message handling.
245
+
246
+ **Rate limits:** Polling every 10s on a single inbox = 6 req/min = 0.6% of
247
+ Microsoft Graph's 10,000 req/10min per-user limit. Safe for personal inboxes.
248
+
193
249
  ## Add-account flow (Outlook)
194
250
 
195
251
  1. Agent calls `add_account({ provider: "outlook" })`.
@@ -240,6 +296,10 @@ src/
240
296
  client.ts # Gmail API (googleapis)
241
297
  index.ts # GmailProvider implementation
242
298
  shared/ # shared utilities across providers
299
+ watcher/
300
+ manager.ts # WatcherManager — inbox poll loop + dedup
301
+ webhook.ts # HTTP POST with exponential backoff retry
302
+ index.ts # barrel export
243
303
  tools/
244
304
  index.ts # MCP tool registrations
245
305
  accounts.ts # list/add/remove/complete-add account tools
package/dist/cli.js CHANGED
@@ -3568,7 +3568,7 @@ function registerTools(server, opts) {
3568
3568
  // package.json
3569
3569
  var package_default = {
3570
3570
  name: "hypermail-mcp",
3571
- version: "0.6.3",
3571
+ version: "0.7.0",
3572
3572
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
3573
3573
  type: "module",
3574
3574
  bin: {
@@ -3638,6 +3638,116 @@ var package_default = {
3638
3638
  // src/version.ts
3639
3639
  var VERSION = package_default.version;
3640
3640
 
3641
+ // src/watcher/webhook.ts
3642
+ async function postWebhook(email, config) {
3643
+ if (!config.webhook) return false;
3644
+ const { url, retry } = config.webhook;
3645
+ const maxAttempts = retry.maxAttempts;
3646
+ const baseDelayMs = retry.baseDelayMs;
3647
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3648
+ if (attempt > 0) {
3649
+ const delay = baseDelayMs * 2 ** (attempt - 1);
3650
+ await sleep(delay);
3651
+ }
3652
+ try {
3653
+ const res = await fetch(url, {
3654
+ method: "POST",
3655
+ headers: { "content-type": "application/json" },
3656
+ body: JSON.stringify(email)
3657
+ });
3658
+ if (res.ok) return true;
3659
+ console.error(
3660
+ `[hypermail-watch] webhook POST ${email.id} attempt ${attempt + 1}/${maxAttempts}: HTTP ${res.status}`
3661
+ );
3662
+ } catch (err) {
3663
+ const code = err.code ?? "";
3664
+ console.error(
3665
+ `[hypermail-watch] webhook POST ${email.id} attempt ${attempt + 1}/${maxAttempts}: ${code || String(err)}`
3666
+ );
3667
+ }
3668
+ }
3669
+ console.error(
3670
+ `[hypermail-watch] webhook delivery failed after ${maxAttempts} retries for ${email.id}`
3671
+ );
3672
+ return false;
3673
+ }
3674
+ function sleep(ms) {
3675
+ return new Promise((resolve) => setTimeout(resolve, ms));
3676
+ }
3677
+
3678
+ // src/watcher/manager.ts
3679
+ var WatcherManager = class {
3680
+ constructor(store, registry, config) {
3681
+ this.store = store;
3682
+ this.registry = registry;
3683
+ this.config = config;
3684
+ }
3685
+ store;
3686
+ registry;
3687
+ config;
3688
+ intervalId = null;
3689
+ /** Start the poll loop. Fires immediately on the first tick, then every
3690
+ * `pollIntervalSeconds`. Safe to call multiple times — subsequent calls
3691
+ * are no-ops. */
3692
+ start() {
3693
+ if (this.intervalId !== null) return;
3694
+ this.poll();
3695
+ this.intervalId = setInterval(
3696
+ () => this.poll(),
3697
+ this.config.pollIntervalSeconds * 1e3
3698
+ );
3699
+ }
3700
+ /** Stop the poll loop and release the interval. Safe to call when already
3701
+ * stopped. */
3702
+ stop() {
3703
+ if (this.intervalId !== null) {
3704
+ clearInterval(this.intervalId);
3705
+ this.intervalId = null;
3706
+ }
3707
+ }
3708
+ // ── private ──
3709
+ async poll() {
3710
+ const accounts = this.store.listAccounts();
3711
+ for (const acct of accounts) {
3712
+ try {
3713
+ await this.pollAccount(acct.email);
3714
+ } catch (err) {
3715
+ console.error(
3716
+ `[hypermail-watch] poll failed for ${acct.email}:`,
3717
+ err
3718
+ );
3719
+ }
3720
+ }
3721
+ }
3722
+ async pollAccount(email) {
3723
+ const { provider, account } = this.registry.resolveByEmail(email);
3724
+ const result = await provider.listEmails(account, {
3725
+ folder: "inbox",
3726
+ limit: 50
3727
+ });
3728
+ const knownIds = [...account.lastSeenIds ?? []];
3729
+ const newEmails = result.items.filter((e) => !knownIds.includes(e.id));
3730
+ if (newEmails.length === 0) return;
3731
+ for (const summary of newEmails) {
3732
+ try {
3733
+ const full = await provider.readEmail(account, summary.id);
3734
+ await this.emit(full);
3735
+ knownIds.unshift(summary.id);
3736
+ } catch (err) {
3737
+ console.error(
3738
+ `[hypermail-watch] emission failed for ${email}/${summary.id}:`,
3739
+ err
3740
+ );
3741
+ }
3742
+ }
3743
+ const capped = knownIds.slice(0, 200);
3744
+ await this.store.upsertAccount({ ...account, lastSeenIds: capped });
3745
+ }
3746
+ async emit(full) {
3747
+ await postWebhook(full, this.config);
3748
+ }
3749
+ };
3750
+
3641
3751
  // src/config.ts
3642
3752
  import { readFileSync } from "fs";
3643
3753
  import { z as z7 } from "zod";
@@ -3663,8 +3773,15 @@ var providersConfigSchema = z7.object({
3663
3773
  gmail: gmailProviderSchema.optional()
3664
3774
  });
3665
3775
  var watchConfigSchema = z7.object({
3666
- enabled: z7.boolean().default(true),
3667
- pollIntervalSeconds: z7.number().int().min(10).max(3600).default(60)
3776
+ enabled: z7.boolean().default(false),
3777
+ pollIntervalSeconds: z7.number().int().min(10).max(3600).default(10),
3778
+ webhook: z7.object({
3779
+ url: z7.string(),
3780
+ retry: z7.object({
3781
+ maxAttempts: z7.number().int().min(1).max(10).default(5),
3782
+ baseDelayMs: z7.number().int().min(100).default(1e3)
3783
+ }).optional()
3784
+ }).optional()
3668
3785
  });
3669
3786
  var rawConfigSchema = z7.object({
3670
3787
  dataDir: z7.string().optional(),
@@ -3760,11 +3877,20 @@ function loadConfig(configPath, cliOverrides = {}) {
3760
3877
  port: cliOverrides.port ?? parsed.http?.port ?? 3e3,
3761
3878
  host: cliOverrides.host ?? parsed.http?.host ?? "127.0.0.1"
3762
3879
  };
3880
+ let watch;
3881
+ if (parsed.watch || process.env.HYPERMAIL_WATCH_ENABLED === "true") {
3882
+ watch = {
3883
+ enabled: process.env.HYPERMAIL_WATCH_ENABLED === "true" || Boolean(parsed.watch?.enabled),
3884
+ pollIntervalSeconds: parsed.watch?.pollIntervalSeconds ?? 10,
3885
+ webhook: parsed.watch?.webhook
3886
+ };
3887
+ }
3763
3888
  return {
3764
3889
  dataDir: cliOverrides.dataDir ?? parsed.dataDir ?? process.env.HYPERMAIL_MCP_DATA_DIR,
3765
3890
  http,
3766
3891
  tools: parsed.tools ? { disabled: parsed.tools.disabled, enabled: parsed.tools.enabled } : void 0,
3767
- providers: parsed.providers
3892
+ providers: parsed.providers,
3893
+ watch
3768
3894
  };
3769
3895
  }
3770
3896
  function resolveTools(config) {
@@ -3783,6 +3909,14 @@ async function startServer(opts) {
3783
3909
  const store = await AccountStore.open({ dataDir: config.dataDir });
3784
3910
  const registry = buildRegistry({ store, providers: config.providers });
3785
3911
  const tools = resolveTools(config);
3912
+ let watcher;
3913
+ if (config.watch?.enabled) {
3914
+ watcher = new WatcherManager(store, registry, config.watch);
3915
+ watcher.start();
3916
+ const stop = () => watcher?.stop();
3917
+ process.on("SIGTERM", stop);
3918
+ process.on("SIGINT", stop);
3919
+ }
3786
3920
  const createServer = () => {
3787
3921
  const s = new McpServer(
3788
3922
  { name: "hypermail-mcp", version: VERSION },