/**
 * File-per-message body storage backend.
 *
 * Disk layout: {basePath}/{accountId}/<xx>/<uuid>.eml
 * Filename is an opaque UUID. Two-char prefix dir for filesystem fan-out.
 *
 * CRITICAL: the on-disk filename carries NO semantic meaning — not folder
 * id, not UID, not Message-ID. A new UUID is minted on every `putMessage`.
 * Moves, UID renumbers, UIDVALIDITY bumps cannot shadow a body because no
 * filename is ever reused. DB's `body_path` column is the sole authority
 * on where a given message's body lives.
 */
import * as fs from "node:fs";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
export class FileMessageStore {
    basePath;
    constructor(basePath) {
        this.basePath = basePath;
        fs.mkdirSync(basePath, { recursive: true });
    }
    /** Fresh opaque path per call, returned RELATIVE to `basePath`. The DB
     *  stores this relative form so the body store can be relocated (or
     *  the user folder renamed — e.g. `.mailx → .rmfmail`) without the
     *  body_path entries breaking. */
    newRelativePath(accountId) {
        const uuid = randomUUID().replace(/-/g, "");
        const prefix = uuid.slice(0, 2);
        return path.join(accountId, prefix, `${uuid}.eml`);
    }
    /** Resolve a stored path. Accepts either a relative path (post-2026-05
     *  format) or an absolute path (legacy entries until next-launch
     *  migration rewrites them). Either way, refuse anything outside the
     *  store as a directory-traversal guard. */
    resolveStored(p) {
        if (!p)
            return "";
        const abs = path.isAbsolute(p) ? p : path.resolve(this.basePath, p);
        const rel = path.relative(path.resolve(this.basePath), abs);
        if (rel.startsWith("..") || path.isAbsolute(rel))
            return "";
        return abs;
    }
    /** Write a new body. Returns a path RELATIVE to basePath; caller stores
     *  it in `body_path`. The (folderId, uid) args are kept for interface
     *  compatibility; they do NOT affect the filename.
     *
     *  Defensive content check: if the FIRST line of the would-be .eml
     *  matches an IMAP client-command pattern (`<tag> <UPPERCASE_CMD> ...`),
     *  the upstream IMAP parser desynced and wrote one of mailx's own
     *  outbound commands into the response stream — see the
     *  a39a84e1853441bcac09e1b3e1463e8d.eml corruption (2026-05-23). Refuse
     *  the write rather than persist garbage that the message-viewer will
     *  later render as broken. The caller's exception path treats this the
     *  same as any other transient fetch error; the reconciler re-enqueues. */
    async putMessage(accountId, _folderId, _uid, raw) {
        if (raw.length >= 6) {
            // Look at the first ≤512 bytes to spot a leaked IMAP command.
            // Pattern: optional CRLFs, then `<tag> <CMD> ...` where tag
            // starts with a letter+digits (iflow tags are like `A123`,
            // `B47`) and CMD is all-caps with at least 4 chars.
            const sniff = raw.subarray(0, Math.min(512, raw.length)).toString("ascii");
            const firstNonBlank = sniff.replace(/^\s+/, "");
            // Strict: tag is letter + digits, then a space, then an
            // all-caps IMAP command keyword. Real RFC822 headers can't
            // match because they require a `Name: value` shape (colon),
            // and `Return-Path:`, `Received:`, `Subject:` etc. are
            // capitalized but include a colon and mixed case in tokens.
            if (/^[A-Z]+\d+ (UID )?(FETCH|SEARCH|STORE|COPY|MOVE|EXPUNGE|SELECT|EXAMINE|APPEND|LIST|LSUB|STATUS|NOOP|IDLE|DONE|LOGIN|LOGOUT|CAPABILITY|AUTHENTICATE)\b/.test(firstNonBlank)) {
                throw new Error(`putMessage: refusing to write corrupt body — IMAP command leaked into response stream (first line: ${firstNonBlank.split(/\r?\n/)[0].substring(0, 100)})`);
            }
        }
        const rel = this.newRelativePath(accountId);
        const abs = path.join(this.basePath, rel);
        fs.mkdirSync(path.dirname(abs), { recursive: true });
        fs.writeFileSync(abs, raw);
        return rel;
    }
    /** Resolve a stored body_path (relative or absolute) to an absolute
     *  filesystem path. Returns "" if the input doesn't resolve to a file
     *  inside the store. UI / "Source" actions need the absolute path so
     *  they can open / pass it to OS file pickers without re-introducing
     *  the basePath context every time. */
    absolutePath(stored) {
        return this.resolveStored(stored);
    }
    /** Read by stored path (relative or absolute). */
    async readByPath(stored) {
        const abs = this.resolveStored(stored);
        if (!abs)
            throw new Error(`refusing to read outside store: ${stored}`);
        return fs.readFileSync(abs);
    }
    async hasByPath(stored) {
        const abs = this.resolveStored(stored);
        return !!abs && fs.existsSync(abs);
    }
    async unlinkByPath(stored) {
        const abs = this.resolveStored(stored);
        if (!abs)
            return;
        if (fs.existsSync(abs))
            fs.unlinkSync(abs);
    }
    /** One-shot migration: rewrite absolute body_path values that point at
     *  this store's basePath into relative form. Called once from db.ts on
     *  startup (gated by a kv flag); silently skipped after first run.
     *  Returns the count of rewritten paths.
     *
     *  Bodies whose absolute path is OUTSIDE basePath (the legacy
     *  `~/.mailx/mailxstore/...` entries on a machine where basePath is
     *  now `~/.rmfmail/mailxstore`) are left alone — the migration in
     *  mailx-settings already moves the files; this rewrite is purely
     *  about getting body_path to a relative shape. */
    rewriteAbsoluteToRelative(rows, update) {
        let n = 0;
        const baseAbs = path.resolve(this.basePath);
        for (const r of rows) {
            if (!r.body_path || !path.isAbsolute(r.body_path))
                continue;
            const rel = path.relative(baseAbs, path.resolve(r.body_path));
            if (rel.startsWith("..") || path.isAbsolute(rel)) {
                // Outside this store — try mapping `.mailx → .rmfmail` if the
                // legacy dir was the source. Then re-test.
                const remapped = r.body_path.replace(/[\\/]\.mailx[\\/]mailxstore[\\/]/i, `${path.sep}.rmfmail${path.sep}mailxstore${path.sep}`);
                if (remapped !== r.body_path) {
                    const rel2 = path.relative(baseAbs, path.resolve(remapped));
                    if (!rel2.startsWith("..") && !path.isAbsolute(rel2)) {
                        update(r.id, rel2.replace(/\\/g, "/"));
                        n++;
                    }
                }
                continue;
            }
            update(r.id, rel.replace(/\\/g, "/"));
            n++;
        }
        return n;
    }
    // MessageStore interface compatibility (unused once all callers migrate to
    // path-based reads). These used to compose {folderId}/{uid}.eml and they
    // would resurrect the comingling bug if restored. Kept as throwing stubs
    // so any accidental caller surfaces loudly rather than silently misbehave.
    getMessagePath(_accountId, _folderId, _uid) {
        throw new Error("FileMessageStore.getMessagePath is retired — read body_path from DB");
    }
    async getMessage(_accountId, _folderId, _uid) {
        throw new Error("FileMessageStore.getMessage(folder,uid) is retired — use readByPath(body_path)");
    }
    async hasMessage(_accountId, _folderId, _uid) {
        throw new Error("FileMessageStore.hasMessage(folder,uid) is retired — use hasByPath(body_path)");
    }
    async deleteMessage(_accountId, _folderId, _uid) {
        throw new Error("FileMessageStore.deleteMessage(folder,uid) is retired — use unlinkByPath(body_path)");
    }
}
//# sourceMappingURL=file-store.js.map