apple-mail-mcp 1.6.10 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -270,6 +270,55 @@ Then send:
270
270
 
271
271
  The default `applescript` transport is unchanged; SMTP is opt-in per call.
272
272
 
273
+ ##### IMAP backend (read/search) — opt-in, Phase 1
274
+
275
+ AppleScript runs `search`/`list` predicates client-side over the Apple Event
276
+ bridge, which is slow and can time out (false-empty) on large Gmail/IMAP
277
+ mailboxes (see [#24](https://github.com/sweetrb/apple-mail-mcp/issues/24)). When
278
+ an account is configured for IMAP, `search-messages` and `list-messages` instead
279
+ run a **server-side IMAP search** ([#43](https://github.com/sweetrb/apple-mail-mcp/issues/43)) —
280
+ typically sub-second and correct on the same mailbox where AppleScript times out.
281
+ This is **opt-in and additive**: any account without IMAP configured behaves
282
+ exactly as before (AppleScript). When an account is IMAP-configured,
283
+ `search-messages`/`list-messages` (read) and `create-mailbox`/`rename-mailbox`/
284
+ `delete-mailbox` (folder ops) route to IMAP. The folder ops are the key win for
285
+ server accounts: IMAP's `CREATE`/`RENAME`/`DELETE` succeed on exactly the
286
+ iCloud/Gmail/Workspace/Exchange mailboxes where Mail.app's AppleScript bridge
287
+ can't (#42). `get-message` and message-level mutations (mark/flag/move/delete-
288
+ message) stay on AppleScript for now — they key off a message id, and the IMAP
289
+ read rows report **UIDs** (a different, per-mailbox namespace), so routing them
290
+ safely needs a UID-aware design (tracked on #43).
291
+
292
+ Routing is conservative: only a call whose explicit `account` matches the
293
+ configured IMAP account goes to IMAP; everything else falls through to
294
+ AppleScript.
295
+
296
+ | Variable | Required | Default | Description |
297
+ |----------|----------|---------|-------------|
298
+ | `APPLE_MAIL_MCP_IMAP_USER` | Yes | — | Login address; setting it enables IMAP |
299
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | No | = user | Mail account name to match for routing |
300
+ | `APPLE_MAIL_MCP_IMAP_HOST` | No | `imap.gmail.com` | IMAP server hostname |
301
+ | `APPLE_MAIL_MCP_IMAP_PORT` | No | `993` | IMAP port (993 = implicit TLS) |
302
+ | `APPLE_MAIL_MCP_IMAP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
303
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | No | — | Keychain item service/server name |
304
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
305
+
306
+ As with SMTP, the password is read from the macOS **Keychain** by default (use
307
+ an app-specific password for Gmail/Workspace/iCloud), so no secret goes in
308
+ config. Gmail label semantics: common names (`All Mail`, `Sent`, `Trash`,
309
+ `Spam`, `Important`, …) map to their `[Gmail]/…` IMAP paths automatically.
310
+
311
+ > Note: each call currently opens its own IMAP connection (no pooling yet), so
312
+ > expect a few seconds of connection overhead per call. Phase 2 added the folder
313
+ > ops (create/rename/delete-mailbox) — resolving the IMAP slice of
314
+ > [#42](https://github.com/sweetrb/apple-mail-mcp/issues/42). IMAP-backed
315
+ > message-level mutations are still future work (see #43).
316
+ >
317
+ > **iCloud:** set `APPLE_MAIL_MCP_IMAP_HOST=imap.mail.me.com`, `APPLE_MAIL_MCP_IMAP_USER`
318
+ > to your iCloud address, `APPLE_MAIL_MCP_IMAP_ACCOUNT` to the Mail account name
319
+ > (e.g. `iCloud`), and use an **app-specific password** (from appleid.apple.com)
320
+ > stored in the Keychain.
321
+
273
322
  ---
274
323
 
275
324
  #### `send-serial-email`
package/build/index.js CHANGED
@@ -25,6 +25,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
25
25
  import { z } from "zod";
26
26
  import { AppleMailManager } from "./services/appleMailManager.js";
27
27
  import { sendViaSmtp } from "./services/smtpMailer.js";
28
+ import { isImapAccount, imapSearchMessages, imapListMessages, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, } from "./services/imapClient.js";
28
29
  import { createSerialGate } from "./utils/serialize.js";
29
30
  // =============================================================================
30
31
  // Shared Validation Schemas
@@ -157,7 +158,23 @@ server.tool("search-messages", {
157
158
  dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
158
159
  dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
159
160
  limit: z.number().optional().describe("Maximum number of results (default: 50)"),
160
- }, withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
161
+ }, withErrorHandling(async ({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
162
+ // IMAP backend (issue #43): server-side search when this account is
163
+ // explicitly configured for IMAP; otherwise fall through to AppleScript.
164
+ if (isImapAccount(account)) {
165
+ return successResponse(await imapSearchMessages({
166
+ query,
167
+ mailbox,
168
+ account,
169
+ limit,
170
+ dateFrom,
171
+ dateTo,
172
+ from,
173
+ subject,
174
+ isRead,
175
+ isFlagged,
176
+ }));
177
+ }
161
178
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
162
179
  const coverageBlock = partialCoverageBlock(diagnostics);
163
180
  if (messages.length === 0) {
@@ -200,7 +217,12 @@ server.tool("list-messages", {
200
217
  offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
201
218
  from: z.string().optional().describe("Filter by sender email address or name"),
202
219
  unreadOnly: z.boolean().optional().describe("Only show unread messages"),
203
- }, withErrorHandling(({ mailbox, account, limit = 50, offset = 0, from }) => {
220
+ }, withErrorHandling(async ({ mailbox, account, limit = 50, offset = 0, from, unreadOnly }) => {
221
+ // IMAP backend (issue #43): server-side listing when this account is
222
+ // explicitly configured for IMAP; otherwise fall through to AppleScript.
223
+ if (isImapAccount(account)) {
224
+ return successResponse(await imapListMessages({ mailbox, account, limit, offset, from, unreadOnly }));
225
+ }
204
226
  const { messages, diagnostics } = mailManager.listMessagesWithDiagnostics(mailbox, account, limit, from, offset);
205
227
  const coverageBlock = partialCoverageBlock(diagnostics);
206
228
  if (messages.length === 0) {
@@ -560,7 +582,15 @@ server.tool("get-unread-count", {
560
582
  server.tool("create-mailbox", {
561
583
  name: z.string().min(1, "Mailbox name is required"),
562
584
  account: z.string().optional().describe("Account to create the mailbox in"),
563
- }, withErrorHandling(({ name, account }) => {
585
+ }, withErrorHandling(async ({ name, account }) => {
586
+ // IMAP backend (issue #43, Phase 2): server-side folder op when this account
587
+ // is IMAP-configured; otherwise AppleScript.
588
+ if (isImapAccount(account)) {
589
+ const r = await imapCreateMailbox(name);
590
+ if (!r.success)
591
+ return errorResponse(r.error || `Failed to create mailbox "${name}"`);
592
+ return successResponse(r.info || `Mailbox "${name}" created`);
593
+ }
564
594
  const success = mailManager.createMailbox(name, account);
565
595
  if (!success) {
566
596
  return errorResponse(`Failed to create mailbox "${name}"`);
@@ -571,7 +601,13 @@ server.tool("create-mailbox", {
571
601
  server.tool("delete-mailbox", {
572
602
  name: z.string().min(1, "Mailbox name is required"),
573
603
  account: z.string().optional().describe("Account containing the mailbox"),
574
- }, withErrorHandling(({ name, account }) => {
604
+ }, withErrorHandling(async ({ name, account }) => {
605
+ if (isImapAccount(account)) {
606
+ const r = await imapDeleteMailbox(name);
607
+ if (!r.success)
608
+ return errorResponse(r.error || `Failed to delete mailbox "${name}"`);
609
+ return successResponse(r.info || `Mailbox "${name}" deleted`);
610
+ }
575
611
  const { success, error } = mailManager.deleteMailbox(name, account);
576
612
  if (!success) {
577
613
  return errorResponse(error || `Failed to delete mailbox "${name}"`);
@@ -583,7 +619,14 @@ server.tool("rename-mailbox", {
583
619
  oldName: z.string().min(1, "Current mailbox name is required"),
584
620
  newName: z.string().min(1, "New mailbox name is required"),
585
621
  account: z.string().optional().describe("Account containing the mailbox"),
586
- }, withErrorHandling(({ oldName, newName, account }) => {
622
+ }, withErrorHandling(async ({ oldName, newName, account }) => {
623
+ if (isImapAccount(account)) {
624
+ const r = await imapRenameMailbox(oldName, newName);
625
+ if (!r.success) {
626
+ return errorResponse(r.error || `Failed to rename mailbox "${oldName}" to "${newName}"`);
627
+ }
628
+ return successResponse(r.info || `Mailbox renamed from "${oldName}" to "${newName}"`);
629
+ }
587
630
  const { success, error } = mailManager.renameMailbox(oldName, newName, account);
588
631
  if (!success) {
589
632
  return errorResponse(error || `Failed to rename mailbox "${oldName}" to "${newName}"`);
@@ -0,0 +1,108 @@
1
+ export declare const IMAP_ENV: {
2
+ readonly user: "APPLE_MAIL_MCP_IMAP_USER";
3
+ readonly account: "APPLE_MAIL_MCP_IMAP_ACCOUNT";
4
+ readonly host: "APPLE_MAIL_MCP_IMAP_HOST";
5
+ readonly port: "APPLE_MAIL_MCP_IMAP_PORT";
6
+ readonly password: "APPLE_MAIL_MCP_IMAP_PASSWORD";
7
+ readonly keychainService: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE";
8
+ readonly keychainAccount: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT";
9
+ };
10
+ export interface ImapConfig {
11
+ host: string;
12
+ port: number;
13
+ secure: boolean;
14
+ user: string;
15
+ pass: string;
16
+ accountLabel: string;
17
+ }
18
+ export interface ImapSearchArgs {
19
+ query?: string;
20
+ account?: string;
21
+ from?: string;
22
+ subject?: string;
23
+ mailbox?: string;
24
+ limit?: number;
25
+ dateFrom?: string;
26
+ dateTo?: string;
27
+ isRead?: boolean;
28
+ isFlagged?: boolean;
29
+ unreadOnly?: boolean;
30
+ offset?: number;
31
+ }
32
+ interface ImapAddress {
33
+ name?: string;
34
+ address?: string;
35
+ }
36
+ interface ImapEnvelope {
37
+ subject?: string;
38
+ date?: Date | string;
39
+ from?: ImapAddress[];
40
+ }
41
+ interface ImapMessage {
42
+ uid: number;
43
+ envelope?: ImapEnvelope;
44
+ flags?: Set<string>;
45
+ }
46
+ interface MailboxLock {
47
+ release: () => void;
48
+ }
49
+ interface ImapMailboxListing {
50
+ path: string;
51
+ name: string;
52
+ }
53
+ export interface ImapClientLike {
54
+ connect(): Promise<void>;
55
+ getMailboxLock(path: string): Promise<MailboxLock>;
56
+ search(query: Record<string, unknown>, opts: {
57
+ uid: true;
58
+ }): Promise<number[] | false>;
59
+ fetch(range: string, query: Record<string, unknown>, opts: {
60
+ uid: true;
61
+ }): AsyncIterable<ImapMessage>;
62
+ list(): Promise<ImapMailboxListing[]>;
63
+ mailboxCreate(path: string): Promise<{
64
+ path: string;
65
+ created: boolean;
66
+ }>;
67
+ mailboxRename(path: string, newPath: string): Promise<{
68
+ path: string;
69
+ newPath: string;
70
+ }>;
71
+ mailboxDelete(path: string): Promise<{
72
+ path: string;
73
+ }>;
74
+ logout(): Promise<void>;
75
+ }
76
+ export type ImapConnect = (cfg: ImapConfig) => Promise<ImapClientLike>;
77
+ /** True only when IMAP is configured AND the explicit `account` matches it. */
78
+ export declare function isImapAccount(account: string | undefined, env?: NodeJS.ProcessEnv): boolean;
79
+ export declare function resolveImapConfig(env?: NodeJS.ProcessEnv): ImapConfig;
80
+ /** Map common (Gmail) mailbox names to their IMAP paths. */
81
+ export declare function resolveMailboxPath(mailbox: string | undefined, mode: "search" | "list"): string;
82
+ export declare function imapSearchMessages(args: ImapSearchArgs, deps?: {
83
+ connect?: ImapConnect;
84
+ config?: ImapConfig;
85
+ }): Promise<string>;
86
+ export declare function imapListMessages(args: ImapSearchArgs, deps?: {
87
+ connect?: ImapConnect;
88
+ config?: ImapConfig;
89
+ }): Promise<string>;
90
+ export interface ImapOpResult {
91
+ success: boolean;
92
+ error?: string;
93
+ info?: string;
94
+ }
95
+ export declare function imapCreateMailbox(name: string, deps?: {
96
+ connect?: ImapConnect;
97
+ config?: ImapConfig;
98
+ }): Promise<ImapOpResult>;
99
+ export declare function imapDeleteMailbox(name: string, deps?: {
100
+ connect?: ImapConnect;
101
+ config?: ImapConfig;
102
+ }): Promise<ImapOpResult>;
103
+ export declare function imapRenameMailbox(oldName: string, newName: string, deps?: {
104
+ connect?: ImapConnect;
105
+ config?: ImapConfig;
106
+ }): Promise<ImapOpResult>;
107
+ export {};
108
+ //# sourceMappingURL=imapClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"imapClient.d.ts","sourceRoot":"","sources":["../../src/services/imapClient.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,260 @@
1
+ /**
2
+ * IMAP backend for read/search (issue #43, Phase 1).
3
+ *
4
+ * AppleScript-over-Mail.app is the default. When an account is explicitly
5
+ * configured for IMAP (env below), `search-messages` and `list-messages` route
6
+ * here instead, running a SERVER-SIDE search — orders of magnitude faster than
7
+ * AppleScript's client-side `whose` enumeration on large Gmail mailboxes, and
8
+ * correct (no false-empty on timeout). Read-only; mutations stay on AppleScript.
9
+ *
10
+ * Opt-in via env (mirrors the SMTP transport pattern):
11
+ * APPLE_MAIL_MCP_IMAP_USER (required — enables IMAP; the login address)
12
+ * APPLE_MAIL_MCP_IMAP_ACCOUNT (Mail account name to match for routing; default = USER)
13
+ * APPLE_MAIL_MCP_IMAP_HOST (default imap.gmail.com)
14
+ * APPLE_MAIL_MCP_IMAP_PORT (default 993, implicit TLS)
15
+ * APPLE_MAIL_MCP_IMAP_PASSWORD (else Keychain via the two vars below)
16
+ * APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE / _KEYCHAIN_ACCOUNT
17
+ *
18
+ * @module services/imapClient
19
+ */
20
+ import { ImapFlow } from "imapflow";
21
+ import { readKeychainPassword } from "../services/smtpMailer.js";
22
+ export const IMAP_ENV = {
23
+ user: "APPLE_MAIL_MCP_IMAP_USER",
24
+ account: "APPLE_MAIL_MCP_IMAP_ACCOUNT",
25
+ host: "APPLE_MAIL_MCP_IMAP_HOST",
26
+ port: "APPLE_MAIL_MCP_IMAP_PORT",
27
+ password: "APPLE_MAIL_MCP_IMAP_PASSWORD",
28
+ keychainService: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE",
29
+ keychainAccount: "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT",
30
+ };
31
+ /** True only when IMAP is configured AND the explicit `account` matches it. */
32
+ export function isImapAccount(account, env = process.env) {
33
+ const user = env[IMAP_ENV.user]?.trim();
34
+ if (!user || !account)
35
+ return false;
36
+ const label = env[IMAP_ENV.account]?.trim() || user;
37
+ return account === label || account === user;
38
+ }
39
+ export function resolveImapConfig(env = process.env) {
40
+ const user = env[IMAP_ENV.user]?.trim();
41
+ if (!user) {
42
+ throw new Error(`IMAP not configured. Set ${IMAP_ENV.user} (login address) to enable it.`);
43
+ }
44
+ const host = env[IMAP_ENV.host]?.trim() || "imap.gmail.com";
45
+ const port = env[IMAP_ENV.port] ? Number.parseInt(env[IMAP_ENV.port], 10) : 993;
46
+ if (!Number.isInteger(port) || port <= 0) {
47
+ throw new Error(`Invalid ${IMAP_ENV.port}: "${env[IMAP_ENV.port]}".`);
48
+ }
49
+ let pass = env[IMAP_ENV.password];
50
+ if (!pass) {
51
+ const svc = env[IMAP_ENV.keychainService]?.trim();
52
+ const acct = env[IMAP_ENV.keychainAccount]?.trim() || user;
53
+ if (svc)
54
+ pass = readKeychainPassword(svc, acct) ?? undefined;
55
+ }
56
+ if (!pass) {
57
+ throw new Error(`No IMAP password. Set ${IMAP_ENV.password}, or ${IMAP_ENV.keychainService}/${IMAP_ENV.keychainAccount} for the Keychain.`);
58
+ }
59
+ return {
60
+ host,
61
+ port,
62
+ secure: port === 993,
63
+ user,
64
+ pass,
65
+ accountLabel: env[IMAP_ENV.account]?.trim() || user,
66
+ };
67
+ }
68
+ const defaultConnect = async (cfg) => {
69
+ const client = new ImapFlow({
70
+ host: cfg.host,
71
+ port: cfg.port,
72
+ secure: cfg.secure,
73
+ auth: { user: cfg.user, pass: cfg.pass },
74
+ logger: false,
75
+ });
76
+ await client.connect();
77
+ return client;
78
+ };
79
+ /** Map common (Gmail) mailbox names to their IMAP paths. */
80
+ export function resolveMailboxPath(mailbox, mode) {
81
+ if (!mailbox)
82
+ return mode === "search" ? "[Gmail]/All Mail" : "INBOX";
83
+ const map = {
84
+ "all mail": "[Gmail]/All Mail",
85
+ "sent mail": "[Gmail]/Sent Mail",
86
+ sent: "[Gmail]/Sent Mail",
87
+ trash: "[Gmail]/Trash",
88
+ drafts: "[Gmail]/Drafts",
89
+ spam: "[Gmail]/Spam",
90
+ junk: "[Gmail]/Spam",
91
+ starred: "[Gmail]/Starred",
92
+ important: "[Gmail]/Important",
93
+ };
94
+ return map[mailbox.trim().toLowerCase()] ?? mailbox;
95
+ }
96
+ function buildCriteria(a, listMode) {
97
+ const c = {};
98
+ if (a.query)
99
+ c.or = [{ subject: a.query }, { from: a.query }];
100
+ if (a.from)
101
+ c.from = a.from;
102
+ if (a.subject)
103
+ c.subject = a.subject;
104
+ if (a.isRead === true)
105
+ c.seen = true;
106
+ if (a.isRead === false)
107
+ c.unseen = true;
108
+ if (a.unreadOnly && listMode)
109
+ c.unseen = true;
110
+ if (a.isFlagged === true)
111
+ c.flagged = true;
112
+ if (a.isFlagged === false)
113
+ c.unflagged = true;
114
+ if (a.dateFrom)
115
+ c.since = new Date(a.dateFrom);
116
+ if (a.dateTo)
117
+ c.before = new Date(a.dateTo);
118
+ if (Object.keys(c).length === 0)
119
+ c.all = true;
120
+ return c;
121
+ }
122
+ function formatRow(m) {
123
+ const env = m.envelope ?? {};
124
+ const subject = env.subject || "(no subject)";
125
+ const a = env.from?.[0];
126
+ const from = a
127
+ ? a.name
128
+ ? `${a.name} <${a.address ?? ""}>`
129
+ : (a.address ?? "(unknown)")
130
+ : "(unknown)";
131
+ const date = env.date ? new Date(env.date).toLocaleDateString() : "";
132
+ const read = m.flags?.has("\\Seen") ? "read" : "unread";
133
+ return ` - UID: ${m.uid} | ${date} | ${subject} (from: ${from}) [${read}]`;
134
+ }
135
+ async function run(args, listMode, deps) {
136
+ const cfg = deps.config ?? resolveImapConfig();
137
+ const client = await (deps.connect ?? defaultConnect)(cfg);
138
+ try {
139
+ const path = resolveMailboxPath(args.mailbox, listMode ? "list" : "search");
140
+ const lock = await client.getMailboxLock(path);
141
+ try {
142
+ const found = await client.search(buildCriteria(args, listMode), { uid: true });
143
+ const uids = Array.isArray(found) ? found : [];
144
+ if (uids.length === 0) {
145
+ return `No messages found via IMAP in "${path}" (account ${cfg.accountLabel}).`;
146
+ }
147
+ const limit = args.limit ?? 50;
148
+ const offset = args.offset ?? 0;
149
+ // UIDs are ascending → newest are the highest. Apply offset+limit from the newest end.
150
+ const newest = uids
151
+ .slice()
152
+ .reverse()
153
+ .slice(offset, offset + limit);
154
+ const byUid = new Map();
155
+ for await (const msg of client.fetch(newest.join(","), { envelope: true, flags: true }, { uid: true })) {
156
+ byUid.set(msg.uid, formatRow(msg));
157
+ }
158
+ const rows = newest.map((u) => byUid.get(u)).filter((r) => Boolean(r));
159
+ const verb = listMode ? "listed" : "matched";
160
+ return (`Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, mailbox "${path}"; ${uids.length} total ${verb}):\n` +
161
+ rows.join("\n") +
162
+ `\n\nNote: IDs are IMAP UIDs. get-message and message mutations still use the AppleScript path (Phase 1 is read/search only).`);
163
+ }
164
+ finally {
165
+ lock.release();
166
+ }
167
+ }
168
+ finally {
169
+ await client.logout().catch(() => undefined);
170
+ }
171
+ }
172
+ export function imapSearchMessages(args, deps = {}) {
173
+ return run(args, false, deps);
174
+ }
175
+ export function imapListMessages(args, deps = {}) {
176
+ return run(args, true, deps);
177
+ }
178
+ function errText(e) {
179
+ return e instanceof Error ? e.message : String(e);
180
+ }
181
+ /** Connect, run `fn`, always log out. */
182
+ async function withClient(deps, fn) {
183
+ const cfg = deps.config ?? resolveImapConfig();
184
+ const client = await (deps.connect ?? defaultConnect)(cfg);
185
+ try {
186
+ return await fn(client, cfg);
187
+ }
188
+ finally {
189
+ await client.logout().catch(() => undefined);
190
+ }
191
+ }
192
+ /**
193
+ * Resolve a user-supplied mailbox name to an actual server path by listing the
194
+ * mailboxes and matching on full path, then leaf name (case-insensitive).
195
+ * Returns null when no such mailbox exists.
196
+ */
197
+ async function findMailboxPath(client, name) {
198
+ const wanted = name.trim().toLowerCase();
199
+ const boxes = await client.list();
200
+ const byPath = boxes.find((b) => b.path.toLowerCase() === wanted);
201
+ if (byPath)
202
+ return byPath.path;
203
+ const byName = boxes.find((b) => b.name.toLowerCase() === wanted);
204
+ return byName ? byName.path : null;
205
+ }
206
+ export function imapCreateMailbox(name, deps = {}) {
207
+ return withClient(deps, async (client) => {
208
+ try {
209
+ const res = await client.mailboxCreate(name);
210
+ return res.created
211
+ ? { success: true, info: `Created mailbox "${res.path}".` }
212
+ : { success: true, info: `Mailbox "${res.path}" already existed.` };
213
+ }
214
+ catch (e) {
215
+ return { success: false, error: `IMAP create failed for "${name}": ${errText(e)}` };
216
+ }
217
+ });
218
+ }
219
+ export function imapDeleteMailbox(name, deps = {}) {
220
+ return withClient(deps, async (client, cfg) => {
221
+ const path = await findMailboxPath(client, name);
222
+ if (!path) {
223
+ return {
224
+ success: false,
225
+ error: `Mailbox "${name}" not found on IMAP account ${cfg.accountLabel}.`,
226
+ };
227
+ }
228
+ try {
229
+ await client.mailboxDelete(path);
230
+ return {
231
+ success: true,
232
+ info: `Deleted mailbox "${path}" via IMAP (account ${cfg.accountLabel}).`,
233
+ };
234
+ }
235
+ catch (e) {
236
+ return { success: false, error: `IMAP delete failed for "${path}": ${errText(e)}` };
237
+ }
238
+ });
239
+ }
240
+ export function imapRenameMailbox(oldName, newName, deps = {}) {
241
+ return withClient(deps, async (client, cfg) => {
242
+ const path = await findMailboxPath(client, oldName);
243
+ if (!path) {
244
+ return {
245
+ success: false,
246
+ error: `Mailbox "${oldName}" not found on IMAP account ${cfg.accountLabel}.`,
247
+ };
248
+ }
249
+ try {
250
+ const res = await client.mailboxRename(path, newName);
251
+ return { success: true, info: `Renamed "${res.path}" to "${res.newPath}" via IMAP.` };
252
+ }
253
+ catch (e) {
254
+ return {
255
+ success: false,
256
+ error: `IMAP rename failed for "${path}" -> "${newName}": ${errText(e)}`,
257
+ };
258
+ }
259
+ });
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "1.6.10",
3
+ "version": "1.8.0",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -59,6 +59,7 @@
59
59
  ],
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.29.0",
62
+ "imapflow": "^1.4.1",
62
63
  "nodemailer": "^9.0.0",
63
64
  "zod": "^3.22.4"
64
65
  },