import { ImapFlow } from "imapflow";
const CONNECT_TIMEOUT = 10_000;
const COMMAND_TIMEOUT = 30_000;
/** Diagnostic logging to stderr (captured by claude-agent-stderr log on Pi) */
function diag(op, step, detail) {
    const ts = new Date().toISOString();
    console.error(`[email] ${op}: ${step}${detail ? ` — ${detail}` : ""} [${ts}]`);
}
/**
 * Safely close an IMAP client. Falls back to close() if logout() throws
 * (e.g., when a download stream was left open mid-consumption).
 */
async function safeLogout(client) {
    try {
        await client.logout();
    }
    catch {
        try {
            client.close();
        }
        catch { /* best effort */ }
    }
}
// ─── Hard cap for tool-layer limit enforcement ──────────────────────
export const MAX_LIMIT = 50;
/**
 * Resolve an IMAP folder name from a logical folder key.
 * - "inbox" → "INBOX"
 * - "sent" → discovered via \Sent special-use flag (RFC 6154)
 *
 * Throws if the requested folder cannot be found on this server.
 */
export async function resolveFolderName(client, folder) {
    if (folder === "inbox")
        return "INBOX";
    const mailboxes = await client.list();
    for (const mb of mailboxes) {
        if (mb.specialUse === "\\Sent") {
            return mb.path;
        }
    }
    throw new Error("Sent folder not found on this IMAP server — the server does not advertise a \\Sent special-use folder.");
}
function createClient(config, password) {
    return new ImapFlow({
        host: config.imapHost,
        port: config.imapPort,
        secure: config.imapSecurity === "tls",
        auth: {
            user: config.email,
            pass: password,
        },
        logger: false,
        tls: {
            rejectUnauthorized: true,
        },
        connectionTimeout: CONNECT_TIMEOUT,
        greetingTimeout: CONNECT_TIMEOUT,
        socketTimeout: COMMAND_TIMEOUT,
    });
}
/**
 * Strip HTML tags and decode common entities.
 * Used for extracting readable text from HTML-only email bodies.
 */
export function stripHtml(html) {
    return html
        .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
        .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
        .replace(/<[^>]+>/g, " ")
        .replace(/&amp;/g, "&")
        .replace(/&lt;/g, "<")
        .replace(/&gt;/g, ">")
        .replace(/&quot;/g, '"')
        .replace(/&#39;/g, "'")
        .replace(/&nbsp;/g, " ")
        .replace(/\s+/g, " ")
        .trim();
}
/** Max bytes to fetch per message body (4KB is plenty for a 500-char preview). */
const BODY_MAX_BYTES = 4096;
/** Max characters for body preview in tool output. */
const BODY_PREVIEW_LENGTH = 500;
/** Hard cap on a single attachment's bytes — over this size, metadata is surfaced
 *  but bytes are not downloaded. Matches typical provider send limits. */
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
/**
 * Walk an IMAP bodyStructure and return metadata for every attachment-shaped
 * part. No bytes are downloaded — cheap, runs at preview time.
 *
 * An attachment-shaped part is one of:
 *  - disposition === "attachment" (RFC 2183), OR
 *  - has a filename in parameters.name or dispositionParameters.filename AND
 *    is not a multipart container.
 *
 * Inline parts without a filename (e.g. the message body) are excluded.
 */
export function walkAttachmentMetadata(structure) {
    if (!structure)
        return [];
    const out = [];
    const visit = (node) => {
        if (node.childNodes && node.childNodes.length > 0) {
            for (const child of node.childNodes)
                visit(child);
            return;
        }
        const filename = node.dispositionParameters?.filename?.trim() ||
            node.parameters?.name?.trim() ||
            "";
        const isAttachmentDisposition = typeof node.disposition === "string" &&
            node.disposition.toLowerCase() === "attachment";
        const isMultipart = typeof node.type === "string" && node.type.toLowerCase().startsWith("multipart/");
        if (isMultipart)
            return;
        if (!isAttachmentDisposition && !filename)
            return;
        if (!node.part)
            return; // can't fetch bytes without a part key
        out.push({
            partKey: node.part,
            filename: filename || `unnamed-${node.part}`,
            mimeType: (node.type ?? "application/octet-stream").toLowerCase(),
            sizeBytes: typeof node.size === "number" ? node.size : 0,
            encoding: node.encoding ?? null,
        });
    };
    visit(structure);
    return out;
}
function formatBytes(n) {
    if (n < 1024)
        return `${n} B`;
    if (n < 1024 * 1024)
        return `${(n / 1024).toFixed(1)} KB`;
    return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/**
 * Format an AttachmentMeta[] as a single-line operator-readable footer.
 * Returns empty string when the array is empty so callers can skip the
 * line entirely.
 */
export function formatAttachmentsFooter(attachments) {
    if (attachments.length === 0)
        return "";
    const parts = attachments
        .map((a) => `${a.filename} (${a.mimeType}, ${formatBytes(a.sizeBytes)})`)
        .join("; ");
    return `Attachments (${attachments.length}): ${parts}`;
}
/**
 * Download decoded bytes for a list of (uid, partKey) attachments from IMAP.
 * Opens one connection, reuses it across all requests. Per-attachment
 * failures are isolated — one bad part does not abort the batch.
 */
export async function fetchAttachmentBytesForMessages(config, password, requests) {
    if (requests.length === 0)
        return [];
    const client = createClient(config, password);
    const results = [];
    try {
        await client.connect();
        const lock = await client.getMailboxLock("INBOX");
        try {
            for (const req of requests) {
                if (req.expectedSize > MAX_ATTACHMENT_BYTES) {
                    diag("fetchAttachmentBytes", "oversize", `uid=${req.uid} part=${req.partKey} size=${req.expectedSize} cap=${MAX_ATTACHMENT_BYTES}`);
                    results.push({ uid: req.uid, partKey: req.partKey, bytes: null, failureReason: "oversize" });
                    continue;
                }
                try {
                    const { content } = await client.download(String(req.uid), req.partKey, { uid: true });
                    if (!content) {
                        diag("fetchAttachmentBytes", "no-stream", `uid=${req.uid} part=${req.partKey}`);
                        results.push({ uid: req.uid, partKey: req.partKey, bytes: null, failureReason: "fetch-error" });
                        continue;
                    }
                    const chunks = [];
                    let total = 0;
                    let overflow = false;
                    for await (const chunk of content) {
                        const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
                        total += buf.length;
                        if (total > MAX_ATTACHMENT_BYTES) {
                            overflow = true;
                            break;
                        }
                        chunks.push(buf);
                    }
                    if (overflow) {
                        diag("fetchAttachmentBytes", "stream-oversize", `uid=${req.uid} part=${req.partKey} streamed=${total}`);
                        results.push({ uid: req.uid, partKey: req.partKey, bytes: null, failureReason: "oversize" });
                        continue;
                    }
                    // ImapFlow's download() returns already-decoded bytes when given a
                    // partKey, so we do not re-decode against `encoding` here.
                    const bytes = Buffer.concat(chunks);
                    if (req.expectedSize > 0 && bytes.length === 0) {
                        diag("fetchAttachmentBytes", "short-buffer", `uid=${req.uid} part=${req.partKey} expected=${req.expectedSize} got=0`);
                        results.push({ uid: req.uid, partKey: req.partKey, bytes: null, failureReason: "short-buffer" });
                        continue;
                    }
                    results.push({ uid: req.uid, partKey: req.partKey, bytes, failureReason: null });
                }
                catch (err) {
                    diag("fetchAttachmentBytes", "fetch-error", `uid=${req.uid} part=${req.partKey} ${err instanceof Error ? err.message : String(err)}`);
                    results.push({ uid: req.uid, partKey: req.partKey, bytes: null, failureReason: "fetch-error" });
                }
            }
        }
        finally {
            lock.release();
        }
    }
    finally {
        await safeLogout(client);
    }
    return results;
}
/**
 * Decode a raw IMAP body part buffer according to its transfer encoding.
 * Returns a UTF-8 string. Falls back to raw UTF-8 for unknown encodings.
 */
export function decodeBodyPart(buf, encoding) {
    const enc = encoding?.toLowerCase().trim();
    if (!enc || enc === "7bit" || enc === "8bit" || enc === "binary") {
        return buf.toString("utf-8");
    }
    if (enc === "base64") {
        return Buffer.from(buf.toString("ascii").replace(/\s/g, ""), "base64").toString("utf-8");
    }
    if (enc === "quoted-printable") {
        return buf
            .toString("ascii")
            .replace(/=\r?\n/g, "")
            .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
    }
    // Unknown encoding — best-effort UTF-8
    return buf.toString("utf-8");
}
/**
 * Extract a text body preview from a fetched message's inline bodyParts + bodyStructure.
 *
 * Determines the content type and transfer encoding of part "1" from
 * bodyStructure (single-part: structure itself, multipart: first childNode).
 * Skips non-text parts and multipart containers. Strips HTML. Truncates
 * to BODY_PREVIEW_LENGTH characters.
 */
export function extractBodyFromFetch(msg) {
    const buf = msg.bodyParts?.get("1");
    if (!buf || buf.length === 0)
        return "(body unavailable)";
    const structure = msg.bodyStructure;
    if (!structure)
        return "(body unavailable)";
    // Determine content type and encoding for IMAP part "1":
    // - Single-part message: bodyStructure itself describes the body
    // - Multipart message: first childNode describes part "1"
    let type;
    let encoding;
    if (structure.childNodes && structure.childNodes.length > 0) {
        const firstChild = structure.childNodes[0];
        type = firstChild.type?.toLowerCase();
        encoding = firstChild.encoding;
    }
    else {
        type = structure.type?.toLowerCase();
        encoding = structure.encoding;
    }
    // Skip non-text content (binary attachments, images, nested multipart containers)
    if (!type || !type.startsWith("text/"))
        return "(body unavailable)";
    let text = decodeBodyPart(buf, encoding);
    if (type === "text/html") {
        text = stripHtml(text);
    }
    text = text.trim();
    if (!text)
        return "(body unavailable)";
    return text.length > BODY_PREVIEW_LENGTH
        ? text.slice(0, BODY_PREVIEW_LENGTH) + "\u2026"
        : text;
}
/**
 * Test IMAP connectivity and authentication.
 * Returns null on success, error message on failure.
 */
export async function testConnection(config, password) {
    const client = createClient(config, password);
    try {
        await client.connect();
        await client.logout();
        return null;
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("AUTHENTICATIONFAILED") || msg.includes("Invalid credentials") || msg.includes("LOGIN failed")) {
            return `IMAP authentication failed for ${config.email}. Check the password — most providers require an app-specific password when 2FA is enabled.`;
        }
        if (msg.includes("ECONNREFUSED")) {
            return `IMAP connection refused at ${config.imapHost}:${config.imapPort}. Check that IMAP is enabled in your email provider settings.`;
        }
        if (msg.includes("ENOTFOUND")) {
            return `IMAP host ${config.imapHost} not found. Check the hostname.`;
        }
        if (msg.includes("certificate") || msg.includes("TLS") || msg.includes("SSL")) {
            return `IMAP TLS error connecting to ${config.imapHost}:${config.imapPort}. Try port 993 (TLS) or 143 (STARTTLS).`;
        }
        if (msg.includes("ETIMEDOUT") || msg.includes("timeout")) {
            return `IMAP connection timed out to ${config.imapHost}:${config.imapPort}. Check the host and port.`;
        }
        return `IMAP connection failed: ${msg}`;
    }
}
// ─── Over-fetch multiplier for client-side TO filtering ─────────────
// When filtering by recipient client-side (instead of via IMAP SEARCH TO,
// which full-scans headers and times out on large mailboxes), we fetch
// more envelopes than requested to compensate for non-matching messages.
const TO_OVERFETCH_MULTIPLIER = 3;
// ─── Default since floor for unbounded alias searches ───────────────
const DEFAULT_SINCE_DAYS = 7;
/**
 * Check whether an IMAP envelope's TO field contains the target address.
 * Case-insensitive comparison. Strict: if the server didn't return TO
 * addresses, the message is excluded — we cannot confirm it was addressed
 * to the target.
 */
export function matchesRecipient(envelope, targetAddress) {
    if (!envelope.to || envelope.to.length === 0)
        return false;
    const target = targetAddress.toLowerCase();
    return envelope.to.some((addr) => addr.address?.toLowerCase() === target);
}
/**
 * Build IMAP SEARCH criteria for fetchRecent.
 * Never includes TO — that filter is applied client-side to avoid
 * full-mailbox header scans that timeout on large mailboxes.
 */
export function buildFetchRecentCriteria(options) {
    const { sender, since, before, to, limit = 20 } = options;
    const criteria = {};
    if (sender)
        criteria.from = sender;
    if (since)
        criteria.since = since;
    if (before)
        criteria.before = before;
    // TO is intentionally excluded — filtered client-side after fetch
    if (Object.keys(criteria).length === 0) {
        criteria.all = true;
    }
    return {
        criteria,
        clientSideTo: to,
        fetchLimit: to ? limit * TO_OVERFETCH_MULTIPLIER : limit,
    };
}
/**
 * Build IMAP SEARCH criteria for searchMessages.
 * Never includes TO — that filter is applied client-side.
 * When TO was the only filter (no other criteria provide bounding),
 * injects a since floor to prevent unbounded full-mailbox scans.
 */
export function buildSearchCriteria(options) {
    const { sender, subject, body, since, before, to } = options;
    const criteria = {};
    if (sender)
        criteria.from = sender;
    if (subject)
        criteria.subject = subject;
    if (body)
        criteria.body = body;
    if (since)
        criteria.since = since;
    if (before)
        criteria.before = before;
    // TO is intentionally excluded — filtered client-side after fetch
    // When TO was the only effective filter (nothing else bounds the search),
    // inject a date floor to prevent unbounded full-mailbox scans
    if (to && Object.keys(criteria).length === 0) {
        const sinceFloor = new Date();
        sinceFloor.setDate(sinceFloor.getDate() - DEFAULT_SINCE_DAYS);
        criteria.since = sinceFloor;
    }
    if (Object.keys(criteria).length === 0) {
        criteria.all = true;
    }
    return {
        criteria,
        clientSideTo: to,
    };
}
/**
 * Fetch recent messages from a mailbox folder.
 * Returns the most recent N messages with cursor-based pagination.
 *
 * TO filtering is applied client-side (not via IMAP SEARCH) to avoid
 * full-mailbox header scans that exceed socketTimeout on large mailboxes.
 */
export async function fetchRecent(config, password, options = {}) {
    const { limit = 20, sender, subjectPattern, since, before, to, folder = "inbox", beforeUid } = options;
    const client = createClient(config, password);
    // Compile subject regex once before the fetch loop
    let subjectRegex = null;
    if (subjectPattern) {
        try {
            subjectRegex = new RegExp(subjectPattern, "i");
        }
        catch { /* invalid — ignore */ }
    }
    const { criteria, clientSideTo, fetchLimit } = buildFetchRecentCriteria({
        sender, since, before, to, limit,
    });
    const t0 = Date.now();
    diag("fetchRecent", "start", `folder=${folder} criteria=${JSON.stringify(criteria)} fetchLimit=${fetchLimit} clientSideTo=${clientSideTo ?? "none"} beforeUid=${beforeUid ?? "none"}`);
    try {
        await client.connect();
        diag("fetchRecent", "connected", `${Date.now() - t0}ms`);
        const folderPath = await resolveFolderName(client, folder);
        diag("fetchRecent", "folder resolved", `${folder} → ${folderPath}`);
        const lock = await client.getMailboxLock(folderPath);
        diag("fetchRecent", "mailbox locked", `${Date.now() - t0}ms`);
        try {
            const t1 = Date.now();
            const searchResult = await client.search(criteria, { uid: true });
            let uids = searchResult || [];
            diag("fetchRecent", "search done", `${uids.length} UIDs in ${Date.now() - t1}ms (total ${Date.now() - t0}ms)`);
            // Apply cursor: keep only UIDs older than (less than) beforeUid
            if (beforeUid && beforeUid > 0) {
                uids = uids.filter((uid) => uid < beforeUid);
                diag("fetchRecent", "cursor applied", `${uids.length} UIDs after beforeUid=${beforeUid} filter`);
            }
            if (uids.length === 0)
                return { messages: [] };
            // Determine if more results exist beyond what we'll return
            const totalAfterCursor = uids.length;
            // Take the most recent UIDs (over-fetched when client-side TO filtering)
            const recentUids = uids.slice(-fetchLimit);
            const messages = [];
            const t2 = Date.now();
            const uidList = recentUids.map(String).join(",");
            diag("fetchRecent", "fetching envelopes", `UIDs=[${uidList}]`);
            let envelopeCount = 0;
            for await (const msg of client.fetch(uidList, {
                envelope: true,
                uid: true,
                bodyStructure: true,
                bodyParts: [{ key: "1", start: 0, maxLength: BODY_MAX_BYTES }],
            }, { uid: true })) {
                envelopeCount++;
                diag("fetchRecent", `envelope #${envelopeCount}`, `uid=${msg.uid} from=${msg.envelope?.from?.[0]?.address ?? "?"} at ${Date.now() - t2}ms`);
                // Client-side TO filter (replaces IMAP SEARCH TO)
                if (clientSideTo && !matchesRecipient(msg.envelope ?? {}, clientSideTo))
                    continue;
                const fromAddr = msg.envelope?.from?.[0]?.address ?? "(unknown)";
                const subject = msg.envelope?.subject ?? "(no subject)";
                const date = msg.envelope?.date?.toISOString() ?? "(unknown date)";
                const messageId = msg.envelope?.messageId ?? null;
                const inReplyTo = msg.envelope?.inReplyTo ?? null;
                if (subjectRegex && !subjectRegex.test(subject))
                    continue;
                // Stop once we have enough matches
                if (messages.length >= limit)
                    break;
                const bodyPreview = extractBodyFromFetch(msg);
                diag("fetchRecent", `body #${envelopeCount}`, `uid=${msg.uid} preview=${bodyPreview.length}chars type=${msg.bodyStructure?.type ?? "?"}`);
                const attachments = walkAttachmentMetadata(msg.bodyStructure);
                messages.push({
                    uid: msg.uid,
                    messageId,
                    inReplyTo,
                    from: fromAddr,
                    subject,
                    date,
                    bodyPreview,
                    attachments,
                });
            }
            diag("fetchRecent", "fetch done", `${envelopeCount} envelopes, ${messages.length} messages in ${Date.now() - t2}ms (total ${Date.now() - t0}ms)`);
            // Return newest first
            messages.reverse();
            // Calculate pagination cursor: use the oldest UID in the *fetched* set
            // (not the filtered set) to avoid skipping messages between filtered-out
            // and kept messages when client-side TO/subject filtering is active
            const result = { messages };
            if (totalAfterCursor > fetchLimit && recentUids.length > 0) {
                const oldestFetchedUid = recentUids[0]; // UIDs are sorted ascending
                result.nextBeforeUid = oldestFetchedUid;
                diag("fetchRecent", "pagination", `nextBeforeUid=${oldestFetchedUid}`);
            }
            else {
                diag("fetchRecent", "pagination", "exhausted");
            }
            return result;
        }
        finally {
            lock.release();
        }
    }
    catch (err) {
        diag("fetchRecent", "FAILED", `${err instanceof Error ? err.message : String(err)} after ${Date.now() - t0}ms`);
        throw err;
    }
    finally {
        await safeLogout(client);
    }
}
/**
 * Search a mailbox folder by query criteria with cursor-based pagination.
 */
export async function searchMessages(config, password, query) {
    const { sender, subject, body, since, before, limit = 20, to, folder = "inbox", beforeUid } = query;
    const client = createClient(config, password);
    // When client-side TO filtering is active, overfetch to compensate for non-matching messages
    const fetchLimit = to ? limit * TO_OVERFETCH_MULTIPLIER : limit;
    const { criteria, clientSideTo } = buildSearchCriteria({
        sender, subject, body, since, before, to,
    });
    const t0 = Date.now();
    diag("searchMessages", "start", `folder=${folder} criteria=${JSON.stringify(criteria)} clientSideTo=${clientSideTo ?? "none"} beforeUid=${beforeUid ?? "none"}`);
    try {
        await client.connect();
        diag("searchMessages", "connected", `${Date.now() - t0}ms`);
        const folderPath = await resolveFolderName(client, folder);
        diag("searchMessages", "folder resolved", `${folder} → ${folderPath}`);
        const lock = await client.getMailboxLock(folderPath);
        diag("searchMessages", "mailbox locked", `${Date.now() - t0}ms`);
        try {
            const t1 = Date.now();
            const searchResult = await client.search(criteria, { uid: true });
            let uids = searchResult || [];
            diag("searchMessages", "search done", `${uids.length} UIDs in ${Date.now() - t1}ms (total ${Date.now() - t0}ms)`);
            // Apply cursor: keep only UIDs older than (less than) beforeUid
            if (beforeUid && beforeUid > 0) {
                uids = uids.filter((uid) => uid < beforeUid);
                diag("searchMessages", "cursor applied", `${uids.length} UIDs after beforeUid=${beforeUid} filter`);
            }
            if (uids.length === 0)
                return { messages: [] };
            const totalAfterCursor = uids.length;
            const recentUids = uids.slice(-fetchLimit);
            const messages = [];
            const t2 = Date.now();
            let envelopeCount = 0;
            for await (const msg of client.fetch(recentUids.map(String).join(","), {
                envelope: true,
                uid: true,
                bodyStructure: true,
                bodyParts: [{ key: "1", start: 0, maxLength: BODY_MAX_BYTES }],
            }, { uid: true })) {
                envelopeCount++;
                // Client-side TO filter (replaces IMAP SEARCH TO)
                if (clientSideTo && !matchesRecipient(msg.envelope ?? {}, clientSideTo))
                    continue;
                const fromAddr = msg.envelope?.from?.[0]?.address ?? "(unknown)";
                const subj = msg.envelope?.subject ?? "(no subject)";
                const date = msg.envelope?.date?.toISOString() ?? "(unknown date)";
                const messageId = msg.envelope?.messageId ?? null;
                const inReplyTo = msg.envelope?.inReplyTo ?? null;
                // Stop once we have enough matches
                if (messages.length >= limit)
                    break;
                const bodyPreview = extractBodyFromFetch(msg);
                const attachments = walkAttachmentMetadata(msg.bodyStructure);
                messages.push({
                    uid: msg.uid,
                    messageId,
                    inReplyTo,
                    from: fromAddr,
                    subject: subj,
                    date,
                    bodyPreview,
                    attachments,
                });
            }
            diag("searchMessages", "fetch done", `${envelopeCount} envelopes, ${messages.length} messages in ${Date.now() - t2}ms (total ${Date.now() - t0}ms)`);
            messages.reverse();
            const result = { messages };
            if (totalAfterCursor > fetchLimit && recentUids.length > 0) {
                const oldestFetchedUid = recentUids[0]; // UIDs are sorted ascending
                result.nextBeforeUid = oldestFetchedUid;
                diag("searchMessages", "pagination", `nextBeforeUid=${oldestFetchedUid}`);
            }
            else {
                diag("searchMessages", "pagination", "exhausted");
            }
            return result;
        }
        finally {
            lock.release();
        }
    }
    catch (err) {
        diag("searchMessages", "FAILED", `${err instanceof Error ? err.message : String(err)} after ${Date.now() - t0}ms`);
        throw err;
    }
    finally {
        await safeLogout(client);
    }
}
/**
 * Fetch emails from INBOX since a given UID (exclusive) for incremental polling.
 * Returns the UIDVALIDITY and all matching messages with UID > sinceUid.
 *
 * When sinceUid is 0 (first poll), fetches emails from the last 7 days
 * to avoid pulling the entire mailbox on large accounts.
 *
 * Client-side TO filtering is applied when recipientAddress differs
 * from the auth email (alias/catchall scenarios).
 */
export async function fetchSinceUid(config, password, sinceUid, recipientAddress) {
    const client = createClient(config, password);
    const t0 = Date.now();
    const isFirstPoll = sinceUid === 0;
    const filterTo = recipientAddress.toLowerCase() !== config.email.toLowerCase();
    diag("fetchSinceUid", "start", `sinceUid=${sinceUid} firstPoll=${isFirstPoll} filterTo=${filterTo ? recipientAddress : "none"}`);
    try {
        await client.connect();
        diag("fetchSinceUid", "connected", `${Date.now() - t0}ms`);
        const mailbox = await client.mailboxOpen("INBOX");
        const uidValidity = Number(mailbox.uidValidity);
        diag("fetchSinceUid", "mailbox open", `uidValidity=${uidValidity} exists=${mailbox.exists}`);
        if (mailbox.exists === 0) {
            return { uidValidity, emails: [], maxUid: 0 };
        }
        // Build search criteria
        const criteria = {};
        if (isFirstPoll) {
            // First poll: fetch last 7 days only
            const sinceDate = new Date();
            sinceDate.setDate(sinceDate.getDate() - DEFAULT_SINCE_DAYS);
            criteria.since = sinceDate;
        }
        else {
            // Incremental: UID > sinceUid
            criteria.uid = `${sinceUid + 1}:*`;
        }
        const t1 = Date.now();
        const searchResult = await client.search(criteria, { uid: true });
        const uids = searchResult || [];
        diag("fetchSinceUid", "search done", `${uids.length} UIDs in ${Date.now() - t1}ms`);
        if (uids.length === 0) {
            return { uidValidity, emails: [], maxUid: sinceUid };
        }
        const emails = [];
        let maxUid = sinceUid;
        const t2 = Date.now();
        const uidList = uids.map(String).join(",");
        for await (const msg of client.fetch(uidList, {
            envelope: true,
            uid: true,
            bodyStructure: true,
            bodyParts: [{ key: "1", start: 0, maxLength: BODY_MAX_BYTES }],
        }, { uid: true })) {
            // Track highest UID for high-water mark
            const uid = Number(msg.uid);
            if (uid > maxUid)
                maxUid = uid;
            // Client-side TO filter for alias/catchall accounts
            if (filterTo && !matchesRecipient(msg.envelope ?? {}, recipientAddress))
                continue;
            const fromAddr = msg.envelope?.from?.[0]?.address ?? "(unknown)";
            const fromName = msg.envelope?.from?.[0]?.name ?? null;
            const subject = msg.envelope?.subject ?? "(no subject)";
            const date = msg.envelope?.date?.toISOString() ?? new Date().toISOString();
            const messageId = msg.envelope?.messageId ?? null;
            const inReplyTo = msg.envelope?.inReplyTo ?? null;
            const toAddresses = (msg.envelope?.to ?? []).map((a) => a.address).filter((a) => !!a);
            const ccAddresses = (msg.envelope?.cc ?? []).map((a) => a.address).filter((a) => !!a);
            const bodyPreview = extractBodyFromFetch(msg);
            const attachments = walkAttachmentMetadata(msg.bodyStructure);
            emails.push({ uid, messageId, inReplyTo, toAddresses, ccAddresses, fromAddress: fromAddr, fromName, subject, date, bodyPreview, attachments });
        }
        diag("fetchSinceUid", "fetch done", `${emails.length} emails (maxUid=${maxUid}) in ${Date.now() - t2}ms (total ${Date.now() - t0}ms)`);
        return { uidValidity, emails, maxUid };
    }
    catch (err) {
        diag("fetchSinceUid", "FAILED", `${err instanceof Error ? err.message : String(err)} after ${Date.now() - t0}ms`);
        throw err;
    }
    finally {
        await safeLogout(client);
    }
}
/**
 * Resolve an envelope by Message-ID via IMAP. Searches INBOX first, then
 * the \Sent special-use folder. Returns null when no folder yields a hit.
 *
 * Uses `header: { "message-id": ... }` IMAP SEARCH — sufficient on every
 * server that honours RFC 3501 header search. The Message-ID is sent with
 * angle brackets restored because some servers (notably Gmail) only match
 * the raw header value, not the bare identifier.
 */
export async function fetchEnvelopeByMessageId(config, password, messageId) {
    const cleanId = messageId.replace(/^<|>$/g, "");
    const needle = `<${cleanId}>`;
    const client = createClient(config, password);
    const t0 = Date.now();
    diag("fetchEnvelopeByMessageId", "start", `messageId=${cleanId}`);
    try {
        await client.connect();
        diag("fetchEnvelopeByMessageId", "connected", `${Date.now() - t0}ms`);
        const folders = ["INBOX"];
        try {
            const sentPath = await resolveFolderName(client, "sent");
            folders.push(sentPath);
        }
        catch {
            // No \Sent folder advertised — only search INBOX.
        }
        for (const folderPath of folders) {
            const lock = await client.getMailboxLock(folderPath);
            try {
                const uids = await client.search({ header: { "message-id": needle } }, { uid: true });
                if (!uids || uids.length === 0)
                    continue;
                const uid = uids[0];
                for await (const msg of client.fetch(String(uid), { envelope: true, uid: true }, { uid: true })) {
                    const env = msg.envelope ?? {};
                    const fromAddr = env.from?.[0]?.address ?? "";
                    if (!fromAddr)
                        continue;
                    diag("fetchEnvelopeByMessageId", "match", `folder=${folderPath} uid=${msg.uid} in ${Date.now() - t0}ms`);
                    return {
                        uid: msg.uid,
                        mailbox: folderPath,
                        messageId: env.messageId ?? messageId,
                        fromAddress: fromAddr,
                        fromName: env.from?.[0]?.name ?? null,
                        toAddresses: (env.to ?? [])
                            .map((a) => a.address)
                            .filter((a) => !!a),
                        ccAddresses: (env.cc ?? [])
                            .map((a) => a.address)
                            .filter((a) => !!a),
                        subject: env.subject ?? "",
                        date: env.date?.toISOString() ?? "",
                    };
                }
            }
            finally {
                lock.release();
            }
        }
        diag("fetchEnvelopeByMessageId", "no-match", `searched=${folders.join(",")} in ${Date.now() - t0}ms`);
        return null;
    }
    catch (err) {
        diag("fetchEnvelopeByMessageId", "FAILED", `${err instanceof Error ? err.message : String(err)} after ${Date.now() - t0}ms`);
        throw err;
    }
    finally {
        await safeLogout(client);
    }
}
/**
 * Get the inbox message count.
 */
export async function getInboxCount(config, password) {
    const client = createClient(config, password);
    try {
        await client.connect();
        const mailbox = await client.mailboxOpen("INBOX");
        return mailbox.exists;
    }
    finally {
        await safeLogout(client);
    }
}
//# sourceMappingURL=imap.js.map