hypermail-mcp 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,1101 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { randomUUID as randomUUID2 } from "crypto";
8
+ import { createServer as createHttpServer } from "http";
9
+
10
+ // src/store/account-store.ts
11
+ import {
12
+ createCipheriv,
13
+ createDecipheriv,
14
+ randomBytes,
15
+ createHash
16
+ } from "crypto";
17
+ import { promises as fs } from "fs";
18
+ import { homedir } from "os";
19
+ import path from "path";
20
+ var FILE_NAME = "accounts.json.enc";
21
+ var ALGO = "aes-256-gcm";
22
+ var KEY_LEN = 32;
23
+ var AccountStore = class _AccountStore {
24
+ constructor(filePath, key, data) {
25
+ this.filePath = filePath;
26
+ this.key = key;
27
+ this.data = data;
28
+ }
29
+ filePath;
30
+ key;
31
+ data;
32
+ static async open(opts = {}) {
33
+ const dataDir = resolveDataDir(opts.dataDir);
34
+ await fs.mkdir(dataDir, { recursive: true, mode: 448 });
35
+ const filePath = path.join(dataDir, FILE_NAME);
36
+ const key = opts.key ?? await resolveKey(dataDir);
37
+ let data;
38
+ try {
39
+ const buf = await fs.readFile(filePath);
40
+ data = decrypt(buf, key);
41
+ } catch (err) {
42
+ if (err.code === "ENOENT") {
43
+ data = { version: 1, accounts: [] };
44
+ } else {
45
+ throw err;
46
+ }
47
+ }
48
+ return new _AccountStore(filePath, key, data);
49
+ }
50
+ listAccounts() {
51
+ return this.data.accounts.map((a) => ({ ...a }));
52
+ }
53
+ getAccount(email) {
54
+ const norm = email.trim().toLowerCase();
55
+ const rec = this.data.accounts.find((a) => a.email.toLowerCase() === norm);
56
+ return rec ? { ...rec } : void 0;
57
+ }
58
+ async upsertAccount(rec) {
59
+ const norm = rec.email.trim().toLowerCase();
60
+ const next = { ...rec, email: norm };
61
+ const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
62
+ if (idx >= 0) this.data.accounts[idx] = next;
63
+ else this.data.accounts.push(next);
64
+ await this.flush();
65
+ return { ...next };
66
+ }
67
+ async removeAccount(email) {
68
+ const norm = email.trim().toLowerCase();
69
+ const before = this.data.accounts.length;
70
+ this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
71
+ if (this.data.accounts.length === before) return false;
72
+ await this.flush();
73
+ return true;
74
+ }
75
+ async flush() {
76
+ const buf = encrypt(this.data, this.key);
77
+ const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
78
+ await fs.writeFile(tmp, buf, { mode: 384 });
79
+ await fs.rename(tmp, this.filePath);
80
+ }
81
+ };
82
+ function encrypt(data, key) {
83
+ const iv = randomBytes(12);
84
+ const cipher = createCipheriv(ALGO, key, iv);
85
+ const plaintext = Buffer.from(JSON.stringify(data), "utf8");
86
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
87
+ const tag = cipher.getAuthTag();
88
+ return Buffer.concat([Buffer.from([1]), iv, tag, ct]);
89
+ }
90
+ function decrypt(buf, key) {
91
+ if (buf.length < 1 + 12 + 16 + 1) throw new Error("accounts file truncated");
92
+ const v = buf[0];
93
+ if (v !== 1) throw new Error(`unsupported accounts file version: ${v}`);
94
+ const iv = buf.subarray(1, 13);
95
+ const tag = buf.subarray(13, 29);
96
+ const ct = buf.subarray(29);
97
+ const decipher = createDecipheriv(ALGO, key, iv);
98
+ decipher.setAuthTag(tag);
99
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
100
+ const parsed = JSON.parse(pt.toString("utf8"));
101
+ if (parsed.version !== 1 || !Array.isArray(parsed.accounts)) {
102
+ throw new Error("accounts file is malformed");
103
+ }
104
+ return parsed;
105
+ }
106
+ function resolveDataDir(explicit) {
107
+ if (explicit && explicit.length > 0) return path.resolve(explicit);
108
+ const env = process.env.HYPERMAIL_MCP_DATA_DIR;
109
+ if (env && env.length > 0) return path.resolve(env);
110
+ return path.join(homedir(), ".hypermail-mcp");
111
+ }
112
+ function parseEnvKey(raw) {
113
+ const s = raw.trim();
114
+ if (/^[0-9a-fA-F]{64}$/.test(s)) return Buffer.from(s, "hex");
115
+ try {
116
+ const buf = Buffer.from(s, "base64");
117
+ if (buf.length === KEY_LEN) return buf;
118
+ } catch {
119
+ }
120
+ return createHash("sha256").update(s, "utf8").digest();
121
+ }
122
+ async function resolveKey(dataDir) {
123
+ const env = process.env.HYPERMAIL_MCP_KEY;
124
+ if (env && env.length > 0) {
125
+ const k = parseEnvKey(env);
126
+ if (k) return k;
127
+ }
128
+ const fromKeytar = await tryKeytarGet();
129
+ if (fromKeytar) return fromKeytar;
130
+ const keyFile = path.join(dataDir, "master.key");
131
+ try {
132
+ const existing = await fs.readFile(keyFile);
133
+ if (existing.length === KEY_LEN) return existing;
134
+ } catch {
135
+ }
136
+ const gen = randomBytes(KEY_LEN);
137
+ await fs.writeFile(keyFile, gen, { mode: 384 });
138
+ await tryKeytarSet(gen);
139
+ return gen;
140
+ }
141
+ async function tryKeytarGet() {
142
+ try {
143
+ const mod = await import("keytar");
144
+ const val = await mod.getPassword("hypermail-mcp", "master");
145
+ if (val) {
146
+ const buf = Buffer.from(val, "base64");
147
+ if (buf.length === KEY_LEN) return buf;
148
+ }
149
+ } catch {
150
+ }
151
+ return void 0;
152
+ }
153
+ async function tryKeytarSet(key) {
154
+ try {
155
+ const mod = await import("keytar");
156
+ await mod.setPassword("hypermail-mcp", "master", key.toString("base64"));
157
+ } catch {
158
+ }
159
+ }
160
+
161
+ // src/providers/outlook/index.ts
162
+ import { randomUUID } from "crypto";
163
+ import { writeFileSync } from "fs";
164
+ import { tmpdir } from "os";
165
+ import { join as pathJoin } from "path";
166
+ import { ResponseType } from "@microsoft/microsoft-graph-client";
167
+
168
+ // src/providers/outlook/client.ts
169
+ import "isomorphic-fetch";
170
+ import {
171
+ Client
172
+ } from "@microsoft/microsoft-graph-client";
173
+
174
+ // src/providers/outlook/auth.ts
175
+ import {
176
+ PublicClientApplication
177
+ } from "@azure/msal-node";
178
+ var DEFAULT_CLIENT_ID = "084a3e9f-a9f4-43f7-89f9-d229cf97853e";
179
+ var DEFAULT_SCOPES = [
180
+ "offline_access",
181
+ "User.Read",
182
+ "Mail.ReadWrite",
183
+ "Mail.Send"
184
+ ];
185
+ function makeConfig(prevCacheJson) {
186
+ const clientId = process.env.MS_CLIENT_ID || DEFAULT_CLIENT_ID;
187
+ const tenant = process.env.MS_TENANT_ID || "common";
188
+ return {
189
+ auth: {
190
+ clientId,
191
+ authority: `https://login.microsoftonline.com/${tenant}`
192
+ },
193
+ cache: prevCacheJson ? {
194
+ // msal-node supports an in-memory cache plugin; we hydrate manually
195
+ // below via deserialize after construction.
196
+ } : void 0
197
+ };
198
+ }
199
+ function buildPca(prevCacheJson) {
200
+ const pca = new PublicClientApplication(makeConfig(prevCacheJson));
201
+ if (prevCacheJson) {
202
+ pca.getTokenCache().deserialize(prevCacheJson);
203
+ }
204
+ return pca;
205
+ }
206
+ function beginDeviceCode(scopes = DEFAULT_SCOPES) {
207
+ const pca = buildPca();
208
+ let resolve;
209
+ let reject;
210
+ const result = new Promise(
211
+ (res, rej) => {
212
+ resolve = res;
213
+ reject = rej;
214
+ }
215
+ );
216
+ let userCode = "";
217
+ let verificationUri = "";
218
+ let message = "";
219
+ let expiresAt = new Date(Date.now() + 15 * 6e4).toISOString();
220
+ let aborted = false;
221
+ const ready = new Promise((r) => {
222
+ pca.acquireTokenByDeviceCode({
223
+ scopes,
224
+ deviceCodeCallback: (info) => {
225
+ if (!info.userCode || !info.verificationUri) {
226
+ reject(
227
+ new Error(
228
+ "Microsoft device-code endpoint returned no code. Check MS_CLIENT_ID is a valid Azure Entra public-client application."
229
+ )
230
+ );
231
+ return;
232
+ }
233
+ userCode = info.userCode;
234
+ verificationUri = info.verificationUri;
235
+ message = info.message;
236
+ if (info.expiresIn) {
237
+ expiresAt = new Date(Date.now() + info.expiresIn * 1e3).toISOString();
238
+ }
239
+ r();
240
+ }
241
+ }).then((authResult) => {
242
+ if (aborted) return;
243
+ if (!authResult || !authResult.account) {
244
+ reject(new Error("device-code flow returned no account"));
245
+ return;
246
+ }
247
+ const cache = pca.getTokenCache().serialize();
248
+ const tokens = {
249
+ msalCache: cache,
250
+ homeAccountId: authResult.account.homeAccountId,
251
+ tenantId: authResult.account.tenantId,
252
+ username: authResult.account.username,
253
+ scopes
254
+ };
255
+ resolve({ tokens, account: authResult.account });
256
+ }).catch((err) => {
257
+ if (!aborted) reject(err);
258
+ });
259
+ });
260
+ return {
261
+ // these are placeholders until ready resolves
262
+ get userCode() {
263
+ return userCode;
264
+ },
265
+ get verificationUri() {
266
+ return verificationUri;
267
+ },
268
+ get message() {
269
+ return message;
270
+ },
271
+ get expiresAt() {
272
+ return expiresAt;
273
+ },
274
+ result,
275
+ cancel() {
276
+ aborted = true;
277
+ },
278
+ // hidden helper for the caller to await initial code
279
+ // (typed via a cast below where used)
280
+ ...{ _ready: ready }
281
+ };
282
+ }
283
+ async function awaitDeviceCodeReady(b) {
284
+ const r = b._ready;
285
+ await r;
286
+ }
287
+ async function acquireAccessToken(tokens, scopes = DEFAULT_SCOPES) {
288
+ const pca = buildPca(tokens.msalCache);
289
+ const cache = pca.getTokenCache();
290
+ const account = await cache.getAccountByHomeId(tokens.homeAccountId) ?? (await cache.getAllAccounts()).find((a) => a.username === tokens.username);
291
+ if (!account) {
292
+ throw new Error("no MSAL account in cache \u2014 re-run add_account");
293
+ }
294
+ const res = await pca.acquireTokenSilent({ account, scopes });
295
+ if (!res?.accessToken) {
296
+ throw new Error("acquireTokenSilent returned no access token");
297
+ }
298
+ const next = {
299
+ ...tokens,
300
+ msalCache: cache.serialize(),
301
+ scopes
302
+ };
303
+ return { accessToken: res.accessToken, tokens: next };
304
+ }
305
+
306
+ // src/providers/outlook/client.ts
307
+ var OutlookClientFactory = class {
308
+ constructor(store) {
309
+ this.store = store;
310
+ }
311
+ store;
312
+ cache = /* @__PURE__ */ new Map();
313
+ get(account) {
314
+ const key = account.email.toLowerCase();
315
+ const existing = this.cache.get(key);
316
+ if (existing) return existing;
317
+ const store = this.store;
318
+ const provider = {
319
+ getAccessToken: async () => {
320
+ const fresh = store.getAccount(account.email) ?? account;
321
+ const tokens = fresh.tokens;
322
+ const { accessToken, tokens: nextTokens } = await acquireAccessToken(tokens);
323
+ if (nextTokens.msalCache !== tokens.msalCache) {
324
+ store.upsertAccount({
325
+ ...fresh,
326
+ tokens: nextTokens
327
+ }).catch(() => {
328
+ });
329
+ }
330
+ return accessToken;
331
+ }
332
+ };
333
+ const client = Client.initWithMiddleware({ authProvider: provider });
334
+ this.cache.set(key, client);
335
+ return client;
336
+ }
337
+ /** Drop a cached client (e.g. after removeAccount). */
338
+ invalidate(email) {
339
+ this.cache.delete(email.toLowerCase());
340
+ }
341
+ };
342
+
343
+ // src/providers/outlook/index.ts
344
+ var OutlookProvider = class {
345
+ constructor(opts) {
346
+ this.opts = opts;
347
+ this.clients = new OutlookClientFactory(opts.store);
348
+ }
349
+ opts;
350
+ id = "outlook";
351
+ clients;
352
+ pending = /* @__PURE__ */ new Map();
353
+ // ---------- account lifecycle ----------
354
+ async addAccount(input) {
355
+ const begin = beginDeviceCode();
356
+ await awaitDeviceCodeReady(begin);
357
+ const handle = randomUUID();
358
+ const flow = {
359
+ begin,
360
+ emailHint: input.email,
361
+ startedAt: Date.now(),
362
+ settled: "pending"
363
+ };
364
+ this.pending.set(handle, flow);
365
+ begin.result.then(async ({ tokens, account }) => {
366
+ const email = (account.username || input.email || "").toLowerCase();
367
+ if (!email) {
368
+ flow.settled = "error";
369
+ flow.error = "no email returned from Microsoft account";
370
+ return;
371
+ }
372
+ const rec = {
373
+ email,
374
+ provider: "outlook",
375
+ displayName: account.name ?? void 0,
376
+ tokens,
377
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
378
+ };
379
+ const saved = await this.opts.store.upsertAccount(rec);
380
+ flow.account = saved;
381
+ flow.settled = "ready";
382
+ }).catch((err) => {
383
+ flow.settled = "error";
384
+ flow.error = err instanceof Error ? err.message : String(err);
385
+ });
386
+ return {
387
+ status: "pending",
388
+ handle,
389
+ verification: {
390
+ userCode: begin.userCode,
391
+ verificationUri: begin.verificationUri,
392
+ expiresAt: begin.expiresAt,
393
+ message: begin.message
394
+ }
395
+ };
396
+ }
397
+ async completeAddAccount(handle) {
398
+ const flow = this.pending.get(handle);
399
+ if (!flow) return { status: "error", error: "unknown handle" };
400
+ if (Date.now() - flow.startedAt > 20 * 6e4 && flow.settled === "pending") {
401
+ flow.settled = "expired";
402
+ flow.begin.cancel();
403
+ }
404
+ if (flow.settled === "ready" && flow.account) {
405
+ this.pending.delete(handle);
406
+ return { status: "ready", account: flow.account };
407
+ }
408
+ if (flow.settled === "error") {
409
+ this.pending.delete(handle);
410
+ return { status: "error", error: flow.error ?? "unknown error" };
411
+ }
412
+ if (flow.settled === "expired") {
413
+ this.pending.delete(handle);
414
+ return { status: "expired" };
415
+ }
416
+ return { status: "pending" };
417
+ }
418
+ // ---------- email ops ----------
419
+ async listEmails(account, opts) {
420
+ const client = this.clients.get(account);
421
+ const limit = clampLimit(opts.limit, 25, 100);
422
+ const folder = opts.folder ?? "inbox";
423
+ const filterParts = [];
424
+ if (opts.unreadOnly) filterParts.push("isRead eq false");
425
+ let req = client.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`).top(limit).select([
426
+ "id",
427
+ "subject",
428
+ "from",
429
+ "toRecipients",
430
+ "receivedDateTime",
431
+ "bodyPreview",
432
+ "isRead",
433
+ "hasAttachments"
434
+ ].join(",")).orderby("receivedDateTime DESC");
435
+ if (filterParts.length > 0) req = req.filter(filterParts.join(" and "));
436
+ const res = await req.get();
437
+ return res.value.map((m) => mapSummary(m, folder));
438
+ }
439
+ async searchEmails(account, query, opts) {
440
+ const client = this.clients.get(account);
441
+ const limit = clampLimit(opts.limit, 25, 100);
442
+ const res = await client.api("/me/messages").header("ConsistencyLevel", "eventual").top(limit).search(`"${query.replace(/"/g, '\\"')}"`).select(
443
+ [
444
+ "id",
445
+ "subject",
446
+ "from",
447
+ "toRecipients",
448
+ "receivedDateTime",
449
+ "bodyPreview",
450
+ "isRead",
451
+ "hasAttachments"
452
+ ].join(",")
453
+ ).get();
454
+ return res.value.map((m) => mapSummary(m));
455
+ }
456
+ async readEmail(account, id) {
457
+ const client = this.clients.get(account);
458
+ const m = await client.api(`/me/messages/${encodeURIComponent(id)}`).select(
459
+ [
460
+ "id",
461
+ "subject",
462
+ "from",
463
+ "toRecipients",
464
+ "ccRecipients",
465
+ "bccRecipients",
466
+ "receivedDateTime",
467
+ "bodyPreview",
468
+ "isRead",
469
+ "hasAttachments",
470
+ "body"
471
+ ].join(",")
472
+ ).get();
473
+ let attachments = void 0;
474
+ if (m.hasAttachments) {
475
+ try {
476
+ const attRes = await client.api(`/me/messages/${encodeURIComponent(id)}/attachments`).select("id,name,contentType,size").get();
477
+ attachments = attRes.value.map((a) => ({
478
+ id: a.id,
479
+ name: a.name,
480
+ contentType: a.contentType,
481
+ size: a.size
482
+ }));
483
+ } catch {
484
+ }
485
+ }
486
+ const summary = mapSummary(m);
487
+ const body = m.body;
488
+ return {
489
+ ...summary,
490
+ cc: (m.ccRecipients ?? []).map(mapRecipient),
491
+ bcc: (m.bccRecipients ?? []).map(mapRecipient),
492
+ bodyText: body?.contentType === "text" ? body.content : void 0,
493
+ bodyHtml: body?.contentType === "html" ? body.content : void 0,
494
+ attachments
495
+ };
496
+ }
497
+ async readAttachment(account, messageId, attachmentId) {
498
+ const client = this.clients.get(account);
499
+ const att = await client.api(`/me/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`).select("name,contentType").get();
500
+ const data = await client.api(`/me/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}/$value`).responseType(ResponseType.ARRAYBUFFER).get();
501
+ const outPath = pathJoin(tmpdir(), att.name);
502
+ writeFileSync(outPath, Buffer.from(data));
503
+ return {
504
+ name: att.name,
505
+ contentType: att.contentType,
506
+ path: outPath
507
+ };
508
+ }
509
+ async sendEmail(account, msg) {
510
+ const client = this.clients.get(account);
511
+ const payload = {
512
+ message: {
513
+ subject: msg.subject,
514
+ body: {
515
+ contentType: msg.isHtml ? "HTML" : "Text",
516
+ content: msg.body
517
+ },
518
+ toRecipients: msg.to.map(toRecipient),
519
+ ccRecipients: (msg.cc ?? []).map(toRecipient),
520
+ bccRecipients: (msg.bcc ?? []).map(toRecipient)
521
+ },
522
+ saveToSentItems: true
523
+ };
524
+ await client.api("/me/sendMail").post(payload);
525
+ return { id: "" };
526
+ }
527
+ };
528
+ function mapRecipient(r) {
529
+ return {
530
+ name: r.emailAddress?.name,
531
+ address: r.emailAddress?.address ?? ""
532
+ };
533
+ }
534
+ function mapSummary(m, folder) {
535
+ return {
536
+ id: m.id,
537
+ subject: m.subject ?? "",
538
+ from: m.from ? mapRecipient(m.from) : void 0,
539
+ to: (m.toRecipients ?? []).map(mapRecipient),
540
+ receivedAt: m.receivedDateTime,
541
+ preview: m.bodyPreview,
542
+ isRead: m.isRead,
543
+ hasAttachments: m.hasAttachments,
544
+ folder
545
+ };
546
+ }
547
+ function toRecipient(a) {
548
+ return { emailAddress: { name: a.name, address: a.address } };
549
+ }
550
+ function clampLimit(v, dflt, max) {
551
+ if (!v || v <= 0) return dflt;
552
+ return Math.min(v, max);
553
+ }
554
+
555
+ // src/providers/imap/index.ts
556
+ var NOT_IMPLEMENTED = "IMAP provider is not yet implemented in v1. Tracked at src/providers/imap/index.ts \u2014 see src/providers/types.ts for the contract.";
557
+ var ImapProvider = class {
558
+ id = "imap";
559
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
560
+ async addAccount(_input) {
561
+ throw new Error(NOT_IMPLEMENTED);
562
+ }
563
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
564
+ async completeAddAccount(_handle) {
565
+ return { status: "error", error: NOT_IMPLEMENTED };
566
+ }
567
+ async listEmails(_account, _opts) {
568
+ throw new Error(NOT_IMPLEMENTED);
569
+ }
570
+ async searchEmails(_account, _query, _opts) {
571
+ throw new Error(NOT_IMPLEMENTED);
572
+ }
573
+ async readEmail(_account, _id) {
574
+ throw new Error(NOT_IMPLEMENTED);
575
+ }
576
+ async readAttachment(_account, _messageId, _attachmentId) {
577
+ throw new Error(NOT_IMPLEMENTED);
578
+ }
579
+ async sendEmail(_account, _msg) {
580
+ throw new Error(NOT_IMPLEMENTED);
581
+ }
582
+ };
583
+
584
+ // src/providers/registry.ts
585
+ function buildRegistry(opts) {
586
+ const providers = /* @__PURE__ */ new Map();
587
+ providers.set("outlook", new OutlookProvider({ store: opts.store }));
588
+ providers.set("imap", new ImapProvider());
589
+ function get(id) {
590
+ const p = providers.get(id);
591
+ if (!p) throw new Error(`unknown provider: ${id}`);
592
+ return p;
593
+ }
594
+ function resolveByEmail(email) {
595
+ const account = opts.store.getAccount(email);
596
+ if (!account) {
597
+ throw new Error(
598
+ `no account registered for "${email}". Call add_account first.`
599
+ );
600
+ }
601
+ return { provider: get(account.provider), account };
602
+ }
603
+ return {
604
+ get,
605
+ resolveByEmail,
606
+ list: () => Array.from(providers.values())
607
+ };
608
+ }
609
+
610
+ // src/tools/index.ts
611
+ import { z } from "zod";
612
+
613
+ // src/html-to-markdown.ts
614
+ import TurndownService from "turndown";
615
+ var turndown = new TurndownService();
616
+ function htmlToMarkdown(html) {
617
+ return turndown.turndown(html);
618
+ }
619
+ function selectBody(msg, format) {
620
+ switch (format) {
621
+ case "markdown": {
622
+ if (msg.bodyHtml) return htmlToMarkdown(msg.bodyHtml);
623
+ if (msg.bodyText) return msg.bodyText;
624
+ return "";
625
+ }
626
+ case "html": {
627
+ if (msg.bodyHtml) return msg.bodyHtml;
628
+ if (msg.bodyText) return msg.bodyText;
629
+ return "";
630
+ }
631
+ case "text": {
632
+ if (msg.bodyText) return msg.bodyText;
633
+ if (msg.bodyHtml) return msg.bodyHtml.replace(/<[^>]*>/g, "");
634
+ return "";
635
+ }
636
+ }
637
+ }
638
+
639
+ // src/tools/index.ts
640
+ function ok(data) {
641
+ return {
642
+ content: [
643
+ { type: "text", text: JSON.stringify(data, null, 2) }
644
+ ]
645
+ };
646
+ }
647
+ function fail(message) {
648
+ return {
649
+ isError: true,
650
+ content: [{ type: "text", text: message }]
651
+ };
652
+ }
653
+ var emailAddrSchema = z.object({
654
+ address: z.string().email(),
655
+ name: z.string().optional()
656
+ });
657
+ function registerTools(server, opts) {
658
+ const { store, registry, readOnly = false } = opts;
659
+ server.registerTool(
660
+ "list_accounts",
661
+ {
662
+ description: "List all email accounts known to this server (no secrets). Use the returned `email` value as the `account` argument to other tools.",
663
+ inputSchema: {}
664
+ },
665
+ async () => {
666
+ const rows = store.listAccounts().map((a) => ({
667
+ email: a.email,
668
+ provider: a.provider,
669
+ displayName: a.displayName,
670
+ addedAt: a.addedAt,
671
+ hasSignature: !!a.signature,
672
+ hasStyle: !!(a.style && (a.style.fontFamily || a.style.fontSize || a.style.fontColor))
673
+ }));
674
+ return ok({ accounts: rows });
675
+ }
676
+ );
677
+ server.registerTool(
678
+ "add_account",
679
+ {
680
+ description: "Start adding an email account. For Outlook this returns a device code the user must enter at the verification URL; then call `complete_add_account` with the returned `handle` to finalize. Disabled in --read-only mode.",
681
+ inputSchema: {
682
+ provider: z.enum(["outlook", "imap", "gmail"]).describe("Email backend. v1 only fully implements 'outlook'."),
683
+ email: z.string().email().optional().describe("Optional hint \u2014 the provider will verify it against the auth result."),
684
+ config: z.record(z.unknown()).optional().describe("Provider-specific config (e.g. IMAP host/port). Unused for Outlook.")
685
+ }
686
+ },
687
+ async (args) => {
688
+ if (readOnly) return fail("server is in --read-only mode; add_account is disabled");
689
+ const provider = registry.get(args.provider);
690
+ try {
691
+ const res = await provider.addAccount({ email: args.email, config: args.config });
692
+ return ok(res);
693
+ } catch (err) {
694
+ return fail(errMsg(err));
695
+ }
696
+ }
697
+ );
698
+ server.registerTool(
699
+ "complete_add_account",
700
+ {
701
+ description: "Poll/finalize a pending add_account flow. Returns `pending` until the user completes the device-code step, then `ready` with the persisted account.",
702
+ inputSchema: {
703
+ provider: z.enum(["outlook", "imap", "gmail"]),
704
+ handle: z.string().min(1)
705
+ }
706
+ },
707
+ async (args) => {
708
+ const provider = registry.get(args.provider);
709
+ if (!provider.completeAddAccount) {
710
+ return fail(`provider ${args.provider} has no async add-account flow`);
711
+ }
712
+ try {
713
+ const res = await provider.completeAddAccount(args.handle);
714
+ return ok(res);
715
+ } catch (err) {
716
+ return fail(errMsg(err));
717
+ }
718
+ }
719
+ );
720
+ server.registerTool(
721
+ "get_account_settings",
722
+ {
723
+ description: "Get signature (HTML) and style preferences for an account.",
724
+ inputSchema: { account: z.string().email() }
725
+ },
726
+ async (args) => {
727
+ try {
728
+ const acct = store.getAccount(args.account);
729
+ if (!acct) return fail(`no account registered for "${args.account}"`);
730
+ return ok({ signature: acct.signature ?? null, style: acct.style ?? null });
731
+ } catch (err) {
732
+ return fail(errMsg(err));
733
+ }
734
+ }
735
+ );
736
+ server.registerTool(
737
+ "set_account_settings",
738
+ {
739
+ description: "Set signature (HTML snippet) and/or style preferences for an account. Disabled in --read-only mode.",
740
+ inputSchema: {
741
+ account: z.string().email(),
742
+ signature: z.string().optional().describe("HTML snippet \u2014 may contain formatting, images, links. Pass null to clear."),
743
+ style: z.object({
744
+ fontFamily: z.string().optional(),
745
+ fontSize: z.string().optional(),
746
+ fontColor: z.string().optional()
747
+ }).optional().describe("Font preferences applied to outgoing HTML emails. Pass null to clear.")
748
+ }
749
+ },
750
+ async (args) => {
751
+ if (readOnly) return fail("server is in --read-only mode; set_account_settings is disabled");
752
+ try {
753
+ const acct = store.getAccount(args.account);
754
+ if (!acct) return fail(`no account registered for "${args.account}"`);
755
+ const updated = await store.upsertAccount({
756
+ ...acct,
757
+ signature: args.signature ?? acct.signature,
758
+ style: args.style ?? acct.style
759
+ });
760
+ return ok({ signature: updated.signature ?? null, style: updated.style ?? null });
761
+ } catch (err) {
762
+ return fail(errMsg(err));
763
+ }
764
+ }
765
+ );
766
+ server.registerTool(
767
+ "remove_account",
768
+ {
769
+ description: "Forget an account and delete its stored tokens. Disabled in --read-only mode.",
770
+ inputSchema: { email: z.string().email() }
771
+ },
772
+ async (args) => {
773
+ if (readOnly) return fail("server is in --read-only mode; remove_account is disabled");
774
+ const removed = await store.removeAccount(args.email);
775
+ return ok({ removed, email: args.email });
776
+ }
777
+ );
778
+ server.registerTool(
779
+ "list_emails",
780
+ {
781
+ description: "List recent emails in a folder of the given account. Pass the user's email address as `account`; the server routes to the correct backend automatically.",
782
+ inputSchema: {
783
+ account: z.string().email(),
784
+ folder: z.string().default("inbox").optional(),
785
+ limit: z.number().int().positive().max(100).optional(),
786
+ unreadOnly: z.boolean().optional()
787
+ }
788
+ },
789
+ async (args) => {
790
+ try {
791
+ const { provider, account } = registry.resolveByEmail(args.account);
792
+ const items = await provider.listEmails(account, {
793
+ folder: args.folder,
794
+ limit: args.limit,
795
+ unreadOnly: args.unreadOnly
796
+ });
797
+ return ok({ account: account.email, count: items.length, items });
798
+ } catch (err) {
799
+ return fail(errMsg(err));
800
+ }
801
+ }
802
+ );
803
+ server.registerTool(
804
+ "search_emails",
805
+ {
806
+ description: "Search emails by free-text query (KQL on Outlook). Returns lightweight summaries.",
807
+ inputSchema: {
808
+ account: z.string().email(),
809
+ query: z.string().min(1),
810
+ limit: z.number().int().positive().max(100).optional()
811
+ }
812
+ },
813
+ async (args) => {
814
+ try {
815
+ const { provider, account } = registry.resolveByEmail(args.account);
816
+ const items = await provider.searchEmails(account, args.query, {
817
+ limit: args.limit
818
+ });
819
+ return ok({ account: account.email, count: items.length, items });
820
+ } catch (err) {
821
+ return fail(errMsg(err));
822
+ }
823
+ }
824
+ );
825
+ server.registerTool(
826
+ "read_email",
827
+ {
828
+ description: "Fetch a single email with full body and recipients by id. Body is returned as `body` with `bodyFormat` indicating the format. Default format is 'markdown' \u2014 HTML is automatically converted to save context tokens.",
829
+ inputSchema: {
830
+ account: z.string().email(),
831
+ id: z.string().min(1),
832
+ format: z.enum(["markdown", "html", "text"]).default("markdown").optional().describe(
833
+ "Output body format. 'markdown' converts HTML to Markdown (default), 'html' returns the raw HTML, 'text' returns plain text."
834
+ )
835
+ }
836
+ },
837
+ async (args) => {
838
+ try {
839
+ const { provider, account } = registry.resolveByEmail(args.account);
840
+ const msg = await provider.readEmail(account, args.id);
841
+ const format = args.format ?? "markdown";
842
+ const body = selectBody(msg, format);
843
+ return ok({
844
+ id: msg.id,
845
+ subject: msg.subject,
846
+ from: msg.from,
847
+ to: msg.to,
848
+ cc: msg.cc,
849
+ bcc: msg.bcc,
850
+ receivedAt: msg.receivedAt,
851
+ preview: msg.preview,
852
+ isRead: msg.isRead,
853
+ hasAttachments: msg.hasAttachments,
854
+ folder: msg.folder,
855
+ attachments: msg.attachments,
856
+ body,
857
+ bodyFormat: format
858
+ });
859
+ } catch (err) {
860
+ return fail(errMsg(err));
861
+ }
862
+ }
863
+ );
864
+ server.registerTool(
865
+ "read_attachment",
866
+ {
867
+ description: "Download an email attachment to a temporary file and return its path. Use messageId and attachmentId from a prior read_email call.",
868
+ inputSchema: {
869
+ account: z.string().email(),
870
+ messageId: z.string().min(1),
871
+ attachmentId: z.string().min(1)
872
+ }
873
+ },
874
+ async (args) => {
875
+ try {
876
+ const { provider, account } = registry.resolveByEmail(args.account);
877
+ const res = await provider.readAttachment(account, args.messageId, args.attachmentId);
878
+ return ok(res);
879
+ } catch (err) {
880
+ return fail(errMsg(err));
881
+ }
882
+ }
883
+ );
884
+ server.registerTool(
885
+ "send_email",
886
+ {
887
+ description: "Send an email from the given account. Automatically appends the account's signature (HTML) and applies style preferences unless `remove_signature` is true. Disabled in --read-only mode.",
888
+ inputSchema: {
889
+ account: z.string().email(),
890
+ to: z.array(emailAddrSchema).min(1),
891
+ cc: z.array(emailAddrSchema).optional(),
892
+ bcc: z.array(emailAddrSchema).optional(),
893
+ subject: z.string(),
894
+ body: z.string(),
895
+ isHtml: z.boolean().optional(),
896
+ remove_signature: z.boolean().default(false).optional().describe("Skip appending the account signature. Style is still applied.")
897
+ }
898
+ },
899
+ async (args) => {
900
+ if (readOnly) return fail("server is in --read-only mode; send_email is disabled");
901
+ try {
902
+ const { provider, account } = registry.resolveByEmail(args.account);
903
+ const composed = composeBody({
904
+ body: args.body,
905
+ isHtml: args.isHtml,
906
+ signature: account.signature,
907
+ style: account.style,
908
+ removeSignature: args.remove_signature
909
+ });
910
+ const res = await provider.sendEmail(account, {
911
+ to: args.to,
912
+ cc: args.cc,
913
+ bcc: args.bcc,
914
+ subject: args.subject,
915
+ body: composed.body,
916
+ isHtml: composed.isHtml
917
+ });
918
+ return ok({ sent: true, ...res });
919
+ } catch (err) {
920
+ return fail(errMsg(err));
921
+ }
922
+ }
923
+ );
924
+ }
925
+ function composeBody(input) {
926
+ const { body, isHtml = false, signature, style, removeSignature = false } = input;
927
+ const hasSignature = !removeSignature && !!signature;
928
+ const hasStyle = !!(style && (style.fontFamily || style.fontSize || style.fontColor));
929
+ if (!hasSignature && !hasStyle) {
930
+ return { body, isHtml };
931
+ }
932
+ const styleAttr = hasStyle ? buildStyleAttr(style) : "";
933
+ if (isHtml) {
934
+ let result2 = hasStyle ? `<div style="${styleAttr}">${body}</div>` : body;
935
+ if (hasSignature) result2 += `
936
+ <div class="signature">${signature}</div>`;
937
+ return { body: result2, isHtml: true };
938
+ }
939
+ const escaped = escapeHtml(body);
940
+ let result = `<div style="${styleAttr}">${escaped}</div>`;
941
+ if (hasSignature) result += `
942
+ <div class="signature">${signature}</div>`;
943
+ return { body: result, isHtml: true };
944
+ }
945
+ function buildStyleAttr(style) {
946
+ const parts = [];
947
+ if (style.fontFamily) parts.push(`font-family: ${style.fontFamily}`);
948
+ if (style.fontSize) parts.push(`font-size: ${style.fontSize}`);
949
+ if (style.fontColor) parts.push(`color: ${style.fontColor}`);
950
+ return parts.join("; ");
951
+ }
952
+ function escapeHtml(text) {
953
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/\n/g, "<br>");
954
+ }
955
+ function errMsg(err) {
956
+ if (err instanceof Error) return err.message;
957
+ return String(err);
958
+ }
959
+
960
+ // src/version.ts
961
+ var VERSION = "0.1.0";
962
+
963
+ // src/server.ts
964
+ async function startServer(opts = {}) {
965
+ const store = await AccountStore.open({ dataDir: opts.dataDir });
966
+ const registry = buildRegistry({ store });
967
+ const server = new McpServer(
968
+ { name: "hypermail-mcp", version: VERSION },
969
+ { capabilities: { tools: {}, logging: {} } }
970
+ );
971
+ registerTools(server, { store, registry, readOnly: !!opts.readOnly });
972
+ if (opts.http) {
973
+ await startHttp(server, opts.host ?? "127.0.0.1", opts.port ?? 3e3);
974
+ } else {
975
+ const transport = new StdioServerTransport();
976
+ await server.connect(transport);
977
+ }
978
+ }
979
+ async function startHttp(server, host, port) {
980
+ const sessions = /* @__PURE__ */ new Map();
981
+ const http = createHttpServer(async (req, res) => {
982
+ try {
983
+ if (!req.url || !req.url.startsWith("/mcp")) {
984
+ res.statusCode = 404;
985
+ res.end("not found");
986
+ return;
987
+ }
988
+ const sessionId = req.headers["mcp-session-id"] ?? void 0;
989
+ let transport = sessionId ? sessions.get(sessionId) : void 0;
990
+ if (!transport) {
991
+ transport = new StreamableHTTPServerTransport({
992
+ sessionIdGenerator: () => randomUUID2(),
993
+ onsessioninitialized: (sid) => {
994
+ sessions.set(sid, transport);
995
+ }
996
+ });
997
+ transport.onclose = () => {
998
+ if (transport.sessionId) sessions.delete(transport.sessionId);
999
+ };
1000
+ await server.connect(transport);
1001
+ }
1002
+ let body = void 0;
1003
+ if (req.method === "POST" || req.method === "DELETE") {
1004
+ const chunks = [];
1005
+ for await (const chunk of req) chunks.push(chunk);
1006
+ const raw = Buffer.concat(chunks).toString("utf8");
1007
+ body = raw ? JSON.parse(raw) : void 0;
1008
+ }
1009
+ await transport.handleRequest(req, res, body);
1010
+ } catch (err) {
1011
+ console.error("[hypermail-mcp] http error:", err);
1012
+ if (!res.headersSent) {
1013
+ res.statusCode = 500;
1014
+ res.end("internal error");
1015
+ }
1016
+ }
1017
+ });
1018
+ await new Promise((resolve) => http.listen(port, host, resolve));
1019
+ console.error(`[hypermail-mcp] listening on http://${host}:${port}/mcp`);
1020
+ }
1021
+
1022
+ // src/cli.ts
1023
+ function parseArgs(argv) {
1024
+ const out = {
1025
+ http: false,
1026
+ port: 3e3,
1027
+ host: "127.0.0.1",
1028
+ readOnly: false,
1029
+ help: false
1030
+ };
1031
+ for (let i = 0; i < argv.length; i++) {
1032
+ const a = argv[i];
1033
+ switch (a) {
1034
+ case "--http":
1035
+ out.http = true;
1036
+ break;
1037
+ case "--port":
1038
+ out.port = Number(argv[++i] ?? "3000");
1039
+ break;
1040
+ case "--host":
1041
+ out.host = String(argv[++i] ?? "127.0.0.1");
1042
+ break;
1043
+ case "--data-dir":
1044
+ out.dataDir = String(argv[++i] ?? "");
1045
+ break;
1046
+ case "--read-only":
1047
+ out.readOnly = true;
1048
+ break;
1049
+ case "-h":
1050
+ case "--help":
1051
+ out.help = true;
1052
+ break;
1053
+ default:
1054
+ if (a && a.startsWith("--")) {
1055
+ }
1056
+ }
1057
+ }
1058
+ return out;
1059
+ }
1060
+ function printHelp() {
1061
+ const msg = `hypermail-mcp \u2014 unified email MCP server
1062
+
1063
+ Usage:
1064
+ hypermail-mcp [options]
1065
+
1066
+ Options:
1067
+ --http Run as Streamable HTTP server (default: stdio)
1068
+ --port <n> HTTP port (default: 3000)
1069
+ --host <addr> HTTP bind address (default: 127.0.0.1)
1070
+ --data-dir <path> Where to store the encrypted accounts file
1071
+ (default: $HYPERMAIL_MCP_DATA_DIR or ~/.hypermail-mcp)
1072
+ --read-only Disable tools that modify state (send_email, remove_account, add_account)
1073
+ -h, --help Show this help
1074
+
1075
+ Environment:
1076
+ HYPERMAIL_MCP_DATA_DIR Same as --data-dir
1077
+ HYPERMAIL_MCP_KEY 32-byte key (base64 or hex) for at-rest encryption
1078
+ MS_CLIENT_ID Azure AD public client (application) ID
1079
+ MS_TENANT_ID Tenant (default: "common")
1080
+ `;
1081
+ process.stdout.write(msg);
1082
+ }
1083
+ async function main() {
1084
+ const opts = parseArgs(process.argv.slice(2));
1085
+ if (opts.help) {
1086
+ printHelp();
1087
+ return;
1088
+ }
1089
+ await startServer({
1090
+ http: opts.http,
1091
+ port: opts.port,
1092
+ host: opts.host,
1093
+ dataDir: opts.dataDir,
1094
+ readOnly: opts.readOnly
1095
+ });
1096
+ }
1097
+ main().catch((err) => {
1098
+ console.error("[hypermail-mcp] fatal:", err);
1099
+ process.exit(1);
1100
+ });
1101
+ //# sourceMappingURL=cli.js.map