hypermail-mcp 0.4.0 → 0.4.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/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
- import { randomUUID as randomUUID2 } from "crypto";
7
+ import { randomUUID as randomUUID6 } from "crypto";
8
8
  import { createServer as createHttpServer } from "http";
9
9
 
10
10
  // src/store/account-store.ts
@@ -159,12 +159,31 @@ async function tryKeytarSet(key) {
159
159
  }
160
160
 
161
161
  // src/providers/outlook/index.ts
162
- import { randomUUID } from "crypto";
162
+ import { randomUUID as randomUUID2 } from "crypto";
163
163
  import { writeFileSync } from "fs";
164
164
  import { tmpdir } from "os";
165
165
  import { join as pathJoin } from "path";
166
166
  import { ResponseType } from "@microsoft/microsoft-graph-client";
167
167
 
168
+ // src/providers/shared/inline-images.ts
169
+ import { randomUUID } from "crypto";
170
+ function parseInlineImages(html) {
171
+ const images = [];
172
+ const re = /src="data:image\/([\w+]+);base64,([^"]+)"/gi;
173
+ const transformed = html.replace(re, (_fullMatch, mimeSubtype, b64) => {
174
+ const contentId = `sig-img-${randomUUID()}`;
175
+ const ext = mimeSubtype.toLowerCase().replace(/\+/g, "-") === "svg-xml" ? "svg" : mimeSubtype.toLowerCase().replace(/\+/g, "-");
176
+ images.push({
177
+ cid: contentId,
178
+ contentBytes: b64,
179
+ contentType: `image/${mimeSubtype}`,
180
+ filename: `signature-image.${ext}`
181
+ });
182
+ return `src="cid:${contentId}"`;
183
+ });
184
+ return { body: transformed, images };
185
+ }
186
+
168
187
  // src/providers/outlook/client.ts
169
188
  import "isomorphic-fetch";
170
189
  import {
@@ -182,9 +201,14 @@ var DEFAULT_SCOPES = [
182
201
  "Mail.ReadWrite",
183
202
  "Mail.Send"
184
203
  ];
185
- function makeConfig(prevCacheJson) {
186
- const clientId = process.env.MS_CLIENT_ID || DEFAULT_CLIENT_ID;
187
- const tenant = process.env.MS_TENANT_ID || "common";
204
+ function isSerializedTokens(obj) {
205
+ if (typeof obj !== "object" || obj === null) return false;
206
+ const o = obj;
207
+ return typeof o.msalCache === "string" && typeof o.homeAccountId === "string" && typeof o.tenantId === "string" && typeof o.username === "string" && Array.isArray(o.scopes);
208
+ }
209
+ function makeConfig(prevCacheJson, clientIdOverride, tenantOverride) {
210
+ const clientId = clientIdOverride || process.env.MS_CLIENT_ID || DEFAULT_CLIENT_ID;
211
+ const tenant = tenantOverride || process.env.MS_TENANT_ID || "common";
188
212
  return {
189
213
  auth: {
190
214
  clientId,
@@ -196,15 +220,15 @@ function makeConfig(prevCacheJson) {
196
220
  } : void 0
197
221
  };
198
222
  }
199
- function buildPca(prevCacheJson) {
200
- const pca = new PublicClientApplication(makeConfig(prevCacheJson));
223
+ function buildPca(prevCacheJson, clientIdOverride, tenantOverride) {
224
+ const pca = new PublicClientApplication(makeConfig(prevCacheJson, clientIdOverride, tenantOverride));
201
225
  if (prevCacheJson) {
202
226
  pca.getTokenCache().deserialize(prevCacheJson);
203
227
  }
204
228
  return pca;
205
229
  }
206
- function beginDeviceCode(scopes = DEFAULT_SCOPES) {
207
- const pca = buildPca();
230
+ function beginDeviceCode(scopes = DEFAULT_SCOPES, clientIdOverride, tenantOverride) {
231
+ const pca = buildPca(void 0, clientIdOverride, tenantOverride);
208
232
  let resolve;
209
233
  let reject;
210
234
  const result = new Promise(
@@ -284,8 +308,8 @@ async function awaitDeviceCodeReady(b) {
284
308
  const r = b._ready;
285
309
  await r;
286
310
  }
287
- async function acquireAccessToken(tokens, scopes = DEFAULT_SCOPES) {
288
- const pca = buildPca(tokens.msalCache);
311
+ async function acquireAccessToken(tokens, scopes = DEFAULT_SCOPES, clientIdOverride, tenantOverride) {
312
+ const pca = buildPca(tokens.msalCache, clientIdOverride, tenantOverride);
289
313
  const cache = pca.getTokenCache();
290
314
  const account = await cache.getAccountByHomeId(tokens.homeAccountId) ?? (await cache.getAllAccounts()).find((a) => a.username === tokens.username);
291
315
  if (!account) {
@@ -305,10 +329,14 @@ async function acquireAccessToken(tokens, scopes = DEFAULT_SCOPES) {
305
329
 
306
330
  // src/providers/outlook/client.ts
307
331
  var OutlookClientFactory = class {
308
- constructor(store) {
332
+ constructor(store, clientId, tenantId) {
309
333
  this.store = store;
334
+ this.clientId = clientId;
335
+ this.tenantId = tenantId;
310
336
  }
311
337
  store;
338
+ clientId;
339
+ tenantId;
312
340
  cache = /* @__PURE__ */ new Map();
313
341
  get(account) {
314
342
  const key = account.email.toLowerCase();
@@ -318,8 +346,18 @@ var OutlookClientFactory = class {
318
346
  const provider = {
319
347
  getAccessToken: async () => {
320
348
  const fresh = store.getAccount(account.email) ?? account;
349
+ if (!isSerializedTokens(fresh.tokens)) {
350
+ throw new Error(
351
+ "Outlook account tokens are missing or corrupted \u2014 re-run add_account"
352
+ );
353
+ }
321
354
  const tokens = fresh.tokens;
322
- const { accessToken, tokens: nextTokens } = await acquireAccessToken(tokens);
355
+ const { accessToken, tokens: nextTokens } = await acquireAccessToken(
356
+ tokens,
357
+ void 0,
358
+ this.clientId,
359
+ this.tenantId
360
+ );
323
361
  if (nextTokens.msalCache !== tokens.msalCache) {
324
362
  store.upsertAccount({
325
363
  ...fresh,
@@ -342,37 +380,35 @@ var OutlookClientFactory = class {
342
380
 
343
381
  // src/providers/outlook/index.ts
344
382
  function convertInlineImages(body) {
345
- const attachments = [];
346
- const re = /src="data:image\/([\w+]+);base64,([^"]+)"/gi;
347
- const transformed = body.replace(re, (_fullMatch, mimeSubtype, b64) => {
348
- const contentId = `sig-img-${randomUUID()}`;
349
- const ext = mimeSubtype.toLowerCase().replace(/\+/g, "-") === "svg-xml" ? "svg" : mimeSubtype.toLowerCase().replace(/\+/g, "-");
350
- attachments.push({
351
- "@odata.type": "#microsoft.graph.fileAttachment",
352
- name: `signature-image.${ext}`,
353
- contentType: `image/${mimeSubtype}`,
354
- contentId,
355
- contentBytes: b64,
356
- isInline: true
357
- });
358
- return `src="cid:${contentId}"`;
359
- });
383
+ const { body: transformed, images } = parseInlineImages(body);
384
+ const attachments = images.map((img) => ({
385
+ "@odata.type": "#microsoft.graph.fileAttachment",
386
+ name: img.filename,
387
+ contentType: img.contentType,
388
+ contentId: img.cid,
389
+ contentBytes: img.contentBytes,
390
+ isInline: true
391
+ }));
360
392
  return { body: transformed, attachments };
361
393
  }
362
394
  var OutlookProvider = class {
363
395
  constructor(opts) {
364
396
  this.opts = opts;
365
- this.clients = new OutlookClientFactory(opts.store);
397
+ this.clientId = opts.clientId;
398
+ this.tenantId = opts.tenantId;
399
+ this.clients = new OutlookClientFactory(opts.store, opts.clientId, opts.tenantId);
366
400
  }
367
401
  opts;
368
402
  id = "outlook";
369
403
  clients;
370
404
  pending = /* @__PURE__ */ new Map();
405
+ clientId;
406
+ tenantId;
371
407
  // ---------- account lifecycle ----------
372
408
  async addAccount(input) {
373
- const begin = beginDeviceCode();
409
+ const begin = beginDeviceCode(void 0, this.clientId, this.tenantId);
374
410
  await awaitDeviceCodeReady(begin);
375
- const handle = randomUUID();
411
+ const handle = randomUUID2();
376
412
  const flow = {
377
413
  begin,
378
414
  emailHint: input.email,
@@ -440,7 +476,7 @@ var OutlookProvider = class {
440
476
  const folder = opts.folder ?? "inbox";
441
477
  const filterParts = [];
442
478
  if (opts.unreadOnly) filterParts.push("isRead eq false");
443
- let req = client.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`).top(limit).select([
479
+ let req = client.api(`/me/mailFolders/${encodeURIComponent(folder)}/messages`).top(limit).skip(opts.skip ?? 0).select([
444
480
  "id",
445
481
  "subject",
446
482
  "from",
@@ -452,7 +488,10 @@ var OutlookProvider = class {
452
488
  ].join(",")).orderby("receivedDateTime DESC");
453
489
  if (filterParts.length > 0) req = req.filter(filterParts.join(" and "));
454
490
  const res = await req.get();
455
- return res.value.map((m) => mapSummary(m, folder));
491
+ return {
492
+ items: res.value.map((m) => mapSummary(m, folder)),
493
+ hasMore: !!res["@odata.nextLink"]
494
+ };
456
495
  }
457
496
  async searchEmails(account, query, opts) {
458
497
  const client = this.clients.get(account);
@@ -543,83 +582,25 @@ var OutlookProvider = class {
543
582
  }
544
583
  return draft.id;
545
584
  }
546
- async sendEmail(account, msg) {
547
- const client = this.clients.get(account);
548
- if (msg.inReplyTo && msg.forwardMessageId) {
549
- throw new Error(
550
- "inReplyTo and forwardMessageId are mutually exclusive \u2014 use one or the other"
551
- );
552
- }
553
- const converted = convertInlineImages(msg.body);
554
- if (msg.forwardMessageId) {
555
- const draftId = await this.buildDraftFromReference(
556
- client,
557
- `/me/messages/${encodeURIComponent(msg.forwardMessageId)}/createForward`,
558
- {
559
- message: {
560
- toRecipients: msg.to.map(toRecipient),
561
- ccRecipients: (msg.cc ?? []).map(toRecipient),
562
- bccRecipients: (msg.bcc ?? []).map(toRecipient)
563
- },
564
- comment: ""
565
- },
566
- converted
567
- );
568
- await client.api(`/me/messages/${draftId}/send`).post({});
569
- return { id: draftId };
570
- }
571
- if (msg.inReplyTo) {
572
- const createEndpoint = msg.replyAll ? `/me/messages/${encodeURIComponent(msg.inReplyTo)}/createReplyAll` : `/me/messages/${encodeURIComponent(msg.inReplyTo)}/createReply`;
573
- const draftId = await this.buildDraftFromReference(
574
- client,
575
- createEndpoint,
576
- {},
577
- converted
578
- );
579
- await client.api(`/me/messages/${draftId}/send`).post({});
580
- return { id: draftId };
581
- }
582
- const payload = {
583
- message: {
584
- subject: msg.subject,
585
- body: {
586
- contentType: msg.isHtml ? "HTML" : "Text",
587
- content: converted.body
588
- },
589
- toRecipients: msg.to.map(toRecipient),
590
- ccRecipients: (msg.cc ?? []).map(toRecipient),
591
- bccRecipients: (msg.bcc ?? []).map(toRecipient)
592
- },
593
- saveToSentItems: true
594
- };
595
- if (converted.attachments.length > 0) {
596
- payload.message.attachments = converted.attachments;
597
- }
598
- await client.api("/me/sendMail").post(payload);
599
- return { id: "" };
600
- }
601
- async saveDraft(account, msg) {
585
+ // Shared backend for sendEmail and saveDraft — handles forward, reply, and
586
+ // new-message paths. The `mode` controls whether the message is sent
587
+ // immediately or saved as a draft.
588
+ async sendOrSave(account, msg, mode) {
602
589
  const client = this.clients.get(account);
603
- if (msg.inReplyTo && msg.forwardMessageId) {
604
- throw new Error(
605
- "inReplyTo and forwardMessageId are mutually exclusive \u2014 use one or the other"
606
- );
607
- }
608
590
  const converted = convertInlineImages(msg.body);
591
+ const toRecipients = msg.to.map(toRecipient);
592
+ const ccRecipients = (msg.cc ?? []).map(toRecipient);
593
+ const bccRecipients = (msg.bcc ?? []).map(toRecipient);
609
594
  if (msg.forwardMessageId) {
610
595
  const draftId = await this.buildDraftFromReference(
611
596
  client,
612
597
  `/me/messages/${encodeURIComponent(msg.forwardMessageId)}/createForward`,
613
- {
614
- message: {
615
- toRecipients: msg.to.map(toRecipient),
616
- ccRecipients: (msg.cc ?? []).map(toRecipient),
617
- bccRecipients: (msg.bcc ?? []).map(toRecipient)
618
- },
619
- comment: ""
620
- },
598
+ { message: { toRecipients, ccRecipients, bccRecipients }, comment: "" },
621
599
  converted
622
600
  );
601
+ if (mode === "send") {
602
+ await client.api(`/me/messages/${draftId}/send`).post({});
603
+ }
623
604
  return { id: draftId };
624
605
  }
625
606
  if (msg.inReplyTo) {
@@ -630,24 +611,40 @@ var OutlookProvider = class {
630
611
  {},
631
612
  converted
632
613
  );
614
+ if (mode === "send") {
615
+ await client.api(`/me/messages/${draftId}/send`).post({});
616
+ }
633
617
  return { id: draftId };
634
618
  }
635
- const draftPayload = {
619
+ const messagePayload = {
636
620
  subject: msg.subject,
637
621
  body: {
638
622
  contentType: msg.isHtml ? "HTML" : "Text",
639
623
  content: converted.body
640
624
  },
641
- toRecipients: msg.to.map(toRecipient),
642
- ccRecipients: (msg.cc ?? []).map(toRecipient),
643
- bccRecipients: (msg.bcc ?? []).map(toRecipient)
625
+ toRecipients,
626
+ ccRecipients,
627
+ bccRecipients
644
628
  };
645
629
  if (converted.attachments.length > 0) {
646
- draftPayload.attachments = converted.attachments;
630
+ messagePayload.attachments = converted.attachments;
631
+ }
632
+ if (mode === "send") {
633
+ await client.api("/me/sendMail").post({
634
+ message: messagePayload,
635
+ saveToSentItems: true
636
+ });
637
+ return { id: "" };
647
638
  }
648
- const draft = await client.api("/me/messages").post(draftPayload);
639
+ const draft = await client.api("/me/messages").post(messagePayload);
649
640
  return { id: draft.id };
650
641
  }
642
+ async sendEmail(account, msg) {
643
+ return this.sendOrSave(account, msg, "send");
644
+ }
645
+ async saveDraft(account, msg) {
646
+ return this.sendOrSave(account, msg, "draft");
647
+ }
651
648
  async updateDraft(account, id, update) {
652
649
  const client = this.clients.get(account);
653
650
  const payload = {};
@@ -680,7 +677,67 @@ var OutlookProvider = class {
680
677
  const client = this.clients.get(account);
681
678
  await client.api(`/me/messages/${encodeURIComponent(id)}/move`).post({ destinationId });
682
679
  }
680
+ async sendDraft(account, id) {
681
+ const client = this.clients.get(account);
682
+ await client.api(`/me/messages/${encodeURIComponent(id)}/send`).post({});
683
+ return { id };
684
+ }
685
+ async addAttachmentToDraft(account, draftId, name, contentBytes, contentType) {
686
+ const client = this.clients.get(account);
687
+ const att = await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post({
688
+ "@odata.type": "#microsoft.graph.fileAttachment",
689
+ name,
690
+ contentType: contentType ?? "application/octet-stream",
691
+ contentBytes
692
+ });
693
+ return {
694
+ id: draftId,
695
+ attachment: { id: att.id, name: att.name, contentType: att.contentType }
696
+ };
697
+ }
698
+ async markRead(account, id, isRead) {
699
+ const client = this.clients.get(account);
700
+ await client.api(`/me/messages/${encodeURIComponent(id)}`).patch({ isRead });
701
+ }
702
+ async listFolders(account, opts) {
703
+ const client = this.clients.get(account);
704
+ const endpoint = opts.parentFolderId ? `/me/mailFolders/${encodeURIComponent(opts.parentFolderId)}/childFolders` : "/me/mailFolders";
705
+ const res = await client.api(endpoint).select([
706
+ "id",
707
+ "displayName",
708
+ "parentFolderId",
709
+ "childFolderCount",
710
+ "totalItemCount",
711
+ "unreadItemCount"
712
+ ].join(",")).get();
713
+ return (res.value ?? []).map(mapFolder);
714
+ }
715
+ async createFolder(account, input) {
716
+ const client = this.clients.get(account);
717
+ const parentId = input.parentFolderId ?? "msgfolderroot";
718
+ const created = await client.api(`/me/mailFolders/${encodeURIComponent(parentId)}/childFolders`).post({ displayName: input.displayName });
719
+ return mapFolder(created);
720
+ }
721
+ async renameFolder(account, folderId, newName) {
722
+ const client = this.clients.get(account);
723
+ const updated = await client.api(`/me/mailFolders/${encodeURIComponent(folderId)}`).patch({ displayName: newName });
724
+ return mapFolder(updated);
725
+ }
726
+ async deleteFolder(account, folderId) {
727
+ const client = this.clients.get(account);
728
+ await client.api(`/me/mailFolders/${encodeURIComponent(folderId)}`).delete();
729
+ }
683
730
  };
731
+ function mapFolder(f) {
732
+ return {
733
+ id: f.id,
734
+ displayName: f.displayName,
735
+ parentFolderId: f.parentFolderId,
736
+ childFolderCount: f.childFolderCount,
737
+ totalItemCount: f.totalItemCount,
738
+ unreadItemCount: f.unreadItemCount
739
+ };
740
+ }
684
741
  function mapRecipient(r) {
685
742
  return {
686
743
  name: r.emailAddress?.name,
@@ -708,133 +765,1750 @@ function clampLimit(v, dflt, max) {
708
765
  return Math.min(v, max);
709
766
  }
710
767
 
711
- // src/providers/imap/index.ts
712
- 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.";
713
- var ImapProvider = class {
714
- id = "imap";
715
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
716
- async addAccount(_input) {
717
- throw new Error(NOT_IMPLEMENTED);
718
- }
719
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
720
- async completeAddAccount(_handle) {
721
- return { status: "error", error: NOT_IMPLEMENTED };
722
- }
723
- async listEmails(_account, _opts) {
724
- throw new Error(NOT_IMPLEMENTED);
768
+ // src/providers/imap/client.ts
769
+ import { ImapFlow } from "imapflow";
770
+ import nodemailer from "nodemailer";
771
+ function isImapTokens(obj) {
772
+ if (typeof obj !== "object" || obj === null) return false;
773
+ const o = obj;
774
+ return typeof o.host === "string" && typeof o.port === "number" && typeof o.user === "string" && typeof o.password === "string" && typeof o.smtpHost === "string" && typeof o.smtpPort === "number";
775
+ }
776
+ function extractTokens(account) {
777
+ if (!isImapTokens(account.tokens)) {
778
+ throw new Error(
779
+ "IMAP account tokens are missing or corrupted \u2014 re-run add_account"
780
+ );
725
781
  }
726
- async searchEmails(_account, _query, _opts) {
727
- throw new Error(NOT_IMPLEMENTED);
782
+ return account.tokens;
783
+ }
784
+ var ImapClient = class {
785
+ constructor(tokens) {
786
+ this.tokens = tokens;
728
787
  }
729
- async readEmail(_account, _id) {
730
- throw new Error(NOT_IMPLEMENTED);
788
+ tokens;
789
+ imap = null;
790
+ transporter = null;
791
+ connecting = null;
792
+ /** Get (or create) the ImapFlow instance. */
793
+ async getImap() {
794
+ if (this.imap) return this.imap;
795
+ this.imap = new ImapFlow({
796
+ host: this.tokens.host,
797
+ port: this.tokens.port,
798
+ secure: this.tokens.secure,
799
+ auth: {
800
+ user: this.tokens.user,
801
+ pass: this.tokens.password
802
+ },
803
+ logger: false
804
+ });
805
+ if (!this.connecting) {
806
+ this.connecting = this.imap.connect().catch((err) => {
807
+ this.imap = null;
808
+ this.connecting = null;
809
+ throw err;
810
+ });
811
+ }
812
+ await this.connecting;
813
+ return this.imap;
731
814
  }
732
- async readAttachment(_account, _messageId, _attachmentId) {
733
- throw new Error(NOT_IMPLEMENTED);
815
+ /** Get (or create) a nodemailer SMTP transporter. */
816
+ getTransporter() {
817
+ if (this.transporter) return this.transporter;
818
+ this.transporter = nodemailer.createTransport({
819
+ host: this.tokens.smtpHost,
820
+ port: this.tokens.smtpPort,
821
+ secure: this.tokens.smtpSecure,
822
+ auth: {
823
+ user: this.tokens.user,
824
+ pass: this.tokens.password
825
+ }
826
+ });
827
+ return this.transporter;
734
828
  }
735
- async sendEmail(_account, _msg) {
736
- throw new Error(NOT_IMPLEMENTED);
829
+ /**
830
+ * Acquire a mailbox lock and run `fn` with the mailbox selected.
831
+ * Releases the lock automatically after `fn` completes.
832
+ */
833
+ async withMailbox(mailbox, fn) {
834
+ const imap = await this.getImap();
835
+ const lock = await imap.getMailboxLock(mailbox);
836
+ try {
837
+ return await fn(imap);
838
+ } finally {
839
+ lock.release();
840
+ }
737
841
  }
738
- async saveDraft(_account, _msg) {
739
- throw new Error(NOT_IMPLEMENTED);
842
+ /** Disconnect IMAP and close the SMTP pool. */
843
+ async disconnect() {
844
+ if (this.imap) {
845
+ try {
846
+ await this.imap.logout();
847
+ } catch {
848
+ }
849
+ this.imap = null;
850
+ this.connecting = null;
851
+ }
852
+ if (this.transporter) {
853
+ this.transporter.close();
854
+ this.transporter = null;
855
+ }
740
856
  }
741
- async updateDraft(_account, _id, _update) {
742
- throw new Error(NOT_IMPLEMENTED);
857
+ };
858
+ var ImapClientFactory = class {
859
+ cache = /* @__PURE__ */ new Map();
860
+ get(account) {
861
+ const key = account.email.toLowerCase();
862
+ const existing = this.cache.get(key);
863
+ if (existing) return existing;
864
+ const tokens = extractTokens(account);
865
+ const client = new ImapClient(tokens);
866
+ this.cache.set(key, client);
867
+ return client;
743
868
  }
744
- async moveEmail(_account, _id, _destinationId) {
745
- throw new Error(NOT_IMPLEMENTED);
869
+ /** Drop a cached client (e.g. after removeAccount). */
870
+ invalidate(email) {
871
+ const key = email.toLowerCase();
872
+ const existing = this.cache.get(key);
873
+ if (existing) {
874
+ existing.disconnect().catch(() => {
875
+ });
876
+ this.cache.delete(key);
877
+ }
746
878
  }
747
879
  };
748
880
 
749
- // src/providers/registry.ts
750
- function buildRegistry(opts) {
751
- const providers = /* @__PURE__ */ new Map();
752
- providers.set("outlook", new OutlookProvider({ store: opts.store }));
753
- providers.set("imap", new ImapProvider());
754
- function get(id) {
755
- const p = providers.get(id);
756
- if (!p) throw new Error(`unknown provider: ${id}`);
757
- return p;
881
+ // src/providers/imap/read-ops.ts
882
+ import { createWriteStream } from "fs";
883
+ import { tmpdir as tmpdir2 } from "os";
884
+ import { join as pathJoin2 } from "path";
885
+ import { pipeline } from "stream/promises";
886
+
887
+ // src/providers/imap/helpers.ts
888
+ var WELL_KNOWN_TO_IMAP = {
889
+ archive: "Archive",
890
+ deleteditems: "Trash",
891
+ inbox: "INBOX",
892
+ drafts: "Drafts",
893
+ sentitems: "Sent",
894
+ junkemail: "Junk",
895
+ outbox: "Outbox"
896
+ };
897
+ function resolveFolder(wellKnownOrPath) {
898
+ return WELL_KNOWN_TO_IMAP[wellKnownOrPath.toLowerCase()] ?? wellKnownOrPath;
899
+ }
900
+ function clampLimit2(v, dflt, max) {
901
+ if (!v || v <= 0) return dflt;
902
+ return Math.min(v, max);
903
+ }
904
+ function encodeId(folder, uid) {
905
+ return `${folder}/${uid}`;
906
+ }
907
+ function decodeId(id) {
908
+ const idx = id.lastIndexOf("/");
909
+ if (idx === -1) throw new Error(`invalid message ID: ${id}`);
910
+ const folder = id.slice(0, idx);
911
+ const uid = Number(id.slice(idx + 1));
912
+ if (!Number.isFinite(uid) || uid <= 0) {
913
+ throw new Error(`invalid message UID in ID: ${id}`);
758
914
  }
759
- function resolveByEmail(email) {
760
- const account = opts.store.getAccount(email);
761
- if (!account) {
762
- throw new Error(
763
- `no account registered for "${email}". Call add_account first.`
764
- );
915
+ return { folder, uid };
916
+ }
917
+ function findAttachments(node) {
918
+ const attachments = [];
919
+ const topType = (node.type ?? "").split("/")[0];
920
+ const isAttachment = node.disposition === "attachment" || !!node.type && topType !== "text" && topType !== "multipart" && !node.disposition;
921
+ if (isAttachment) {
922
+ attachments.push({
923
+ part: node.part ?? "1",
924
+ name: node.dispositionParameters?.filename ?? node.parameters?.name ?? "attachment",
925
+ contentType: node.type,
926
+ size: node.size
927
+ });
928
+ }
929
+ if (node.childNodes) {
930
+ for (const child of node.childNodes) {
931
+ attachments.push(...findAttachments(child));
765
932
  }
766
- return { provider: get(account.provider), account };
767
933
  }
934
+ return attachments;
935
+ }
936
+ function findPartByType(node, contentType) {
937
+ if (node.type === contentType) return node.part ?? "1";
938
+ if (node.childNodes) {
939
+ for (const child of node.childNodes) {
940
+ const found = findPartByType(child, contentType);
941
+ if (found) return found;
942
+ }
943
+ }
944
+ return void 0;
945
+ }
946
+ function mapEnvelopeAddr(a) {
947
+ return { name: a.name, address: a.address ?? "" };
948
+ }
949
+ function mapSummary2(uid, folder, envelope, flags = /* @__PURE__ */ new Set()) {
950
+ const fromAddr = envelope.from && envelope.from.length > 0 && envelope.from[0] ? mapEnvelopeAddr(envelope.from[0]) : void 0;
768
951
  return {
769
- get,
770
- resolveByEmail,
771
- list: () => Array.from(providers.values())
952
+ id: encodeId(folder, uid),
953
+ subject: envelope.subject ?? "",
954
+ from: fromAddr,
955
+ to: (envelope.to ?? []).map(mapEnvelopeAddr),
956
+ receivedAt: envelope.date ? envelope.date.toISOString() : void 0,
957
+ isRead: flags.has("\\Seen"),
958
+ folder
959
+ };
960
+ }
961
+ function mapMailboxToListEntry(m) {
962
+ const lastSep = m.path.lastIndexOf("/");
963
+ const parentFolderId = lastSep === -1 ? void 0 : m.path.slice(0, lastSep);
964
+ return {
965
+ id: m.path,
966
+ displayName: m.path,
967
+ parentFolderId,
968
+ childFolderCount: 0,
969
+ totalItemCount: m.status?.messages ?? 0,
970
+ unreadItemCount: m.status?.unseen ?? 0
772
971
  };
773
972
  }
774
973
 
775
- // src/tools/index.ts
776
- import { z } from "zod";
777
-
778
- // src/html-to-markdown.ts
779
- import TurndownService from "turndown";
780
- var turndown = new TurndownService();
781
- function htmlToMarkdown(html) {
782
- return turndown.turndown(html);
974
+ // src/providers/imap/read-ops.ts
975
+ async function listEmails(clients, account, opts) {
976
+ const client = clients.get(account);
977
+ const folder = resolveFolder(opts.folder ?? "INBOX");
978
+ const limit = clampLimit2(opts.limit, 25, 100);
979
+ const skip = opts.skip ?? 0;
980
+ return client.withMailbox(folder, async (imap) => {
981
+ const searchCriteria = {};
982
+ if (opts.unreadOnly) searchCriteria.seen = false;
983
+ const allUids = Object.keys(searchCriteria).length > 0 ? await imap.search(searchCriteria, { uid: true }) : await imap.search({ all: true }, { uid: true });
984
+ allUids.sort((a, b) => b - a);
985
+ const pageUids = allUids.slice(skip, skip + limit);
986
+ const hasMore = skip + limit < allUids.length;
987
+ if (pageUids.length === 0) {
988
+ return { items: [], hasMore };
989
+ }
990
+ const messages = await imap.fetchAll(
991
+ pageUids,
992
+ { envelope: true, flags: true },
993
+ { uid: true }
994
+ );
995
+ const items = [];
996
+ for (const msg of messages) {
997
+ items.push(
998
+ mapSummary2(
999
+ msg.uid,
1000
+ folder,
1001
+ msg.envelope,
1002
+ msg.flags
1003
+ )
1004
+ );
1005
+ }
1006
+ return { items, hasMore };
1007
+ });
783
1008
  }
784
- function selectBody(msg, format) {
785
- switch (format) {
786
- case "markdown": {
787
- if (msg.bodyHtml) return htmlToMarkdown(msg.bodyHtml);
788
- if (msg.bodyText) return msg.bodyText;
789
- return "";
1009
+ async function searchEmails(clients, account, query, opts) {
1010
+ const client = clients.get(account);
1011
+ const limit = clampLimit2(opts.limit, 25, 100);
1012
+ return client.withMailbox("INBOX", async (imap) => {
1013
+ const uids = await imap.search({ text: query }, { uid: true });
1014
+ uids.sort((a, b) => b - a);
1015
+ const pageUids = uids.slice(0, limit);
1016
+ if (pageUids.length === 0) return [];
1017
+ const messages = await imap.fetchAll(
1018
+ pageUids,
1019
+ { envelope: true, flags: true },
1020
+ { uid: true }
1021
+ );
1022
+ return messages.map(
1023
+ (msg) => mapSummary2(
1024
+ msg.uid,
1025
+ "INBOX",
1026
+ msg.envelope,
1027
+ msg.flags
1028
+ )
1029
+ );
1030
+ });
1031
+ }
1032
+ async function readEmail(clients, account, id) {
1033
+ const client = clients.get(account);
1034
+ const { folder, uid } = decodeId(id);
1035
+ return client.withMailbox(folder, async (imap) => {
1036
+ const msg = await imap.fetchOne(
1037
+ uid,
1038
+ { bodyStructure: true, envelope: true, flags: true },
1039
+ { uid: true }
1040
+ );
1041
+ if (!msg || !msg.envelope) {
1042
+ throw new Error(`message not found: ${id}`);
790
1043
  }
791
- case "html": {
792
- if (msg.bodyHtml) return msg.bodyHtml;
793
- if (msg.bodyText) return msg.bodyText;
794
- return "";
1044
+ const envelope = msg.envelope;
1045
+ const structure = msg.bodyStructure;
1046
+ const flags = msg.flags ?? /* @__PURE__ */ new Set();
1047
+ let bodyText;
1048
+ let bodyHtml;
1049
+ const textPart = findPartByType(structure, "text/plain");
1050
+ const htmlPart = findPartByType(structure, "text/html");
1051
+ if (textPart) {
1052
+ const { content } = await imap.download(uid, textPart, { uid: true });
1053
+ const chunks = [];
1054
+ for await (const chunk of content) {
1055
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1056
+ }
1057
+ bodyText = Buffer.concat(chunks).toString("utf-8");
795
1058
  }
796
- case "text": {
797
- if (msg.bodyText) return msg.bodyText;
798
- if (msg.bodyHtml) return msg.bodyHtml.replace(/<[^>]*>/g, "");
799
- return "";
1059
+ if (htmlPart) {
1060
+ const { content } = await imap.download(uid, htmlPart, { uid: true });
1061
+ const chunks = [];
1062
+ for await (const chunk of content) {
1063
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1064
+ }
1065
+ bodyHtml = Buffer.concat(chunks).toString("utf-8");
1066
+ }
1067
+ const attachments = findAttachments(
1068
+ structure
1069
+ ).map((a) => ({
1070
+ id: a.part,
1071
+ name: a.name,
1072
+ contentType: a.contentType,
1073
+ size: a.size
1074
+ }));
1075
+ const summary = mapSummary2(uid, folder, envelope, flags);
1076
+ return {
1077
+ ...summary,
1078
+ cc: (envelope.cc ?? []).map(mapEnvelopeAddr),
1079
+ bcc: (envelope.bcc ?? []).map(mapEnvelopeAddr),
1080
+ bodyText,
1081
+ bodyHtml,
1082
+ attachments: attachments.length > 0 ? attachments : void 0,
1083
+ hasAttachments: attachments.length > 0
1084
+ };
1085
+ });
1086
+ }
1087
+ async function readAttachment(clients, account, messageId, attachmentId) {
1088
+ const client = clients.get(account);
1089
+ const { folder, uid } = decodeId(messageId);
1090
+ return client.withMailbox(folder, async (imap) => {
1091
+ const msg = await imap.fetchOne(
1092
+ uid,
1093
+ { bodyStructure: true },
1094
+ { uid: true }
1095
+ );
1096
+ let name = "attachment";
1097
+ let contentType;
1098
+ if (msg && msg.bodyStructure) {
1099
+ const attachments = findAttachments(msg.bodyStructure);
1100
+ const match = attachments.find((a) => a.part === attachmentId);
1101
+ if (match) {
1102
+ name = match.name;
1103
+ contentType = match.contentType;
1104
+ }
800
1105
  }
1106
+ const { meta, content } = await imap.download(uid, attachmentId, {
1107
+ uid: true
1108
+ });
1109
+ const outPath = pathJoin2(tmpdir2(), name);
1110
+ await pipeline(
1111
+ content,
1112
+ createWriteStream(outPath)
1113
+ );
1114
+ return {
1115
+ name,
1116
+ contentType: contentType ?? meta.contentType,
1117
+ path: outPath
1118
+ };
1119
+ });
1120
+ }
1121
+ async function listFolders(clients, account, opts) {
1122
+ const client = clients.get(account);
1123
+ const imap = await client.getImap();
1124
+ const mailboxes = await imap.list({
1125
+ statusQuery: { messages: true, unseen: true, uidNext: true }
1126
+ });
1127
+ let results = mailboxes.map(mapMailboxToListEntry);
1128
+ if (opts.parentFolderId) {
1129
+ const parentPath = opts.parentFolderId;
1130
+ results = results.filter(
1131
+ (f) => f.parentFolderId === parentPath || parentPath === "INBOX" && f.displayName === "INBOX"
1132
+ );
1133
+ } else {
1134
+ const allPaths = new Set(results.map((f) => f.displayName));
1135
+ results = results.filter((f) => {
1136
+ const lastSep = f.displayName.lastIndexOf("/");
1137
+ if (lastSep === -1) return true;
1138
+ const parent = f.displayName.slice(0, lastSep);
1139
+ return !allPaths.has(parent);
1140
+ });
801
1141
  }
1142
+ return results;
802
1143
  }
803
1144
 
804
- // src/tools/index.ts
805
- function ok(data, structuredContent) {
806
- const result = {
807
- content: [
808
- { type: "text", text: JSON.stringify(data, null, 2) }
809
- ]
1145
+ // src/providers/imap/write-ops.ts
1146
+ import { randomUUID as randomUUID3 } from "crypto";
1147
+ import MailComposer from "nodemailer/lib/mail-composer/index.js";
1148
+ async function addAccount(clients, store, input) {
1149
+ const cfg = input.config ?? {};
1150
+ const host = String(cfg.host ?? "");
1151
+ const port = Number(cfg.port ?? 993);
1152
+ const secure = cfg.secure !== false;
1153
+ const user = String(cfg.user ?? input.email ?? "");
1154
+ const password = String(cfg.password ?? "");
1155
+ const smtpHost = String(cfg.smtpHost ?? host);
1156
+ const smtpPort = Number(cfg.smtpPort ?? 587);
1157
+ const smtpSecure = cfg.smtpSecure === true;
1158
+ if (!host || !user || !password) {
1159
+ throw new Error(
1160
+ "IMAP requires config: { host, port?, secure?, user, password, smtpHost?, smtpPort?, smtpSecure? }"
1161
+ );
1162
+ }
1163
+ const tokens = {
1164
+ host,
1165
+ port,
1166
+ secure,
1167
+ user,
1168
+ password,
1169
+ smtpHost: smtpHost || host,
1170
+ smtpPort: smtpPort || 587,
1171
+ smtpSecure
810
1172
  };
811
- if (structuredContent !== void 0) {
812
- result.structuredContent = structuredContent;
1173
+ const client = clients.get({
1174
+ email: user.toLowerCase(),
1175
+ provider: "imap",
1176
+ tokens,
1177
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
1178
+ });
1179
+ try {
1180
+ await client.getImap();
1181
+ } finally {
1182
+ clients.invalidate(user.toLowerCase());
813
1183
  }
814
- return result;
1184
+ try {
1185
+ const t = client.getTransporter();
1186
+ await t.verify();
1187
+ } catch {
1188
+ }
1189
+ const email = user.toLowerCase();
1190
+ const rec = {
1191
+ email,
1192
+ provider: "imap",
1193
+ displayName: input.email ?? user,
1194
+ tokens,
1195
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
1196
+ };
1197
+ const saved = await store.upsertAccount(rec);
1198
+ return { status: "ready", account: saved };
815
1199
  }
816
- function fail(message) {
1200
+ function completeAddAccount() {
817
1201
  return {
818
- isError: true,
819
- content: [{ type: "text", text: message }]
1202
+ status: "error",
1203
+ error: "IMAP accounts are set up synchronously \u2014 no polling needed. Call add_account with IMAP config to create the account directly."
820
1204
  };
821
1205
  }
822
- var emailAddrSchema = z.object({
823
- address: z.string().email(),
824
- name: z.string().optional()
825
- });
826
- var emailAddrOutputSchema = z.object({
827
- name: z.string().optional(),
828
- address: z.string()
1206
+ async function sendEmail(clients, account, msg) {
1207
+ const client = clients.get(account);
1208
+ const transporter = client.getTransporter();
1209
+ const mailOptions = {
1210
+ from: `${account.displayName ?? ""} <${account.email}>`,
1211
+ to: msg.to.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", "),
1212
+ subject: msg.subject
1213
+ };
1214
+ if (msg.isHtml) {
1215
+ mailOptions.html = msg.body;
1216
+ } else {
1217
+ mailOptions.text = msg.body;
1218
+ }
1219
+ mailOptions.attachDataUrls = true;
1220
+ if (msg.cc && msg.cc.length > 0) {
1221
+ mailOptions.cc = msg.cc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1222
+ }
1223
+ if (msg.bcc && msg.bcc.length > 0) {
1224
+ mailOptions.bcc = msg.bcc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1225
+ }
1226
+ if (msg.inReplyTo || msg.forwardMessageId) {
1227
+ const refId = msg.inReplyTo ?? msg.forwardMessageId;
1228
+ if (refId) {
1229
+ try {
1230
+ const { folder: refFolder, uid: refUid } = decodeId(refId);
1231
+ const refMsg = await client.withMailbox(refFolder, async (imap) => {
1232
+ return imap.fetchOne(
1233
+ refUid,
1234
+ { envelope: true, source: true },
1235
+ { uid: true }
1236
+ );
1237
+ });
1238
+ if (refMsg?.envelope) {
1239
+ const env = refMsg.envelope;
1240
+ if (msg.inReplyTo && env.messageId && !msg.forwardMessageId) {
1241
+ mailOptions.inReplyTo = env.messageId;
1242
+ mailOptions.references = env.messageId;
1243
+ }
1244
+ }
1245
+ if (msg.forwardMessageId && refMsg?.source) {
1246
+ const sourceStr = typeof refMsg.source === "string" ? refMsg.source : Buffer.from(refMsg.source).toString("utf-8");
1247
+ const divider = '\n\n<div style="line-height:12px"><br></div>\n\n<div style="border-left:2px solid #ccc; padding-left:8px; margin-left:0; color:#666">\n---------- Forwarded message ---------<br>' + sourceStr + "\n</div>";
1248
+ if (mailOptions.html) {
1249
+ mailOptions.html += divider;
1250
+ } else if (mailOptions.text) {
1251
+ mailOptions.text += "\n\n---------- Forwarded message ---------\n" + sourceStr;
1252
+ }
1253
+ }
1254
+ } catch {
1255
+ }
1256
+ }
1257
+ }
1258
+ const info = await transporter.sendMail(mailOptions);
1259
+ try {
1260
+ const rawMsg = await buildRawMessage(account, msg, info.messageId);
1261
+ await client.withMailbox("Sent", async (imap) => {
1262
+ await imap.append("Sent", rawMsg, ["\\Seen"]);
1263
+ });
1264
+ } catch {
1265
+ }
1266
+ return { id: info.messageId };
1267
+ }
1268
+ async function saveDraft(clients, account, msg) {
1269
+ const client = clients.get(account);
1270
+ const rawMsg = await buildRawMessage(account, msg);
1271
+ return client.withMailbox("Drafts", async (imap) => {
1272
+ const result = await imap.append("Drafts", rawMsg, ["\\Draft"]);
1273
+ return { id: encodeId("Drafts", result.uid) };
1274
+ });
1275
+ }
1276
+ async function updateDraft(clients, account, id, update) {
1277
+ const client = clients.get(account);
1278
+ const { folder, uid } = decodeId(id);
1279
+ return client.withMailbox(folder, async (imap) => {
1280
+ const existing = await imap.fetchOne(
1281
+ uid,
1282
+ { source: true, envelope: true },
1283
+ { uid: true }
1284
+ );
1285
+ if (!existing?.source) {
1286
+ throw new Error(`draft not found: ${id}`);
1287
+ }
1288
+ const origSubject = existing.envelope ? existing.envelope.subject ?? "" : "";
1289
+ const updatedMsg = {
1290
+ from: `${account.displayName ?? ""} <${account.email}>`,
1291
+ subject: update.subject ?? origSubject,
1292
+ attachDataUrls: true
1293
+ };
1294
+ if (update.body !== void 0) {
1295
+ if (update.isHtml) {
1296
+ updatedMsg.html = update.body;
1297
+ } else {
1298
+ updatedMsg.text = update.body;
1299
+ }
1300
+ }
1301
+ const raw = await new Promise((resolve, reject) => {
1302
+ const mc = new MailComposer(updatedMsg);
1303
+ mc.compile().build((err, buf) => {
1304
+ if (err) reject(err);
1305
+ else resolve(buf);
1306
+ });
1307
+ });
1308
+ await imap.messageDelete(uid, { uid: true });
1309
+ const result = await imap.append(folder, raw, ["\\Draft"]);
1310
+ return { id: encodeId(folder, result.uid) };
1311
+ });
1312
+ }
1313
+ async function moveEmail(clients, account, id, destinationId) {
1314
+ const client = clients.get(account);
1315
+ const { folder, uid } = decodeId(id);
1316
+ const dest = resolveFolder(destinationId);
1317
+ return client.withMailbox(folder, async (imap) => {
1318
+ await imap.messageMove(uid, dest, { uid: true });
1319
+ });
1320
+ }
1321
+ async function sendDraft(clients, account, id) {
1322
+ const client = clients.get(account);
1323
+ const { folder, uid } = decodeId(id);
1324
+ return client.withMailbox(folder, async (imap) => {
1325
+ const draft = await imap.fetchOne(
1326
+ uid,
1327
+ { source: true },
1328
+ { uid: true }
1329
+ );
1330
+ if (!draft?.source) {
1331
+ throw new Error(`draft not found: ${id}`);
1332
+ }
1333
+ const sourceStr = typeof draft.source === "string" ? draft.source : Buffer.from(draft.source).toString("utf-8");
1334
+ const transporter = client.getTransporter();
1335
+ const info = await transporter.sendMail({ raw: sourceStr });
1336
+ try {
1337
+ await imap.messageMove(uid, "Sent", { uid: true });
1338
+ } catch {
1339
+ }
1340
+ return { id: info.messageId };
1341
+ });
1342
+ }
1343
+ async function addAttachmentToDraft(clients, account, draftId, name, contentBytes, contentType) {
1344
+ const client = clients.get(account);
1345
+ const { folder, uid } = decodeId(draftId);
1346
+ return client.withMailbox(folder, async (imap) => {
1347
+ const existing = await imap.fetchOne(
1348
+ uid,
1349
+ { source: true },
1350
+ { uid: true }
1351
+ );
1352
+ if (!existing?.source) {
1353
+ throw new Error(`draft not found: ${draftId}`);
1354
+ }
1355
+ const sourceStr = typeof existing.source === "string" ? existing.source : Buffer.from(existing.source).toString("utf-8");
1356
+ const built = await new Promise((resolve, reject) => {
1357
+ const mc = new MailComposer({
1358
+ raw: sourceStr,
1359
+ attachments: [
1360
+ {
1361
+ filename: name,
1362
+ content: Buffer.from(contentBytes, "base64"),
1363
+ contentType: contentType ?? "application/octet-stream"
1364
+ }
1365
+ ]
1366
+ });
1367
+ mc.compile().build((err, buf) => {
1368
+ if (err) reject(err);
1369
+ else resolve(buf);
1370
+ });
1371
+ });
1372
+ await imap.messageDelete(uid, { uid: true });
1373
+ const result = await imap.append(folder, built, ["\\Draft"]);
1374
+ return {
1375
+ id: encodeId(folder, result.uid),
1376
+ attachment: {
1377
+ id: randomUUID3(),
1378
+ name,
1379
+ contentType: contentType ?? "application/octet-stream"
1380
+ }
1381
+ };
1382
+ });
1383
+ }
1384
+ async function markRead(clients, account, id, isRead) {
1385
+ const client = clients.get(account);
1386
+ const { folder, uid } = decodeId(id);
1387
+ return client.withMailbox(folder, async (imap) => {
1388
+ if (isRead) {
1389
+ await imap.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
1390
+ } else {
1391
+ await imap.messageFlagsRemove(uid, ["\\Seen"], { uid: true });
1392
+ }
1393
+ });
1394
+ }
1395
+ async function createFolder(clients, account, input) {
1396
+ const client = clients.get(account);
1397
+ const imap = await client.getImap();
1398
+ const path2 = input.parentFolderId ? `${input.parentFolderId}/${input.displayName}` : input.displayName;
1399
+ const result = await imap.mailboxCreate(path2);
1400
+ return {
1401
+ id: result.path,
1402
+ displayName: result.path,
1403
+ parentFolderId: input.parentFolderId,
1404
+ childFolderCount: 0,
1405
+ totalItemCount: 0,
1406
+ unreadItemCount: 0
1407
+ };
1408
+ }
1409
+ async function renameFolder(clients, account, folderId, newName) {
1410
+ const client = clients.get(account);
1411
+ const imap = await client.getImap();
1412
+ const lastSep = folderId.lastIndexOf("/");
1413
+ const newPath = lastSep === -1 ? newName : folderId.slice(0, lastSep + 1) + newName;
1414
+ const result = await imap.mailboxRename(folderId, newPath);
1415
+ return {
1416
+ id: result.path,
1417
+ displayName: result.path,
1418
+ parentFolderId: lastSep === -1 ? void 0 : folderId.slice(0, lastSep),
1419
+ childFolderCount: 0,
1420
+ totalItemCount: 0,
1421
+ unreadItemCount: 0
1422
+ };
1423
+ }
1424
+ async function deleteFolder(clients, account, folderId) {
1425
+ const client = clients.get(account);
1426
+ const imap = await client.getImap();
1427
+ await imap.mailboxDelete(folderId);
1428
+ }
1429
+ async function buildRawMessage(account, msg, messageId) {
1430
+ const mailOptions = {
1431
+ from: `${account.displayName ?? ""} <${account.email}>`,
1432
+ to: msg.to.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", "),
1433
+ subject: msg.subject,
1434
+ attachDataUrls: true
1435
+ };
1436
+ if (msg.isHtml) {
1437
+ mailOptions.html = msg.body;
1438
+ } else {
1439
+ mailOptions.text = msg.body;
1440
+ }
1441
+ if (msg.cc && msg.cc.length > 0) {
1442
+ mailOptions.cc = msg.cc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1443
+ }
1444
+ if (msg.bcc && msg.bcc.length > 0) {
1445
+ mailOptions.bcc = msg.bcc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1446
+ }
1447
+ if (messageId) {
1448
+ mailOptions.messageId = messageId;
1449
+ }
1450
+ return new Promise((resolve, reject) => {
1451
+ const mc = new MailComposer(mailOptions);
1452
+ mc.compile().build((err, buf) => {
1453
+ if (err) reject(err);
1454
+ else resolve(buf.toString("utf-8"));
1455
+ });
1456
+ });
1457
+ }
1458
+
1459
+ // src/providers/imap/index.ts
1460
+ var ImapProvider = class {
1461
+ constructor(store) {
1462
+ this.store = store;
1463
+ }
1464
+ store;
1465
+ id = "imap";
1466
+ clients = new ImapClientFactory();
1467
+ // ---------- account lifecycle ----------
1468
+ async addAccount(input) {
1469
+ if (!this.store) throw new Error("IMAP provider requires an AccountStore");
1470
+ return addAccount(this.clients, this.store, input);
1471
+ }
1472
+ async completeAddAccount(_handle) {
1473
+ return completeAddAccount();
1474
+ }
1475
+ // ---------- browse ----------
1476
+ async listEmails(account, opts) {
1477
+ return listEmails(this.clients, account, opts);
1478
+ }
1479
+ async searchEmails(account, query, opts) {
1480
+ return searchEmails(this.clients, account, query, opts);
1481
+ }
1482
+ async readEmail(account, id) {
1483
+ return readEmail(this.clients, account, id);
1484
+ }
1485
+ async readAttachment(account, messageId, attachmentId) {
1486
+ return readAttachment(this.clients, account, messageId, attachmentId);
1487
+ }
1488
+ // ---------- compose ----------
1489
+ async sendEmail(account, msg) {
1490
+ return sendEmail(this.clients, account, msg);
1491
+ }
1492
+ async saveDraft(account, msg) {
1493
+ return saveDraft(this.clients, account, msg);
1494
+ }
1495
+ async updateDraft(account, id, update) {
1496
+ return updateDraft(this.clients, account, id, update);
1497
+ }
1498
+ async moveEmail(account, id, destinationId) {
1499
+ return moveEmail(this.clients, account, id, destinationId);
1500
+ }
1501
+ async sendDraft(account, id) {
1502
+ return sendDraft(this.clients, account, id);
1503
+ }
1504
+ async addAttachmentToDraft(account, draftId, name, contentBytes, contentType) {
1505
+ return addAttachmentToDraft(this.clients, account, draftId, name, contentBytes, contentType);
1506
+ }
1507
+ // ---------- organize ----------
1508
+ async markRead(account, id, isRead) {
1509
+ return markRead(this.clients, account, id, isRead);
1510
+ }
1511
+ // ---------- folders ----------
1512
+ async listFolders(account, opts) {
1513
+ return listFolders(this.clients, account, opts);
1514
+ }
1515
+ async createFolder(account, input) {
1516
+ return createFolder(this.clients, account, input);
1517
+ }
1518
+ async renameFolder(account, folderId, newName) {
1519
+ return renameFolder(this.clients, account, folderId, newName);
1520
+ }
1521
+ async deleteFolder(account, folderId) {
1522
+ return deleteFolder(this.clients, account, folderId);
1523
+ }
1524
+ };
1525
+
1526
+ // src/providers/gmail/index.ts
1527
+ import { randomUUID as randomUUID5 } from "crypto";
1528
+
1529
+ // src/providers/gmail/auth.ts
1530
+ import { OAuth2Client } from "google-auth-library";
1531
+ var GOOGLE_DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code";
1532
+ var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
1533
+ var DEFAULT_SCOPES2 = [
1534
+ "https://www.googleapis.com/auth/gmail.modify"
1535
+ ];
1536
+ function isSerializedGmailTokens(obj) {
1537
+ if (typeof obj !== "object" || obj === null) return false;
1538
+ const o = obj;
1539
+ return typeof o.clientId === "string" && (o.clientSecret === void 0 || typeof o.clientSecret === "string") && typeof o.accessToken === "string" && typeof o.refreshToken === "string" && typeof o.expiryDate === "number" && Array.isArray(o.scopes) && typeof o.email === "string";
1540
+ }
1541
+ function buildOAuth2Client(tokens) {
1542
+ const client = new OAuth2Client({
1543
+ clientId: tokens?.clientId,
1544
+ clientSecret: tokens?.clientSecret
1545
+ });
1546
+ if (tokens) {
1547
+ client.setCredentials({
1548
+ access_token: tokens.accessToken,
1549
+ refresh_token: tokens.refreshToken,
1550
+ expiry_date: tokens.expiryDate,
1551
+ scope: tokens.scopes.join(" ")
1552
+ });
1553
+ }
1554
+ return client;
1555
+ }
1556
+ async function getEmailFromToken(accessToken) {
1557
+ const res = await fetch(
1558
+ "https://gmail.googleapis.com/gmail/v1/users/me/profile",
1559
+ {
1560
+ headers: {
1561
+ Authorization: `Bearer ${accessToken}`
1562
+ }
1563
+ }
1564
+ );
1565
+ if (!res.ok) {
1566
+ const body = await res.text().catch(() => "");
1567
+ throw new Error(
1568
+ `Failed to get Gmail profile (${res.status}): ${body}`
1569
+ );
1570
+ }
1571
+ const data = await res.json();
1572
+ return data.emailAddress;
1573
+ }
1574
+ function beginDeviceCode2(scopes = DEFAULT_SCOPES2, clientIdOverride, clientSecretOverride) {
1575
+ const clientId = clientIdOverride || process.env.GOOGLE_CLIENT_ID;
1576
+ if (!clientId) {
1577
+ throw new Error(
1578
+ "GOOGLE_CLIENT_ID is required for Gmail OAuth \u2014 set it in env or provider config"
1579
+ );
1580
+ }
1581
+ const clientSecret = clientSecretOverride || process.env.GOOGLE_CLIENT_SECRET || void 0;
1582
+ let resolve;
1583
+ let reject;
1584
+ const result = new Promise(
1585
+ (res, rej) => {
1586
+ resolve = res;
1587
+ reject = rej;
1588
+ }
1589
+ );
1590
+ let userCode = "";
1591
+ let verificationUri = "";
1592
+ let message = "";
1593
+ let expiresAt = new Date(Date.now() + 15 * 6e4).toISOString();
1594
+ let aborted = false;
1595
+ const ready = (async () => {
1596
+ try {
1597
+ const dcParams = new URLSearchParams();
1598
+ dcParams.set("client_id", clientId);
1599
+ if (clientSecret) dcParams.set("client_secret", clientSecret);
1600
+ dcParams.set("scope", scopes.join(" "));
1601
+ const dcRes = await fetch(GOOGLE_DEVICE_CODE_URL, {
1602
+ method: "POST",
1603
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1604
+ body: dcParams.toString()
1605
+ });
1606
+ if (!dcRes.ok) {
1607
+ const errBody = await dcRes.json().catch(() => ({}));
1608
+ throw new Error(
1609
+ `Google device-code request failed: ${errBody.error_description ?? errBody.error ?? dcRes.statusText}`
1610
+ );
1611
+ }
1612
+ const dcData = await dcRes.json();
1613
+ userCode = dcData.user_code;
1614
+ verificationUri = dcData.verification_url;
1615
+ const deviceCode = dcData.device_code;
1616
+ let interval = dcData.interval ?? 5;
1617
+ if (dcData.expires_in) {
1618
+ expiresAt = new Date(
1619
+ Date.now() + dcData.expires_in * 1e3
1620
+ ).toISOString();
1621
+ }
1622
+ message = `Go to ${verificationUri} and enter code: ${userCode}`;
1623
+ const tokenParams = new URLSearchParams();
1624
+ tokenParams.set("client_id", clientId);
1625
+ if (clientSecret) tokenParams.set("client_secret", clientSecret);
1626
+ tokenParams.set("device_code", deviceCode);
1627
+ tokenParams.set(
1628
+ "grant_type",
1629
+ "urn:ietf:params:oauth:grant-type:device_code"
1630
+ );
1631
+ const deadline = Date.now() + dcData.expires_in * 1e3;
1632
+ while (Date.now() < deadline && !aborted) {
1633
+ await new Promise((r) => setTimeout(r, interval * 1e3));
1634
+ if (aborted) return;
1635
+ const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
1636
+ method: "POST",
1637
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1638
+ body: tokenParams.toString()
1639
+ });
1640
+ const tokenData = await tokenRes.json();
1641
+ if (tokenData.access_token) {
1642
+ const email = await getEmailFromToken(tokenData.access_token);
1643
+ const tokens = {
1644
+ clientId,
1645
+ clientSecret,
1646
+ accessToken: tokenData.access_token,
1647
+ refreshToken: tokenData.refresh_token ?? "",
1648
+ expiryDate: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1e3 : Date.now() + 36e5,
1649
+ scopes: tokenData.scope ? tokenData.scope.split(" ") : scopes,
1650
+ email
1651
+ };
1652
+ resolve({ tokens, email });
1653
+ return;
1654
+ }
1655
+ switch (tokenData.error) {
1656
+ case "authorization_pending":
1657
+ break;
1658
+ // keep polling
1659
+ case "slow_down":
1660
+ interval += 1;
1661
+ break;
1662
+ case "expired_token":
1663
+ throw new Error("Device code expired \u2014 please try again");
1664
+ case "access_denied":
1665
+ throw new Error("User denied access");
1666
+ default:
1667
+ throw new Error(
1668
+ `Token request failed: ${tokenData.error ?? "unknown error"}`
1669
+ );
1670
+ }
1671
+ }
1672
+ if (!aborted) {
1673
+ throw new Error("Device code expired \u2014 please try again");
1674
+ }
1675
+ } catch (err) {
1676
+ if (!aborted) reject(err);
1677
+ }
1678
+ })();
1679
+ return {
1680
+ get userCode() {
1681
+ return userCode;
1682
+ },
1683
+ get verificationUri() {
1684
+ return verificationUri;
1685
+ },
1686
+ get message() {
1687
+ return message;
1688
+ },
1689
+ get expiresAt() {
1690
+ return expiresAt;
1691
+ },
1692
+ result,
1693
+ cancel() {
1694
+ aborted = true;
1695
+ },
1696
+ ...{ _ready: ready }
1697
+ };
1698
+ }
1699
+ async function awaitDeviceCodeReady2(b) {
1700
+ const r = b._ready;
1701
+ await r;
1702
+ }
1703
+
1704
+ // src/providers/gmail/client.ts
1705
+ import { google } from "googleapis";
1706
+ var GmailClientFactory = class {
1707
+ constructor(store, clientId, clientSecret) {
1708
+ this.store = store;
1709
+ this.clientId = clientId;
1710
+ this.clientSecret = clientSecret;
1711
+ }
1712
+ store;
1713
+ clientId;
1714
+ clientSecret;
1715
+ cache = /* @__PURE__ */ new Map();
1716
+ get(account) {
1717
+ const key = account.email.toLowerCase();
1718
+ const existing = this.cache.get(key);
1719
+ if (existing) return existing;
1720
+ if (!isSerializedGmailTokens(account.tokens)) {
1721
+ throw new Error(
1722
+ "Gmail account tokens are missing or corrupted \u2014 re-run add_account"
1723
+ );
1724
+ }
1725
+ const tokens = account.tokens;
1726
+ const resolvedClientId = tokens.clientId || this.clientId;
1727
+ const resolvedSecret = tokens.clientSecret || this.clientSecret;
1728
+ const auth = buildOAuth2Client({
1729
+ ...tokens,
1730
+ clientId: resolvedClientId ?? tokens.clientId,
1731
+ clientSecret: resolvedSecret
1732
+ });
1733
+ const store = this.store;
1734
+ auth.on("tokens", (updated) => {
1735
+ if (!updated.refresh_token && !updated.access_token) return;
1736
+ const fresh = store.getAccount(account.email) ?? account;
1737
+ const currentTokens = isSerializedGmailTokens(fresh.tokens) ? fresh.tokens : tokens;
1738
+ const nextTokens = {
1739
+ ...currentTokens,
1740
+ accessToken: updated.access_token ?? currentTokens.accessToken,
1741
+ refreshToken: updated.refresh_token ?? currentTokens.refreshToken,
1742
+ expiryDate: updated.expiry_date ?? currentTokens.expiryDate,
1743
+ scopes: updated.scope ? updated.scope.split(" ") : currentTokens.scopes
1744
+ };
1745
+ store.upsertAccount({
1746
+ ...fresh,
1747
+ tokens: nextTokens
1748
+ }).catch(() => {
1749
+ });
1750
+ });
1751
+ const gmail = google.gmail({ version: "v1", auth });
1752
+ const entry = { auth, gmail };
1753
+ this.cache.set(key, entry);
1754
+ return entry;
1755
+ }
1756
+ /** Drop a cached client (e.g. after removeAccount). */
1757
+ invalidate(email) {
1758
+ this.cache.delete(email.toLowerCase());
1759
+ }
1760
+ };
1761
+
1762
+ // src/providers/gmail/read-ops.ts
1763
+ import { writeFileSync as writeFileSync2 } from "fs";
1764
+ import { tmpdir as tmpdir3 } from "os";
1765
+ import { join as pathJoin3 } from "path";
1766
+
1767
+ // src/providers/gmail/helpers.ts
1768
+ import MailComposer2 from "nodemailer/lib/mail-composer/index.js";
1769
+ var WELL_KNOWN_TO_LABEL = {
1770
+ inbox: "INBOX",
1771
+ sentitems: "SENT",
1772
+ drafts: "DRAFT",
1773
+ deleteditems: "TRASH",
1774
+ junkemail: "SPAM",
1775
+ outbox: ""
1776
+ // Gmail has no outbox; sendEmail handles this.
1777
+ };
1778
+ function resolveLabel(wellKnownOrId) {
1779
+ const lower = wellKnownOrId.toLowerCase();
1780
+ return WELL_KNOWN_TO_LABEL[lower] ?? wellKnownOrId;
1781
+ }
1782
+ function resolveLabelsForMove(destinationId) {
1783
+ if (destinationId.toLowerCase() === "archive") {
1784
+ return { addLabelIds: [], removeLabelIds: ["INBOX"] };
1785
+ }
1786
+ return { addLabelIds: [resolveLabel(destinationId)], removeLabelIds: [] };
1787
+ }
1788
+ function mapHeaderAddr(raw) {
1789
+ if (!raw) return [];
1790
+ const addrs = raw.split(",");
1791
+ return addrs.map((a) => {
1792
+ const trimmed = a.trim();
1793
+ const match = trimmed.match(/^(.+?)\s*<(.+@.+)>$/);
1794
+ if (match) return { name: match[1].trim(), address: match[2] };
1795
+ return { address: trimmed };
1796
+ });
1797
+ }
1798
+ function findHeader(headers, name) {
1799
+ if (!headers) return void 0;
1800
+ const h = headers.find(
1801
+ (h2) => h2.name?.toLowerCase() === name.toLowerCase()
1802
+ );
1803
+ return h?.value ?? void 0;
1804
+ }
1805
+ function decodeBody(body) {
1806
+ if (!body?.data) return "";
1807
+ return Buffer.from(
1808
+ body.data.replace(/-/g, "+").replace(/_/g, "/"),
1809
+ "base64"
1810
+ ).toString("utf-8");
1811
+ }
1812
+ function parsePayload(payload, prefix = "") {
1813
+ let bodyText;
1814
+ let bodyHtml;
1815
+ const attachments = [];
1816
+ function walk(part, partPrefix) {
1817
+ const mime = part.mimeType ?? "";
1818
+ if (mime === "text/plain" && bodyText === void 0) {
1819
+ bodyText = decodeBody(part.body ?? {});
1820
+ return;
1821
+ }
1822
+ if (mime === "text/html" && bodyHtml === void 0) {
1823
+ bodyHtml = decodeBody(part.body ?? {});
1824
+ return;
1825
+ }
1826
+ if (part.parts) {
1827
+ for (let i = 0; i < part.parts.length; i++) {
1828
+ walk(part.parts[i], (partPrefix ? `${partPrefix}.` : "") + String(i));
1829
+ }
1830
+ return;
1831
+ }
1832
+ const topType = mime.split("/")[0] ?? "";
1833
+ const hasFilename = !!part.filename || !!part.body?.attachmentId;
1834
+ const isAttachment = hasFilename || !!mime && topType !== "text" && topType !== "multipart";
1835
+ if (isAttachment && part.body?.attachmentId) {
1836
+ attachments.push({
1837
+ id: part.body.attachmentId,
1838
+ name: part.filename ?? part.partId ?? "attachment",
1839
+ contentType: mime || void 0,
1840
+ size: part.body.size != null ? Number(part.body.size) : void 0
1841
+ });
1842
+ }
1843
+ }
1844
+ walk(payload, prefix);
1845
+ return { bodyText, bodyHtml, attachments };
1846
+ }
1847
+ function mapSummary3(id, headers, flags) {
1848
+ return {
1849
+ id,
1850
+ subject: findHeader(headers, "Subject") ?? "",
1851
+ from: mapHeaderAddr(findHeader(headers, "From"))[0],
1852
+ to: mapHeaderAddr(findHeader(headers, "To")),
1853
+ receivedAt: flags.internalDate ? new Date(Number(flags.internalDate)).toISOString() : void 0,
1854
+ preview: void 0,
1855
+ isRead: !(flags.labelIds?.includes("UNREAD") ?? false),
1856
+ hasAttachments: false
1857
+ };
1858
+ }
1859
+ function mapFolder2(label) {
1860
+ return {
1861
+ id: label.id ?? "",
1862
+ displayName: label.name ?? "",
1863
+ parentFolderId: void 0,
1864
+ childFolderCount: 0,
1865
+ totalItemCount: label.messagesTotal ?? 0,
1866
+ unreadItemCount: label.messagesUnread ?? 0
1867
+ };
1868
+ }
1869
+ function clampLimit3(v, dflt, max) {
1870
+ if (!v || v <= 0) return dflt;
1871
+ return Math.min(v, max);
1872
+ }
1873
+ async function pool(items, concurrency, fn) {
1874
+ const results = new Array(items.length);
1875
+ let idx = 0;
1876
+ async function worker() {
1877
+ while (idx < items.length) {
1878
+ const i = idx++;
1879
+ results[i] = await fn(items[i]);
1880
+ }
1881
+ }
1882
+ const workers = Array.from(
1883
+ { length: Math.min(concurrency, items.length) },
1884
+ () => worker()
1885
+ );
1886
+ await Promise.all(workers);
1887
+ return results;
1888
+ }
1889
+ function base64urlEncode(buf) {
1890
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1891
+ }
1892
+ async function buildRawMessage2(account, msg, messageId) {
1893
+ const { body: transformed, images } = parseInlineImages(msg.body);
1894
+ const mailOptions = {
1895
+ from: `${account.displayName ?? ""} <${account.email}>`,
1896
+ to: msg.to.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", "),
1897
+ subject: msg.subject,
1898
+ attachDataUrls: true
1899
+ };
1900
+ if (msg.isHtml) {
1901
+ mailOptions.html = transformed;
1902
+ } else {
1903
+ mailOptions.text = transformed;
1904
+ }
1905
+ if (msg.cc && msg.cc.length > 0) {
1906
+ mailOptions.cc = msg.cc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1907
+ }
1908
+ if (msg.bcc && msg.bcc.length > 0) {
1909
+ mailOptions.bcc = msg.bcc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
1910
+ }
1911
+ if (images.length > 0) {
1912
+ mailOptions.attachments = images.map((img) => ({
1913
+ filename: img.filename,
1914
+ content: Buffer.from(img.contentBytes, "base64"),
1915
+ contentType: img.contentType,
1916
+ cid: img.cid
1917
+ }));
1918
+ }
1919
+ if (messageId) {
1920
+ mailOptions.messageId = messageId;
1921
+ }
1922
+ const rawStr = await new Promise((resolve, reject) => {
1923
+ const mc = new MailComposer2(mailOptions);
1924
+ mc.compile().build((err, buf) => {
1925
+ if (err) reject(err);
1926
+ else resolve(buf.toString("utf-8"));
1927
+ });
1928
+ });
1929
+ return { raw: base64urlEncode(Buffer.from(rawStr, "utf-8")) };
1930
+ }
1931
+
1932
+ // src/providers/gmail/read-ops.ts
1933
+ async function listEmails2(clients, account, opts) {
1934
+ const { gmail } = clients.get(account);
1935
+ const limit = clampLimit3(opts.limit, 25, 100);
1936
+ const label = resolveLabel(opts.folder ?? "inbox");
1937
+ const params = {
1938
+ userId: "me",
1939
+ labelIds: [label],
1940
+ maxResults: limit
1941
+ };
1942
+ if (opts.unreadOnly) {
1943
+ params.q = "is:unread";
1944
+ }
1945
+ const allIds = [];
1946
+ let pageToken;
1947
+ do {
1948
+ const res = await gmail.users.messages.list({ ...params, pageToken });
1949
+ if (res.data.messages) allIds.push(...res.data.messages);
1950
+ pageToken = res.data.nextPageToken ?? void 0;
1951
+ } while (pageToken && allIds.length < (opts.skip ?? 0) + limit);
1952
+ const skip = opts.skip ?? 0;
1953
+ const pageIds = allIds.slice(skip, skip + limit);
1954
+ const hasMore = skip + limit < allIds.length;
1955
+ if (pageIds.length === 0) {
1956
+ return { items: [], hasMore };
1957
+ }
1958
+ const items = await pool(pageIds, 10, async (entry) => {
1959
+ const msgId = entry.id ?? "";
1960
+ const msgRes = await gmail.users.messages.get({
1961
+ userId: "me",
1962
+ id: msgId,
1963
+ format: "metadata",
1964
+ metadataHeaders: ["From", "Subject", "To", "Date"]
1965
+ });
1966
+ const msg = msgRes.data;
1967
+ return mapSummary3(msgId, msg.payload?.headers ?? [], {
1968
+ labelIds: msg.labelIds,
1969
+ internalDate: msg.internalDate
1970
+ });
1971
+ });
1972
+ return { items, hasMore };
1973
+ }
1974
+ async function searchEmails2(clients, account, query, opts) {
1975
+ const { gmail } = clients.get(account);
1976
+ const limit = clampLimit3(opts.limit, 25, 100);
1977
+ const res = await gmail.users.messages.list({
1978
+ userId: "me",
1979
+ q: query,
1980
+ maxResults: limit
1981
+ });
1982
+ const ids = res.data.messages ?? [];
1983
+ if (ids.length === 0) return [];
1984
+ const items = await pool(ids, 10, async (entry) => {
1985
+ const msgId = entry.id ?? "";
1986
+ const msgRes = await gmail.users.messages.get({
1987
+ userId: "me",
1988
+ id: msgId,
1989
+ format: "metadata",
1990
+ metadataHeaders: ["From", "Subject", "To", "Date"]
1991
+ });
1992
+ const msg = msgRes.data;
1993
+ return mapSummary3(msgId, msg.payload?.headers ?? [], {
1994
+ labelIds: msg.labelIds,
1995
+ internalDate: msg.internalDate
1996
+ });
1997
+ });
1998
+ return items;
1999
+ }
2000
+ async function readEmail2(clients, account, id) {
2001
+ const { gmail } = clients.get(account);
2002
+ const res = await gmail.users.messages.get({
2003
+ userId: "me",
2004
+ id,
2005
+ format: "full"
2006
+ });
2007
+ const msg = res.data;
2008
+ if (!msg) throw new Error(`message not found: ${id}`);
2009
+ const headers = msg.payload?.headers ?? [];
2010
+ const { bodyText, bodyHtml, attachments } = parsePayload(msg.payload ?? {});
2011
+ const summary = mapSummary3(id, headers, {
2012
+ labelIds: msg.labelIds,
2013
+ internalDate: msg.internalDate
2014
+ });
2015
+ return {
2016
+ ...summary,
2017
+ cc: mapHeaderAddr(findHeader(headers, "Cc")),
2018
+ bcc: mapHeaderAddr(findHeader(headers, "Bcc")),
2019
+ bodyText,
2020
+ bodyHtml,
2021
+ attachments: attachments.length > 0 ? attachments : void 0,
2022
+ hasAttachments: attachments.length > 0
2023
+ };
2024
+ }
2025
+ async function readAttachment2(clients, account, messageId, attachmentId) {
2026
+ const { gmail } = clients.get(account);
2027
+ const msgRes = await gmail.users.messages.get({
2028
+ userId: "me",
2029
+ id: messageId,
2030
+ format: "full"
2031
+ });
2032
+ const { attachments } = parsePayload(msgRes.data.payload ?? {});
2033
+ const match = attachments.find((a) => a.id === attachmentId);
2034
+ const name = match?.name ?? "attachment";
2035
+ const contentType = match?.contentType;
2036
+ const attRes = await gmail.users.messages.attachments.get({
2037
+ userId: "me",
2038
+ messageId,
2039
+ id: attachmentId
2040
+ });
2041
+ const data = attRes.data.data;
2042
+ if (!data) throw new Error("attachment data is empty");
2043
+ const buf = Buffer.from(
2044
+ data.replace(/-/g, "+").replace(/_/g, "/"),
2045
+ "base64"
2046
+ );
2047
+ const outPath = pathJoin3(tmpdir3(), name);
2048
+ writeFileSync2(outPath, buf);
2049
+ return { name, contentType, path: outPath };
2050
+ }
2051
+ async function listFolders2(clients, account, _opts) {
2052
+ const { gmail } = clients.get(account);
2053
+ const res = await gmail.users.labels.list({ userId: "me" });
2054
+ const labels = res.data.labels ?? [];
2055
+ return labels.map(mapFolder2);
2056
+ }
2057
+
2058
+ // src/providers/gmail/write-ops.ts
2059
+ import { randomUUID as randomUUID4 } from "crypto";
2060
+ import { Buffer as Buffer2 } from "buffer";
2061
+ import MailComposer3 from "nodemailer/lib/mail-composer/index.js";
2062
+ async function sendEmail2(clients, account, msg) {
2063
+ const { gmail } = clients.get(account);
2064
+ let threadId;
2065
+ let rawBody;
2066
+ if (msg.forwardMessageId) {
2067
+ const fwdRes = await gmail.users.messages.get({
2068
+ userId: "me",
2069
+ id: msg.forwardMessageId,
2070
+ format: "raw"
2071
+ });
2072
+ threadId = fwdRes.data.threadId ?? void 0;
2073
+ const fwdRaw = fwdRes.data.raw;
2074
+ if (fwdRaw) {
2075
+ const fwdStr = Buffer2.from(
2076
+ fwdRaw.replace(/-/g, "+").replace(/_/g, "/"),
2077
+ "base64"
2078
+ ).toString("utf-8");
2079
+ const divider = '\n\n<div style="line-height:12px"><br></div>\n\n<div style="border-left:2px solid #ccc; padding-left:8px; margin-left:0; color:#666">\n---------- Forwarded message ---------<br>' + fwdStr + "\n</div>";
2080
+ const combinedMsg = { ...msg, body: msg.body + divider };
2081
+ rawBody = await buildRawMessage2(account, combinedMsg);
2082
+ } else {
2083
+ rawBody = await buildRawMessage2(account, msg);
2084
+ }
2085
+ } else {
2086
+ rawBody = await buildRawMessage2(account, msg);
2087
+ if (msg.inReplyTo) {
2088
+ try {
2089
+ const refRes = await gmail.users.messages.get({
2090
+ userId: "me",
2091
+ id: msg.inReplyTo,
2092
+ format: "minimal"
2093
+ });
2094
+ threadId = refRes.data.threadId ?? void 0;
2095
+ } catch {
2096
+ }
2097
+ }
2098
+ }
2099
+ const sendRes = await gmail.users.messages.send({
2100
+ userId: "me",
2101
+ requestBody: {
2102
+ raw: rawBody.raw,
2103
+ threadId
2104
+ }
2105
+ });
2106
+ return { id: sendRes.data.id ?? "" };
2107
+ }
2108
+ async function saveDraft2(clients, account, msg) {
2109
+ const { gmail } = clients.get(account);
2110
+ const { raw } = await buildRawMessage2(account, msg);
2111
+ let threadId;
2112
+ if (msg.inReplyTo) {
2113
+ try {
2114
+ const refRes = await gmail.users.messages.get({
2115
+ userId: "me",
2116
+ id: msg.inReplyTo,
2117
+ format: "minimal"
2118
+ });
2119
+ threadId = refRes.data.threadId ?? void 0;
2120
+ } catch {
2121
+ }
2122
+ }
2123
+ const draftRes = await gmail.users.drafts.create({
2124
+ userId: "me",
2125
+ requestBody: {
2126
+ message: { raw, threadId }
2127
+ }
2128
+ });
2129
+ return { id: draftRes.data.message?.id ?? draftRes.data.id ?? "" };
2130
+ }
2131
+ async function updateDraft2(clients, account, id, update) {
2132
+ const { gmail } = clients.get(account);
2133
+ const draftRes = await gmail.users.drafts.get({
2134
+ userId: "me",
2135
+ id,
2136
+ format: "raw"
2137
+ });
2138
+ const existingMessage = draftRes.data.message;
2139
+ if (!existingMessage?.raw) {
2140
+ throw new Error(`draft not found: ${id}`);
2141
+ }
2142
+ const existingHeaders = existingMessage.payload?.headers ?? [];
2143
+ const origSubject = findHeader(existingHeaders, "Subject") ?? "";
2144
+ const rawStr = Buffer2.from(
2145
+ existingMessage.raw.replace(/-/g, "+").replace(/_/g, "/"),
2146
+ "base64"
2147
+ ).toString("utf-8");
2148
+ const existingTo = update.to ?? mapHeaderAddr(findHeader(existingHeaders, "To"));
2149
+ const existingCc = update.cc ?? mapHeaderAddr(findHeader(existingHeaders, "Cc"));
2150
+ const existingBcc = update.bcc ?? mapHeaderAddr(findHeader(existingHeaders, "Bcc"));
2151
+ const { raw } = await buildRawMessage2(account, {
2152
+ to: existingTo,
2153
+ subject: update.subject ?? origSubject,
2154
+ body: update.body ?? "",
2155
+ isHtml: update.isHtml,
2156
+ cc: existingCc.length > 0 ? existingCc : void 0,
2157
+ bcc: existingBcc.length > 0 ? existingBcc : void 0
2158
+ });
2159
+ const updated = await gmail.users.drafts.update({
2160
+ userId: "me",
2161
+ id,
2162
+ requestBody: {
2163
+ message: {
2164
+ raw,
2165
+ threadId: existingMessage.threadId ?? void 0
2166
+ }
2167
+ }
2168
+ });
2169
+ return { id: updated.data.message?.id ?? updated.data.id ?? id };
2170
+ }
2171
+ async function moveEmail2(clients, account, id, destinationId) {
2172
+ const { gmail } = clients.get(account);
2173
+ const { addLabelIds, removeLabelIds } = resolveLabelsForMove(destinationId);
2174
+ await gmail.users.messages.modify({
2175
+ userId: "me",
2176
+ id,
2177
+ requestBody: { addLabelIds, removeLabelIds }
2178
+ });
2179
+ }
2180
+ async function sendDraft2(clients, account, id) {
2181
+ const { gmail } = clients.get(account);
2182
+ const res = await gmail.users.drafts.send({
2183
+ userId: "me",
2184
+ requestBody: { id }
2185
+ });
2186
+ return { id: res.data.id ?? id };
2187
+ }
2188
+ async function addAttachmentToDraft2(clients, account, draftId, name, contentBytes, contentType) {
2189
+ const { gmail } = clients.get(account);
2190
+ const draftRes = await gmail.users.drafts.get({
2191
+ userId: "me",
2192
+ id: draftId,
2193
+ format: "raw"
2194
+ });
2195
+ const existingMessage = draftRes.data.message;
2196
+ if (!existingMessage?.raw) {
2197
+ throw new Error(`draft not found: ${draftId}`);
2198
+ }
2199
+ const rawStr = Buffer2.from(
2200
+ existingMessage.raw.replace(/-/g, "+").replace(/_/g, "/"),
2201
+ "base64"
2202
+ ).toString("utf-8");
2203
+ const newRawStr = await new Promise((resolve, reject) => {
2204
+ const mc = new MailComposer3({
2205
+ raw: rawStr,
2206
+ attachments: [
2207
+ {
2208
+ filename: name,
2209
+ content: Buffer2.from(contentBytes, "base64"),
2210
+ contentType: contentType ?? "application/octet-stream"
2211
+ }
2212
+ ]
2213
+ });
2214
+ mc.compile().build((err, buf) => {
2215
+ if (err) reject(err);
2216
+ else resolve(buf.toString("utf-8"));
2217
+ });
2218
+ });
2219
+ const updated = await gmail.users.drafts.update({
2220
+ userId: "me",
2221
+ id: draftId,
2222
+ requestBody: {
2223
+ message: {
2224
+ raw: base64urlEncode(Buffer2.from(newRawStr, "utf-8")),
2225
+ threadId: existingMessage.threadId ?? void 0
2226
+ }
2227
+ }
2228
+ });
2229
+ return {
2230
+ id: updated.data.message?.id ?? updated.data.id ?? draftId,
2231
+ attachment: {
2232
+ id: randomUUID4(),
2233
+ name,
2234
+ contentType: contentType ?? "application/octet-stream"
2235
+ }
2236
+ };
2237
+ }
2238
+ async function markRead2(clients, account, id, isRead) {
2239
+ const { gmail } = clients.get(account);
2240
+ await gmail.users.messages.modify({
2241
+ userId: "me",
2242
+ id,
2243
+ requestBody: {
2244
+ removeLabelIds: isRead ? ["UNREAD"] : void 0,
2245
+ addLabelIds: isRead ? void 0 : ["UNREAD"]
2246
+ }
2247
+ });
2248
+ }
2249
+ async function createFolder2(clients, account, input) {
2250
+ const { gmail } = clients.get(account);
2251
+ const created = await gmail.users.labels.create({
2252
+ userId: "me",
2253
+ requestBody: {
2254
+ name: input.displayName,
2255
+ messageListVisibility: "show",
2256
+ labelListVisibility: "labelShow"
2257
+ }
2258
+ });
2259
+ return mapFolder2(created.data);
2260
+ }
2261
+ async function renameFolder2(clients, account, folderId, newName) {
2262
+ const { gmail } = clients.get(account);
2263
+ const updated = await gmail.users.labels.patch({
2264
+ userId: "me",
2265
+ id: folderId,
2266
+ requestBody: { name: newName }
2267
+ });
2268
+ return mapFolder2(updated.data);
2269
+ }
2270
+ async function deleteFolder2(clients, account, folderId) {
2271
+ const { gmail } = clients.get(account);
2272
+ await gmail.users.labels.delete({
2273
+ userId: "me",
2274
+ id: folderId
2275
+ });
2276
+ }
2277
+
2278
+ // src/providers/gmail/index.ts
2279
+ var GmailProvider = class {
2280
+ constructor(opts) {
2281
+ this.opts = opts;
2282
+ this.clientId = opts.clientId;
2283
+ this.clientSecret = opts.clientSecret;
2284
+ this.clients = new GmailClientFactory(
2285
+ opts.store,
2286
+ opts.clientId,
2287
+ opts.clientSecret
2288
+ );
2289
+ }
2290
+ opts;
2291
+ id = "gmail";
2292
+ clients;
2293
+ pending = /* @__PURE__ */ new Map();
2294
+ clientId;
2295
+ clientSecret;
2296
+ // ── account lifecycle ──
2297
+ async addAccount(input) {
2298
+ const begin = beginDeviceCode2(
2299
+ void 0,
2300
+ this.clientId,
2301
+ this.clientSecret
2302
+ );
2303
+ await awaitDeviceCodeReady2(begin);
2304
+ const handle = randomUUID5();
2305
+ const flow = {
2306
+ begin,
2307
+ emailHint: input.email,
2308
+ startedAt: Date.now(),
2309
+ settled: "pending"
2310
+ };
2311
+ this.pending.set(handle, flow);
2312
+ begin.result.then(async ({ tokens, email }) => {
2313
+ const resolvedEmail = (email || input.email || "").toLowerCase();
2314
+ if (!resolvedEmail) {
2315
+ flow.settled = "error";
2316
+ flow.error = "no email returned from Google account";
2317
+ return;
2318
+ }
2319
+ const rec = {
2320
+ email: resolvedEmail,
2321
+ provider: "gmail",
2322
+ displayName: resolvedEmail,
2323
+ tokens,
2324
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
2325
+ };
2326
+ const saved = await this.opts.store.upsertAccount(rec);
2327
+ flow.account = saved;
2328
+ flow.settled = "ready";
2329
+ }).catch((err) => {
2330
+ flow.settled = "error";
2331
+ flow.error = err instanceof Error ? err.message : String(err);
2332
+ });
2333
+ return {
2334
+ status: "pending",
2335
+ handle,
2336
+ verification: {
2337
+ userCode: begin.userCode,
2338
+ verificationUri: begin.verificationUri,
2339
+ expiresAt: begin.expiresAt,
2340
+ message: begin.message
2341
+ }
2342
+ };
2343
+ }
2344
+ async completeAddAccount(handle) {
2345
+ const flow = this.pending.get(handle);
2346
+ if (!flow) return { status: "error", error: "unknown handle" };
2347
+ if (Date.now() - flow.startedAt > 20 * 6e4 && flow.settled === "pending") {
2348
+ flow.settled = "expired";
2349
+ flow.begin.cancel();
2350
+ }
2351
+ if (flow.settled === "ready" && flow.account) {
2352
+ this.pending.delete(handle);
2353
+ return { status: "ready", account: flow.account };
2354
+ }
2355
+ if (flow.settled === "error") {
2356
+ this.pending.delete(handle);
2357
+ return { status: "error", error: flow.error ?? "unknown error" };
2358
+ }
2359
+ if (flow.settled === "expired") {
2360
+ this.pending.delete(handle);
2361
+ return { status: "expired" };
2362
+ }
2363
+ return { status: "pending" };
2364
+ }
2365
+ // ── browse ──
2366
+ async listEmails(account, opts) {
2367
+ return listEmails2(this.clients, account, opts);
2368
+ }
2369
+ async searchEmails(account, query, opts) {
2370
+ return searchEmails2(this.clients, account, query, opts);
2371
+ }
2372
+ async readEmail(account, id) {
2373
+ return readEmail2(this.clients, account, id);
2374
+ }
2375
+ async readAttachment(account, messageId, attachmentId) {
2376
+ return readAttachment2(this.clients, account, messageId, attachmentId);
2377
+ }
2378
+ // ── compose ──
2379
+ async sendEmail(account, msg) {
2380
+ return sendEmail2(this.clients, account, msg);
2381
+ }
2382
+ async saveDraft(account, msg) {
2383
+ return saveDraft2(this.clients, account, msg);
2384
+ }
2385
+ async updateDraft(account, id, update) {
2386
+ return updateDraft2(this.clients, account, id, update);
2387
+ }
2388
+ async moveEmail(account, id, destinationId) {
2389
+ return moveEmail2(this.clients, account, id, destinationId);
2390
+ }
2391
+ async sendDraft(account, id) {
2392
+ return sendDraft2(this.clients, account, id);
2393
+ }
2394
+ async addAttachmentToDraft(account, draftId, name, contentBytes, contentType) {
2395
+ return addAttachmentToDraft2(
2396
+ this.clients,
2397
+ account,
2398
+ draftId,
2399
+ name,
2400
+ contentBytes,
2401
+ contentType
2402
+ );
2403
+ }
2404
+ // ── organize ──
2405
+ async markRead(account, id, isRead) {
2406
+ return markRead2(this.clients, account, id, isRead);
2407
+ }
2408
+ // ── folders ──
2409
+ async listFolders(account, opts) {
2410
+ return listFolders2(this.clients, account, opts);
2411
+ }
2412
+ async createFolder(account, input) {
2413
+ return createFolder2(this.clients, account, input);
2414
+ }
2415
+ async renameFolder(account, folderId, newName) {
2416
+ return renameFolder2(this.clients, account, folderId, newName);
2417
+ }
2418
+ async deleteFolder(account, folderId) {
2419
+ return deleteFolder2(this.clients, account, folderId);
2420
+ }
2421
+ };
2422
+
2423
+ // src/providers/registry.ts
2424
+ function buildRegistry(opts) {
2425
+ const outlookCfg = opts.providers?.outlook;
2426
+ const providers = /* @__PURE__ */ new Map();
2427
+ providers.set("outlook", new OutlookProvider({
2428
+ store: opts.store,
2429
+ clientId: outlookCfg?.clientId,
2430
+ tenantId: outlookCfg?.tenantId
2431
+ }));
2432
+ providers.set("imap", new ImapProvider(opts.store));
2433
+ const gmailCfg = opts.providers?.gmail;
2434
+ providers.set("gmail", new GmailProvider({
2435
+ store: opts.store,
2436
+ clientId: gmailCfg?.clientId,
2437
+ clientSecret: gmailCfg?.clientSecret
2438
+ }));
2439
+ function get(id) {
2440
+ const p = providers.get(id);
2441
+ if (!p) throw new Error(`unknown provider: ${id}`);
2442
+ return p;
2443
+ }
2444
+ function resolveByEmail(email) {
2445
+ const account = opts.store.getAccount(email);
2446
+ if (!account) {
2447
+ throw new Error(
2448
+ `no account registered for "${email}". Call add_account first.`
2449
+ );
2450
+ }
2451
+ return { provider: get(account.provider), account };
2452
+ }
2453
+ return {
2454
+ get,
2455
+ resolveByEmail,
2456
+ list: () => Array.from(providers.values())
2457
+ };
2458
+ }
2459
+
2460
+ // src/tools/shared.ts
2461
+ import { z } from "zod";
2462
+ function ok(data, structuredContent) {
2463
+ const result = {
2464
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
2465
+ };
2466
+ if (structuredContent !== void 0) {
2467
+ result.structuredContent = structuredContent;
2468
+ }
2469
+ return result;
2470
+ }
2471
+ function fail(message) {
2472
+ return {
2473
+ isError: true,
2474
+ content: [{ type: "text", text: message }]
2475
+ };
2476
+ }
2477
+ function errMsg(err) {
2478
+ if (err instanceof Error) return err.message;
2479
+ return String(err);
2480
+ }
2481
+ var providerIdEnum = z.enum(["outlook", "imap", "gmail"]);
2482
+ var emailAddrSchema = z.object({
2483
+ address: z.string().email(),
2484
+ name: z.string().optional()
2485
+ });
2486
+ var emailAddrOutputSchema = z.object({
2487
+ name: z.string().optional(),
2488
+ address: z.string()
829
2489
  });
830
2490
  var accountSummaryOutputSchema = z.object({
831
2491
  email: z.string(),
832
- provider: z.enum(["outlook", "imap", "gmail"]),
2492
+ provider: providerIdEnum,
833
2493
  displayName: z.string().optional(),
834
2494
  addedAt: z.string(),
835
2495
  hasSignature: z.boolean(),
836
2496
  hasStyle: z.boolean()
837
2497
  });
2498
+ var styleOutputSchema = z.object({
2499
+ fontFamily: z.string().optional(),
2500
+ fontSize: z.string().optional(),
2501
+ fontColor: z.string().optional()
2502
+ });
2503
+ var accountFullOutputSchema = z.object({
2504
+ email: z.string(),
2505
+ provider: providerIdEnum,
2506
+ displayName: z.string().optional(),
2507
+ tokens: z.record(z.unknown()),
2508
+ addedAt: z.string(),
2509
+ signature: z.string().optional(),
2510
+ style: styleOutputSchema.optional()
2511
+ });
838
2512
  var emailSummaryOutputSchema = z.object({
839
2513
  id: z.string(),
840
2514
  subject: z.string(),
@@ -852,374 +2526,680 @@ var attachmentMetaOutputSchema = z.object({
852
2526
  contentType: z.string().optional(),
853
2527
  size: z.number().optional()
854
2528
  });
855
- var styleOutputSchema = z.object({
856
- fontFamily: z.string().optional(),
857
- fontSize: z.string().optional(),
858
- fontColor: z.string().optional()
2529
+ var folderInfoOutputSchema = z.object({
2530
+ id: z.string(),
2531
+ displayName: z.string(),
2532
+ parentFolderId: z.string().optional(),
2533
+ childFolderCount: z.number(),
2534
+ totalItemCount: z.number(),
2535
+ unreadItemCount: z.number()
859
2536
  });
860
- function registerTools(server, opts) {
861
- const { store, registry, readOnly = false, draftOnly = false } = opts;
2537
+ function composeBody(input) {
2538
+ const { body, isHtml = false, signature, style, includeSignature } = input;
2539
+ const hasSignature = includeSignature && !!signature;
2540
+ const hasStyle = !!(style && (style.fontFamily || style.fontSize || style.fontColor));
2541
+ if (!hasSignature && !hasStyle) {
2542
+ return { body, isHtml };
2543
+ }
2544
+ const styleAttr = hasStyle ? buildStyleAttr(style) : "";
2545
+ if (isHtml) {
2546
+ let result2 = hasStyle ? `<div style="${styleAttr}">${body}</div>` : body;
2547
+ if (hasSignature) result2 += `
2548
+ <div class="signature">${signature}</div>`;
2549
+ return { body: result2, isHtml: true };
2550
+ }
2551
+ const escaped = escapeHtml(body);
2552
+ let result = `<div style="${styleAttr}">${escaped}</div>`;
2553
+ if (hasSignature) result += `
2554
+ <div class="signature">${signature}</div>`;
2555
+ return { body: result, isHtml: true };
2556
+ }
2557
+ function buildStyleAttr(style) {
2558
+ const parts = [];
2559
+ if (style.fontFamily) parts.push(`font-family: ${style.fontFamily}`);
2560
+ if (style.fontSize) parts.push(`font-size: ${style.fontSize}`);
2561
+ if (style.fontColor) parts.push(`color: ${style.fontColor}`);
2562
+ return parts.join("; ");
2563
+ }
2564
+ function escapeHtml(text) {
2565
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/\n/g, "<br>");
2566
+ }
2567
+ function shouldRegister(name, tools) {
2568
+ if (tools.enabledTools) {
2569
+ return tools.enabledTools.has(name);
2570
+ }
2571
+ if (tools.disabledTools) {
2572
+ return !tools.disabledTools.has(name);
2573
+ }
2574
+ return true;
2575
+ }
2576
+
2577
+ // src/tools/accounts.ts
2578
+ import { z as z2 } from "zod";
2579
+ function registerAccountTools(server, ctx) {
2580
+ const { store, registry, tools } = ctx;
862
2581
  const listAccountsOutputSchema = {
863
- accounts: z.array(accountSummaryOutputSchema)
2582
+ accounts: z2.array(accountSummaryOutputSchema)
864
2583
  };
865
- server.registerTool(
866
- "list_accounts",
867
- {
868
- description: "List all email accounts known to this server (no secrets). Use the returned `email` value as the `account` argument to other tools.",
869
- inputSchema: {},
870
- outputSchema: listAccountsOutputSchema
871
- },
872
- async () => {
873
- const rows = store.listAccounts().map((a) => ({
874
- email: a.email,
875
- provider: a.provider,
876
- displayName: a.displayName,
877
- addedAt: a.addedAt,
878
- hasSignature: !!a.signature,
879
- hasStyle: !!(a.style && (a.style.fontFamily || a.style.fontSize || a.style.fontColor))
880
- }));
881
- const data = { accounts: rows };
882
- return ok(data, data);
883
- }
884
- );
885
- const addAccountOutputSchema = z.discriminatedUnion("status", [
886
- z.object({
887
- status: z.literal("pending"),
888
- handle: z.string(),
889
- verification: z.object({
890
- userCode: z.string(),
891
- verificationUri: z.string(),
892
- expiresAt: z.string(),
893
- message: z.string()
2584
+ if (shouldRegister("list_accounts", tools)) {
2585
+ server.registerTool(
2586
+ "list_accounts",
2587
+ {
2588
+ description: "List all email accounts known to this server (no secrets). Use the returned `email` value as the `account` argument to other tools.",
2589
+ inputSchema: {},
2590
+ outputSchema: listAccountsOutputSchema
2591
+ },
2592
+ async () => {
2593
+ const rows = store.listAccounts().map((a) => ({
2594
+ email: a.email,
2595
+ provider: a.provider,
2596
+ displayName: a.displayName,
2597
+ addedAt: a.addedAt,
2598
+ hasSignature: !!a.signature,
2599
+ hasStyle: !!(a.style && (a.style.fontFamily || a.style.fontSize || a.style.fontColor))
2600
+ }));
2601
+ const data = { accounts: rows };
2602
+ return ok(data, data);
2603
+ }
2604
+ );
2605
+ }
2606
+ const addAccountOutputSchema = z2.discriminatedUnion("status", [
2607
+ z2.object({
2608
+ status: z2.literal("pending"),
2609
+ handle: z2.string(),
2610
+ verification: z2.object({
2611
+ userCode: z2.string(),
2612
+ verificationUri: z2.string(),
2613
+ expiresAt: z2.string(),
2614
+ message: z2.string()
894
2615
  })
895
2616
  }),
896
- z.object({
897
- status: z.literal("ready"),
898
- account: z.object({
899
- email: z.string(),
900
- provider: z.enum(["outlook", "imap", "gmail"]),
901
- displayName: z.string().optional(),
902
- tokens: z.record(z.unknown()),
903
- addedAt: z.string(),
904
- signature: z.string().optional(),
905
- style: styleOutputSchema.optional()
906
- })
2617
+ z2.object({
2618
+ status: z2.literal("ready"),
2619
+ account: accountFullOutputSchema
907
2620
  })
908
2621
  ]);
909
- server.registerTool(
910
- "add_account",
911
- {
912
- 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.",
913
- inputSchema: {
914
- provider: z.enum(["outlook", "imap", "gmail"]).describe("Email backend. v1 only fully implements 'outlook'."),
915
- email: z.string().email().optional().describe("Optional hint \u2014 the provider will verify it against the auth result."),
916
- config: z.record(z.unknown()).optional().describe("Provider-specific config (e.g. IMAP host/port). Unused for Outlook.")
2622
+ if (shouldRegister("add_account", tools)) {
2623
+ server.registerTool(
2624
+ "add_account",
2625
+ {
2626
+ 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.",
2627
+ inputSchema: {
2628
+ provider: providerIdEnum.describe("Email backend. 'outlook' (Microsoft Graph) and 'imap' are fully implemented."),
2629
+ email: z2.string().email().optional().describe(
2630
+ "Optional hint \u2014 the provider will verify it against the auth result."
2631
+ ),
2632
+ config: z2.record(z2.unknown()).optional().describe(
2633
+ "Provider-specific config (e.g. IMAP host/port). Unused for Outlook."
2634
+ )
2635
+ },
2636
+ outputSchema: addAccountOutputSchema
917
2637
  },
918
- outputSchema: addAccountOutputSchema
919
- },
920
- async (args) => {
921
- if (readOnly) return fail("server is in --read-only mode; add_account is disabled");
922
- const provider = registry.get(args.provider);
923
- try {
924
- const res = await provider.addAccount({ email: args.email, config: args.config });
925
- return ok(res, res);
926
- } catch (err) {
927
- return fail(errMsg(err));
2638
+ async (args) => {
2639
+ const provider = registry.get(args.provider);
2640
+ try {
2641
+ const res = await provider.addAccount({
2642
+ email: args.email,
2643
+ config: args.config
2644
+ });
2645
+ return ok(res, res);
2646
+ } catch (err) {
2647
+ return fail(errMsg(err));
2648
+ }
928
2649
  }
929
- }
930
- );
931
- const completeAddAccountOutputSchema = z.object({
932
- status: z.enum(["pending", "ready", "expired", "error"]),
933
- account: z.object({
934
- email: z.string(),
935
- provider: z.enum(["outlook", "imap", "gmail"]),
936
- displayName: z.string().optional(),
937
- tokens: z.record(z.unknown()),
938
- addedAt: z.string(),
939
- signature: z.string().optional(),
940
- style: styleOutputSchema.optional()
941
- }).optional(),
942
- error: z.string().optional()
2650
+ );
2651
+ }
2652
+ const completeAddAccountOutputSchema = z2.object({
2653
+ status: z2.enum(["pending", "ready", "expired", "error"]),
2654
+ account: accountFullOutputSchema.optional(),
2655
+ error: z2.string().optional()
943
2656
  });
944
- server.registerTool(
945
- "complete_add_account",
946
- {
947
- description: "Poll/finalize a pending add_account flow. Returns `pending` until the user completes the device-code step, then `ready` with the persisted account.",
948
- inputSchema: {
949
- provider: z.enum(["outlook", "imap", "gmail"]),
950
- handle: z.string().min(1)
2657
+ if (shouldRegister("complete_add_account", tools)) {
2658
+ server.registerTool(
2659
+ "complete_add_account",
2660
+ {
2661
+ description: "Poll/finalize a pending add_account flow. Returns `pending` until the user completes the device-code step, then `ready` with the persisted account.",
2662
+ inputSchema: {
2663
+ provider: providerIdEnum,
2664
+ handle: z2.string().min(1)
2665
+ },
2666
+ outputSchema: completeAddAccountOutputSchema
951
2667
  },
952
- outputSchema: completeAddAccountOutputSchema
953
- },
954
- async (args) => {
955
- const provider = registry.get(args.provider);
956
- if (!provider.completeAddAccount) {
957
- return fail(`provider ${args.provider} has no async add-account flow`);
958
- }
959
- try {
960
- const res = await provider.completeAddAccount(args.handle);
961
- return ok(res, res);
962
- } catch (err) {
963
- return fail(errMsg(err));
2668
+ async (args) => {
2669
+ const provider = registry.get(args.provider);
2670
+ if (!provider.completeAddAccount) {
2671
+ return fail(
2672
+ `provider ${args.provider} has no async add-account flow`
2673
+ );
2674
+ }
2675
+ try {
2676
+ const res = await provider.completeAddAccount(args.handle);
2677
+ return ok(res, res);
2678
+ } catch (err) {
2679
+ return fail(errMsg(err));
2680
+ }
964
2681
  }
965
- }
966
- );
2682
+ );
2683
+ }
967
2684
  const accountSettingsOutputSchema = {
968
- signature: z.string().nullable(),
2685
+ signature: z2.string().nullable(),
969
2686
  style: styleOutputSchema.nullable()
970
2687
  };
971
- server.registerTool(
972
- "get_account_settings",
973
- {
974
- description: "Get signature (HTML) and style preferences for an account.",
975
- inputSchema: { account: z.string().email() },
976
- outputSchema: accountSettingsOutputSchema
977
- },
978
- async (args) => {
979
- try {
980
- const acct = store.getAccount(args.account);
981
- if (!acct) return fail(`no account registered for "${args.account}"`);
982
- const data = { signature: acct.signature ?? null, style: acct.style ?? null };
983
- return ok(data, data);
984
- } catch (err) {
985
- return fail(errMsg(err));
2688
+ if (shouldRegister("get_account_settings", tools)) {
2689
+ server.registerTool(
2690
+ "get_account_settings",
2691
+ {
2692
+ description: "Get signature (HTML) and style preferences for an account.",
2693
+ inputSchema: { account: z2.string().email() },
2694
+ outputSchema: accountSettingsOutputSchema
2695
+ },
2696
+ async (args) => {
2697
+ try {
2698
+ const acct = store.getAccount(args.account);
2699
+ if (!acct)
2700
+ return fail(`no account registered for "${args.account}"`);
2701
+ const data = {
2702
+ signature: acct.signature ?? null,
2703
+ style: acct.style ?? null
2704
+ };
2705
+ return ok(data, data);
2706
+ } catch (err) {
2707
+ return fail(errMsg(err));
2708
+ }
986
2709
  }
987
- }
988
- );
989
- server.registerTool(
990
- "set_account_settings",
991
- {
992
- description: "Set signature (HTML snippet) and/or style preferences for an account. Disabled in --read-only mode.",
993
- inputSchema: {
994
- account: z.string().email(),
995
- signature: z.string().optional().describe("HTML snippet \u2014 may contain formatting, images, links. Pass null to clear."),
996
- style: z.object({
997
- fontFamily: z.string().optional(),
998
- fontSize: z.string().optional(),
999
- fontColor: z.string().optional()
1000
- }).optional().describe("Font preferences applied to outgoing HTML emails. Pass null to clear.")
2710
+ );
2711
+ }
2712
+ if (shouldRegister("set_account_settings", tools)) {
2713
+ server.registerTool(
2714
+ "set_account_settings",
2715
+ {
2716
+ description: "Set signature (HTML snippet) and/or style preferences for an account. Disabled in --read-only mode.",
2717
+ inputSchema: {
2718
+ account: z2.string().email(),
2719
+ signature: z2.string().optional().describe(
2720
+ "HTML snippet \u2014 may contain formatting, images, links. Pass null to clear."
2721
+ ),
2722
+ style: z2.object({
2723
+ fontFamily: z2.string().optional(),
2724
+ fontSize: z2.string().optional(),
2725
+ fontColor: z2.string().optional()
2726
+ }).optional().describe(
2727
+ "Font preferences applied to outgoing HTML emails. Pass null to clear."
2728
+ )
2729
+ },
2730
+ outputSchema: accountSettingsOutputSchema
1001
2731
  },
1002
- outputSchema: accountSettingsOutputSchema
1003
- },
1004
- async (args) => {
1005
- if (readOnly) return fail("server is in --read-only mode; set_account_settings is disabled");
1006
- try {
1007
- const acct = store.getAccount(args.account);
1008
- if (!acct) return fail(`no account registered for "${args.account}"`);
1009
- const updated = await store.upsertAccount({
1010
- ...acct,
1011
- signature: args.signature ?? acct.signature,
1012
- style: args.style ?? acct.style
1013
- });
1014
- const data = { signature: updated.signature ?? null, style: updated.style ?? null };
1015
- return ok(data, data);
1016
- } catch (err) {
1017
- return fail(errMsg(err));
2732
+ async (args) => {
2733
+ try {
2734
+ const acct = store.getAccount(args.account);
2735
+ if (!acct)
2736
+ return fail(`no account registered for "${args.account}"`);
2737
+ const updated = await store.upsertAccount({
2738
+ ...acct,
2739
+ signature: args.signature ?? acct.signature,
2740
+ style: args.style ?? acct.style
2741
+ });
2742
+ const data = {
2743
+ signature: updated.signature ?? null,
2744
+ style: updated.style ?? null
2745
+ };
2746
+ return ok(data, data);
2747
+ } catch (err) {
2748
+ return fail(errMsg(err));
2749
+ }
1018
2750
  }
1019
- }
1020
- );
2751
+ );
2752
+ }
1021
2753
  const removeAccountOutputSchema = {
1022
- removed: z.boolean(),
1023
- email: z.string()
2754
+ removed: z2.boolean(),
2755
+ email: z2.string()
1024
2756
  };
1025
- server.registerTool(
1026
- "remove_account",
1027
- {
1028
- description: "Forget an account and delete its stored tokens. Disabled in --read-only mode.",
1029
- inputSchema: { email: z.string().email() },
1030
- outputSchema: removeAccountOutputSchema
1031
- },
1032
- async (args) => {
1033
- if (readOnly) return fail("server is in --read-only mode; remove_account is disabled");
1034
- const removed = await store.removeAccount(args.email);
1035
- const data = { removed, email: args.email };
1036
- return ok(data, data);
2757
+ if (shouldRegister("remove_account", tools)) {
2758
+ server.registerTool(
2759
+ "remove_account",
2760
+ {
2761
+ description: "Forget an account and delete its stored tokens. Disabled in --read-only mode.",
2762
+ inputSchema: { email: z2.string().email() },
2763
+ outputSchema: removeAccountOutputSchema
2764
+ },
2765
+ async (args) => {
2766
+ const removed = await store.removeAccount(args.email);
2767
+ const data = { removed, email: args.email };
2768
+ return ok(data, data);
2769
+ }
2770
+ );
2771
+ }
2772
+ }
2773
+
2774
+ // src/tools/browse.ts
2775
+ import { z as z3 } from "zod";
2776
+
2777
+ // src/html-to-markdown.ts
2778
+ import TurndownService from "turndown";
2779
+ var turndown = new TurndownService();
2780
+ function htmlToMarkdown(html) {
2781
+ return turndown.turndown(html);
2782
+ }
2783
+ function selectBody(msg, format) {
2784
+ switch (format) {
2785
+ case "markdown": {
2786
+ if (msg.bodyHtml) return htmlToMarkdown(msg.bodyHtml);
2787
+ if (msg.bodyText) return msg.bodyText;
2788
+ return "";
1037
2789
  }
1038
- );
2790
+ case "html": {
2791
+ if (msg.bodyHtml) return msg.bodyHtml;
2792
+ if (msg.bodyText) return msg.bodyText;
2793
+ return "";
2794
+ }
2795
+ case "text": {
2796
+ if (msg.bodyText) return msg.bodyText;
2797
+ if (msg.bodyHtml) return msg.bodyHtml.replace(/<[^>]*>/g, "");
2798
+ return "";
2799
+ }
2800
+ }
2801
+ }
2802
+
2803
+ // src/tools/browse.ts
2804
+ function registerBrowseTools(server, ctx) {
2805
+ const { registry, tools } = ctx;
1039
2806
  const emailListOutputSchema = {
1040
- account: z.string(),
1041
- count: z.number(),
1042
- items: z.array(emailSummaryOutputSchema)
2807
+ account: z3.string(),
2808
+ count: z3.number(),
2809
+ items: z3.array(emailSummaryOutputSchema),
2810
+ skip: z3.number(),
2811
+ hasMore: z3.boolean()
1043
2812
  };
1044
- server.registerTool(
1045
- "list_emails",
1046
- {
1047
- 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.",
1048
- inputSchema: {
1049
- account: z.string().email(),
1050
- folder: z.string().default("inbox").optional(),
1051
- limit: z.number().int().positive().max(100).optional(),
1052
- unreadOnly: z.boolean().optional()
2813
+ const searchEmailsOutputSchema = {
2814
+ account: z3.string(),
2815
+ count: z3.number(),
2816
+ items: z3.array(emailSummaryOutputSchema)
2817
+ };
2818
+ if (shouldRegister("list_emails", tools)) {
2819
+ server.registerTool(
2820
+ "list_emails",
2821
+ {
2822
+ 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.",
2823
+ inputSchema: {
2824
+ account: z3.string().email(),
2825
+ folder: z3.string().default("inbox").optional(),
2826
+ limit: z3.number().int().positive().max(100).optional(),
2827
+ unreadOnly: z3.boolean().optional(),
2828
+ skip: z3.number().int().min(0).optional()
2829
+ },
2830
+ outputSchema: emailListOutputSchema
1053
2831
  },
1054
- outputSchema: emailListOutputSchema
1055
- },
1056
- async (args) => {
1057
- try {
1058
- const { provider, account } = registry.resolveByEmail(args.account);
1059
- const items = await provider.listEmails(account, {
1060
- folder: args.folder,
1061
- limit: args.limit,
1062
- unreadOnly: args.unreadOnly
1063
- });
1064
- const data = { account: account.email, count: items.length, items };
1065
- return ok(data, data);
1066
- } catch (err) {
1067
- return fail(errMsg(err));
2832
+ async (args) => {
2833
+ try {
2834
+ const { provider, account } = registry.resolveByEmail(args.account);
2835
+ const { items, hasMore } = await provider.listEmails(account, {
2836
+ folder: args.folder,
2837
+ limit: args.limit,
2838
+ unreadOnly: args.unreadOnly,
2839
+ skip: args.skip
2840
+ });
2841
+ const data = {
2842
+ account: account.email,
2843
+ count: items.length,
2844
+ items,
2845
+ skip: args.skip ?? 0,
2846
+ hasMore
2847
+ };
2848
+ return ok(data, data);
2849
+ } catch (err) {
2850
+ return fail(errMsg(err));
2851
+ }
1068
2852
  }
1069
- }
1070
- );
1071
- server.registerTool(
1072
- "search_emails",
1073
- {
1074
- description: "Search emails by free-text query (KQL on Outlook). Returns lightweight summaries.",
1075
- inputSchema: {
1076
- account: z.string().email(),
1077
- query: z.string().min(1),
1078
- limit: z.number().int().positive().max(100).optional()
2853
+ );
2854
+ }
2855
+ if (shouldRegister("search_emails", tools)) {
2856
+ server.registerTool(
2857
+ "search_emails",
2858
+ {
2859
+ description: "Search emails by free-text query (KQL on Outlook). Returns lightweight summaries.",
2860
+ inputSchema: {
2861
+ account: z3.string().email(),
2862
+ query: z3.string().min(1),
2863
+ limit: z3.number().int().positive().max(100).optional()
2864
+ },
2865
+ outputSchema: searchEmailsOutputSchema
2866
+ },
2867
+ async (args) => {
2868
+ try {
2869
+ const { provider, account } = registry.resolveByEmail(args.account);
2870
+ const items = await provider.searchEmails(account, args.query, {
2871
+ limit: args.limit
2872
+ });
2873
+ const data = {
2874
+ account: account.email,
2875
+ count: items.length,
2876
+ items
2877
+ };
2878
+ return ok(data, data);
2879
+ } catch (err) {
2880
+ return fail(errMsg(err));
2881
+ }
2882
+ }
2883
+ );
2884
+ }
2885
+ const readEmailOutputSchema = {
2886
+ id: z3.string(),
2887
+ subject: z3.string(),
2888
+ from: emailAddrOutputSchema.optional(),
2889
+ to: z3.array(emailAddrOutputSchema).optional(),
2890
+ cc: z3.array(emailAddrOutputSchema).optional(),
2891
+ bcc: z3.array(emailAddrOutputSchema).optional(),
2892
+ receivedAt: z3.string().optional(),
2893
+ preview: z3.string().optional(),
2894
+ isRead: z3.boolean().optional(),
2895
+ hasAttachments: z3.boolean().optional(),
2896
+ folder: z3.string().optional(),
2897
+ attachments: z3.array(attachmentMetaOutputSchema).optional(),
2898
+ body: z3.string(),
2899
+ bodyFormat: z3.enum(["markdown", "html", "text"])
2900
+ };
2901
+ if (shouldRegister("read_email", tools)) {
2902
+ server.registerTool(
2903
+ "read_email",
2904
+ {
2905
+ 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.",
2906
+ inputSchema: {
2907
+ account: z3.string().email(),
2908
+ id: z3.string().min(1),
2909
+ format: z3.enum(["markdown", "html", "text"]).default("markdown").optional().describe(
2910
+ "Output body format. 'markdown' converts HTML to Markdown (default), 'html' returns the raw HTML, 'text' returns plain text."
2911
+ )
2912
+ },
2913
+ outputSchema: readEmailOutputSchema
2914
+ },
2915
+ async (args) => {
2916
+ try {
2917
+ const { provider, account } = registry.resolveByEmail(args.account);
2918
+ const msg = await provider.readEmail(account, args.id);
2919
+ const format = args.format ?? "markdown";
2920
+ const body = selectBody(msg, format);
2921
+ const data = {
2922
+ id: msg.id,
2923
+ subject: msg.subject,
2924
+ from: msg.from,
2925
+ to: msg.to,
2926
+ cc: msg.cc,
2927
+ bcc: msg.bcc,
2928
+ receivedAt: msg.receivedAt,
2929
+ preview: msg.preview,
2930
+ isRead: msg.isRead,
2931
+ hasAttachments: msg.hasAttachments,
2932
+ folder: msg.folder,
2933
+ attachments: msg.attachments,
2934
+ body,
2935
+ bodyFormat: format
2936
+ };
2937
+ return ok(data, data);
2938
+ } catch (err) {
2939
+ return fail(errMsg(err));
2940
+ }
2941
+ }
2942
+ );
2943
+ }
2944
+ const readAttachmentOutputSchema = {
2945
+ name: z3.string(),
2946
+ contentType: z3.string().optional(),
2947
+ path: z3.string()
2948
+ };
2949
+ if (shouldRegister("read_attachment", tools)) {
2950
+ server.registerTool(
2951
+ "read_attachment",
2952
+ {
2953
+ description: "Download an email attachment to a temporary file and return its path. Use messageId and attachmentId from a prior read_email call.",
2954
+ inputSchema: {
2955
+ account: z3.string().email(),
2956
+ messageId: z3.string().min(1),
2957
+ attachmentId: z3.string().min(1)
2958
+ },
2959
+ outputSchema: readAttachmentOutputSchema
2960
+ },
2961
+ async (args) => {
2962
+ try {
2963
+ const { provider, account } = registry.resolveByEmail(args.account);
2964
+ const res = await provider.readAttachment(
2965
+ account,
2966
+ args.messageId,
2967
+ args.attachmentId
2968
+ );
2969
+ return ok(res, res);
2970
+ } catch (err) {
2971
+ return fail(errMsg(err));
2972
+ }
2973
+ }
2974
+ );
2975
+ }
2976
+ }
2977
+
2978
+ // src/tools/folders.ts
2979
+ import { z as z4 } from "zod";
2980
+ function registerFolderTools(server, ctx) {
2981
+ const { registry, tools } = ctx;
2982
+ const listFoldersOutputSchema = {
2983
+ account: z4.string(),
2984
+ count: z4.number(),
2985
+ items: z4.array(folderInfoOutputSchema)
2986
+ };
2987
+ if (shouldRegister("list_folders", tools)) {
2988
+ server.registerTool(
2989
+ "list_folders",
2990
+ {
2991
+ description: "List available mail folders. Returns top-level folders by default, or child folders of the given parent when `parentFolderId` is provided.",
2992
+ inputSchema: {
2993
+ account: z4.string().email(),
2994
+ parentFolderId: z4.string().optional().describe(
2995
+ "When provided, lists child folders of this folder. When omitted, lists top-level folders (children of the root)."
2996
+ )
2997
+ },
2998
+ outputSchema: listFoldersOutputSchema
2999
+ },
3000
+ async (args) => {
3001
+ try {
3002
+ const { provider, account } = registry.resolveByEmail(args.account);
3003
+ const items = await provider.listFolders(account, {
3004
+ parentFolderId: args.parentFolderId
3005
+ });
3006
+ const data = {
3007
+ account: account.email,
3008
+ count: items.length,
3009
+ items
3010
+ };
3011
+ return ok(data, data);
3012
+ } catch (err) {
3013
+ return fail(errMsg(err));
3014
+ }
3015
+ }
3016
+ );
3017
+ }
3018
+ const createFolderOutputSchema = {
3019
+ created: z4.literal(true),
3020
+ folder: folderInfoOutputSchema
3021
+ };
3022
+ if (shouldRegister("create_folder", tools)) {
3023
+ server.registerTool(
3024
+ "create_folder",
3025
+ {
3026
+ description: "Create a new mail folder. Creates under the root folder by default, or under the specified parent when `parentFolderId` is provided. Disabled in --read-only mode.",
3027
+ inputSchema: {
3028
+ account: z4.string().email(),
3029
+ displayName: z4.string().min(1).describe("Name of the new folder"),
3030
+ parentFolderId: z4.string().optional().describe(
3031
+ "When provided, creates the folder as a child of this folder. When omitted, creates under the root folder."
3032
+ )
3033
+ },
3034
+ outputSchema: createFolderOutputSchema
1079
3035
  },
1080
- outputSchema: emailListOutputSchema
1081
- },
1082
- async (args) => {
1083
- try {
1084
- const { provider, account } = registry.resolveByEmail(args.account);
1085
- const items = await provider.searchEmails(account, args.query, {
1086
- limit: args.limit
1087
- });
1088
- const data = { account: account.email, count: items.length, items };
1089
- return ok(data, data);
1090
- } catch (err) {
1091
- return fail(errMsg(err));
3036
+ async (args) => {
3037
+ try {
3038
+ const { provider, account } = registry.resolveByEmail(args.account);
3039
+ const folder = await provider.createFolder(account, {
3040
+ displayName: args.displayName,
3041
+ parentFolderId: args.parentFolderId
3042
+ });
3043
+ const data = { created: true, folder };
3044
+ return ok(data, data);
3045
+ } catch (err) {
3046
+ return fail(errMsg(err));
3047
+ }
1092
3048
  }
1093
- }
1094
- );
1095
- const readEmailOutputSchema = {
1096
- id: z.string(),
1097
- subject: z.string(),
1098
- from: emailAddrOutputSchema.optional(),
1099
- to: z.array(emailAddrOutputSchema).optional(),
1100
- cc: z.array(emailAddrOutputSchema).optional(),
1101
- bcc: z.array(emailAddrOutputSchema).optional(),
1102
- receivedAt: z.string().optional(),
1103
- preview: z.string().optional(),
1104
- isRead: z.boolean().optional(),
1105
- hasAttachments: z.boolean().optional(),
1106
- folder: z.string().optional(),
1107
- attachments: z.array(attachmentMetaOutputSchema).optional(),
1108
- body: z.string(),
1109
- bodyFormat: z.enum(["markdown", "html", "text"])
3049
+ );
3050
+ }
3051
+ const deleteFolderOutputSchema = {
3052
+ deleted: z4.literal(true),
3053
+ id: z4.string()
1110
3054
  };
1111
- server.registerTool(
1112
- "read_email",
1113
- {
1114
- 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.",
1115
- inputSchema: {
1116
- account: z.string().email(),
1117
- id: z.string().min(1),
1118
- format: z.enum(["markdown", "html", "text"]).default("markdown").optional().describe(
1119
- "Output body format. 'markdown' converts HTML to Markdown (default), 'html' returns the raw HTML, 'text' returns plain text."
1120
- )
3055
+ if (shouldRegister("delete_folder", tools)) {
3056
+ server.registerTool(
3057
+ "delete_folder",
3058
+ {
3059
+ description: "Delete a mail folder by ID. Disabled in --read-only mode.",
3060
+ inputSchema: {
3061
+ account: z4.string().email(),
3062
+ folderId: z4.string().min(1).describe("ID of the folder to delete")
3063
+ },
3064
+ outputSchema: deleteFolderOutputSchema
1121
3065
  },
1122
- outputSchema: readEmailOutputSchema
1123
- },
1124
- async (args) => {
1125
- try {
1126
- const { provider, account } = registry.resolveByEmail(args.account);
1127
- const msg = await provider.readEmail(account, args.id);
1128
- const format = args.format ?? "markdown";
1129
- const body = selectBody(msg, format);
1130
- const data = {
1131
- id: msg.id,
1132
- subject: msg.subject,
1133
- from: msg.from,
1134
- to: msg.to,
1135
- cc: msg.cc,
1136
- bcc: msg.bcc,
1137
- receivedAt: msg.receivedAt,
1138
- preview: msg.preview,
1139
- isRead: msg.isRead,
1140
- hasAttachments: msg.hasAttachments,
1141
- folder: msg.folder,
1142
- attachments: msg.attachments,
1143
- body,
1144
- bodyFormat: format
1145
- };
1146
- return ok(data, data);
1147
- } catch (err) {
1148
- return fail(errMsg(err));
3066
+ async (args) => {
3067
+ try {
3068
+ const { provider, account } = registry.resolveByEmail(args.account);
3069
+ await provider.deleteFolder(account, args.folderId);
3070
+ const data = { deleted: true, id: args.folderId };
3071
+ return ok(data, data);
3072
+ } catch (err) {
3073
+ return fail(errMsg(err));
3074
+ }
1149
3075
  }
1150
- }
1151
- );
1152
- const readAttachmentOutputSchema = {
1153
- name: z.string(),
1154
- contentType: z.string().optional(),
1155
- path: z.string()
3076
+ );
3077
+ }
3078
+ const renameFolderOutputSchema = {
3079
+ renamed: z4.literal(true),
3080
+ folder: folderInfoOutputSchema
1156
3081
  };
1157
- server.registerTool(
1158
- "read_attachment",
1159
- {
1160
- description: "Download an email attachment to a temporary file and return its path. Use messageId and attachmentId from a prior read_email call.",
1161
- inputSchema: {
1162
- account: z.string().email(),
1163
- messageId: z.string().min(1),
1164
- attachmentId: z.string().min(1)
3082
+ if (shouldRegister("rename_folder", tools)) {
3083
+ server.registerTool(
3084
+ "rename_folder",
3085
+ {
3086
+ description: "Rename an existing mail folder. Disabled in --read-only mode.",
3087
+ inputSchema: {
3088
+ account: z4.string().email(),
3089
+ folderId: z4.string().min(1).describe("ID of the folder to rename"),
3090
+ newName: z4.string().min(1).describe("New display name for the folder")
3091
+ },
3092
+ outputSchema: renameFolderOutputSchema
1165
3093
  },
1166
- outputSchema: readAttachmentOutputSchema
1167
- },
1168
- async (args) => {
1169
- try {
1170
- const { provider, account } = registry.resolveByEmail(args.account);
1171
- const res = await provider.readAttachment(account, args.messageId, args.attachmentId);
1172
- return ok(res, res);
1173
- } catch (err) {
1174
- return fail(errMsg(err));
3094
+ async (args) => {
3095
+ try {
3096
+ const { provider, account } = registry.resolveByEmail(args.account);
3097
+ const folder = await provider.renameFolder(
3098
+ account,
3099
+ args.folderId,
3100
+ args.newName
3101
+ );
3102
+ const data = { renamed: true, folder };
3103
+ return ok(data, data);
3104
+ } catch (err) {
3105
+ return fail(errMsg(err));
3106
+ }
1175
3107
  }
1176
- }
1177
- );
1178
- {
1179
- const schema = {
1180
- account: z.string().email(),
1181
- id: z.string().min(1).describe("Message ID to move")
1182
- };
1183
- const archiveOutputSchema = {
1184
- archived: z.literal(true),
1185
- id: z.string()
1186
- };
3108
+ );
3109
+ }
3110
+ }
3111
+
3112
+ // src/tools/organize.ts
3113
+ import { z as z5 } from "zod";
3114
+ function registerOrganizeTools(server, ctx) {
3115
+ const { registry, tools } = ctx;
3116
+ async function moveToWellKnown(args, destination, resultKey) {
3117
+ const { provider, account } = registry.resolveByEmail(args.account);
3118
+ await provider.moveEmail(account, args.id, destination);
3119
+ const data = { id: args.id };
3120
+ data[resultKey] = true;
3121
+ return ok(data, data);
3122
+ }
3123
+ async function markReadState(args, isRead) {
3124
+ const { provider, account } = registry.resolveByEmail(args.account);
3125
+ await provider.markRead(account, args.id, isRead);
3126
+ const data = { marked: true, id: args.id, isRead };
3127
+ return ok(data, data);
3128
+ }
3129
+ const archiveMoveSchema = {
3130
+ account: z5.string().email(),
3131
+ id: z5.string().min(1).describe("Message ID to move")
3132
+ };
3133
+ const archiveOutputSchema = {
3134
+ archived: z5.literal(true),
3135
+ id: z5.string()
3136
+ };
3137
+ if (shouldRegister("archive_email", tools)) {
1187
3138
  server.registerTool(
1188
3139
  "archive_email",
1189
3140
  {
1190
3141
  description: "Move a message to the Archive folder. Disabled in --read-only mode.",
1191
- inputSchema: schema,
3142
+ inputSchema: archiveMoveSchema,
1192
3143
  outputSchema: archiveOutputSchema
1193
3144
  },
1194
3145
  async (args) => {
1195
- if (readOnly) return fail("server is in --read-only mode; archive_email is disabled");
1196
3146
  try {
1197
- const { provider, account } = registry.resolveByEmail(args.account);
1198
- await provider.moveEmail(account, args.id, "archive");
1199
- const data = { archived: true, id: args.id };
1200
- return ok(data, data);
3147
+ return await moveToWellKnown(args, "archive", "archived");
1201
3148
  } catch (err) {
1202
3149
  return fail(errMsg(err));
1203
3150
  }
1204
3151
  }
1205
3152
  );
1206
- const trashOutputSchema = {
1207
- trashed: z.literal(true),
1208
- id: z.string()
1209
- };
3153
+ }
3154
+ const trashOutputSchema = {
3155
+ trashed: z5.literal(true),
3156
+ id: z5.string()
3157
+ };
3158
+ if (shouldRegister("trash_email", tools)) {
1210
3159
  server.registerTool(
1211
3160
  "trash_email",
1212
3161
  {
1213
3162
  description: "Move a message to the Deleted Items (trash) folder. Disabled in --read-only mode.",
1214
- inputSchema: schema,
3163
+ inputSchema: archiveMoveSchema,
1215
3164
  outputSchema: trashOutputSchema
1216
3165
  },
1217
3166
  async (args) => {
1218
- if (readOnly) return fail("server is in --read-only mode; trash_email is disabled");
3167
+ try {
3168
+ return await moveToWellKnown(args, "deleteditems", "trashed");
3169
+ } catch (err) {
3170
+ return fail(errMsg(err));
3171
+ }
3172
+ }
3173
+ );
3174
+ }
3175
+ const moveEmailOutputSchema = {
3176
+ moved: z5.literal(true),
3177
+ id: z5.string(),
3178
+ destination: z5.string()
3179
+ };
3180
+ if (shouldRegister("move_email", tools)) {
3181
+ server.registerTool(
3182
+ "move_email",
3183
+ {
3184
+ description: "Move a message to any folder by well-known name (e.g. 'inbox', 'drafts', 'junkemail', 'sentitems', 'outbox') or custom folder ID. Disabled in --read-only mode.",
3185
+ inputSchema: {
3186
+ account: z5.string().email(),
3187
+ id: z5.string().min(1).describe("Message ID to move"),
3188
+ destination: z5.string().min(1).describe(
3189
+ "Destination folder \u2014 a well-known folder name ('archive', 'deleteditems', 'inbox', 'drafts', 'junkemail', 'sentitems', 'outbox') or a raw folder ID."
3190
+ )
3191
+ },
3192
+ outputSchema: moveEmailOutputSchema
3193
+ },
3194
+ async (args) => {
1219
3195
  try {
1220
3196
  const { provider, account } = registry.resolveByEmail(args.account);
1221
- await provider.moveEmail(account, args.id, "deleteditems");
1222
- const data = { trashed: true, id: args.id };
3197
+ await provider.moveEmail(account, args.id, args.destination);
3198
+ const data = {
3199
+ moved: true,
3200
+ id: args.id,
3201
+ destination: args.destination
3202
+ };
1223
3203
  return ok(data, data);
1224
3204
  } catch (err) {
1225
3205
  return fail(errMsg(err));
@@ -1227,59 +3207,77 @@ function registerTools(server, opts) {
1227
3207
  }
1228
3208
  );
1229
3209
  }
1230
- const moveEmailOutputSchema = {
1231
- moved: z.literal(true),
1232
- id: z.string(),
1233
- destination: z.string()
3210
+ const markReadInputSchema = {
3211
+ account: z5.string().email(),
3212
+ id: z5.string().min(1).describe("Message ID to mark as read")
1234
3213
  };
1235
- server.registerTool(
1236
- "move_email",
1237
- {
1238
- description: "Move a message to any folder by well-known name (e.g. 'inbox', 'drafts', 'junkemail', 'sentitems', 'outbox') or custom folder ID. Disabled in --read-only mode.",
1239
- inputSchema: {
1240
- account: z.string().email(),
1241
- id: z.string().min(1).describe("Message ID to move"),
1242
- destination: z.string().min(1).describe(
1243
- "Destination folder \u2014 a well-known folder name ('archive', 'deleteditems', 'inbox', 'drafts', 'junkemail', 'sentitems', 'outbox') or a raw folder ID."
1244
- )
3214
+ const markReadOutputSchema = {
3215
+ marked: z5.literal(true),
3216
+ id: z5.string(),
3217
+ isRead: z5.boolean()
3218
+ };
3219
+ if (shouldRegister("mark_read", tools)) {
3220
+ server.registerTool(
3221
+ "mark_read",
3222
+ {
3223
+ description: "Mark a message as read. Disabled in --read-only mode.",
3224
+ inputSchema: markReadInputSchema,
3225
+ outputSchema: markReadOutputSchema
1245
3226
  },
1246
- outputSchema: moveEmailOutputSchema
1247
- },
1248
- async (args) => {
1249
- if (readOnly) return fail("server is in --read-only mode; move_email is disabled");
1250
- try {
1251
- const { provider, account } = registry.resolveByEmail(args.account);
1252
- await provider.moveEmail(account, args.id, args.destination);
1253
- const data = { moved: true, id: args.id, destination: args.destination };
1254
- return ok(data, data);
1255
- } catch (err) {
1256
- return fail(errMsg(err));
3227
+ async (args) => {
3228
+ try {
3229
+ return await markReadState(args, true);
3230
+ } catch (err) {
3231
+ return fail(errMsg(err));
3232
+ }
1257
3233
  }
1258
- }
1259
- );
1260
- const sendEmailSchema = z.object({
1261
- account: z.string().email(),
1262
- to: z.array(emailAddrSchema).min(1),
1263
- cc: z.array(emailAddrSchema).optional(),
1264
- bcc: z.array(emailAddrSchema).optional(),
1265
- subject: z.string(),
1266
- body: z.string(),
1267
- isHtml: z.boolean().optional(),
1268
- include_signature: z.boolean().describe(
3234
+ );
3235
+ }
3236
+ if (shouldRegister("mark_unread", tools)) {
3237
+ server.registerTool(
3238
+ "mark_unread",
3239
+ {
3240
+ description: "Mark a message as unread. Disabled in --read-only mode.",
3241
+ inputSchema: markReadInputSchema,
3242
+ outputSchema: markReadOutputSchema
3243
+ },
3244
+ async (args) => {
3245
+ try {
3246
+ return await markReadState(args, false);
3247
+ } catch (err) {
3248
+ return fail(errMsg(err));
3249
+ }
3250
+ }
3251
+ );
3252
+ }
3253
+ }
3254
+
3255
+ // src/tools/compose.ts
3256
+ import { z as z6 } from "zod";
3257
+ function registerComposeTools(server, ctx) {
3258
+ const { store, registry, tools } = ctx;
3259
+ const sendEmailSchema = z6.object({
3260
+ account: z6.string().email(),
3261
+ to: z6.array(emailAddrSchema).min(1),
3262
+ cc: z6.array(emailAddrSchema).optional(),
3263
+ bcc: z6.array(emailAddrSchema).optional(),
3264
+ subject: z6.string(),
3265
+ body: z6.string(),
3266
+ isHtml: z6.boolean().optional(),
3267
+ include_signature: z6.boolean().describe(
1269
3268
  "Whether to append the account's saved HTML signature to the email. If true, don't include a signature in the body param to avoid double signature. Returns an error if true but no signature is configured for this account."
1270
3269
  ),
1271
- inReplyTo: z.string().optional().describe(
3270
+ inReplyTo: z6.string().optional().describe(
1272
3271
  "Message ID to reply to. When set, sends as a threaded reply which includes the quoted thread history automatically."
1273
3272
  ),
1274
- replyAll: z.boolean().default(false).optional().describe(
3273
+ replyAll: z6.boolean().default(false).optional().describe(
1275
3274
  "When true and `inReplyTo` is set, reply to all recipients instead of just the sender."
1276
3275
  ),
1277
- forwardMessageId: z.string().optional().describe(
3276
+ forwardMessageId: z6.string().optional().describe(
1278
3277
  "Message ID to forward. When set, sends as a forward of the specified message, preserving the original content. Mutually exclusive with `inReplyTo`."
1279
3278
  )
1280
3279
  });
1281
3280
  async function handleSendOrDraft(args, action, resultKey, toolName) {
1282
- if (readOnly) return fail(`server is in --read-only mode; ${toolName} is disabled`);
1283
3281
  try {
1284
3282
  const { provider, account } = registry.resolveByEmail(args.account);
1285
3283
  if (args.include_signature && !account.signature) {
@@ -1321,10 +3319,10 @@ function registerTools(server, opts) {
1321
3319
  }
1322
3320
  }
1323
3321
  const sendEmailOutputSchema = {
1324
- sent: z.literal(true),
1325
- id: z.string()
3322
+ sent: z6.literal(true),
3323
+ id: z6.string()
1326
3324
  };
1327
- if (!draftOnly) {
3325
+ if (shouldRegister("send_email", tools)) {
1328
3326
  server.registerTool(
1329
3327
  "send_email",
1330
3328
  {
@@ -1341,142 +3339,328 @@ function registerTools(server, opts) {
1341
3339
  );
1342
3340
  }
1343
3341
  const draftEmailOutputSchema = {
1344
- draft: z.literal(true),
1345
- id: z.string(),
1346
- draftHtml: z.string().optional()
3342
+ draft: z6.literal(true),
3343
+ id: z6.string(),
3344
+ draftHtml: z6.string().optional()
1347
3345
  };
1348
- server.registerTool(
1349
- "draft_email",
1350
- {
1351
- description: "Create a draft email from the given account without sending it. Works identically to send_email \u2014 appends signature when `include_signature` is true, applies style, and supports replies and forwards \u2014 but saves the message to the Drafts folder instead of sending. Returns the draft message ID and the draft's HTML body content (`draftHtml`). Before sending the draft, inspect `draftHtml` to verify the draft looks correct: no duplicate signature blocks, no broken or missing inline images, no malformed HTML, and no other formatting issues. Disabled in --read-only mode.",
1352
- inputSchema: sendEmailSchema,
1353
- outputSchema: draftEmailOutputSchema
1354
- },
1355
- async (args) => handleSendOrDraft(
1356
- args,
1357
- (p, a, m) => p.saveDraft(a, m),
1358
- "draft",
1359
- "draft_email"
1360
- )
1361
- );
1362
- const editDraftSchema = z.object({
1363
- account: z.string().email(),
1364
- id: z.string().min(1).describe("Draft message ID to edit"),
1365
- to: z.array(emailAddrSchema).optional(),
1366
- cc: z.array(emailAddrSchema).optional(),
1367
- bcc: z.array(emailAddrSchema).optional(),
1368
- subject: z.string().optional(),
1369
- body: z.string().optional(),
1370
- isHtml: z.boolean().optional(),
1371
- include_signature: z.boolean().optional().describe(
3346
+ if (shouldRegister("draft_email", tools)) {
3347
+ server.registerTool(
3348
+ "draft_email",
3349
+ {
3350
+ description: "Create a draft email from the given account without sending it. Works identically to send_email \u2014 appends signature when `include_signature` is true, applies style, and supports replies and forwards \u2014 but saves the message to the Drafts folder instead of sending. Returns the draft message ID and the draft's HTML body content (`draftHtml`). Before sending the draft, inspect `draftHtml` to verify the draft looks correct: no duplicate signature blocks, no broken or missing inline images, no malformed HTML, and no other formatting issues. Disabled in --read-only mode.",
3351
+ inputSchema: sendEmailSchema,
3352
+ outputSchema: draftEmailOutputSchema
3353
+ },
3354
+ async (args) => handleSendOrDraft(
3355
+ args,
3356
+ (p, a, m) => p.saveDraft(a, m),
3357
+ "draft",
3358
+ "draft_email"
3359
+ )
3360
+ );
3361
+ }
3362
+ const editDraftSchema = z6.object({
3363
+ account: z6.string().email(),
3364
+ id: z6.string().min(1).describe("Draft message ID to edit"),
3365
+ to: z6.array(emailAddrSchema).optional(),
3366
+ cc: z6.array(emailAddrSchema).optional(),
3367
+ bcc: z6.array(emailAddrSchema).optional(),
3368
+ subject: z6.string().optional(),
3369
+ body: z6.string().optional(),
3370
+ isHtml: z6.boolean().optional(),
3371
+ include_signature: z6.boolean().optional().describe(
1372
3372
  "Whether to re-apply the account's saved HTML signature to the body. If true, don't include a signature in the body param. Only meaningful when `body` is also provided. Returns an error if true but no signature is configured for this account."
1373
3373
  )
1374
3374
  });
1375
3375
  const editDraftOutputSchema = {
1376
- edited: z.literal(true),
1377
- id: z.string(),
1378
- draftHtml: z.string().optional()
3376
+ edited: z6.literal(true),
3377
+ id: z6.string(),
3378
+ draftHtml: z6.string().optional()
1379
3379
  };
1380
- server.registerTool(
1381
- "edit_draft",
1382
- {
1383
- description: "Edit an existing draft email by ID. Only the fields you provide are updated \u2014 unmentioned fields stay unchanged. When `body` is provided and `include_signature` is true, the account's signature is re-applied. Returns the draft ID and the draft's updated HTML body content (`draftHtml`). Before sending, inspect `draftHtml` to verify the draft looks correct. Does not support changing `inReplyTo` or `forwardMessageId` \u2014 those are set at creation time via `draft_email`. Disabled in --read-only mode.",
1384
- inputSchema: editDraftSchema,
1385
- outputSchema: editDraftOutputSchema
1386
- },
1387
- async (args) => {
1388
- const a = args;
1389
- if (readOnly) return fail("server is in --read-only mode; edit_draft is disabled");
1390
- try {
1391
- const { provider, account } = registry.resolveByEmail(a.account);
1392
- if (a.include_signature && !account.signature) {
1393
- return fail(
1394
- "include_signature is true but no signature is configured for this account. Set up a signature first with set_account_settings."
1395
- );
1396
- }
1397
- let bodyPayload;
1398
- let isHtmlPayload;
1399
- if (a.body !== void 0) {
1400
- const composed = composeBody({
1401
- body: a.body,
1402
- isHtml: a.isHtml,
1403
- signature: account.signature,
1404
- style: account.style,
1405
- includeSignature: !!a.include_signature
3380
+ if (shouldRegister("edit_draft", tools)) {
3381
+ server.registerTool(
3382
+ "edit_draft",
3383
+ {
3384
+ description: "Edit an existing draft email by ID. Only the fields you provide are updated \u2014 unmentioned fields stay unchanged. When `body` is provided and `include_signature` is true, the account's signature is re-applied. Returns the draft ID and the draft's updated HTML body content (`draftHtml`). Before sending, inspect `draftHtml` to verify the draft looks correct. Does not support changing `inReplyTo` or `forwardMessageId` \u2014 those are set at creation time via `draft_email`. Disabled in --read-only mode.",
3385
+ inputSchema: editDraftSchema,
3386
+ outputSchema: editDraftOutputSchema
3387
+ },
3388
+ async (args) => {
3389
+ const a = args;
3390
+ try {
3391
+ const { provider, account } = registry.resolveByEmail(a.account);
3392
+ if (a.include_signature && !account.signature) {
3393
+ return fail(
3394
+ "include_signature is true but no signature is configured for this account. Set up a signature first with set_account_settings."
3395
+ );
3396
+ }
3397
+ let bodyPayload;
3398
+ let isHtmlPayload;
3399
+ if (a.body !== void 0) {
3400
+ const composed = composeBody({
3401
+ body: a.body,
3402
+ isHtml: a.isHtml,
3403
+ signature: account.signature,
3404
+ style: account.style,
3405
+ includeSignature: !!a.include_signature
3406
+ });
3407
+ bodyPayload = composed.body;
3408
+ isHtmlPayload = composed.isHtml;
3409
+ }
3410
+ const res = await provider.updateDraft(account, a.id, {
3411
+ to: a.to,
3412
+ cc: a.cc,
3413
+ bcc: a.bcc,
3414
+ subject: a.subject,
3415
+ body: bodyPayload,
3416
+ isHtml: isHtmlPayload
1406
3417
  });
1407
- bodyPayload = composed.body;
1408
- isHtmlPayload = composed.isHtml;
3418
+ const draft = await provider.readEmail(account, res.id);
3419
+ const result = {
3420
+ edited: true,
3421
+ id: res.id,
3422
+ draftHtml: draft.bodyHtml
3423
+ };
3424
+ return ok(result, result);
3425
+ } catch (err) {
3426
+ return fail(errMsg(err));
1409
3427
  }
1410
- const res = await provider.updateDraft(account, a.id, {
1411
- to: a.to,
1412
- cc: a.cc,
1413
- bcc: a.bcc,
1414
- subject: a.subject,
1415
- body: bodyPayload,
1416
- isHtml: isHtmlPayload
1417
- });
1418
- const draft = await provider.readEmail(account, res.id);
1419
- const result = {
1420
- edited: true,
1421
- id: res.id,
1422
- draftHtml: draft.bodyHtml
1423
- };
1424
- return ok(result, result);
1425
- } catch (err) {
1426
- return fail(errMsg(err));
1427
3428
  }
1428
- }
1429
- );
1430
- }
1431
- function composeBody(input) {
1432
- const { body, isHtml = false, signature, style, includeSignature } = input;
1433
- const hasSignature = includeSignature && !!signature;
1434
- const hasStyle = !!(style && (style.fontFamily || style.fontSize || style.fontColor));
1435
- if (!hasSignature && !hasStyle) {
1436
- return { body, isHtml };
3429
+ );
1437
3430
  }
1438
- const styleAttr = hasStyle ? buildStyleAttr(style) : "";
1439
- if (isHtml) {
1440
- let result2 = hasStyle ? `<div style="${styleAttr}">${body}</div>` : body;
1441
- if (hasSignature) result2 += `
1442
- <div class="signature">${signature}</div>`;
1443
- return { body: result2, isHtml: true };
3431
+ const sendDraftOutputSchema = {
3432
+ sent: z6.literal(true),
3433
+ id: z6.string()
3434
+ };
3435
+ if (shouldRegister("send_draft", tools)) {
3436
+ server.registerTool(
3437
+ "send_draft",
3438
+ {
3439
+ description: "Send an existing draft email by ID. Use this with draft IDs returned by `draft_email` or `edit_draft`. Disabled in --read-only mode.",
3440
+ inputSchema: {
3441
+ account: z6.string().email(),
3442
+ id: z6.string().min(1).describe("Draft message ID to send")
3443
+ },
3444
+ outputSchema: sendDraftOutputSchema
3445
+ },
3446
+ async (args) => {
3447
+ try {
3448
+ const { provider, account } = registry.resolveByEmail(args.account);
3449
+ const res = await provider.sendDraft(account, args.id);
3450
+ const data = { sent: true, id: res.id };
3451
+ return ok(data, data);
3452
+ } catch (err) {
3453
+ return fail(errMsg(err));
3454
+ }
3455
+ }
3456
+ );
3457
+ }
3458
+ const addAttachmentOutputSchema = {
3459
+ attached: z6.literal(true),
3460
+ id: z6.string(),
3461
+ attachment: z6.object({
3462
+ id: z6.string(),
3463
+ name: z6.string(),
3464
+ contentType: z6.string().optional()
3465
+ })
3466
+ };
3467
+ if (shouldRegister("add_attachment_to_draft", tools)) {
3468
+ server.registerTool(
3469
+ "add_attachment_to_draft",
3470
+ {
3471
+ description: "Add a file attachment to an existing draft email by ID. `contentBytes` must be base64-encoded file content. `contentType` is the MIME type (e.g. 'application/pdf'); defaults to 'application/octet-stream' if omitted. Disabled in --read-only mode.",
3472
+ inputSchema: {
3473
+ account: z6.string().email(),
3474
+ id: z6.string().min(1).describe("Draft message ID"),
3475
+ name: z6.string().min(1).describe("Attachment filename (e.g. 'report.pdf')"),
3476
+ contentBytes: z6.string().min(1).describe("Base64-encoded file content"),
3477
+ contentType: z6.string().optional().describe("MIME type (e.g. 'application/pdf')")
3478
+ },
3479
+ outputSchema: addAttachmentOutputSchema
3480
+ },
3481
+ async (args) => {
3482
+ try {
3483
+ const { provider, account } = registry.resolveByEmail(args.account);
3484
+ const res = await provider.addAttachmentToDraft(
3485
+ account,
3486
+ args.id,
3487
+ args.name,
3488
+ args.contentBytes,
3489
+ args.contentType
3490
+ );
3491
+ const data = {
3492
+ attached: true,
3493
+ id: res.id,
3494
+ attachment: res.attachment
3495
+ };
3496
+ return ok(data, data);
3497
+ } catch (err) {
3498
+ return fail(errMsg(err));
3499
+ }
3500
+ }
3501
+ );
1444
3502
  }
1445
- const escaped = escapeHtml(body);
1446
- let result = `<div style="${styleAttr}">${escaped}</div>`;
1447
- if (hasSignature) result += `
1448
- <div class="signature">${signature}</div>`;
1449
- return { body: result, isHtml: true };
1450
- }
1451
- function buildStyleAttr(style) {
1452
- const parts = [];
1453
- if (style.fontFamily) parts.push(`font-family: ${style.fontFamily}`);
1454
- if (style.fontSize) parts.push(`font-size: ${style.fontSize}`);
1455
- if (style.fontColor) parts.push(`color: ${style.fontColor}`);
1456
- return parts.join("; ");
1457
- }
1458
- function escapeHtml(text) {
1459
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/\n/g, "<br>");
1460
3503
  }
1461
- function errMsg(err) {
1462
- if (err instanceof Error) return err.message;
1463
- return String(err);
3504
+
3505
+ // src/tools/index.ts
3506
+ function registerTools(server, opts) {
3507
+ const { store, registry, tools } = opts;
3508
+ registerAccountTools(server, { store, registry, tools });
3509
+ registerBrowseTools(server, { registry, tools });
3510
+ registerFolderTools(server, { registry, tools });
3511
+ registerOrganizeTools(server, { registry, tools });
3512
+ registerComposeTools(server, { store, registry, tools });
1464
3513
  }
1465
3514
 
1466
3515
  // src/version.ts
1467
- var VERSION = "0.3.0";
3516
+ var VERSION = "0.4.1";
3517
+
3518
+ // src/config.ts
3519
+ import { readFileSync } from "fs";
3520
+ import { z as z7 } from "zod";
3521
+ var httpConfigSchema = z7.object({
3522
+ enabled: z7.boolean().default(false),
3523
+ port: z7.number().int().min(1).max(65535).default(3e3),
3524
+ host: z7.string().default("127.0.0.1")
3525
+ });
3526
+ var toolsConfigSchema = z7.object({
3527
+ disabled: z7.array(z7.string()).optional(),
3528
+ enabled: z7.array(z7.string()).optional()
3529
+ });
3530
+ var outlookProviderSchema = z7.object({
3531
+ clientId: z7.string().optional(),
3532
+ tenantId: z7.string().optional()
3533
+ });
3534
+ var gmailProviderSchema = z7.object({
3535
+ clientId: z7.string().optional(),
3536
+ clientSecret: z7.string().optional()
3537
+ });
3538
+ var providersConfigSchema = z7.object({
3539
+ outlook: outlookProviderSchema.optional(),
3540
+ gmail: gmailProviderSchema.optional()
3541
+ });
3542
+ var rawConfigSchema = z7.object({
3543
+ dataDir: z7.string().optional(),
3544
+ http: httpConfigSchema.optional(),
3545
+ tools: toolsConfigSchema.optional(),
3546
+ providers: providersConfigSchema.optional()
3547
+ });
3548
+ var KNOWN_TOOLS = [
3549
+ "list_accounts",
3550
+ "add_account",
3551
+ "complete_add_account",
3552
+ "get_account_settings",
3553
+ "set_account_settings",
3554
+ "remove_account",
3555
+ "list_emails",
3556
+ "search_emails",
3557
+ "read_email",
3558
+ "read_attachment",
3559
+ "archive_email",
3560
+ "trash_email",
3561
+ "move_email",
3562
+ "mark_read",
3563
+ "mark_unread",
3564
+ "list_folders",
3565
+ "create_folder",
3566
+ "delete_folder",
3567
+ "rename_folder",
3568
+ "send_email",
3569
+ "draft_email",
3570
+ "edit_draft",
3571
+ "send_draft",
3572
+ "add_attachment_to_draft"
3573
+ ];
3574
+ var ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
3575
+ function resolveEnvVars(value) {
3576
+ return value.replace(ENV_VAR_RE, (_match, name) => {
3577
+ return process.env[name] ?? "";
3578
+ });
3579
+ }
3580
+ function deepResolve(obj) {
3581
+ if (typeof obj === "string") return resolveEnvVars(obj);
3582
+ if (Array.isArray(obj)) return obj.map(deepResolve);
3583
+ if (obj && typeof obj === "object") {
3584
+ const out = {};
3585
+ for (const [key, val] of Object.entries(obj)) {
3586
+ out[key] = deepResolve(val);
3587
+ }
3588
+ return out;
3589
+ }
3590
+ return obj;
3591
+ }
3592
+ function validateToolNames(toolNames, listName) {
3593
+ if (!toolNames || toolNames.length === 0) return;
3594
+ const known = new Set(KNOWN_TOOLS);
3595
+ for (const name of toolNames) {
3596
+ if (!known.has(name)) {
3597
+ throw new Error(
3598
+ `Unknown tool "${name}" in ${listName}. Known tools: ${KNOWN_TOOLS.join(", ")}`
3599
+ );
3600
+ }
3601
+ }
3602
+ }
3603
+ function loadConfig(configPath, cliOverrides = {}) {
3604
+ let raw = {};
3605
+ if (configPath) {
3606
+ try {
3607
+ raw = JSON.parse(readFileSync(configPath, "utf-8"));
3608
+ } catch (err) {
3609
+ const detail = err instanceof SyntaxError ? "Invalid JSON" : err instanceof Error ? err.message : String(err);
3610
+ throw new Error(`Failed to read config file "${configPath}": ${detail}`);
3611
+ }
3612
+ }
3613
+ raw = deepResolve(raw);
3614
+ const parsed = rawConfigSchema.parse(raw);
3615
+ if (parsed.tools) {
3616
+ if (parsed.tools.disabled && parsed.tools.enabled) {
3617
+ throw new Error(
3618
+ "tools.disabled and tools.enabled are mutually exclusive \u2014 use one or the other"
3619
+ );
3620
+ }
3621
+ if (parsed.tools.enabled !== void 0 && parsed.tools.enabled.length === 0) {
3622
+ throw new Error(
3623
+ "tools.enabled is empty \u2014 at least one tool must be listed. To enable all tools, omit the tools section entirely."
3624
+ );
3625
+ }
3626
+ validateToolNames(parsed.tools.disabled, "tools.disabled");
3627
+ validateToolNames(parsed.tools.enabled, "tools.enabled");
3628
+ }
3629
+ const http = {
3630
+ enabled: cliOverrides.http ?? parsed.http?.enabled ?? false,
3631
+ port: cliOverrides.port ?? parsed.http?.port ?? 3e3,
3632
+ host: cliOverrides.host ?? parsed.http?.host ?? "127.0.0.1"
3633
+ };
3634
+ return {
3635
+ dataDir: cliOverrides.dataDir ?? parsed.dataDir ?? process.env.HYPERMAIL_MCP_DATA_DIR,
3636
+ http,
3637
+ tools: parsed.tools ? { disabled: parsed.tools.disabled, enabled: parsed.tools.enabled } : void 0,
3638
+ providers: parsed.providers
3639
+ };
3640
+ }
3641
+ function resolveTools(config) {
3642
+ if (!config.tools) {
3643
+ return { enabledTools: null, disabledTools: null };
3644
+ }
3645
+ return {
3646
+ enabledTools: config.tools.enabled ? new Set(config.tools.enabled) : null,
3647
+ disabledTools: config.tools.disabled ? new Set(config.tools.disabled) : null
3648
+ };
3649
+ }
1468
3650
 
1469
3651
  // src/server.ts
1470
- async function startServer(opts = {}) {
1471
- const store = await AccountStore.open({ dataDir: opts.dataDir });
1472
- const registry = buildRegistry({ store });
3652
+ async function startServer(opts) {
3653
+ const { config } = opts;
3654
+ const store = await AccountStore.open({ dataDir: config.dataDir });
3655
+ const registry = buildRegistry({ store, providers: config.providers });
3656
+ const tools = resolveTools(config);
1473
3657
  const server = new McpServer(
1474
3658
  { name: "hypermail-mcp", version: VERSION },
1475
3659
  { capabilities: { tools: {}, logging: {} } }
1476
3660
  );
1477
- registerTools(server, { store, registry, readOnly: !!opts.readOnly, draftOnly: !!opts.draftOnly });
1478
- if (opts.http) {
1479
- await startHttp(server, opts.host ?? "127.0.0.1", opts.port ?? 3e3);
3661
+ registerTools(server, { store, registry, tools });
3662
+ if (config.http.enabled) {
3663
+ await startHttp(server, config.http.host, config.http.port);
1480
3664
  } else {
1481
3665
  const transport = new StdioServerTransport();
1482
3666
  await server.connect(transport);
@@ -1495,7 +3679,7 @@ async function startHttp(server, host, port) {
1495
3679
  let transport = sessionId ? sessions.get(sessionId) : void 0;
1496
3680
  if (!transport) {
1497
3681
  transport = new StreamableHTTPServerTransport({
1498
- sessionIdGenerator: () => randomUUID2(),
3682
+ sessionIdGenerator: () => randomUUID6(),
1499
3683
  onsessioninitialized: (sid) => {
1500
3684
  sessions.set(sid, transport);
1501
3685
  }
@@ -1531,7 +3715,6 @@ function parseArgs(argv) {
1531
3715
  http: false,
1532
3716
  port: 3e3,
1533
3717
  host: "127.0.0.1",
1534
- readOnly: false,
1535
3718
  help: false
1536
3719
  };
1537
3720
  for (let i = 0; i < argv.length; i++) {
@@ -1549,8 +3732,8 @@ function parseArgs(argv) {
1549
3732
  case "--data-dir":
1550
3733
  out.dataDir = String(argv[++i] ?? "");
1551
3734
  break;
1552
- case "--read-only":
1553
- out.readOnly = true;
3735
+ case "--config":
3736
+ out.config = String(argv[++i] ?? "");
1554
3737
  break;
1555
3738
  case "-h":
1556
3739
  case "--help":
@@ -1575,14 +3758,23 @@ Options:
1575
3758
  --host <addr> HTTP bind address (default: 127.0.0.1)
1576
3759
  --data-dir <path> Where to store the encrypted accounts file
1577
3760
  (default: $HYPERMAIL_MCP_DATA_DIR or ~/.hypermail-mcp)
1578
- --read-only Disable tools that modify state (send_email, remove_account, add_account)
3761
+ --config <path> Path to hypermail-config.json
1579
3762
  -h, --help Show this help
1580
3763
 
1581
- Environment:
1582
- HYPERMAIL_MCP_DATA_DIR Same as --data-dir
1583
- HYPERMAIL_MCP_KEY 32-byte key (base64 or hex) for at-rest encryption
1584
- MS_CLIENT_ID Azure AD public client (application) ID
1585
- MS_TENANT_ID Tenant (default: "common")
3764
+ Configuration:
3765
+ All server settings (data dir, HTTP, tool filtering, provider credentials)
3766
+ live in hypermail-config.json. Pass it via --config.
3767
+
3768
+ Example hypermail-config.json:
3769
+ {
3770
+ "dataDir": "/path/to/data",
3771
+ "http": { "enabled": false },
3772
+ "tools": { "disabled": ["send_email"] },
3773
+ "providers": {
3774
+ "outlook": { "clientId": "\${MS_CLIENT_ID}", "tenantId": "\${MS_TENANT_ID}" },
3775
+ "gmail": { "clientId": "\${GOOGLE_CLIENT_ID}", "clientSecret": "\${GOOGLE_CLIENT_SECRET}" }
3776
+ }
3777
+ }
1586
3778
  `;
1587
3779
  process.stdout.write(msg);
1588
3780
  }
@@ -1592,15 +3784,13 @@ async function main() {
1592
3784
  printHelp();
1593
3785
  return;
1594
3786
  }
1595
- const draftOnly = process.env.HYPERMAIL_DRAFT_ONLY === "true";
1596
- await startServer({
3787
+ const config = loadConfig(opts.config, {
1597
3788
  http: opts.http,
1598
3789
  port: opts.port,
1599
3790
  host: opts.host,
1600
- dataDir: opts.dataDir,
1601
- readOnly: opts.readOnly,
1602
- draftOnly
3791
+ dataDir: opts.dataDir
1603
3792
  });
3793
+ await startServer({ config });
1604
3794
  }
1605
3795
  main().catch((err) => {
1606
3796
  console.error("[hypermail-mcp] fatal:", err);