/ src / models / MailCloneModel.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\models;

use seekquarry\yioop\configs as C;

/**
 * Persists MailSite clone jobs and their per-message dedup state.
 * Backs the IMAP-clone-to-local feature (item 48 follow-up, v10
 * todo): a Yioop user submits a clone of one of their configured
 * IMAP accounts into their MailSite mailbox; the MediaUpdater-
 * driven MailCloneJob picks up the row and walks the source
 * IMAP server folder by folder, message by message, calling
 * appendMessage on the destination FileMailStorage. State that
 * needs to survive between MediaUpdater ticks lives in
 * MAIL_CLONE_JOB; the per-message imported set that prevents
 * double-import on resume / re-run lives in MAIL_CLONE_SEEN.
 *
 * Two tables managed here:
 *
 *   MAIL_CLONE_JOB
 *     One row per clone submission. STATUS transitions: pending
 *     -> running -> done (or failed or cancelled). FOLDERS_DONE
 *     is a JSON-encoded list of folder names already fully
 *     drained; CURRENT_FOLDER + CURRENT_UID together form the
 *     resume cursor inside the current folder, and the two
 *     CURRENT_UID_PARTIAL_* columns extend the cursor across
 *     ticks for very large messages (>2 MB) that ratchet through
 *     in BODY.PEEK[<offset.size>] chunks instead of fetching
 *     whole.
 *
 *   MAIL_CLONE_SEEN
 *     Composite key (JOB_ID, SRC_UIDVALIDITY, SRC_UID). Records
 *     every message the job has appended to the destination
 *     successfully. The job consults this set before fetching
 *     each source UID; presence means skip. UIDVALIDITY plus
 *     UID is the RFC 3501 stable identifier (unchanged across
 *     IMAP sessions unless the server rebuilds the mailbox),
 *     which makes the dedup key cheap and exact. Rows are
 *     garbage-collected once the parent job's row has been in
 *     STATUS='done' for more than 30 days.
 *
 * Access control: every public method takes $user_id and
 * refuses to surface rows belonging to a different user. The
 * one exception is listClaimable(), which crosses the user
 * boundary because the MediaUpdater process needs to enumerate
 * runnable jobs across all users; only daemon code is expected
 * to call it, never a request handler.
 *
 * @author Chris Pollett
 */
class MailCloneModel extends Model
{
    /**
     * Inserts a fresh pending clone-job row and returns its
     * generated ID. Callers (the userMailStartClone handler)
     * have already validated the source-account ownership and
     * destination-user match; this method does not re-check.
     * STARTED_AT and UPDATED_AT are stamped to the current
     * wall-clock time so the MediaUpdater's claim ordering sees
     * the row immediately. FOLDERS_DONE is initialized to the
     * empty JSON array '[]' so the job loop's
     * !in_array(folder, decode(FOLDERS_DONE)) test is well-
     * defined on the very first folder.
     *
     * @param int $user_id Yioop USER_ID submitting the clone
     * @param int $src_account MAIL_ACCOUNT.ID of the IMAP source
     * @param string $destination_user MailSite local-part to
     *      write into (typically the user's own Yioop username)
     * @param string $mode 'add' (preserve existing destination
     *      mail) or 'wipe' (purge destination first)
     * @param bool $dry_run true to simulate without actually
     *      writing to the destination FileMailStorage
     * @param int $folder_cap newest-N-per-folder limit, or 0 for
     *      no limit (clone the entire folder)
     * @return int the new MAIL_CLONE_JOB.ID. The PdoManager's
     *      insertID() returns a string (PDO::lastInsertId
     *      always returns a string per upstream contract); this
     *      method casts to int so callers can compare with
     *      strict-mode in_array against int-cast row IDs.
     */
    public function insertJob($user_id, $src_account,
        $destination_user, $mode, $dry_run = false,
        $folder_cap = 0)
    {
        $now = time();
        $sql = "INSERT INTO MAIL_CLONE_JOB " .
            "(USER_ID, SRC_ACCOUNT, DESTINATION_USER, MODE, " .
            "STATUS, DRY_RUN, FOLDERS_DONE, CURRENT_FOLDER, " .
            "CURRENT_UID, CURRENT_UID_PARTIAL_OFFSET, " .
            "CURRENT_UID_PARTIAL_SIZE, IMPORTED, SKIPPED, " .
            "FAILED, FOLDER_CAP, STARTED_AT, UPDATED_AT) " .
            "VALUES (?, ?, ?, ?, 'pending', ?, '[]', '', 0, 0, " .
            "0, 0, 0, 0, ?, ?, ?)";
        $this->db->execute($sql, [$user_id, $src_account,
            $destination_user, $mode, $dry_run ? 1 : 0,
            (int) $folder_cap, $now, $now]);
        return (int) $this->db->insertID('MAIL_CLONE_JOB');
    }
    /**
     * Returns the most recent pending or running clone job for
     * a user, or null when none exists. Used both by the UI to
     * render the per-user "clone in progress" status banner and
     * by the start-clone handler to enforce the single-job-per-
     * user limit. Sorted by STARTED_AT descending so a brand-new
     * pending row wins if the MediaUpdater has not picked it up
     * yet.
     *
     * @param int $user_id Yioop USER_ID to look up for
     * @return array|null the row as an associative array, or
     *      null when the user has no in-flight clone job
     */
    public function activeJobForUser($user_id)
    {
        $sql = "SELECT * FROM MAIL_CLONE_JOB " .
            "WHERE USER_ID = ? AND STATUS IN ('pending', " .
            "'running') ORDER BY STARTED_AT DESC " .
            $this->db->limitOffset(1);
        $result = $this->db->execute($sql, [$user_id]);
        if ($result === false) {
            return null;
        }
        $row = $this->db->fetchArray($result);
        return ($row === false || empty($row)) ? null : $row;
    }
    /**
     * Returns the user's most recent clone job when (and only
     * when) that latest job is in the 'failed' state, otherwise
     * null. Drives the retry banner: a failed clone would
     * otherwise vanish from the UI (activeJobForUser surfaces only
     * pending/running jobs, deliberately, so the single-job limit
     * and the sidebar cloning badge stay scoped to live work).
     * Keying on the single most-recent row means starting a fresh
     * clone after a failure -- which inserts a newer pending row --
     * immediately stops surfacing the old failed one, and a job
     * that later succeeded or was cancelled never shows here.
     *
     * @param int $user_id Yioop USER_ID to look up for
     * @return array|null the failed job row, or null
     */
    public function recentFailedJobForUser($user_id)
    {
        $sql = "SELECT * FROM MAIL_CLONE_JOB WHERE USER_ID = ? " .
            "ORDER BY STARTED_AT DESC " . $this->db->limitOffset(1);
        $result = $this->db->execute($sql, [$user_id]);
        if ($result === false) {
            return null;
        }
        $row = $this->db->fetchArray($result);
        if ($row === false || empty($row) ||
                $row['STATUS'] !== 'failed') {
            return null;
        }
        return $row;
    }
    /**
     * Returns the row for a specific clone-job id, scoped to the
     * supplied user. Returns null if the row does not exist or
     * belongs to a different user; the access-control check is
     * part of the SELECT predicate, not a separate compare, so
     * the caller cannot accidentally leak rows by forgetting to
     * verify ownership after fetch.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to fetch
     * @param int $user_id Yioop USER_ID that should own the row
     * @return array|null the row as an associative array, or
     *      null when not found or not owned by the user
     */
    public function getJob($job_id, $user_id)
    {
        $sql = "SELECT * FROM MAIL_CLONE_JOB " .
            "WHERE ID = ? AND USER_ID = ? " .
            $this->db->limitOffset(1);
        $result = $this->db->execute($sql, [$job_id, $user_id]);
        if ($result === false) {
            return null;
        }
        $row = $this->db->fetchArray($result);
        return ($row === false || empty($row)) ? null : $row;
    }
    /**
     * Returns the rows the MediaUpdater can pick up on the next
     * tick: every job whose STATUS is 'pending' or 'running',
     * across all users. Daemon-only entrypoint (the MailCloneJob
     * tick loop); never call from a request handler because the
     * result crosses the user-scope boundary on purpose. Order
     * is STARTED_AT ascending so an older pending job is
     * preferred over a newer one when only one tick of work
     * fits.
     *
     * @return array list of clone-job rows; empty when no jobs
     *      are eligible
     */
    public function listClaimable()
    {
        $sql = "SELECT * FROM MAIL_CLONE_JOB " .
            "WHERE STATUS = ? OR STATUS = ? " .
            "ORDER BY STARTED_AT ASC";
        $result = $this->db->execute($sql,
            ['pending', 'running']);
        if ($result === false) {
            return [];
        }
        $rows = [];
        while ($row = $this->db->fetchArray($result)) {
            $rows[] = $row;
        }
        return $rows;
    }
    /**
     * Writes a partial-progress update to an in-flight job. The
     * caller is the MailCloneJob tick code, which has already
     * decided the next cursor position based on what was just
     * processed; this method just stamps it into the row plus
     * the running counters and UPDATED_AT. Passing null for any
     * counter leaves it unchanged (so a partial-message progress
     * tick can advance the cursor without bumping IMPORTED).
     *
     * @param int $job_id MAIL_CLONE_JOB.ID being updated
     * @param array $fields associative array of column => value
     *      pairs to write; keys must come from the allow-list
     *      ALLOWED_PROGRESS_FIELDS (this method silently drops
     *      anything else, so the caller cannot accidentally
     *      mutate USER_ID, SRC_ACCOUNT, STATUS, or MODE)
     * @return bool whether the UPDATE ran
     */
    public function updateProgress($job_id, $fields)
    {
        $allowed = ['CURRENT_FOLDER', 'CURRENT_UID',
            'CURRENT_UID_PARTIAL_OFFSET',
            'CURRENT_UID_PARTIAL_SIZE', 'IMPORTED', 'SKIPPED',
            'FAILED', 'FOLDERS_DONE'];
        $assignments = [];
        $values = [];
        foreach ($fields as $key => $value) {
            if (!in_array($key, $allowed, true)) {
                continue;
            }
            $assignments[] = "$key = ?";
            $values[] = $value;
        }
        if (empty($assignments)) {
            return false;
        }
        $assignments[] = "UPDATED_AT = ?";
        $values[] = time();
        $values[] = $job_id;
        $sql = "UPDATE MAIL_CLONE_JOB SET " .
            implode(", ", $assignments) . " WHERE ID = ?";
        return $this->db->execute($sql, $values) !== false;
    }
    /**
     * Transitions a job's STATUS column to one of the terminal
     * or in-flight values: pending, running, done, failed,
     * cancelled. Bumps UPDATED_AT in the same statement.
     * Separate from updateProgress so the STATUS column is not
     * in the progress-fields allow-list -- callers have to
     * decide explicitly to change run-state, not slip it in
     * with a counter bump.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID being transitioned
     * @param string $status new STATUS value
     * @return bool whether the UPDATE ran
     */
    public function setStatus($job_id, $status)
    {
        $sql = "UPDATE MAIL_CLONE_JOB SET STATUS = ?, " .
            "UPDATED_AT = ? WHERE ID = ?";
        return $this->db->execute($sql,
            [$status, time(), $job_id]) !== false;
    }
    /**
     * Cancels a user's job by id. STATUS goes to 'cancelled' so
     * the MailCloneJob tick loop drops it on next entry. The
     * user-scope predicate in the UPDATE means a cancel cannot
     * affect any other user's job by id-guess. Returns true
     * even when zero rows match (e.g. the job already finished
     * or never existed for this user) -- the caller's intent
     * was "make sure this job is not running for me", which the
     * post-condition satisfies either way.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to cancel
     * @param int $user_id Yioop USER_ID that should own the row
     * @return bool whether the UPDATE ran (not whether it
     *      matched a row)
     */
    public function cancelJob($job_id, $user_id)
    {
        $sql = "UPDATE MAIL_CLONE_JOB SET STATUS = 'cancelled', " .
            "UPDATED_AT = ? WHERE ID = ? AND USER_ID = ? AND " .
            "STATUS IN ('pending', 'running')";
        return $this->db->execute($sql,
            [time(), $job_id, $user_id]) !== false;
    }
    /**
     * Requeues a failed clone job so the MediaUpdater picks it up
     * again. Flips STATUS from 'failed' back to 'pending'; the job
     * resumes from its checkpointed progress (CURRENT_FOLDER /
     * CURRENT_UID / FOLDERS_DONE) and skips already-imported
     * messages via MAIL_CLONE_SEEN, so a retry costs only the work
     * not yet done. Scoped to the owning user and gated on the
     * current status being 'failed' so it cannot disturb a job
     * that is pending, running, done, or cancelled.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to requeue
     * @param int $user_id owning USER_ID, for authorization
     * @return bool whether the requeue UPDATE ran
     */
    public function retryJob($job_id, $user_id)
    {
        $sql = "UPDATE MAIL_CLONE_JOB SET STATUS = 'pending', " .
            "UPDATED_AT = ? WHERE ID = ? AND USER_ID = ? AND " .
            "STATUS = 'failed'";
        return $this->db->execute($sql,
            [time(), $job_id, $user_id]) !== false;
    }
    /**
     * Marks one source UID as already-imported under a job, so
     * a resume tick skips it. Idempotent: the composite primary
     * key on MAIL_CLONE_SEEN (JOB_ID, SRC_UIDVALIDITY, SRC_UID)
     * makes a second insert of the same triple harmless under
     * the portable insertIgnore wrapper (INSERT IGNORE on MySQL,
     * INSERT OR IGNORE on SQLite, INSERT ... ON CONFLICT DO
     * NOTHING on Postgres/PDO). The dedicated isSeen precheck
     * the older implementation did is no longer needed since
     * insertIgnore handles the duplicate case directly; one
     * trip to the DB instead of two.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID this dedup row belongs to
     * @param int $uidvalidity source folder's UIDVALIDITY
     * @param int $uid source message's UID within that folder
     * @return bool whether the row is now present
     */
    public function markSeen($job_id, $uidvalidity, $uid)
    {
        $sql = $this->db->insertIgnore(
            "INSERT INTO MAIL_CLONE_SEEN (JOB_ID, " .
            "SRC_UIDVALIDITY, SRC_UID) VALUES (?, ?, ?)");
        return $this->db->execute($sql,
            [$job_id, $uidvalidity, $uid]) !== false;
    }
    /**
     * Bulk version of markSeen. Wraps the inserts in a single
     * transaction so the DBMS does one commit (and on storage
     * engines that fsync per transaction, one fsync) rather
     * than fsync-per-row. Used by MailCloneJob::advanceBatch
     * once per pipelined IMAP fetch, where the saved fsyncs
     * are the largest single per-message cost on a default
     * SQLite configuration.
     *
     * The caller passes a list of [uidvalidity, uid] tuples;
     * the job_id is shared across the batch. Empty input is a
     * no-op. Failed inserts inside the transaction are
     * tolerated -- insertIgnore makes duplicates harmless --
     * but a transaction-level error throws (the caller will
     * catch and downgrade to per-row inserts on the next tick).
     *
     * @param int $job_id MAIL_CLONE_JOB.ID all rows belong to
     * @param array $tuples list of [uidvalidity, uid] pairs
     * @return bool true on commit, false on failure
     */
    public function markSeenBulk($job_id, $tuples)
    {
        if (empty($tuples)) {
            return true;
        }
        $sql = $this->db->insertIgnore(
            "INSERT INTO MAIL_CLONE_SEEN (JOB_ID, " .
            "SRC_UIDVALIDITY, SRC_UID) VALUES (?, ?, ?)");
        $this->db->beginTransaction();
        try {
            foreach ($tuples as $tuple) {
                $this->db->execute($sql, [$job_id,
                    (int) $tuple[0], (int) $tuple[1]]);
            }
            $this->db->commit();
            return true;
        } catch (\Exception $exception) {
            /* No portable rollback in DatasourceManager; PDO
               auto-rolls-back the implicit transaction when the
               handle is destroyed or the next beginTransaction
               starts, so simply not committing is safe. Re-throw
               so the caller can fall back to per-row inserts. */
            throw $exception;
        }
    }
    /**
     * Returns true when the (job, UIDVALIDITY, UID) triple has
     * already been recorded as imported. Backs the per-message
     * "should I fetch this UID?" gate in the clone tick loop;
     * the lookup hits the composite primary key and is O(log n)
     * regardless of how many messages a job has already
     * imported.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to scope the lookup
     * @param int $uidvalidity source folder's UIDVALIDITY
     * @param int $uid source message's UID within that folder
     * @return bool whether the message was already imported
     */
    public function isSeen($job_id, $uidvalidity, $uid)
    {
        $sql = "SELECT JOB_ID FROM MAIL_CLONE_SEEN " .
            "WHERE JOB_ID = ? AND SRC_UIDVALIDITY = ? " .
            "AND SRC_UID = ? " . $this->db->limitOffset(1);
        $result = $this->db->execute($sql,
            [$job_id, $uidvalidity, $uid]);
        if ($result === false) {
            return false;
        }
        $row = $this->db->fetchArray($result);
        return !empty($row);
    }
    /**
     * Garbage-collects MAIL_CLONE_SEEN rows whose parent job
     * has been in STATUS='done' for more than the supplied
     * retention window. Called opportunistically from the
     * MailCloneJob tick code. The default retention is one
     * month (C\ONE_MONTH); within that window an operator can
     * re-clone the same source with full dedup credit, beyond
     * it dedup regresses to "first run wipes and re-imports",
     * which is the right trade since the source state has
     * presumably drifted enough that a fresh pass is preferable
     * to relying on month-old UIDVALIDITY/UID pairs.
     *
     * Implementation uses the two-step "fetch IDs then delete by
     * ID list" pattern Yioop's existing models follow rather
     * than a DELETE...WHERE IN (SELECT...) subquery, which keeps
     * the SQL flat and portable across SQLite, MySQL, Postgres,
     * Oracle, and DB2 (the only forms Yioop targets).
     *
     * @param int $retention_seconds keep dedup rows newer than
     *      this many seconds past the parent job's completion;
     *      default C\ONE_MONTH
     * @return bool whether the cleanup ran without error
     */
    public function purgeOldSeen($retention_seconds = null)
    {
        if ($retention_seconds === null) {
            $retention_seconds = C\ONE_MONTH;
        }
        $cutoff = time() - $retention_seconds;
        $find_sql = "SELECT ID FROM MAIL_CLONE_JOB WHERE " .
            "STATUS = 'done' AND UPDATED_AT < ?";
        $find_result = $this->db->execute($find_sql, [$cutoff]);
        if ($find_result === false) {
            return false;
        }
        $stale_ids = [];
        while ($row = $this->db->fetchArray($find_result)) {
            $stale_ids[] = (int) $row['ID'];
        }
        if (empty($stale_ids)) {
            return true;
        }
        $delete_sql = "DELETE FROM MAIL_CLONE_SEEN " .
            "WHERE JOB_ID = ?";
        foreach ($stale_ids as $stale_id) {
            $this->db->execute($delete_sql, [$stale_id]);
        }
        return true;
    }
    /**
     * Records a per-message error against a clone job. Used by
     * MailCloneJob whenever a single UID's import attempt fails
     * in a way the user might want to see (pipelined FETCH read
     * error, empty body, partial-fetch socket loss, etc.). The
     * row stays in MAIL_CLONE_ERROR with RESOLVED_AT=0; a later
     * successful retry can flip RESOLVED_AT and RESOLVED_VIA via
     * markErrorsResolved so the UI can surface the rescue rather
     * than just letting the error disappear.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID this error belongs to
     * @param string $folder source folder name being processed
     * @param int $uidvalidity source folder's UIDVALIDITY, or 0
     *      when the error is folder-scoped rather than UID-scoped
     * @param int $uid source UID the error was attributed to, or
     *      0 when the error is folder-scoped
     * @param string $kind short snake_case token classifying the
     *      failure (e.g. 'pipelined_fetch_eof', 'empty_body',
     *      'partial_fetch_failed'); used by the UI to group
     *      similar errors
     * @param string $message human-readable detail line; truncated
     *      to MAIL_LAST_ERROR_LEN on insert
     */
    public function recordError($job_id, $folder, $uidvalidity,
        $uid, $kind, $message)
    {
        $sql = "INSERT INTO MAIL_CLONE_ERROR (JOB_ID, FOLDER, " .
            "SRC_UIDVALIDITY, SRC_UID, ERROR_KIND, MESSAGE, " .
            "OCCURRED_AT) VALUES (?, ?, ?, ?, ?, ?, ?)";
        $this->db->execute($sql, [(int) $job_id, $folder,
            (int) $uidvalidity, (int) $uid,
            substr($kind, 0, C\MAIL_CLONE_ERROR_KIND_LEN),
            substr($message, 0, C\MAIL_LAST_ERROR_LEN),
            time()]);
    }
    /**
     * Marks every unresolved MAIL_CLONE_ERROR row for the given
     * job whose SRC_UID falls inside $resolved_uids as resolved.
     * Called when a batch of UIDs succeeds via the fall-back
     * single-connection path after their pipelined attempt had
     * recorded an error; the UI can then show "previously failed,
     * rescued via $via" for those errors and stop counting them
     * against the active Failed total.
     *
     * Empty UID list is a no-op. Already-resolved rows are left
     * unchanged (the predicate filters on RESOLVED_AT = 0) so a
     * second rescue does not overwrite the first.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID owning the rows
     * @param int $uidvalidity source folder's UIDVALIDITY
     * @param array $uids list of integer SRC_UID values that just
     *      succeeded
     * @param string $via short snake_case token describing how
     *      the rescue happened (e.g. 'single_connection_retry')
     */
    public function markErrorsResolved($job_id, $uidvalidity,
        $uids, $via)
    {
        if (empty($uids)) {
            return;
        }
        $now = time();
        $via_token = substr($via, 0, C\MAIL_CLONE_ERROR_KIND_LEN);
        $sql = "UPDATE MAIL_CLONE_ERROR SET RESOLVED_AT = ?, " .
            "RESOLVED_VIA = ? WHERE JOB_ID = ? AND " .
            "SRC_UIDVALIDITY = ? AND SRC_UID = ? AND " .
            "RESOLVED_AT = 0";
        foreach ($uids as $uid) {
            $this->db->execute($sql, [$now, $via_token,
                (int) $job_id, (int) $uidvalidity, (int) $uid]);
        }
    }
    /**
     * Returns the count of still-unresolved per-message
     * MAIL_CLONE_ERROR rows for a job. The status payload exposes
     * this as the authoritative "Failed" number so the counter
     * never lags behind the per-row error list shown beneath it.
     * Job-level rows (SRC_UID = 0, e.g. a transient connection
     * blip that the job recovered from on the next tick) are
     * excluded so the "Failed" count reflects messages that failed
     * to clone, not whole-job hiccups.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to count for
     * @return int number of unresolved per-message errors
     */
    public function unresolvedErrorCount($job_id)
    {
        $sql = "SELECT COUNT(*) AS C FROM MAIL_CLONE_ERROR " .
            "WHERE JOB_ID = ? AND RESOLVED_AT = 0 AND SRC_UID > 0";
        $result = $this->db->execute($sql, [(int) $job_id]);
        if ($result === false) {
            return 0;
        }
        $row = $this->db->fetchArray($result);
        return ($row === false || empty($row)) ? 0 :
            (int) $row['C'];
    }
    /**
     * Returns the most recent error rows for a clone job, newest
     * first, capped at $limit. Both unresolved and resolved rows
     * are returned so the UI can show "rescued via X" entries
     * alongside still-open failures; the caller filters or
     * groups as needed.
     *
     * @param int $job_id MAIL_CLONE_JOB.ID to list errors for
     * @param int $limit maximum rows to return
     * @return array list of error rows (associative arrays)
     */
    public function recentErrors($job_id, $limit = 50)
    {
        $sql = "SELECT * FROM MAIL_CLONE_ERROR WHERE JOB_ID = ? " .
            "ORDER BY OCCURRED_AT DESC " .
            $this->db->limitOffset((int) $limit);
        $result = $this->db->execute($sql, [(int) $job_id]);
        if ($result === false) {
            return [];
        }
        $rows = [];
        while ($row = $this->db->fetchArray($result)) {
            $rows[] = $row;
        }
        return $rows;
    }
}
X