apple-mail-mcp 2.8.2 → 2.8.3

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.
Files changed (65) hide show
  1. package/README.md +5 -5
  2. package/build/cli.js +12083 -138
  3. package/build/index.js +82684 -1687
  4. package/package.json +3 -4
  5. package/build/cli.d.ts +0 -24
  6. package/build/cli.d.ts.map +0 -1
  7. package/build/index.d.ts +0 -23
  8. package/build/index.d.ts.map +0 -1
  9. package/build/services/appleMailManager.d.ts +0 -680
  10. package/build/services/appleMailManager.d.ts.map +0 -1
  11. package/build/services/appleMailManager.js +0 -3143
  12. package/build/services/fileConfig.d.ts +0 -7
  13. package/build/services/fileConfig.d.ts.map +0 -1
  14. package/build/services/fileConfig.js +0 -52
  15. package/build/services/imapClient.d.ts +0 -312
  16. package/build/services/imapClient.d.ts.map +0 -1
  17. package/build/services/imapClient.js +0 -1023
  18. package/build/services/imapIdle.d.ts +0 -58
  19. package/build/services/imapIdle.d.ts.map +0 -1
  20. package/build/services/imapIdle.js +0 -151
  21. package/build/services/imapMultiAccount.d.ts +0 -124
  22. package/build/services/imapMultiAccount.d.ts.map +0 -1
  23. package/build/services/imapMultiAccount.js +0 -253
  24. package/build/services/messageRouter.d.ts +0 -24
  25. package/build/services/messageRouter.d.ts.map +0 -1
  26. package/build/services/messageRouter.js +0 -31
  27. package/build/services/replyForward.d.ts +0 -87
  28. package/build/services/replyForward.d.ts.map +0 -1
  29. package/build/services/replyForward.js +0 -150
  30. package/build/services/smtpMailer.d.ts +0 -160
  31. package/build/services/smtpMailer.d.ts.map +0 -1
  32. package/build/services/smtpMailer.js +0 -268
  33. package/build/services/templateStore.d.ts +0 -18
  34. package/build/services/templateStore.d.ts.map +0 -1
  35. package/build/services/templateStore.js +0 -91
  36. package/build/tools/doctor.d.ts +0 -23
  37. package/build/tools/doctor.d.ts.map +0 -1
  38. package/build/tools/doctor.js +0 -74
  39. package/build/tools/resourcesAndPrompts.d.ts +0 -14
  40. package/build/tools/resourcesAndPrompts.d.ts.map +0 -1
  41. package/build/tools/resourcesAndPrompts.js +0 -109
  42. package/build/tools/respond.d.ts +0 -48
  43. package/build/tools/respond.d.ts.map +0 -1
  44. package/build/tools/respond.js +0 -95
  45. package/build/tools/thread.d.ts +0 -19
  46. package/build/tools/thread.d.ts.map +0 -1
  47. package/build/tools/thread.js +0 -32
  48. package/build/types.d.ts +0 -434
  49. package/build/types.d.ts.map +0 -1
  50. package/build/types.js +0 -13
  51. package/build/utils/applescript.d.ts +0 -45
  52. package/build/utils/applescript.d.ts.map +0 -1
  53. package/build/utils/applescript.js +0 -446
  54. package/build/utils/attachmentMaterialize.d.ts +0 -9
  55. package/build/utils/attachmentMaterialize.d.ts.map +0 -1
  56. package/build/utils/attachmentMaterialize.js +0 -38
  57. package/build/utils/mimeParse.d.ts +0 -62
  58. package/build/utils/mimeParse.d.ts.map +0 -1
  59. package/build/utils/mimeParse.js +0 -317
  60. package/build/utils/orphan.d.ts +0 -25
  61. package/build/utils/orphan.d.ts.map +0 -1
  62. package/build/utils/orphan.js +0 -26
  63. package/build/utils/serialize.d.ts +0 -30
  64. package/build/utils/serialize.d.ts.map +0 -1
  65. package/build/utils/serialize.js +0 -41
@@ -1,58 +0,0 @@
1
- import type { ImapConfig } from "../services/imapClient.js";
2
- /** Minimal event-capable client surface the watcher needs (testable). */
3
- export interface IdleClient {
4
- connect(): Promise<void>;
5
- mailboxOpen(path: string): Promise<{
6
- exists: number;
7
- }>;
8
- on(event: "exists", handler: (data: {
9
- count: number;
10
- prevCount: number;
11
- }) => void): void;
12
- on(event: "close" | "error", handler: (err?: unknown) => void): void;
13
- logout(): Promise<void>;
14
- }
15
- export type IdleConnect = (cfg: ImapConfig) => Promise<IdleClient>;
16
- export interface NewMailEvent {
17
- account: string;
18
- count: number;
19
- prevCount: number;
20
- }
21
- export declare class ImapIdleWatcher {
22
- private readonly opts;
23
- private readonly connect;
24
- private readonly mailbox;
25
- private readonly reconnectMs;
26
- private readonly pollMs;
27
- private readonly clients;
28
- private readonly timers;
29
- private readonly polls;
30
- private readonly lastCount;
31
- private stopped;
32
- constructor(opts: {
33
- configs: ImapConfig[];
34
- onNewMail: (e: NewMailEvent) => void;
35
- connect?: IdleConnect;
36
- mailbox?: string;
37
- reconnectMs?: number;
38
- /** Poll interval (ms) as a fallback for servers that don't push IDLE EXISTS. 0 disables. */
39
- pollMs?: number;
40
- });
41
- /** Begin watching every configured account. Resolves once watches are kicked off. */
42
- start(): Promise<void>;
43
- /**
44
- * Compare an observed message count to the account's baseline and fire
45
- * onNewMail on growth. Updates the baseline either way (so an expunge that
46
- * lowers the count re-bases without a spurious later fire).
47
- */
48
- private observe;
49
- private watch;
50
- /** Polling fallback: re-check the count for servers that don't push EXISTS. */
51
- private startPoll;
52
- private scheduleReconnect;
53
- /** Stop all watches and close connections. */
54
- stop(): Promise<void>;
55
- /** Accounts currently being watched (test/diagnostics). */
56
- watchedAccounts(): string[];
57
- }
58
- //# sourceMappingURL=imapIdle.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"imapIdle.d.ts","sourceRoot":"","sources":["../../src/services/imapIdle.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,yEAAyE;AACzE,MAAM,WAAW,UAAU;IACzB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IACzF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAC;IACrE,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,UAAU,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;AAEnE,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAoBD,qBAAa,eAAe;IAaxB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAZvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAc;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqC;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IACvD,OAAO,CAAC,OAAO,CAAS;gBAGL,IAAI,EAAE;QACrB,OAAO,EAAE,UAAU,EAAE,CAAC;QACtB,SAAS,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,CAAC;QACrC,OAAO,CAAC,EAAE,WAAW,CAAC;QACtB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,4FAA4F;QAC5F,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB;IAQH,qFAAqF;IAC/E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B;;;;OAIG;IACH,OAAO,CAAC,OAAO;YASD,KAAK;IAqBnB,+EAA+E;IAC/E,OAAO,CAAC,SAAS;IAiBjB,OAAO,CAAC,iBAAiB;IAoBzB,8CAA8C;IACxC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAW3B,2DAA2D;IAC3D,eAAe,IAAI,MAAM,EAAE;CAG5B"}
@@ -1,151 +0,0 @@
1
- /**
2
- * IMAP IDLE push notifications (B5).
3
- *
4
- * Opt-in (`APPLE_MAIL_MCP_IMAP_IDLE=1`). For each configured IMAP account this
5
- * opens a *dedicated* long-lived connection (separate from the request pool,
6
- * since IDLE holds the connection) on INBOX and watches for new arrivals.
7
- * ImapFlow keeps the mailbox in IDLE automatically and emits an `exists` event
8
- * when the message count grows; we forward that to a callback, which the server
9
- * turns into an MCP notification. Dropped connections reconnect with backoff.
10
- *
11
- * @module services/imapIdle
12
- */
13
- import { ImapFlow } from "imapflow";
14
- const defaultIdleConnect = async (cfg) => {
15
- const client = new ImapFlow({
16
- host: cfg.host,
17
- port: cfg.port,
18
- secure: cfg.secure,
19
- auth: { user: cfg.user, pass: cfg.pass },
20
- logger: false,
21
- });
22
- // ImapFlow is an EventEmitter: an 'error' with no listener (e.g. a socket
23
- // ETIMEOUT during connect, before watch() attaches its handler) is an
24
- // *uncaught* exception that crashes the whole MCP server. Attach a listener
25
- // before connect() so a connection error rejects the promise instead — the
26
- // caller (watch) catches that and schedules a reconnect.
27
- client.on("error", () => { });
28
- await client.connect();
29
- return client;
30
- };
31
- export class ImapIdleWatcher {
32
- opts;
33
- connect;
34
- mailbox;
35
- reconnectMs;
36
- pollMs;
37
- clients = new Map();
38
- timers = new Map();
39
- polls = new Map();
40
- // Per-account baseline message count; growth past it = new mail.
41
- lastCount = new Map();
42
- stopped = false;
43
- constructor(opts) {
44
- this.opts = opts;
45
- this.connect = opts.connect ?? defaultIdleConnect;
46
- this.mailbox = opts.mailbox ?? "INBOX";
47
- this.reconnectMs = opts.reconnectMs ?? 15_000;
48
- this.pollMs = opts.pollMs ?? 60_000;
49
- }
50
- /** Begin watching every configured account. Resolves once watches are kicked off. */
51
- async start() {
52
- this.stopped = false;
53
- await Promise.all(this.opts.configs.map((cfg) => this.watch(cfg)));
54
- }
55
- /**
56
- * Compare an observed message count to the account's baseline and fire
57
- * onNewMail on growth. Updates the baseline either way (so an expunge that
58
- * lowers the count re-bases without a spurious later fire).
59
- */
60
- observe(label, count) {
61
- if (typeof count !== "number")
62
- return;
63
- const prev = this.lastCount.get(label);
64
- if (prev !== undefined && count > prev) {
65
- this.opts.onNewMail({ account: label, count, prevCount: prev });
66
- }
67
- this.lastCount.set(label, count);
68
- }
69
- async watch(cfg) {
70
- if (this.stopped)
71
- return;
72
- const label = cfg.accountLabel;
73
- try {
74
- const client = await this.connect(cfg);
75
- this.clients.set(label, client);
76
- // Real-time path: ImapFlow emits `exists` while idling (where the server
77
- // pushes it). `count` is the new total.
78
- client.on("exists", (d) => {
79
- if (d)
80
- this.observe(label, d.count);
81
- });
82
- client.on("error", () => this.scheduleReconnect(cfg));
83
- client.on("close", () => this.scheduleReconnect(cfg));
84
- const mb = await client.mailboxOpen(this.mailbox); // keeps mailbox open / idling
85
- this.lastCount.set(label, mb.exists); // baseline at start of watch
86
- this.startPoll(cfg);
87
- }
88
- catch {
89
- this.scheduleReconnect(cfg);
90
- }
91
- }
92
- /** Polling fallback: re-check the count for servers that don't push EXISTS. */
93
- startPoll(cfg) {
94
- if (this.pollMs <= 0)
95
- return;
96
- const label = cfg.accountLabel;
97
- const existing = this.polls.get(label);
98
- if (existing)
99
- clearInterval(existing);
100
- const p = setInterval(() => {
101
- const client = this.clients.get(label);
102
- if (!client)
103
- return;
104
- void client
105
- .mailboxOpen(this.mailbox)
106
- .then((mb) => this.observe(label, mb.exists))
107
- .catch(() => this.scheduleReconnect(cfg));
108
- }, this.pollMs);
109
- p.unref?.();
110
- this.polls.set(label, p);
111
- }
112
- scheduleReconnect(cfg) {
113
- if (this.stopped)
114
- return;
115
- const label = cfg.accountLabel;
116
- if (this.timers.has(label))
117
- return; // a reconnect is already pending
118
- const poll = this.polls.get(label);
119
- if (poll) {
120
- clearInterval(poll);
121
- this.polls.delete(label);
122
- }
123
- const old = this.clients.get(label);
124
- this.clients.delete(label);
125
- if (old)
126
- void old.logout().catch(() => undefined);
127
- const t = setTimeout(() => {
128
- this.timers.delete(label);
129
- void this.watch(cfg);
130
- }, this.reconnectMs);
131
- t.unref?.();
132
- this.timers.set(label, t);
133
- }
134
- /** Stop all watches and close connections. */
135
- async stop() {
136
- this.stopped = true;
137
- for (const t of this.timers.values())
138
- clearTimeout(t);
139
- for (const p of this.polls.values())
140
- clearInterval(p);
141
- this.timers.clear();
142
- this.polls.clear();
143
- const clients = [...this.clients.values()];
144
- this.clients.clear();
145
- await Promise.all(clients.map((c) => c.logout().catch(() => undefined)));
146
- }
147
- /** Accounts currently being watched (test/diagnostics). */
148
- watchedAccounts() {
149
- return [...this.clients.keys()];
150
- }
151
- }
@@ -1,124 +0,0 @@
1
- /**
2
- * Multi-account read merge (v2.6.0 — prefer-IMAP reads).
3
- *
4
- * v2.6.0 flips the read tools to PREFER direct IMAP whenever IMAP is configured
5
- * (see `shouldUseImap` in imapClient.ts). When a read is issued with NO explicit
6
- * account, results must still cover EVERY account the user has — so we:
7
- *
8
- * 1. fan the IMAP query out over every configured IMAP account
9
- * (`resolveImapConfigs()`), and
10
- * 2. ALSO run the existing AppleScript all-accounts path,
11
- *
12
- * then merge the two, de-duplicating any message that appears in both backends
13
- * (e.g. a Gmail account that is both IMAP-configured AND visible to Mail.app's
14
- * AppleScript scan) and preferring the IMAP copy (it carries the round-trippable
15
- * `imap:` id that the mutation tools need).
16
- *
17
- * This module holds the backend-agnostic merge/dedup/sort + the per-account IMAP
18
- * fan-out so the three message-list sites (search-messages, get-thread,
19
- * list-messages) don't each re-implement it.
20
- *
21
- * @module services/imapMultiAccount
22
- */
23
- import { type ImapSearchArgs, type ImapConfig, type ImapDeps } from "../services/imapClient.js";
24
- import type { Account } from "../types.js";
25
- /** A structured message row as emitted by either backend (permissive on keys). */
26
- export type MessageRow = Record<string, unknown>;
27
- /**
28
- * Cross-backend dedup key for a message row.
29
- *
30
- * - PREFER the normalized Message-ID when present (`mid:<id>`). It's the only
31
- * globally-unique identity, so two IMAP accounts that both hold the very
32
- * same message (rare, but possible with multi-delivery) collapse to one.
33
- * - FALL BACK to `normalizedSubject|sender|dateReceivedEpoch` when no
34
- * Message-ID is available. The AppleScript backend never exposes a
35
- * Message-ID, so this composite is what actually dedups the common case: a
36
- * Gmail account surfaced by BOTH the IMAP fan-out and the AppleScript
37
- * all-accounts scan. The IMAP and AppleScript copies of one message share
38
- * the same subject, sender, and received timestamp, so they collide here.
39
- *
40
- * Limitation (note for live testing): the composite key assumes the two backends
41
- * report the SAME received timestamp to the second. If Mail.app and the IMAP
42
- * server disagree on `dateReceived` (timezone/rounding), a message could escape
43
- * dedup and appear twice. Message-ID dedup (IMAP-vs-IMAP) is exact; the
44
- * composite is best-effort.
45
- */
46
- export declare function dedupKey(row: MessageRow): string;
47
- /**
48
- * Merge IMAP rows with AppleScript rows: concatenate, de-dup by {@link dedupKey}
49
- * preferring the IMAP copy (IMAP rows are passed first and win on collision),
50
- * sort newest-first by dateReceived, then apply `limit`.
51
- */
52
- export declare function mergeMessages(imapRows: MessageRow[], appleRows: MessageRow[], limit: number): MessageRow[];
53
- /** Gmail/Workspace IMAP host? Its `[Gmail]/All Mail` virtual mailbox is Gmail-only. */
54
- export declare function isGmailHost(host: string): boolean;
55
- /**
56
- * Fan an IMAP message query out over EVERY configured IMAP account and return
57
- * the concatenated structured rows (not yet merged with AppleScript, not yet
58
- * limited — the caller merges + limits). `kind` picks the underlying query so
59
- * the mailbox-default and unreadOnly semantics match the single-account path.
60
- *
61
- * Per-account failures are swallowed (logged) so one unreachable account doesn't
62
- * sink the whole read; the returned `accountsQueried`/`accountsFailed` let the
63
- * caller surface partial-coverage diagnostics.
64
- */
65
- export declare function fanOutImapMessages(args: ImapSearchArgs, kind: "search" | "list", deps?: Omit<ImapDeps, "config" | "account">, configs?: ImapConfig[]): Promise<{
66
- rows: MessageRow[];
67
- accountsQueried: string[];
68
- accountsFailed: string[];
69
- }>;
70
- /**
71
- * Per-pair matcher: does this IMAP config correspond to this Mail.app account?
72
- * Compared case-insensitively, the config's `accountLabel` AND `user` against the
73
- * Mail account's `name` AND `email`. An empty email can't match (avoids
74
- * `"" === ""` false positives). This is the single source of truth for coverage;
75
- * everything else delegates here.
76
- */
77
- export declare function configMatchesAccount(config: ImapConfig, account: Account): boolean;
78
- /** True when ANY config matches this Mail account (delegates to the per-pair matcher). */
79
- export declare function isAccountCoveredByImap(account: Account, configs: ImapConfig[]): boolean;
80
- /**
81
- * Split Mail.app accounts into those covered by IMAP (counted via IMAP) and the
82
- * rest (counted via AppleScript), so reads/counts skip the AppleScript scan for
83
- * IMAP-covered accounts.
84
- */
85
- export declare function partitionAccountsForCounts(accounts: Account[], configs: ImapConfig[]): {
86
- imapCovered: Account[];
87
- appleScriptOnly: Account[];
88
- };
89
- /**
90
- * One unit to count for the count tools (get-unread-count / get-mail-stats),
91
- * carrying exactly ONE source so every account is counted once and only once:
92
- * - `{ kind: "imap", config }` — count this account via IMAP STATUS;
93
- * - `{ kind: "applescript", account }`— count this Mail account via AppleScript.
94
- */
95
- export type CountSource = {
96
- kind: "imap";
97
- config: ImapConfig;
98
- label: string;
99
- } | {
100
- kind: "applescript";
101
- account: Account;
102
- label: string;
103
- };
104
- /**
105
- * Plan the count sources so NO account is double-counted even when the coverage
106
- * heuristic mis-matches (the failure mode the naive `Σimap(all configs) +
107
- * Σapple(uncovered)` had: a config that fails to match its Mail account lands in
108
- * BOTH sums).
109
- *
110
- * Account-centric: walk the Mail.app accounts; each is counted via its FIRST
111
- * matching config (IMAP) or, if none matches, via AppleScript. Then any IMAP
112
- * config that matched NO Mail account (configured-but-not-present-in-Mail.app) is
113
- * added once as an IMAP source. A config is consumed by at most one Mail account,
114
- * so two Mail accounts can't both claim the same config and inflate the total.
115
- */
116
- export declare function planCountSources(accounts: Account[], configs: ImapConfig[]): CountSource[];
117
- /**
118
- * Render the structured message rows into the human text block the read tools
119
- * emit (` - ID: … | date | subject (from: sender) [read|unread]`). Shared so the
120
- * merged path matches the single-backend formatting. `showReadState` mirrors
121
- * search/list (which show [read]) vs. plain list rows.
122
- */
123
- export declare function formatMergedRows(rows: MessageRow[], showReadState?: boolean): string;
124
- //# sourceMappingURL=imapMultiAccount.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"imapMultiAccount.d.ts","sourceRoot":"","sources":["../../src/services/imapMultiAccount.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,QAAQ,EACd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,kFAAkF;AAClF,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAuCjD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAMhD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,UAAU,EAAE,EACtB,SAAS,EAAE,UAAU,EAAE,EACvB,KAAK,EAAE,MAAM,GACZ,UAAU,EAAE,CAcd;AAED,uFAAuF;AACvF,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,cAAc,EACpB,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,GAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAM,EAC/C,OAAO,GAAE,UAAU,EAAyB,GAC3C,OAAO,CAAC;IAAE,IAAI,EAAE,UAAU,EAAE,CAAC;IAAC,eAAe,EAAE,MAAM,EAAE,CAAC;IAAC,cAAc,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CA2BtF;AAmBD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAQlF;AAED,0FAA0F;AAC1F,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,OAAO,CAEvF;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,UAAU,EAAE,GACpB;IAAE,WAAW,EAAE,OAAO,EAAE,CAAC;IAAC,eAAe,EAAE,OAAO,EAAE,CAAA;CAAE,CAQxD;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7D;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,WAAW,EAAE,CAmB1F;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE,aAAa,UAAO,GAAG,MAAM,CAajF"}
@@ -1,253 +0,0 @@
1
- /**
2
- * Multi-account read merge (v2.6.0 — prefer-IMAP reads).
3
- *
4
- * v2.6.0 flips the read tools to PREFER direct IMAP whenever IMAP is configured
5
- * (see `shouldUseImap` in imapClient.ts). When a read is issued with NO explicit
6
- * account, results must still cover EVERY account the user has — so we:
7
- *
8
- * 1. fan the IMAP query out over every configured IMAP account
9
- * (`resolveImapConfigs()`), and
10
- * 2. ALSO run the existing AppleScript all-accounts path,
11
- *
12
- * then merge the two, de-duplicating any message that appears in both backends
13
- * (e.g. a Gmail account that is both IMAP-configured AND visible to Mail.app's
14
- * AppleScript scan) and preferring the IMAP copy (it carries the round-trippable
15
- * `imap:` id that the mutation tools need).
16
- *
17
- * This module holds the backend-agnostic merge/dedup/sort + the per-account IMAP
18
- * fan-out so the three message-list sites (search-messages, get-thread,
19
- * list-messages) don't each re-implement it.
20
- *
21
- * @module services/imapMultiAccount
22
- */
23
- import { imapSearchMessages, imapListMessages, resolveImapConfigs, } from "../services/imapClient.js";
24
- /**
25
- * Normalize a Message-ID for comparison: strip surrounding angle brackets and
26
- * whitespace, lowercase. Returns undefined when the row has no usable id.
27
- */
28
- function normalizeMessageId(row) {
29
- const raw = typeof row.messageId === "string" ? row.messageId.trim() : "";
30
- if (!raw)
31
- return undefined;
32
- const inner = raw.replace(/^<+|>+$/g, "").trim();
33
- return inner ? inner.toLowerCase() : undefined;
34
- }
35
- /**
36
- * Normalize a subject the same way the thread tool does for grouping: drop
37
- * leading Re:/Fwd: prefixes, collapse whitespace, lowercase. Kept local (and
38
- * deliberately simple) so the merge has no dependency cycle with the thread
39
- * module; exactness isn't required — this only feeds the fallback dedup key.
40
- */
41
- function normalizeSubjectForKey(subject) {
42
- const s = typeof subject === "string" ? subject : "";
43
- return s
44
- .replace(/^(\s*(re|fwd|fw|aw|sv|antw)\s*:\s*)+/i, "")
45
- .replace(/\s+/g, " ")
46
- .trim()
47
- .toLowerCase();
48
- }
49
- /** Epoch ms of the row's dateReceived (ISO string or Date), or 0 when absent. */
50
- function dateEpoch(row) {
51
- const d = row.dateReceived;
52
- if (d instanceof Date)
53
- return d.getTime();
54
- if (typeof d === "string" && d) {
55
- const t = new Date(d).getTime();
56
- return Number.isNaN(t) ? 0 : t;
57
- }
58
- return 0;
59
- }
60
- /**
61
- * Cross-backend dedup key for a message row.
62
- *
63
- * - PREFER the normalized Message-ID when present (`mid:<id>`). It's the only
64
- * globally-unique identity, so two IMAP accounts that both hold the very
65
- * same message (rare, but possible with multi-delivery) collapse to one.
66
- * - FALL BACK to `normalizedSubject|sender|dateReceivedEpoch` when no
67
- * Message-ID is available. The AppleScript backend never exposes a
68
- * Message-ID, so this composite is what actually dedups the common case: a
69
- * Gmail account surfaced by BOTH the IMAP fan-out and the AppleScript
70
- * all-accounts scan. The IMAP and AppleScript copies of one message share
71
- * the same subject, sender, and received timestamp, so they collide here.
72
- *
73
- * Limitation (note for live testing): the composite key assumes the two backends
74
- * report the SAME received timestamp to the second. If Mail.app and the IMAP
75
- * server disagree on `dateReceived` (timezone/rounding), a message could escape
76
- * dedup and appear twice. Message-ID dedup (IMAP-vs-IMAP) is exact; the
77
- * composite is best-effort.
78
- */
79
- export function dedupKey(row) {
80
- const mid = normalizeMessageId(row);
81
- if (mid)
82
- return `mid:${mid}`;
83
- const subject = normalizeSubjectForKey(row.subject);
84
- const sender = (typeof row.sender === "string" ? row.sender : "").trim().toLowerCase();
85
- return `k:${subject}|${sender}|${dateEpoch(row)}`;
86
- }
87
- /**
88
- * Merge IMAP rows with AppleScript rows: concatenate, de-dup by {@link dedupKey}
89
- * preferring the IMAP copy (IMAP rows are passed first and win on collision),
90
- * sort newest-first by dateReceived, then apply `limit`.
91
- */
92
- export function mergeMessages(imapRows, appleRows, limit) {
93
- const byKey = new Map();
94
- // IMAP first so its copy wins; AppleScript only fills keys IMAP didn't supply.
95
- for (const r of imapRows) {
96
- const k = dedupKey(r);
97
- if (!byKey.has(k))
98
- byKey.set(k, r);
99
- }
100
- for (const r of appleRows) {
101
- const k = dedupKey(r);
102
- if (!byKey.has(k))
103
- byKey.set(k, r);
104
- }
105
- const merged = [...byKey.values()];
106
- merged.sort((a, b) => dateEpoch(b) - dateEpoch(a)); // newest first
107
- return limit >= 0 ? merged.slice(0, limit) : merged;
108
- }
109
- /** Gmail/Workspace IMAP host? Its `[Gmail]/All Mail` virtual mailbox is Gmail-only. */
110
- export function isGmailHost(host) {
111
- return /(^|\.)gmail\.com$/i.test(host.trim());
112
- }
113
- /**
114
- * Fan an IMAP message query out over EVERY configured IMAP account and return
115
- * the concatenated structured rows (not yet merged with AppleScript, not yet
116
- * limited — the caller merges + limits). `kind` picks the underlying query so
117
- * the mailbox-default and unreadOnly semantics match the single-account path.
118
- *
119
- * Per-account failures are swallowed (logged) so one unreachable account doesn't
120
- * sink the whole read; the returned `accountsQueried`/`accountsFailed` let the
121
- * caller surface partial-coverage diagnostics.
122
- */
123
- export async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
124
- const rows = [];
125
- const accountsQueried = [];
126
- const accountsFailed = [];
127
- for (const config of configs) {
128
- // Default-mailbox resolution is PER-ACCOUNT: the single-account search default
129
- // ("[Gmail]/All Mail") is Gmail-only, so fanning it out to a non-Gmail account
130
- // (e.g. iCloud) makes that SELECT fail and the account silently drops from the
131
- // merged results. When the caller pinned no mailbox, give Gmail hosts their
132
- // All-Mail default (undefined → resolved downstream) and every other host a
133
- // universal "INBOX". (limit is applied AFTER the cross-account merge so the
134
- // global newest-N is correct even when one account dominates.)
135
- const mailbox = args.mailbox ?? (isGmailHost(config.host) ? undefined : "INBOX");
136
- const perAccountArgs = { ...args, account: undefined, mailbox };
137
- try {
138
- const res = kind === "search"
139
- ? await imapSearchMessages(perAccountArgs, { ...deps, config })
140
- : await imapListMessages(perAccountArgs, { ...deps, config });
141
- rows.push(...res.messages);
142
- accountsQueried.push(config.accountLabel);
143
- }
144
- catch (e) {
145
- accountsFailed.push(config.accountLabel);
146
- console.error(`IMAP fan-out failed for account "${config.accountLabel}": ${String(e)}`);
147
- }
148
- }
149
- return { rows, accountsQueried, accountsFailed };
150
- }
151
- // ---------------------------------------------------------------------------
152
- // Account coverage + count partitioning (reads, get-unread-count, get-mail-stats)
153
- //
154
- // Coverage is decided per (config, Mail-account) PAIR by configMatchesAccount.
155
- // - Message-list reads use partitionAccountsForCounts to run AppleScript ONLY
156
- // for accounts no IMAP config covers (IMAP fans out over the configs), so an
157
- // all-IMAP user runs zero AppleScript and never depends on composite dedup.
158
- // - Count tools use planCountSources, which assigns each account EXACTLY ONE
159
- // source (its matching config, else AppleScript) and adds any config that
160
- // matched no account once — so even a heuristic MISS can't double-count.
161
- //
162
- // Matching is case-insensitive on the config's accountLabel/user vs. the Mail
163
- // account's name/email. accountLabel defaults to the login address and users
164
- // typically set it to the Mail.app account NAME, so checking both against both
165
- // catches the common setups (label=accountName, label=email, login=email).
166
- // ---------------------------------------------------------------------------
167
- /**
168
- * Per-pair matcher: does this IMAP config correspond to this Mail.app account?
169
- * Compared case-insensitively, the config's `accountLabel` AND `user` against the
170
- * Mail account's `name` AND `email`. An empty email can't match (avoids
171
- * `"" === ""` false positives). This is the single source of truth for coverage;
172
- * everything else delegates here.
173
- */
174
- export function configMatchesAccount(config, account) {
175
- const name = account.name.trim().toLowerCase();
176
- const email = (account.email ?? "").trim().toLowerCase();
177
- const label = config.accountLabel.trim().toLowerCase();
178
- const user = config.user.trim().toLowerCase();
179
- return (label === name || (!!email && label === email) || user === name || (!!email && user === email));
180
- }
181
- /** True when ANY config matches this Mail account (delegates to the per-pair matcher). */
182
- export function isAccountCoveredByImap(account, configs) {
183
- return configs.some((c) => configMatchesAccount(c, account));
184
- }
185
- /**
186
- * Split Mail.app accounts into those covered by IMAP (counted via IMAP) and the
187
- * rest (counted via AppleScript), so reads/counts skip the AppleScript scan for
188
- * IMAP-covered accounts.
189
- */
190
- export function partitionAccountsForCounts(accounts, configs) {
191
- const imapCovered = [];
192
- const appleScriptOnly = [];
193
- for (const a of accounts) {
194
- if (isAccountCoveredByImap(a, configs))
195
- imapCovered.push(a);
196
- else
197
- appleScriptOnly.push(a);
198
- }
199
- return { imapCovered, appleScriptOnly };
200
- }
201
- /**
202
- * Plan the count sources so NO account is double-counted even when the coverage
203
- * heuristic mis-matches (the failure mode the naive `Σimap(all configs) +
204
- * Σapple(uncovered)` had: a config that fails to match its Mail account lands in
205
- * BOTH sums).
206
- *
207
- * Account-centric: walk the Mail.app accounts; each is counted via its FIRST
208
- * matching config (IMAP) or, if none matches, via AppleScript. Then any IMAP
209
- * config that matched NO Mail account (configured-but-not-present-in-Mail.app) is
210
- * added once as an IMAP source. A config is consumed by at most one Mail account,
211
- * so two Mail accounts can't both claim the same config and inflate the total.
212
- */
213
- export function planCountSources(accounts, configs) {
214
- const sources = [];
215
- const usedConfigs = new Set();
216
- for (const account of accounts) {
217
- const match = configs.find((c) => !usedConfigs.has(c) && configMatchesAccount(c, account));
218
- if (match) {
219
- usedConfigs.add(match);
220
- sources.push({ kind: "imap", config: match, label: account.name });
221
- }
222
- else {
223
- sources.push({ kind: "applescript", account, label: account.name });
224
- }
225
- }
226
- // IMAP accounts that exist in config but not in Mail.app — count them once.
227
- for (const config of configs) {
228
- if (!usedConfigs.has(config)) {
229
- sources.push({ kind: "imap", config, label: config.accountLabel });
230
- }
231
- }
232
- return sources;
233
- }
234
- /**
235
- * Render the structured message rows into the human text block the read tools
236
- * emit (` - ID: … | date | subject (from: sender) [read|unread]`). Shared so the
237
- * merged path matches the single-backend formatting. `showReadState` mirrors
238
- * search/list (which show [read]) vs. plain list rows.
239
- */
240
- export function formatMergedRows(rows, showReadState = true) {
241
- return rows
242
- .map((m) => {
243
- const date = (() => {
244
- const e = dateEpoch(m);
245
- return e ? new Date(e).toLocaleDateString() : "";
246
- })();
247
- const subject = typeof m.subject === "string" ? m.subject : "(no subject)";
248
- const sender = typeof m.sender === "string" ? m.sender : "(unknown)";
249
- const state = showReadState ? ` [${m.isRead ? "read" : "unread"}]` : "";
250
- return ` - ID: ${String(m.id)} | ${date} | ${subject} (from: ${sender})${state}`;
251
- })
252
- .join("\n");
253
- }
@@ -1,24 +0,0 @@
1
- import type { ImapOpResult } from "../services/imapClient.js";
2
- import { type ToolResponse } from "../tools/respond.js";
3
- /** True when the id is an IMAP composite token (routes to IMAP). */
4
- export declare function isImapId(id: string): boolean;
5
- /**
6
- * Route a single-message operation to IMAP (when the id is an `imap:` token) or
7
- * to the AppleScript handler. Maps an IMAP `ImapOpResult` to a tool response,
8
- * preferring its `info` text on success and `error` on failure.
9
- */
10
- export declare function routeMessage(id: string, opts: {
11
- imap: () => Promise<ImapOpResult>;
12
- apple: () => ToolResponse | Promise<ToolResponse>;
13
- ok: string;
14
- fail: string;
15
- /** Optional ack payload attached as `structuredContent` on the IMAP success
16
- * path, so a caller can verify the mutation programmatically regardless of
17
- * backend (A1). The AppleScript path supplies its own via `apple`. */
18
- structured?: Record<string, unknown>;
19
- /** Derive `structuredContent` from the IMAP result on success (e.g. parse
20
- * subject/body from `info`). Takes precedence over `structured`; return
21
- * undefined to attach none. */
22
- structuredFromResult?: (r: ImapOpResult) => Record<string, unknown> | undefined;
23
- }): Promise<ToolResponse>;
24
- //# sourceMappingURL=messageRouter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"messageRouter.d.ts","sourceRoot":"","sources":["../../src/services/messageRouter.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAkC,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvF,oEAAoE;AACpE,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE5C;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,MAAM,EACV,IAAI,EAAE;IACJ,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;IAClC,KAAK,EAAE,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb;;2EAEuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;oCAEgC;IAChC,oBAAoB,CAAC,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;CACjF,GACA,OAAO,CAAC,YAAY,CAAC,CAWvB"}
@@ -1,31 +0,0 @@
1
- /**
2
- * Central backend router for single-message tools (D1).
3
- *
4
- * A single-message id is either an AppleScript numeric id or an `imap:` token.
5
- * Before this, every message handler (get/mark/flag/move/delete) repeated the
6
- * same `if (decodeImapId(id)) … else …` branch. `routeMessage` collapses that
7
- * into one place: run the IMAP op and map its `{success,error,info}` to a tool
8
- * response, or fall through to the AppleScript handler.
9
- *
10
- * @module services/messageRouter
11
- */
12
- import { decodeImapId } from "../services/imapClient.js";
13
- import { successResponse, errorResponse } from "../tools/respond.js";
14
- /** True when the id is an IMAP composite token (routes to IMAP). */
15
- export function isImapId(id) {
16
- return decodeImapId(id) !== null;
17
- }
18
- /**
19
- * Route a single-message operation to IMAP (when the id is an `imap:` token) or
20
- * to the AppleScript handler. Maps an IMAP `ImapOpResult` to a tool response,
21
- * preferring its `info` text on success and `error` on failure.
22
- */
23
- export async function routeMessage(id, opts) {
24
- if (decodeImapId(id)) {
25
- const r = await opts.imap();
26
- return r.success
27
- ? successResponse(r.info ?? opts.ok, opts.structuredFromResult ? opts.structuredFromResult(r) : opts.structured)
28
- : errorResponse(r.error ?? opts.fail);
29
- }
30
- return opts.apple();
31
- }