read-email-mcp 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KelpHect
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # Read Email MCP
2
+
3
+ A read-only [Model Context Protocol](https://modelcontextprotocol.io) server that mirrors any IMAP mailbox into a local SQLite database, then lets AI clients browse, search, and read email without reconnecting to the server on every request.
4
+
5
+ It works with Gmail, Outlook, Yahoo, Fastmail, iCloud, Proton Mail Bridge, self-hosted mail, and any other provider that exposes standard IMAP.
6
+
7
+ > **Version 1.0.0-rc.1** release candidate. The tool surface and configuration are stable for review. Please report issues.
8
+
9
+ ## Why use it
10
+
11
+ - **Fast browsing and search.** Email is synced once into a local SQLite mirror with full-text search. Every browse, search, and read call hits the local copy, so it is quick and never re-logs-in to IMAP per request.
12
+ - **Read-only and safe.** It never sends, deletes, moves, flags, or marks messages. IMAP mailboxes are opened with read-only locks. Your mailbox cannot be mutated by this server.
13
+ - **Provider agnostic.** Point it at any IMAP server with host, port, and TLS settings. No vendor lock-in.
14
+ - **Private by design.** Credentials stay in the process environment. Passwords are never written to disk. Raw `.eml` files live under your home directory and never leave your machine.
15
+ - **MCP native.** Exposes a clean set of tools over stdio so any MCP-compatible client can read email on your behalf.
16
+
17
+ ## Requirements
18
+
19
+ - Node.js 20 or newer
20
+ - A mailbox with IMAP enabled
21
+ - The mailbox email address and password. Most consumer providers (Gmail, Yahoo, iCloud, Outlook with 2FA) require an **app-specific password** instead of your normal sign-in password.
22
+
23
+ ## Quick start
24
+
25
+ ```powershell
26
+ git clone <this-repo>
27
+ cd read-email-mcp
28
+ npm install
29
+ npm run build
30
+ ```
31
+
32
+ Run the server with credentials and an IMAP host:
33
+
34
+ ```powershell
35
+ node dist/index.js `
36
+ --email "you@gmail.com" `
37
+ --password "your-app-password" `
38
+ --imap-host "imap.gmail.com"
39
+ ```
40
+
41
+ Or set environment variables and omit the flags:
42
+
43
+ ```powershell
44
+ $env:EMAIL = "you@gmail.com"
45
+ $env:PASSWORD = "your-app-password"
46
+ $env:IMAP_HOST = "imap.gmail.com"
47
+ node dist/index.js
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ All settings can be passed as CLI args or environment variables. CLI args take precedence over environment variables.
53
+
54
+ | Setting | Required | Env var | CLI arg | Default |
55
+ | --- | --- | --- | --- | --- |
56
+ | Email address | yes | `EMAIL` | `--email` | none |
57
+ | Password | yes | `PASSWORD` | `--password` | none |
58
+ | IMAP host | yes | `IMAP_HOST` | `--imap-host` | none |
59
+ | IMAP port | no | `IMAP_PORT` | `--imap-port` | `993` |
60
+ | IMAP TLS | no | `IMAP_SECURE` | `--imap-secure` | `true` |
61
+ | IMAP login user | no | `IMAP_USER` | `--imap-user` | same as email |
62
+
63
+ `IMAP_SECURE` accepts `true` or `false` (also `1`/`0`, `yes`/`no`, `on`/`off`). Use `false` with port `143` for STARTTLS or plain IMAP.
64
+
65
+ `IMAP_USER` is optional. Set it only when the IMAP login name differs from the mailbox email address. When omitted, the email address is used as the IMAP username.
66
+
67
+ ### Common provider settings
68
+
69
+ | Provider | IMAP host | Port | TLS | Notes |
70
+ | --- | --- | --- | --- | --- |
71
+ | Gmail | `imap.gmail.com` | 993 | true | Needs an app password with 2FA enabled |
72
+ | Outlook / Office 365 | `outlook.office365.com` | 993 | true | Use your Microsoft account password or app password |
73
+ | Yahoo | `imap.mail.yahoo.com` | 993 | true | Needs an app password |
74
+ | iCloud | `imap.mail.me.com` | 993 | true | Needs an app password |
75
+ | Fastmail | `imap.fastmail.com` | 993 | true | Use your Fastmail app password |
76
+ | Proton Mail | `127.0.0.1` | 1143 | false | Requires the Proton Mail Bridge app running locally |
77
+ | Self-hosted | your mail server | 993 or 143 | true or false | Depends on your server config |
78
+
79
+ ## MCP client configuration
80
+
81
+ The server speaks MCP over stdio. Add it to any MCP-compatible client.
82
+
83
+ ### Claude Desktop
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "read-email": {
89
+ "command": "npx",
90
+ "args": [
91
+ "-y",
92
+ "read-email-mcp@1.0.0-rc.1",
93
+ "--email",
94
+ "you@gmail.com",
95
+ "--password",
96
+ "your-app-password",
97
+ "--imap-host",
98
+ "imap.gmail.com"
99
+ ]
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### Codex CLI and other env-based clients
106
+
107
+ Set credentials in the server environment and pass only the binary:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "read-email": {
113
+ "command": "npx",
114
+ "args": ["-y", "read-email-mcp@1.0.0-rc.1"],
115
+ "env": {
116
+ "EMAIL": "you@gmail.com",
117
+ "PASSWORD": "your-app-password",
118
+ "IMAP_HOST": "imap.gmail.com"
119
+ }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ > On npm, pre-release versions like `1.0.0-rc.1` are not selected by `@latest`. Pin the exact version as shown above, or install from source after building.
126
+
127
+ ## Tools
128
+
129
+ The server exposes 12 tools. All read from the local mirror except `sync_email`, which pulls from IMAP.
130
+
131
+ - `sync_email`: pull new messages from IMAP into the local mirror. Call this first, or after a gap, before browsing or searching. Start with a small limit on one mailbox to verify credentials, then sync more.
132
+ - `get_sync_status`: show account, storage path, total messages, latest message date, per-mailbox counts, and the last sync result. Good first check to see whether a sync is needed.
133
+ - `list_mailboxes`: list locally known mailboxes (INBOX, Sent, Drafts, and so on) with message counts and last-sync time.
134
+ - `get_recent_emails`: show the latest emails, newest first, with optional `unreadOnly` and `flaggedOnly`. This is the everyday tool for checking what is new.
135
+ - `browse_email_dates`: summarize message volume as counts per day, week, or month. Useful for finding a busy period before drilling in with a date range.
136
+ - `search_emails`: full-text search across subject, sender and recipient addresses, preview, and body, with filters and keyset pagination.
137
+ - `list_emails`: list email summaries in chronological order with filters and pagination, without full-text search. Prefer this when browsing by sender, date, or mailbox.
138
+ - `get_email`: return full metadata and body (text and HTML) for a single message, by id, Message-ID, or mailbox plus UID.
139
+ - `get_thread`: return all locally known messages in one conversation, matched by Message-ID, In-Reply-To, and References headers.
140
+ - `get_raw_email`: return raw RFC822/MIME source for one message, byte-limited. Use this only when you need original headers or MIME structure.
141
+ - `get_storage_stats`: report SQLite database, WAL, and page statistics for the local mirror.
142
+ - `optimize_email_store`: run lightweight SQLite and FTS maintenance to keep queries fast. Safe to run after large syncs.
143
+
144
+ `search_emails`, `list_emails`, and `get_recent_emails` accept unread and flagged filters. `search_emails` and `list_emails` use `limit`, `cursor`, and `nextCursor` for keyset pagination.
145
+
146
+ ## Typical workflow
147
+
148
+ 1. Run `sync_email` with a small limit on one mailbox to verify credentials.
149
+ 2. Run `get_sync_status` to confirm the mirror is fresh.
150
+ 3. Run `get_recent_emails` with `unreadOnly: true` to see what is new.
151
+ 4. Run `get_email` to read a message, or `get_thread` to follow a conversation.
152
+ 5. Run `search_emails` or `list_emails` to find older messages by sender, subject, date range, or unread and flagged state.
153
+
154
+ ## How sync works
155
+
156
+ Sync connects to IMAP, lists selectable mailboxes, and fetches new messages by tracking the highest UID already seen per mailbox. If a mailbox UIDVALIDITY changes, the local copy for that mailbox is reset and refetched. Raw RFC822 source is written to disk, parsed metadata and body text go into SQLite, and a full-text search index is maintained alongside.
157
+
158
+ Sync is incremental. Subsequent calls only fetch messages with UIDs higher than the stored high-water mark. Mailboxes are opened read-only, so flags and read state on the server are never changed.
159
+
160
+ ## Local storage
161
+
162
+ The mirror is stored under:
163
+
164
+ ```text
165
+ ~/.read-email-mcp/<account-hash>/
166
+ emails.sqlite
167
+ raw/
168
+ ```
169
+
170
+ The account hash is derived from the email address, so multiple accounts coexist without exposing the address in folder names. Passwords are never written to disk.
171
+
172
+ Raw `.eml` files contain full email content. Treat the storage directory as sensitive and protect it the same way you protect your mailbox.
173
+
174
+ ## Safety
175
+
176
+ - The server is read-only. It does not send, delete, move, archive, mark read, or mutate flags.
177
+ - IMAP mailboxes are opened with read-only locks during sync.
178
+ - Credentials live only in the process environment and are never persisted by this server.
179
+ - The SQLite mirror uses WAL mode, a busy timeout, keyset pagination, and periodic FTS optimization for local performance and concurrency.
180
+
181
+ ## Development
182
+
183
+ ```powershell
184
+ npm test # unit tests (no real mailbox needed)
185
+ npm run typecheck # TypeScript checks
186
+ npm run build # compile to dist/
187
+ npm run smoke:mcp # start the built server over stdio and verify all tools
188
+ npm run verify # runs all of the above
189
+ ```
190
+
191
+ Tests use fixtures and fake IMAP clients, so they do not require real credentials or a live mail server.
192
+
193
+ ## Limitations
194
+
195
+ - No sending, replying, or composing email.
196
+ - No deleting, moving, flagging, or marking messages read.
197
+ - No OAuth. App passwords or direct passwords only.
198
+ - No background daemon. Sync runs when the client calls `sync_email`.
199
+ - Attachment bytes are summarized but not extracted to separate files.
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,30 @@
1
+ export interface AppConfig {
2
+ account: {
3
+ email: string;
4
+ accountHash: string;
5
+ };
6
+ imap: {
7
+ host: string;
8
+ port: number;
9
+ secure: boolean;
10
+ auth: {
11
+ user: string;
12
+ pass: string;
13
+ };
14
+ };
15
+ storage: {
16
+ baseDir: string;
17
+ accountDir: string;
18
+ databasePath: string;
19
+ rawDir: string;
20
+ };
21
+ }
22
+ interface LoadConfigOptions {
23
+ dataDir?: string;
24
+ args?: string[];
25
+ }
26
+ export declare const DEFAULT_IMAP_PORT = 993;
27
+ export declare const DEFAULT_IMAP_SECURE = true;
28
+ export declare function hashAccount(email: string): string;
29
+ export declare function loadConfig(env?: NodeJS.ProcessEnv, options?: LoadConfigOptions): AppConfig;
30
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,99 @@
1
+ import crypto from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const DEFAULT_IMAP_PORT = 993;
5
+ export const DEFAULT_IMAP_SECURE = true;
6
+ export function hashAccount(email) {
7
+ return crypto
8
+ .createHash("sha256")
9
+ .update(email.trim().toLowerCase())
10
+ .digest("hex")
11
+ .slice(0, 16);
12
+ }
13
+ export function loadConfig(env = process.env, options = {}) {
14
+ const args = options.args ?? process.argv.slice(2);
15
+ const argEmail = getArgValue(args, "--email");
16
+ const argPassword = getArgValue(args, "--password");
17
+ const argImapHost = getArgValue(args, "--imap-host");
18
+ const argImapPort = getArgValue(args, "--imap-port");
19
+ const argImapSecure = getArgValue(args, "--imap-secure");
20
+ const argImapUser = getArgValue(args, "--imap-user");
21
+ const email = (argEmail ?? env.EMAIL)?.trim().toLowerCase();
22
+ const password = argPassword ?? env.PASSWORD;
23
+ if (!email) {
24
+ throw new Error("Missing required credential: provide --email or EMAIL");
25
+ }
26
+ if (!password) {
27
+ throw new Error("Missing required credential: provide --password or PASSWORD");
28
+ }
29
+ const host = (argImapHost ?? env.IMAP_HOST)?.trim();
30
+ if (!host) {
31
+ throw new Error("Missing required IMAP setting: provide --imap-host or IMAP_HOST (for example imap.gmail.com)");
32
+ }
33
+ const port = parsePort(argImapPort ?? env.IMAP_PORT ?? DEFAULT_IMAP_PORT);
34
+ const secure = parseBoolean(argImapSecure ?? env.IMAP_SECURE, DEFAULT_IMAP_SECURE);
35
+ const customImapUser = argImapUser ?? env.IMAP_USER;
36
+ const authUser = customImapUser && customImapUser.trim() ? customImapUser.trim() : email;
37
+ const accountHash = hashAccount(email);
38
+ const baseDir = options.dataDir ?? path.join(os.homedir(), ".read-email-mcp");
39
+ const accountDir = path.join(baseDir, accountHash);
40
+ return {
41
+ account: {
42
+ email,
43
+ accountHash
44
+ },
45
+ imap: {
46
+ host,
47
+ port,
48
+ secure,
49
+ auth: {
50
+ user: authUser,
51
+ pass: password
52
+ }
53
+ },
54
+ storage: {
55
+ baseDir,
56
+ accountDir,
57
+ databasePath: path.join(accountDir, "emails.sqlite"),
58
+ rawDir: path.join(accountDir, "raw")
59
+ }
60
+ };
61
+ }
62
+ function parsePort(value) {
63
+ if (value === undefined || value === "") {
64
+ return DEFAULT_IMAP_PORT;
65
+ }
66
+ const port = Number(value);
67
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
68
+ throw new Error(`Invalid IMAP port: ${String(value)}. Expected an integer between 1 and 65535.`);
69
+ }
70
+ return port;
71
+ }
72
+ function parseBoolean(value, fallback) {
73
+ if (value === undefined || value === "") {
74
+ return fallback;
75
+ }
76
+ const normalized = value.trim().toLowerCase();
77
+ if (["true", "1", "yes", "on"].includes(normalized)) {
78
+ return true;
79
+ }
80
+ if (["false", "0", "no", "off"].includes(normalized)) {
81
+ return false;
82
+ }
83
+ throw new Error(`Invalid IMAP secure value: ${value}. Expected true or false.`);
84
+ }
85
+ function getArgValue(args, ...names) {
86
+ for (let index = 0; index < args.length; index += 1) {
87
+ const arg = args[index];
88
+ for (const name of names) {
89
+ if (arg === name) {
90
+ return args[index + 1];
91
+ }
92
+ if (arg.startsWith(`${name}=`)) {
93
+ return arg.slice(name.length + 1);
94
+ }
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/imap.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { AppConfig } from "./config.js";
2
+ import type { EmailStore } from "./store.js";
3
+ import type { SyncResultRecord } from "./types.js";
4
+ export interface ImapMailboxInfo {
5
+ path: string;
6
+ delimiter?: string;
7
+ flags?: Iterable<string>;
8
+ }
9
+ export interface ImapFetchMessage {
10
+ uid: number;
11
+ source: Buffer | Uint8Array | string;
12
+ internalDate?: Date | string;
13
+ flags?: Iterable<string>;
14
+ size?: number;
15
+ }
16
+ export interface ImapClientLike {
17
+ connect(): Promise<void>;
18
+ logout(): Promise<void>;
19
+ list(): Iterable<ImapMailboxInfo> | AsyncIterable<ImapMailboxInfo> | Promise<Iterable<ImapMailboxInfo> | AsyncIterable<ImapMailboxInfo>>;
20
+ mailbox?: {
21
+ uidValidity?: string | number | bigint;
22
+ uidNext?: number;
23
+ exists?: number;
24
+ };
25
+ getMailboxLock(mailbox: string, options?: {
26
+ readOnly?: boolean;
27
+ description?: string;
28
+ }): Promise<{
29
+ path: string;
30
+ uidValidity?: string | number | bigint;
31
+ uidNext?: number;
32
+ exists?: number;
33
+ release(): void;
34
+ }>;
35
+ fetch(range: string, query?: unknown, options?: unknown): AsyncIterable<ImapFetchMessage>;
36
+ }
37
+ export type ImapClientFactory = (config: AppConfig["imap"]) => ImapClientLike;
38
+ export interface EmailSyncServiceOptions {
39
+ imap: AppConfig["imap"];
40
+ rawDir: string;
41
+ store: EmailStore;
42
+ clientFactory?: ImapClientFactory;
43
+ }
44
+ export interface SyncOptions {
45
+ mailbox?: string;
46
+ limit?: number;
47
+ since?: string;
48
+ }
49
+ export interface SyncResult extends SyncResultRecord {
50
+ mailboxes: string[];
51
+ }
52
+ export declare class EmailSyncService {
53
+ private readonly options;
54
+ private readonly clientFactory;
55
+ constructor(options: EmailSyncServiceOptions);
56
+ sync(syncOptions?: SyncOptions): Promise<SyncResult>;
57
+ private selectMailboxes;
58
+ private syncMailbox;
59
+ private writeRawEmail;
60
+ }
package/dist/imap.js ADDED
@@ -0,0 +1,255 @@
1
+ import { ImapFlow } from "imapflow";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { parseEmail } from "./parser.js";
6
+ export class EmailSyncService {
7
+ options;
8
+ clientFactory;
9
+ constructor(options) {
10
+ this.options = options;
11
+ this.clientFactory = options.clientFactory ?? createImapFlowClient;
12
+ }
13
+ async sync(syncOptions = {}) {
14
+ const startedAt = new Date().toISOString();
15
+ const client = this.clientFactory(this.options.imap);
16
+ let connected = false;
17
+ const syncedMailboxes = [];
18
+ let syncedMessages = 0;
19
+ try {
20
+ await client.connect();
21
+ connected = true;
22
+ const mailboxes = await this.selectMailboxes(client, syncOptions.mailbox);
23
+ for (const mailbox of mailboxes) {
24
+ if (syncOptions.limit !== undefined && syncedMessages >= syncOptions.limit) {
25
+ break;
26
+ }
27
+ syncedMailboxes.push(mailbox.path);
28
+ const syncedForMailbox = await this.syncMailbox(client, mailbox, syncOptions, syncedMessages);
29
+ syncedMessages += syncedForMailbox;
30
+ }
31
+ const result = {
32
+ ok: true,
33
+ mailbox: syncOptions.mailbox ?? null,
34
+ startedAt,
35
+ finishedAt: new Date().toISOString(),
36
+ message: `Synced ${syncedMessages} message${syncedMessages === 1 ? "" : "s"}`,
37
+ syncedMessages,
38
+ mailboxes: syncedMailboxes
39
+ };
40
+ this.options.store.recordSyncResult(result);
41
+ return result;
42
+ }
43
+ catch (error) {
44
+ const result = {
45
+ ok: false,
46
+ mailbox: syncOptions.mailbox ?? null,
47
+ startedAt,
48
+ finishedAt: new Date().toISOString(),
49
+ message: error instanceof Error ? error.message : String(error),
50
+ syncedMessages,
51
+ mailboxes: syncedMailboxes
52
+ };
53
+ this.options.store.recordSyncResult(result);
54
+ return result;
55
+ }
56
+ finally {
57
+ if (connected) {
58
+ await client.logout();
59
+ }
60
+ }
61
+ }
62
+ async selectMailboxes(client, requestedMailbox) {
63
+ const mailboxes = [];
64
+ const mailboxList = await client.list();
65
+ for await (const mailbox of mailboxList) {
66
+ const flags = [...(mailbox.flags ?? [])];
67
+ if (flags.includes("\\Noselect")) {
68
+ continue;
69
+ }
70
+ if (requestedMailbox && mailbox.path !== requestedMailbox) {
71
+ continue;
72
+ }
73
+ mailboxes.push({
74
+ path: mailbox.path,
75
+ delimiter: mailbox.delimiter ?? "/",
76
+ flags
77
+ });
78
+ }
79
+ if (requestedMailbox && mailboxes.length === 0) {
80
+ mailboxes.push({ path: requestedMailbox, delimiter: "/", flags: [] });
81
+ }
82
+ return mailboxes;
83
+ }
84
+ async syncMailbox(client, mailbox, syncOptions, alreadySynced) {
85
+ const lock = await client.getMailboxLock(mailbox.path, {
86
+ readOnly: true,
87
+ description: "read-email-mcp read-only sync"
88
+ });
89
+ try {
90
+ const mailboxState = getMailboxSyncState(lock, client);
91
+ const uidValidity = mailboxState.uidValidity;
92
+ const existing = this.options.store.getMailbox(mailbox.path);
93
+ const uidValidityChanged = Boolean(existing?.uidValidity && existing.uidValidity !== uidValidity);
94
+ const startUid = uidValidityChanged ? 1 : (existing?.highestUid ?? 0) + 1;
95
+ let highestUid = uidValidityChanged ? 0 : existing?.highestUid ?? 0;
96
+ let count = 0;
97
+ if (uidValidityChanged) {
98
+ this.options.store.deleteMailboxMessages(mailbox.path);
99
+ }
100
+ this.options.store.upsertMailbox({
101
+ path: mailbox.path,
102
+ delimiter: mailbox.delimiter,
103
+ uidValidity,
104
+ highestUid,
105
+ selectable: true,
106
+ lastSyncedAt: existing?.lastSyncedAt ?? null
107
+ });
108
+ const fetchPlan = buildFetchPlan(startUid, syncOptions, alreadySynced, mailboxState.maxUid);
109
+ if (!fetchPlan) {
110
+ this.options.store.upsertMailbox({
111
+ path: mailbox.path,
112
+ delimiter: mailbox.delimiter,
113
+ uidValidity,
114
+ highestUid: Math.max(highestUid, mailboxState.maxUid ?? highestUid),
115
+ selectable: true,
116
+ lastSyncedAt: new Date().toISOString()
117
+ });
118
+ return 0;
119
+ }
120
+ for await (const fetched of client.fetch(fetchPlan.range, { source: true, uid: true, flags: true, internalDate: true, size: true }, { uid: true })) {
121
+ if (syncOptions.limit !== undefined && alreadySynced + count >= syncOptions.limit) {
122
+ break;
123
+ }
124
+ highestUid = Math.max(highestUid, fetched.uid);
125
+ const internalDate = normalizeInternalDate(fetched.internalDate);
126
+ if (syncOptions.since && internalDate && internalDate < syncOptions.since) {
127
+ continue;
128
+ }
129
+ const raw = Buffer.isBuffer(fetched.source) ? fetched.source : Buffer.from(fetched.source);
130
+ const rawPath = await this.writeRawEmail(mailbox.path, uidValidity, fetched.uid, raw);
131
+ const parsed = await parseFetchedEmail(raw, {
132
+ mailbox: mailbox.path,
133
+ uid: fetched.uid,
134
+ uidValidity,
135
+ rawPath,
136
+ internalDate,
137
+ flags: [...(fetched.flags ?? [])],
138
+ size: fetched.size ?? raw.length
139
+ });
140
+ this.options.store.upsertMessage(parsed);
141
+ count += 1;
142
+ }
143
+ this.options.store.upsertMailbox({
144
+ path: mailbox.path,
145
+ delimiter: mailbox.delimiter,
146
+ uidValidity,
147
+ highestUid: Math.max(highestUid, fetchPlan.scannedThroughUid ?? highestUid),
148
+ selectable: true,
149
+ lastSyncedAt: new Date().toISOString()
150
+ });
151
+ return count;
152
+ }
153
+ finally {
154
+ lock.release();
155
+ }
156
+ }
157
+ async writeRawEmail(mailbox, uidValidity, uid, raw) {
158
+ const mailboxDir = path.join(this.options.rawDir, hashMailbox(mailbox));
159
+ await fs.mkdir(mailboxDir, { recursive: true });
160
+ const rawPath = path.join(mailboxDir, `${safeSegment(uidValidity || "unknown")}-${uid}.eml`);
161
+ await fs.writeFile(rawPath, raw);
162
+ return rawPath;
163
+ }
164
+ }
165
+ function createImapFlowClient(config) {
166
+ return new ImapFlow({
167
+ host: config.host,
168
+ port: config.port,
169
+ secure: config.secure,
170
+ auth: config.auth,
171
+ logger: false
172
+ });
173
+ }
174
+ function getMailboxSyncState(lock, client) {
175
+ const uidNext = normalizeUidNumber(lock.uidNext ?? client.mailbox?.uidNext);
176
+ return {
177
+ uidValidity: String(lock.uidValidity ?? client.mailbox?.uidValidity ?? ""),
178
+ maxUid: uidNext === null ? null : Math.max(uidNext - 1, 0)
179
+ };
180
+ }
181
+ function buildFetchPlan(startUid, syncOptions, alreadySynced, maxUid) {
182
+ if (maxUid !== null && startUid > maxUid) {
183
+ return null;
184
+ }
185
+ const remaining = syncOptions.limit === undefined ? null : syncOptions.limit - alreadySynced;
186
+ if (remaining !== null && remaining <= 0) {
187
+ return null;
188
+ }
189
+ let endUid;
190
+ if (remaining === null) {
191
+ endUid = maxUid ?? "*";
192
+ }
193
+ else {
194
+ endUid = startUid + remaining - 1;
195
+ if (maxUid !== null) {
196
+ endUid = Math.min(endUid, maxUid);
197
+ }
198
+ }
199
+ return {
200
+ range: `${startUid}:${endUid}`,
201
+ scannedThroughUid: typeof endUid === "number" && maxUid !== null ? endUid : undefined
202
+ };
203
+ }
204
+ function normalizeUidNumber(value) {
205
+ if (typeof value !== "number" || !Number.isFinite(value)) {
206
+ return null;
207
+ }
208
+ return Math.max(Math.trunc(value), 0);
209
+ }
210
+ async function parseFetchedEmail(raw, context) {
211
+ try {
212
+ return await parseEmail(raw, context);
213
+ }
214
+ catch (error) {
215
+ return {
216
+ mailbox: context.mailbox,
217
+ uid: context.uid,
218
+ uidValidity: context.uidValidity,
219
+ messageId: null,
220
+ inReplyTo: null,
221
+ references: [],
222
+ subject: null,
223
+ date: context.internalDate ?? new Date(0).toISOString(),
224
+ internalDate: context.internalDate,
225
+ from: [],
226
+ to: [],
227
+ cc: [],
228
+ bcc: [],
229
+ flags: context.flags,
230
+ size: context.size,
231
+ hasAttachments: false,
232
+ attachmentCount: 0,
233
+ preview: null,
234
+ text: null,
235
+ html: null,
236
+ headers: {},
237
+ attachments: [],
238
+ rawPath: context.rawPath,
239
+ parseError: error instanceof Error ? error.message : String(error)
240
+ };
241
+ }
242
+ }
243
+ function normalizeInternalDate(value) {
244
+ if (!value) {
245
+ return null;
246
+ }
247
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
248
+ }
249
+ function hashMailbox(mailbox) {
250
+ return crypto.createHash("sha256").update(mailbox).digest("hex").slice(0, 16);
251
+ }
252
+ function safeSegment(value) {
253
+ return value.replace(/[^a-zA-Z0-9_.-]/g, "_");
254
+ }
255
+ //# sourceMappingURL=imap.js.map