/**
 * Cron script: email-auto-respond
 *
 * Polls IMAP for new messages on EmailAccounts with autoRespond enabled.
 * For each new message, invokes the assigned public agent via the Anthropic
 * API and sends the reply via SMTP with threading headers.
 *
 * PID lock at $HOME/{configDir}/email-auto-respond.lock prevents overlapping runs.
 *
 *   PLATFORM_ROOT=... node dist/scripts/email-auto-respond.js
 *
 * ┌─────────────────────────────────────────────────────────────────┐
 * │ CRON (every 1 min)                                             │
 * │  \-> email-auto-respond.ts                                     │
 * │       ├── PID lock                                              │
 * │       ├── Neo4j: EmailAccounts with autoRespond=true            │
 * │       │   └── Skip if pollInterval hasn't elapsed               │
 * │       └── For each account:                                     │
 * │            ├── Read rate limit counters from Neo4j               │
 * │            │   └── Reset if clock hour/day boundary passed      │
 * │            ├── IMAP: fetch UID > lastRespondedUid               │
 * │            ├── For each new message:                            │
 * │            │   ├── Skip auto-replies (RFC 3834)                 │
 * │            │   ├── Skip own address                             │
 * │            │   ├── Skip duplicate sender (1 reply/sender/poll)  │
 * │            │   ├── Check hourly/daily cap → break if hit        │
 * │            │   │   └── Log + Task node (does NOT disable)       │
 * │            │   ├── Assemble system prompt (IDENTITY+SOUL+KNOW)  │
 * │            │   ├── Anthropic API → generate reply               │
 * │            │   ├── SMTP: send with In-Reply-To/References       │
 * │            │   ├── Increment send counters                      │
 * │            │   └── Update lastRespondedUid (per-email)          │
 * │            ├── Update lastPollAt                                │
 * │            └── Circuit breaker (3 fails → disable + Task node)  │
 * └─────────────────────────────────────────────────────────────────┘
 */
import { dispatchEmailToClaude, emailTurnTimeoutMs } from "../lib/claude-bridge.js";
import neo4j from "neo4j-driver";
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { fetchForAutoRespond } from "../lib/imap.js";
import { sendEmail } from "../lib/smtp.js";
import { readPassword } from "../lib/credentials.js";
import { storeOutboundEmail, stripAngleBrackets } from "../lib/graph.js";
import { writeNodeWithEdges } from "../../../../../lib/graph-write/dist/index.js";
/**
 * Look up the screening verdict for an email by its Message-ID.
 * Returns { found: false } if the Email node doesn't exist yet
 * (email-fetch hasn't processed it) or if messageId is null.
 */
async function lookupScreening(driver, messageId, accountId) {
    if (!messageId)
        return { found: false, screening: null, promptInjectionFlag: false };
    const normalised = stripAngleBrackets(messageId);
    if (!normalised)
        return { found: false, screening: null, promptInjectionFlag: false };
    const session = driver.session();
    try {
        const result = await session.run(`MATCH (e:Email {messageId: $messageId, accountId: $accountId})
       RETURN e.screening AS screening, e.promptInjectionFlag AS promptInjectionFlag
       LIMIT 1`, { messageId: normalised, accountId });
        if (result.records.length === 0) {
            return { found: false, screening: null, promptInjectionFlag: false };
        }
        const rec = result.records[0];
        return {
            found: true,
            screening: rec.get("screening") ?? null,
            promptInjectionFlag: rec.get("promptInjectionFlag") === true,
        };
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CIRCUIT_BREAKER_THRESHOLD = 3;
const MAX_EMAILS_PER_POLL = 10;
const DEFAULT_HOURLY_LIMIT = 20;
const DEFAULT_DAILY_LIMIT = 100;
// Brand-aware config directory — reads configDir from brand.json stamped at install time.
// No fallback: if brand.json is missing or incomplete, the platform wasn't properly installed.
function resolveConfigDir() {
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT environment variable is required");
    }
    const brandPath = resolve(platformRoot, "config", "brand.json");
    if (!existsSync(brandPath)) {
        throw new Error(`brand.json not found at ${brandPath} — platform not properly installed`);
    }
    try {
        const brand = JSON.parse(readFileSync(brandPath, "utf-8"));
        if (!brand.configDir) {
            throw new Error(`brand.json at ${brandPath} is missing the configDir field`);
        }
        return brand.configDir;
    }
    catch (err) {
        if (err instanceof SyntaxError) {
            throw new Error(`brand.json at ${brandPath} is not valid JSON: ${err.message}`);
        }
        throw err;
    }
}
const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
const LOCK_DIR = CONFIG_DIR;
const LOCK_FILE = resolve(LOCK_DIR, "email-auto-respond.lock");
const LOG_DIR = resolve(CONFIG_DIR, "logs");
const LOG_FILE = resolve(LOG_DIR, "email-auto-respond.log");
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function log(level, message) {
    const ts = new Date().toISOString();
    const line = `[${ts}] [${level}] ${message}\n`;
    mkdirSync(LOG_DIR, { recursive: true });
    appendFileSync(LOG_FILE, line);
    if (level === "error") {
        console.error(line.trim());
    }
}
// ---------------------------------------------------------------------------
// Lock file
// ---------------------------------------------------------------------------
function acquireLock() {
    mkdirSync(LOCK_DIR, { recursive: true });
    if (existsSync(LOCK_FILE)) {
        const storedPid = readFileSync(LOCK_FILE, "utf-8").trim();
        try {
            process.kill(Number(storedPid), 0);
            return false; // Process still running
        }
        catch {
            unlinkSync(LOCK_FILE); // Stale lock
        }
    }
    writeFileSync(LOCK_FILE, String(process.pid));
    return true;
}
function releaseLock() {
    try {
        if (existsSync(LOCK_FILE)) {
            const storedPid = readFileSync(LOCK_FILE, "utf-8").trim();
            if (storedPid === String(process.pid)) {
                unlinkSync(LOCK_FILE);
            }
        }
    }
    catch {
        // Best-effort cleanup
    }
}
// ---------------------------------------------------------------------------
// Neo4j connection (standalone)
// ---------------------------------------------------------------------------
function connectNeo4j() {
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT environment variable is required");
    }
    let password;
    if (process.env.NEO4J_PASSWORD) {
        password = process.env.NEO4J_PASSWORD;
    }
    else {
        const passwordFile = resolve(platformRoot, "config/.neo4j-password");
        try {
            password = readFileSync(passwordFile, "utf-8").trim();
        }
        catch {
            throw new Error(`Neo4j password not found at ${passwordFile}`);
        }
    }
    // NEO4J_URI must be explicit. Crontab installer prefixes
    // NEO4J_URI=bolt://localhost:${NEO4J_PORT} so the cron path is safe, but the
    // throw-on-unset catches manual runs that forget to source .env.
    const uri = process.env.NEO4J_URI;
    if (!uri) {
        throw new Error("[email/auto-respond] NEO4J_URI unset — refusing to default to bolt://localhost:7687");
    }
    const user = process.env.NEO4J_USER ?? "neo4j";
    console.error(`[email/auto-respond] resolved neo4j_uri=${uri}`);
    return neo4j.driver(uri, neo4j.auth.basic(user, password));
}
async function getAutoRespondAccounts(driver) {
    const session = driver.session();
    try {
        const result = await session.run(`MATCH (e:EmailAccount {autoRespond: true}) RETURN e`);
        return result.records.map((record) => {
            const props = record.get("e").properties;
            return {
                accountId: props.accountId,
                config: {
                    email: props.email,
                    agentAddress: props.agentAddress ?? props.email,
                    imapHost: props.imapHost,
                    imapPort: neo4j.integer.toNumber(props.imapPort),
                    imapSecurity: props.imapSecurity,
                    smtpHost: props.smtpHost,
                    smtpPort: neo4j.integer.toNumber(props.smtpPort),
                    smtpSecurity: props.smtpSecurity,
                },
                autoRespond: {
                    autoRespond: true,
                    respondingAgentSlug: props.respondingAgentSlug ?? null,
                    pollIntervalMinutes: props.pollIntervalMinutes
                        ? neo4j.integer.toNumber(props.pollIntervalMinutes)
                        : 5,
                    lastRespondedUid: props.lastRespondedUid
                        ? neo4j.integer.toNumber(props.lastRespondedUid)
                        : 0,
                    lastPollAt: props.lastPollAt ?? null,
                    consecutiveFailures: props.consecutiveFailures
                        ? neo4j.integer.toNumber(props.consecutiveFailures)
                        : 0,
                    autoRespondHourlyLimit: props.autoRespondHourlyLimit
                        ? neo4j.integer.toNumber(props.autoRespondHourlyLimit)
                        : DEFAULT_HOURLY_LIMIT,
                    autoRespondDailyLimit: props.autoRespondDailyLimit
                        ? neo4j.integer.toNumber(props.autoRespondDailyLimit)
                        : DEFAULT_DAILY_LIMIT,
                },
            };
        });
    }
    finally {
        await session.close();
    }
}
/**
 * Extract the bare email address from a From header. RFC 5322 allows
 * `"Display Name" <addr@example.com>` and `addr@example.com` forms; the
 * bridge keys sessions by the bare address only.
 */
function stripSenderAddress(from) {
    const m = from.match(/<([^>]+)>/);
    if (m)
        return m[1].trim().toLowerCase();
    return from.trim().toLowerCase();
}
/**
 * Build a threaded Re: subject line.
 * Adds "Re: " prefix only if not already present.
 */
function replySubject(originalSubject) {
    const trimmed = originalSubject.trim();
    if (/^re:\s/i.test(trimmed))
        return trimmed;
    return `Re: ${trimmed}`;
}
// ---------------------------------------------------------------------------
// Neo4j update helpers
// ---------------------------------------------------------------------------
async function updateLastRespondedUid(driver, accountId, uid) {
    const session = driver.session();
    try {
        await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       SET e.lastRespondedUid = $uid`, { accountId, uid: neo4j.int(uid) });
    }
    finally {
        await session.close();
    }
}
async function updateLastPollAt(driver, accountId) {
    const session = driver.session();
    try {
        await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       SET e.lastPollAt = $now, e.consecutiveFailures = 0`, { accountId, now: new Date().toISOString() });
    }
    finally {
        await session.close();
    }
}
async function incrementFailures(driver, accountId) {
    const session = driver.session();
    try {
        const result = await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       SET e.consecutiveFailures = COALESCE(e.consecutiveFailures, 0) + 1
       RETURN e.consecutiveFailures AS failures`, { accountId });
        return neo4j.integer.toNumber(result.records[0]?.get("failures") ?? 0);
    }
    finally {
        await session.close();
    }
}
async function disableAutoRespond(driver, accountId, reason) {
    const session = driver.session();
    try {
        await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       SET e.autoRespond = false`, { accountId });
    }
    finally {
        await session.close();
    }
    // Write doctrine: admin notification Task must
    // be created with at least one adjacency. The Task is semantically
    // about the EmailAccount it disabled — link AFFECTS → EmailAccount.
    // If the EmailAccount was deleted between circuit-breaker-trip and
    // this write, writeNodeWithEdges throws 'unresolved-target'; log and
    // continue — the cron loop must survive a missing target node.
    const taskSession = driver.session();
    try {
        const eaResult = await taskSession.run(`MATCH (ea:EmailAccount {accountId: $accountId}) RETURN elementId(ea) AS id LIMIT 1`, { accountId });
        if (eaResult.records.length === 0) {
            log("warn", `Account ${accountId}: EmailAccount not found — skipping admin Task creation for disabled auto-respond`);
            return;
        }
        await writeNodeWithEdges({
            session: taskSession,
            labels: ["Task"],
            props: {
                taskId: randomUUID(),
                accountId,
                title: "Email auto-respond disabled",
                description: `Auto-respond was automatically disabled after ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. Reason: ${reason}. Re-enable via "enable auto-respond" after resolving the issue.`,
                status: "open",
                priority: "high",
                createdAt: new Date().toISOString(),
                scope: "admin",
            },
            relationships: [
                {
                    type: "AFFECTS",
                    direction: "outgoing",
                    targetNodeId: eaResult.records[0].get("id"),
                },
            ],
            createdBy: { source: "email-auto-respond" },
        });
    }
    catch (err) {
        log("error", `Account ${accountId}: failed to create admin Task for disabled auto-respond — ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        await taskSession.close();
    }
}
/**
 * Compute the next UTC clock-hour boundary from a given time.
 * E.g., if now is 14:37 UTC, returns the ISO string for 15:00 UTC.
 */
function nextHourBoundary(now) {
    const next = new Date(now);
    next.setUTCMinutes(0, 0, 0);
    next.setUTCHours(next.getUTCHours() + 1);
    return next.toISOString();
}
/**
 * Compute the next UTC midnight boundary from a given time.
 */
function nextDayBoundary(now) {
    const next = new Date(now);
    next.setUTCHours(0, 0, 0, 0);
    next.setUTCDate(next.getUTCDate() + 1);
    return next.toISOString();
}
/**
 * Read rate limit counters from the EmailAccount node.
 * Returns defaults (0 counts, null boundaries) if not yet set.
 */
async function readRateLimitState(driver, accountId) {
    const session = driver.session();
    try {
        const result = await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       RETURN e.autoRespondSentThisHour AS sentThisHour,
              e.autoRespondSentToday AS sentToday,
              e.autoRespondHourResetAt AS hourResetAt,
              e.autoRespondDayResetAt AS dayResetAt`, { accountId });
        if (result.records.length === 0) {
            return { sentThisHour: 0, sentToday: 0, hourResetAt: null, dayResetAt: null };
        }
        const rec = result.records[0];
        return {
            sentThisHour: rec.get("sentThisHour") ? neo4j.integer.toNumber(rec.get("sentThisHour")) : 0,
            sentToday: rec.get("sentToday") ? neo4j.integer.toNumber(rec.get("sentToday")) : 0,
            hourResetAt: rec.get("hourResetAt") ?? null,
            dayResetAt: rec.get("dayResetAt") ?? null,
        };
    }
    finally {
        await session.close();
    }
}
/**
 * Reset counters if the clock hour or day boundary has passed.
 * Initialises boundaries on first run.
 * Returns the updated in-memory state (does NOT re-read from Neo4j).
 */
async function resetCountersIfNeeded(driver, accountId, state, now) {
    let hourReset = false;
    let dayReset = false;
    // First run — no boundaries set yet
    if (!state.hourResetAt) {
        state.hourResetAt = nextHourBoundary(now);
        state.sentThisHour = 0;
        hourReset = true;
    }
    else if (now >= new Date(state.hourResetAt)) {
        state.sentThisHour = 0;
        state.hourResetAt = nextHourBoundary(now);
        hourReset = true;
    }
    if (!state.dayResetAt) {
        state.dayResetAt = nextDayBoundary(now);
        state.sentToday = 0;
        dayReset = true;
    }
    else if (now >= new Date(state.dayResetAt)) {
        state.sentToday = 0;
        state.dayResetAt = nextDayBoundary(now);
        dayReset = true;
    }
    if (hourReset || dayReset) {
        const session = driver.session();
        try {
            await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
         SET e.autoRespondSentThisHour = $sentThisHour,
             e.autoRespondSentToday = $sentToday,
             e.autoRespondHourResetAt = $hourResetAt,
             e.autoRespondDayResetAt = $dayResetAt`, {
                accountId,
                sentThisHour: neo4j.int(state.sentThisHour),
                sentToday: neo4j.int(state.sentToday),
                hourResetAt: state.hourResetAt,
                dayResetAt: state.dayResetAt,
            });
        }
        catch (err) {
            log("error", `Account ${accountId}: failed to reset rate limit counters — ${err instanceof Error ? err.message : String(err)}`);
        }
        finally {
            await session.close();
        }
    }
    return state;
}
/**
 * Increment both hourly and daily send counters atomically.
 */
async function incrementSendCounters(driver, accountId) {
    const session = driver.session();
    try {
        await session.run(`MATCH (e:EmailAccount {accountId: $accountId})
       SET e.autoRespondSentThisHour = COALESCE(e.autoRespondSentThisHour, 0) + 1,
           e.autoRespondSentToday = COALESCE(e.autoRespondSentToday, 0) + 1`, { accountId });
    }
    catch (err) {
        log("error", `Account ${accountId}: failed to increment send counters — ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        await session.close();
    }
}
/**
 * Create an admin Task node to notify the admin that a rate limit was hit.
 * Dedup: skips creation if an open rate-limit Task already exists for this account.
 */
async function createRateLimitTask(driver, accountId, limitType, count, limit) {
    const session = driver.session();
    try {
        // Check for an existing open rate-limit Task to avoid duplicates across poll cycles
        const existing = await session.run(`MATCH (t:Task {accountId: $accountId, status: "open"})
       WHERE t.title STARTS WITH "Email auto-respond"
         AND t.title ENDS WITH "rate limit reached"
       RETURN t LIMIT 1`, { accountId });
        if (existing.records.length > 0) {
            log("info", `Account ${accountId}: rate limit Task already exists — skipping duplicate creation`);
            return;
        }
        // Write doctrine: AFFECTS → EmailAccount. The
        // dedup check above already ran; if EmailAccount is missing, log
        // and bail — cron must survive.
        const eaResult = await session.run(`MATCH (ea:EmailAccount {accountId: $accountId}) RETURN elementId(ea) AS id LIMIT 1`, { accountId });
        if (eaResult.records.length === 0) {
            log("warn", `Account ${accountId}: EmailAccount not found — skipping rate-limit Task creation`);
            return;
        }
        await writeNodeWithEdges({
            session,
            labels: ["Task"],
            props: {
                taskId: randomUUID(),
                accountId,
                title: `Email auto-respond ${limitType} rate limit reached`,
                description: `Auto-respond sent ${count} replies this ${limitType === "hourly" ? "hour" : "day"}, reaching the ${limitType} limit of ${limit}. Remaining emails in this poll cycle were skipped. The limit resets automatically at the next ${limitType === "hourly" ? "clock hour" : "midnight UTC"}. Adjust limits via "configure auto-respond" if needed.`,
                status: "open",
                priority: "medium",
                createdAt: new Date().toISOString(),
                scope: "admin",
            },
            relationships: [
                {
                    type: "AFFECTS",
                    direction: "outgoing",
                    targetNodeId: eaResult.records[0].get("id"),
                },
            ],
            createdBy: { source: "email-auto-respond" },
        });
    }
    catch (err) {
        log("error", `Account ${accountId}: failed to create rate limit Task node — ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Process a single email
// ---------------------------------------------------------------------------
async function processEmail(msg, emailConfig, emailPassword, driver, accountId) {
    // Build the dispatch payload from the email. Subject/From/Date are
    // prefixed so claude has the threading context inside the PTY turn.
    const userMessage = [
        `From: ${msg.from}`,
        `Subject: ${msg.subject}`,
        `Date: ${msg.date}`,
        "",
        msg.body,
    ].join("\n");
    // Email dispatches through the shared PTY-bridge pattern
    // (out-of-process variant that queries the manager's senderId index
    // because cron runs as a separate Node process). The per-sender PTY
    // persists across cron invocations, so a multi-email thread continues
    // a single claude session keyed by the sender's email address.
    const senderEmail = stripSenderAddress(msg.from);
    const dispatchResult = await dispatchEmailToClaude({
        accountId,
        senderEmail,
        text: `<incoming-email>\n${userMessage}\n</incoming-email>`,
        timeoutMs: emailTurnTimeoutMs(),
    });
    if ("error" in dispatchResult) {
        log("warn", `Auto-respond unavailable for UID ${msg.uid} from ${msg.from}: dispatch ${dispatchResult.error}`);
        return;
    }
    const replyText = dispatchResult.turnText;
    if (!replyText.trim()) {
        log("warn", `Empty response from agent for email UID ${msg.uid} from ${msg.from}`);
        return;
    }
    // Build threading headers
    const inReplyTo = msg.messageId || undefined;
    const references = [msg.references, msg.messageId].filter(Boolean).join(" ") || undefined;
    // Append AI disclosure footer (EU AI Act Article 50 — proactive identification)
    const replyBody = replyText + "\n\n---\nThis reply was generated by an AI assistant.";
    // Send reply
    const subject = replySubject(msg.subject);
    const { messageId: outboundMessageId } = await sendEmail(emailConfig, emailPassword, {
        to: msg.from,
        subject,
        body: replyBody,
        inReplyTo,
        references,
        headers: {
            "Auto-Submitted": "auto-replied",
            "X-AI-Generated": "true",
        },
    });
    log("info", `Replied to UID ${msg.uid} from ${msg.from} — subject: ${msg.subject}`);
    // Store outbound reply as an Email node for audit trail
    if (msg.messageId) {
        try {
            // Look up the original email's emailId by messageId
            const normalisedOrigMsgId = stripAngleBrackets(msg.messageId);
            const lookupSession = driver.session();
            let originalEmailId = null;
            try {
                const lookupResult = await lookupSession.run(`MATCH (e:Email {messageId: $messageId, accountId: $accountId})
           RETURN e.emailId AS emailId LIMIT 1`, { messageId: normalisedOrigMsgId, accountId });
                originalEmailId = lookupResult.records[0]?.get("emailId");
            }
            finally {
                await lookupSession.close();
            }
            if (originalEmailId) {
                const result = await storeOutboundEmail({
                    messageId: outboundMessageId,
                    inReplyTo: msg.messageId,
                    toAddresses: [msg.from],
                    fromAddress: emailConfig.agentAddress,
                    subject,
                    bodyPreview: replyBody,
                    accountId,
                    originalEmailId,
                }, driver);
                log("info", `Stored outbound Email node ${result.emailId} with REPLY_TO edge`);
            }
            else {
                log("warn", `Original email not found in graph for messageId=${msg.messageId} — outbound reply not stored`);
            }
        }
        catch (err) {
            log("error", `Failed to store outbound Email node: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
}
// ---------------------------------------------------------------------------
// Process a single account
// ---------------------------------------------------------------------------
async function processAccount(account, driver) {
    const { accountId, config: emailConfig, autoRespond: arConfig } = account;
    // Check poll interval
    if (arConfig.lastPollAt) {
        const elapsed = Date.now() - new Date(arConfig.lastPollAt).getTime();
        const intervalMs = arConfig.pollIntervalMinutes * 60_000;
        if (elapsed < intervalMs) {
            return; // Not time to poll yet
        }
    }
    // Replies are produced by a per-sender claude PTY session
    // spawned via the loopback claude-session-manager. The PTY-bridge
    // (../lib/claude-bridge.ts) handles role=public + tool-surface gating
    //, so the legacy per-account agentSlug / agentModel /
    // assembleSystemPrompt path is obsolete: claude's binary loads its own
    // identity. The autoRespond flag on the EmailAccount node is the sole
    // on/off knob.
    // Read email password
    let emailPassword;
    try {
        emailPassword = readPassword();
    }
    catch (err) {
        log("error", `Account ${accountId}: email password not available — ${err instanceof Error ? err.message : String(err)}`);
        const failures = await incrementFailures(driver, accountId);
        if (failures >= CIRCUIT_BREAKER_THRESHOLD) {
            await disableAutoRespond(driver, accountId, "Email password file missing");
        }
        return;
    }
    // Fetch new messages via IMAP
    let messages;
    try {
        messages = await fetchForAutoRespond(emailConfig, emailPassword, {
            sinceUid: arConfig.lastRespondedUid,
            agentAddress: emailConfig.agentAddress,
            limit: MAX_EMAILS_PER_POLL,
        });
    }
    catch (err) {
        log("error", `Account ${accountId}: IMAP fetch failed — ${err instanceof Error ? err.message : String(err)}`);
        const failures = await incrementFailures(driver, accountId);
        if (failures >= CIRCUIT_BREAKER_THRESHOLD) {
            await disableAutoRespond(driver, accountId, `IMAP fetch failed: ${err instanceof Error ? err.message : String(err)}`);
            log("error", `Account ${accountId}: auto-respond DISABLED after ${failures} failures`);
        }
        return;
    }
    log("info", `Account ${accountId}: fetched ${messages.length} new messages`);
    if (messages.length === 0) {
        await updateLastPollAt(driver, accountId);
        return;
    }
    // ── Rate limit state ──────────────────────────────────────────────
    const hourlyLimit = arConfig.autoRespondHourlyLimit;
    const dailyLimit = arConfig.autoRespondDailyLimit;
    const now = new Date();
    let rateLimitState;
    try {
        rateLimitState = await readRateLimitState(driver, accountId);
        rateLimitState = await resetCountersIfNeeded(driver, accountId, rateLimitState, now);
    }
    catch (err) {
        // Fail-open: if we can't read counters, proceed without rate limiting.
        // Bounded by MAX_EMAILS_PER_POLL (10) per poll cycle.
        log("error", `Account ${accountId}: failed to read rate limit state — ${err instanceof Error ? err.message : String(err)}. Proceeding without rate limiting.`);
        rateLimitState = { sentThisHour: 0, sentToday: 0, hourResetAt: null, dayResetAt: null };
    }
    // ── Sender dedup (one reply per sender per poll cycle) ────────────
    const repliedSenders = new Set();
    // ── Process each message sequentially ─────────────────────────────
    for (const msg of messages) {
        // Skip auto-replies (loop prevention)
        if (msg.isAutoReply) {
            log("info", `Skipping auto-reply from ${msg.from} (UID ${msg.uid})`);
            await updateLastRespondedUid(driver, accountId, msg.uid);
            continue;
        }
        // Skip emails from the agent's own address (loop prevention)
        if (msg.from.toLowerCase() === emailConfig.agentAddress.toLowerCase()) {
            log("info", `Skipping own email from ${msg.from} (UID ${msg.uid})`);
            await updateLastRespondedUid(driver, accountId, msg.uid);
            continue;
        }
        // Skip emails with no extractable body
        if (!msg.body || msg.body === "(body unavailable)") {
            log("warn", `Skipping email with no body from ${msg.from} (UID ${msg.uid})`);
            await updateLastRespondedUid(driver, accountId, msg.uid);
            continue;
        }
        // Sender dedup — skip if we already replied to this sender in this poll cycle
        const senderKey = msg.from.toLowerCase();
        if (repliedSenders.has(senderKey)) {
            log("info", `Skipping duplicate sender ${msg.from} (UID ${msg.uid}) — already replied this poll cycle`);
            await updateLastRespondedUid(driver, accountId, msg.uid);
            continue;
        }
        // Screening gate — check the Email node's classification in Neo4j
        try {
            const check = await lookupScreening(driver, msg.messageId, accountId);
            if (!check.found) {
                // Email not yet in Neo4j — email-fetch hasn't classified it.
                // Skip without advancing UID. If later emails in this batch advance
                // the UID past this one, this email won't be retried — but it will
                // still be stored by email-fetch with its screening verdict. The
                // security posture (no reply without screening) takes precedence
                // over reply completeness.
                log("info", `Skipping UID ${msg.uid} from ${msg.from} — Email node not yet classified (email-fetch pending)`);
                continue;
            }
            if (check.promptInjectionFlag) {
                log("warn", `Skipping UID ${msg.uid} from ${msg.from} — prompt injection flag set`);
                await updateLastRespondedUid(driver, accountId, msg.uid);
                continue;
            }
            if (check.screening !== "clean") {
                log("info", `Skipping UID ${msg.uid} from ${msg.from} — screening=${check.screening ?? "absent"}`);
                await updateLastRespondedUid(driver, accountId, msg.uid);
                continue;
            }
        }
        catch (err) {
            log("error", `Account ${accountId}: screening lookup failed for UID ${msg.uid} — ${err instanceof Error ? err.message : String(err)}`);
            const failures = await incrementFailures(driver, accountId);
            if (failures >= CIRCUIT_BREAKER_THRESHOLD) {
                await disableAutoRespond(driver, accountId, `Screening lookup failed: ${err instanceof Error ? err.message : String(err)}`);
                log("error", `Account ${accountId}: auto-respond DISABLED after ${failures} failures (screening lookup)`);
                return;
            }
            continue;
        }
        // Rate limit check — hourly
        if (rateLimitState.sentThisHour >= hourlyLimit) {
            log("warn", `Account ${accountId}: hourly rate limit reached (${rateLimitState.sentThisHour}/${hourlyLimit}). Skipping remaining emails.`);
            await createRateLimitTask(driver, accountId, "hourly", rateLimitState.sentThisHour, hourlyLimit);
            break;
        }
        // Rate limit check — daily
        if (rateLimitState.sentToday >= dailyLimit) {
            log("warn", `Account ${accountId}: daily rate limit reached (${rateLimitState.sentToday}/${dailyLimit}). Skipping remaining emails.`);
            await createRateLimitTask(driver, accountId, "daily", rateLimitState.sentToday, dailyLimit);
            break;
        }
        try {
            await processEmail(msg, emailConfig, emailPassword, driver, accountId);
            await updateLastRespondedUid(driver, accountId, msg.uid);
            await incrementSendCounters(driver, accountId);
            rateLimitState.sentThisHour++;
            rateLimitState.sentToday++;
            repliedSenders.add(senderKey);
            // Reset failure counter on success
            await updateLastPollAt(driver, accountId);
        }
        catch (err) {
            log("error", `Account ${accountId}: failed to process email UID ${msg.uid} from ${msg.from} — ${err instanceof Error ? err.message : String(err)}`);
            const failures = await incrementFailures(driver, accountId);
            if (failures >= CIRCUIT_BREAKER_THRESHOLD) {
                await disableAutoRespond(driver, accountId, `Failed to process email from ${msg.from}: ${err instanceof Error ? err.message : String(err)}`);
                log("error", `Account ${accountId}: auto-respond DISABLED after ${failures} failures`);
                return; // Stop processing this account
            }
            // Continue to next message — the UID is NOT advanced so this email will be retried
            continue;
        }
    }
    // Final poll timestamp update
    await updateLastPollAt(driver, accountId);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
    if (!acquireLock()) {
        return; // Another instance is running
    }
    let driver = null;
    try {
        driver = connectNeo4j();
        const accounts = await getAutoRespondAccounts(driver);
        if (accounts.length === 0) {
            releaseLock();
            return; // No auto-respond accounts — silent exit
        }
        log("info", `Processing ${accounts.length} auto-respond account(s)`);
        for (const account of accounts) {
            try {
                await processAccount(account, driver);
            }
            catch (err) {
                log("error", `Unexpected error processing account ${account.accountId}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    catch (err) {
        log("error", `Fatal error: ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        if (driver) {
            try {
                await driver.close();
            }
            catch { /* best effort */ }
        }
        releaseLock();
    }
}
main().catch((err) => {
    log("error", `Unhandled error: ${err instanceof Error ? err.message : String(err)}`);
    releaseLock();
    process.exit(1);
});
//# sourceMappingURL=email-auto-respond.js.map