/ src / models / MailScheduledModel.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 scheduled-send mail messages for the Mail activity. Each
 * row holds the full RFC822 MIME bytes the user composed, the SMTP
 * envelope (from + recipient list), the account it should be sent
 * through, and the scheduled wall-clock time. A dispatcher (run from
 * page-load lazily plus an optional cron job hitting
 * MachineController::scheduledMailDispatch) picks up due rows,
 * delivers them via SmtpClient, and deletes the row on success or
 * marks it failed with the error message.
 *
 * The MIME bytes column is sized at TEXT (effectively unbounded for
 * the supported DBs) since attachments can push a single message
 * past a few megabytes. The envelope is stored separately rather
 * than re-parsed from headers at dispatch time so a malformed
 * header set cannot mask a recipient.
 *
 * All public methods are user-scoped: the caller passes $user_id and
 * the model refuses to surface rows belonging to any other user.
 * This is the access-control boundary; controllers should not
 * bypass it. Cron-time dispatch uses an unfiltered listDue() which
 * is the one method that crosses the user boundary; callers of
 * listDue() must therefore be daemon code, never user-facing
 * request handlers.
 *
 * @author Chris Pollett
 */
class MailScheduledModel extends Model
{
    /**
     * Status value for a message that hasn't been attempted yet.
     */
    const STATUS_PENDING = 'pending';
    /**
     * Status value for a message currently being delivered. Marked
     * transiently by the dispatcher so a second concurrent
     * dispatcher pass won't re-pick the same row. Reset to pending
     * (or deleted) before the dispatcher returns.
     */
    const STATUS_SENDING = 'sending';
    /**
     * Status value for a message whose last delivery attempt
     * failed. LAST_ERROR carries the reason; ATTEMPT_COUNT carries
     * the number of tries. The dispatcher will retry failed rows
     * up to MAIL_SCHEDULED_MAX_ATTEMPTS times with backoff.
     */
    const STATUS_FAILED = 'failed';
    /**
     * Returns the pending scheduled messages for a user, ordered by
     * scheduled-for time (soonest first). Failed messages are
     * included so the Scheduled folder UI can show them with an
     * error indicator and let the user cancel or reschedule. Sent
     * messages have been deleted from this table so they never
     * appear here. BODY_TEXT and BODY_HTML are NOT included; use
     * getMessage to retrieve.
     *
     * @param int $user_id Yioop USER_ID whose scheduled messages
     *      to list
     * @param int $account_id optional filter to one account, or 0
     *      for all accounts owned by the user
     * @return array list of row arrays
     */
    public function getMessagesForUser($user_id, $account_id = 0)
    {
        $db = $this->db;
        $user_id = (int) $user_id;
        $account_id = (int) $account_id;
        $where = "USER_ID = ?";
        $args = [$user_id];
        if ($account_id > 0) {
            $where .= " AND ACCOUNT_ID = ?";
            $args[] = $account_id;
        }
        $sql = "SELECT ID, ACCOUNT_ID, SCHEDULED_AT, CREATED_AT, " .
            "STATUS, LAST_ERROR, ATTEMPT_COUNT, SUBJECT, " .
            "TO_LIST, CC_LIST, BCC_LIST FROM MAIL_SCHEDULED " .
            "WHERE $where ORDER BY SCHEDULED_AT ASC";
        $result = $db->execute($sql, $args);
        $rows = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $rows[] = $row;
            }
        }
        return $rows;
    }
    /**
     * Returns one scheduled message including the body fields and
     * threading headers. Scoped to $user_id so a controller cannot
     * accidentally surface another user's draft.
     *
     * @param int $id row id
     * @param int $user_id Yioop USER_ID who must own the row
     * @return array|null row array with full composer payload, or
     *      null if not found / not owned by user
     */
    public function getMessage($id, $user_id)
    {
        $db = $this->db;
        $sql = "SELECT * FROM MAIL_SCHEDULED " .
            "WHERE ID = ? AND USER_ID = ?";
        $result = $db->execute($sql, [(int) $id, (int) $user_id]);
        if (!$result) {
            return null;
        }
        return $db->fetchArray($result) ?: null;
    }
    /**
     * Returns scheduled messages whose SCHEDULED_AT has arrived and
     * which haven't yet exceeded the retry cap. Used by the
     * dispatcher; intentionally NOT user-scoped (the dispatcher
     * runs on behalf of every user). Callers must not pass the
     * returned rows to user-facing rendering without re-checking
     * USER_ID against the requesting session.
     *
     * @param int $now current unix timestamp (passed in so tests
     *      can advance time deterministically)
     * @param int $max_attempts retries beyond this count are
     *      skipped; the row stays in the failed state until the
     *      user cancels or reschedules
     * @param int $limit maximum rows to return per call so a
     *      single dispatcher pass doesn't hold the DB lock too
     *      long on a backlog
     * @return array list of row arrays with full composer payload
     */
    public function listDue($now, $max_attempts, $limit = 50)
    {
        $db = $this->db;
        $sql = "SELECT * FROM MAIL_SCHEDULED " .
            "WHERE SCHEDULED_AT <= ? " .
            "AND STATUS IN ('" . self::STATUS_PENDING . "', '" .
            self::STATUS_FAILED . "') " .
            "AND ATTEMPT_COUNT < ? " .
            "ORDER BY SCHEDULED_AT ASC " .
            "LIMIT " . ((int) $limit);
        $result = $db->execute($sql,
            [(int) $now, (int) $max_attempts]);
        $rows = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $rows[] = $row;
            }
        }
        return $rows;
    }
    /**
     * Inserts a new scheduled-send message. Returns the new row id
     * so the caller can record it in flash messages or session
     * state. STATUS starts as 'pending', ATTEMPT_COUNT at 0,
     * LAST_ERROR is null.
     *
     * @param int $user_id owner
     * @param int $account_id mail account to send through
     * @param int $scheduled_at unix timestamp; when to send
     * @param array $composer payload from the compose form with
     *      keys: subject, to_list, cc_list, bcc_list, body_text,
     *      body_html, in_reply_to, references
     * @return int|false the new row id, or false on failure
     */
    public function add($user_id, $account_id, $scheduled_at,
        $composer)
    {
        $db = $this->db;
        $now = time();
        $sql = "INSERT INTO MAIL_SCHEDULED (USER_ID, ACCOUNT_ID, " .
            "SCHEDULED_AT, CREATED_AT, STATUS, LAST_ERROR, " .
            "ATTEMPT_COUNT, SUBJECT, TO_LIST, CC_LIST, BCC_LIST, " .
            "BODY_TEXT, BODY_HTML, IN_REPLY_TO, " .
            "REFERENCES_HEADER) VALUES (?, ?, ?, ?, ?, NULL, 0, " .
            "?, ?, ?, ?, ?, ?, ?, ?)";
        $ok = $db->execute($sql, [(int) $user_id,
            (int) $account_id, (int) $scheduled_at, $now,
            self::STATUS_PENDING,
            (string) ($composer['subject'] ?? ''),
            (string) ($composer['to_list'] ?? ''),
            (string) ($composer['cc_list'] ?? ''),
            (string) ($composer['bcc_list'] ?? ''),
            (string) ($composer['body_text'] ?? ''),
            (string) ($composer['body_html'] ?? ''),
            (string) ($composer['in_reply_to'] ?? ''),
            (string) ($composer['references'] ?? '')]);
        if (!$ok) {
            return false;
        }
        return $db->insertID();
    }
    /**
     * Removes a scheduled message. Scoped to $user_id so the
     * Cancel button in the Scheduled folder cannot reach across
     * accounts.
     *
     * @param int $id row id to delete
     * @param int $user_id Yioop USER_ID who must own the row
     * @return bool true if a row was deleted, false otherwise
     */
    public function deleteMessage($id, $user_id)
    {
        $db = $this->db;
        $sql = "DELETE FROM MAIL_SCHEDULED WHERE ID = ? AND " .
            "USER_ID = ?";
        return (bool) $db->execute($sql,
            [(int) $id, (int) $user_id]);
    }
    /**
     * Removes a scheduled message after successful delivery. Used
     * by the dispatcher; not user-scoped because the dispatcher
     * has already verified ownership by virtue of being given the
     * row from listDue. Separated from deleteMessage so the
     * intent at the call site is clear and so any future audit
     * trail can be wired only on the dispatcher path.
     *
     * @param int $id row id to delete
     * @return bool true if the row was deleted
     */
    public function deleteAfterSend($id)
    {
        $db = $this->db;
        $sql = "DELETE FROM MAIL_SCHEDULED WHERE ID = ?";
        return (bool) $db->execute($sql, [(int) $id]);
    }
    /**
     * Records a delivery failure: increments ATTEMPT_COUNT, sets
     * STATUS to failed, and stores the error message. Used by
     * the dispatcher when SMTP send raises an error.
     *
     * @param int $id row id to mark
     * @param string $error error message from the failed attempt;
     *      truncated to a reasonable column width
     * @return bool true if the row was updated
     */
    public function markFailed($id, $error)
    {
        $db = $this->db;
        $clip = substr((string) $error, 0, 500);
        $sql = "UPDATE MAIL_SCHEDULED SET STATUS = ?, " .
            "LAST_ERROR = ?, ATTEMPT_COUNT = ATTEMPT_COUNT + 1 " .
            "WHERE ID = ?";
        return (bool) $db->execute($sql,
            [self::STATUS_FAILED, $clip, (int) $id]);
    }
    /**
     * Transitions a row to the sending state so a second
     * concurrent dispatcher invocation cannot pick the same row.
     * Returns true only if the row was actually transitioned
     * (i.e. it was still pending or failed when we tried), so
     * callers can use the result as an "I own this row now" lock.
     *
     * @param int $id row id to claim
     * @return bool true if claim succeeded; false if another
     *      worker got there first
     */
    public function claim($id)
    {
        $db = $this->db;
        $sql = "UPDATE MAIL_SCHEDULED SET STATUS = ? WHERE ID = ? " .
            "AND STATUS IN (?, ?)";
        $result = $db->execute($sql, [self::STATUS_SENDING,
            (int) $id, self::STATUS_PENDING,
            self::STATUS_FAILED]);
        if (!$result) {
            return false;
        }
        $verify = $db->execute("SELECT STATUS FROM MAIL_SCHEDULED " .
            "WHERE ID = ?", [(int) $id]);
        if (!$verify) {
            return false;
        }
        $row = $db->fetchArray($verify);
        return $row && $row['STATUS'] === self::STATUS_SENDING;
    }
    /**
     * Returns a row to the pending state if a sending attempt was
     * abandoned without resolution (e.g. the dispatcher process
     * died mid-send). Symmetric with claim(); callers should
     * invoke this in a finally block around the send so a dropped
     * connection doesn't leave a row stuck in sending forever.
     *
     * @param int $id row id to release
     * @return bool true if released
     */
    public function release($id)
    {
        $db = $this->db;
        $sql = "UPDATE MAIL_SCHEDULED SET STATUS = ? WHERE ID = ? " .
            "AND STATUS = ?";
        return (bool) $db->execute($sql,
            [self::STATUS_PENDING, (int) $id,
            self::STATUS_SENDING]);
    }
}
X