/ src / library / mail / MailScheduledDispatcher.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\library\mail;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\MailScheduledModel;
use seekquarry\yioop\models\MailAccountModel;

/**
 * Picks up due scheduled-send messages from MAIL_SCHEDULED and
 * delivers them via SmtpClient. Designed to be invoked from
 * three places:
 *
 *   - Lazily, on every Mail page load (controlled by
 *     MAIL_SCHEDULED_LAZY_DISPATCH; default on).
 *   - From a per-minute cron job hitting
 *     MachineController::scheduledMailDispatch.
 *   - From CLI for manual catch-up or operator scripts.
 *
 * The dispatcher claims each row with MailScheduledModel::claim()
 * before working on it, so two overlapping invocations don't
 * double-send. The claim transitions the row from pending/failed
 * to sending; the post-send step either deletes the row
 * (success) or marks it failed (errors) and increments the
 * attempt counter.
 *
 * Calls SmtpClient::sendImmediate() for each delivery; the
 * existing mail-log path captures the SMTP transcript so an
 * operator can debug delivery failures from mail.log just like
 * for immediate sends. Account credentials are looked up at
 * dispatch time (not stored on the scheduled row) so a password
 * rotation between schedule and send is honored.
 *
 * @author Chris Pollett
 */
class MailScheduledDispatcher
{
    /**
     * Runs one dispatch pass: claims due rows, sends them, and
     * resolves each (delete on success, mark failed on error).
     * Returns a summary with counts so the caller can log it or
     * show it to an admin.
     *
     * @param MailScheduledModel $scheduled scheduled-mail store
     * @param MailAccountModel $accounts mail-account store; used
     *      to look up SMTP credentials at dispatch time
     * @param int|null $now current unix timestamp; defaults to
     *      time(). Passed in so tests can advance time
     *      deterministically.
     * @param int $max_attempts retry cap from
     *      C\MAIL_SCHEDULED_MAX_ATTEMPTS by default; passed in
     *      so tests can use a smaller cap.
     * @return array ['claimed' => int, 'sent' => int,
     *      'failed' => int, 'skipped' => int]
     */
    public static function dispatch($scheduled, $accounts,
        $now = null, $max_attempts = null)
    {
        if ($now === null) {
            $now = time();
        }
        if ($max_attempts === null) {
            $max_attempts = (int) C\MAIL_SCHEDULED_MAX_ATTEMPTS;
        }
        $stats = ['claimed' => 0, 'sent' => 0, 'failed' => 0,
            'skipped' => 0];
        $due = $scheduled->listDue($now, $max_attempts);
        foreach ($due as $row) {
            if (!$scheduled->claim($row['ID'])) {
                $stats['skipped']++;
                continue;
            }
            $stats['claimed']++;
            try {
                $account = $accounts->getAccountWithSmtpPassword(
                    $row['ACCOUNT_ID'], $row['USER_ID']);
                if (!$account) {
                    $scheduled->markFailed($row['ID'],
                        "account not found or no SMTP password");
                    $stats['failed']++;
                    continue;
                }
                $ok = self::sendOne($row, $account);
                if ($ok['ok']) {
                    $scheduled->deleteAfterSend($row['ID']);
                    $stats['sent']++;
                } else {
                    $scheduled->markFailed($row['ID'],
                        $ok['error']);
                    $stats['failed']++;
                }
            } catch (\Throwable $e) {
                $scheduled->markFailed($row['ID'],
                    $e->getMessage());
                $stats['failed']++;
            }
        }
        return $stats;
    }
    /**
     * Sends one scheduled message via SmtpClient using the
     * supplied account's outgoing settings. Reconstructs the
     * recipient lists from the comma-separated TO_LIST/CC_LIST/
     * BCC_LIST fields. Adds threading headers (In-Reply-To,
     * References) when the row carries them. Returns
     * ['ok' => bool, 'error' => string]; error is empty on
     * success.
     *
     * @param array $row scheduled-message row from listDue
     * @param array $account mail-account row with SMTP fields and
     *      a decrypted SMTP_PASSWORD
     * @return array{ok: bool, error: string}
     */
    protected static function sendOne($row, $account)
    {
        $to_list = self::parseAddressList($row['TO_LIST'] ?? '');
        $cc_list = self::parseAddressList($row['CC_LIST'] ?? '');
        $bcc_list = self::parseAddressList($row['BCC_LIST'] ?? '');
        if (empty($to_list) && empty($cc_list) &&
                empty($bcc_list)) {
            return ['ok' => false,
                'error' => 'no recipients'];
        }
        $smtp = new SmtpClient(
            $account['SMTP_USERNAME'] ?? '',
            $account['SMTP_HOST'] ?? '',
            $account['SMTP_PORT'] ?? 587,
            $account['SMTP_USERNAME'] ?? '',
            $account['SMTP_PASSWORD'] ?? '',
            $account['SMTP_TLS_MODE'] ?? 'starttls');
        $from = MailAccountModel::senderEmail($account);
        $to = ['to' => $to_list, 'cc' => $cc_list,
            'bcc' => $bcc_list];
        $extra_headers = [];
        if (!empty($row['IN_REPLY_TO'])) {
            $extra_headers['In-Reply-To'] = $row['IN_REPLY_TO'];
        }
        if (!empty($row['REFERENCES_HEADER'])) {
            $extra_headers['References'] =
                $row['REFERENCES_HEADER'];
        }
        $body = $row['BODY_TEXT'] ?? '';
        if (empty($body) && !empty($row['BODY_HTML'])) {
            $body = $row['BODY_HTML'];
            $extra_headers['Content-Type'] =
                'text/html; charset=utf-8';
        }
        $ok = $smtp->sendImmediate($row['SUBJECT'] ?? '', $from,
            $to, $body, [], $extra_headers);
        if (!$ok) {
            return ['ok' => false,
                'error' => $smtp->getLastError()];
        }
        return ['ok' => true, 'error' => ''];
    }
    /**
     * Splits a comma-separated address list into an array of
     * trimmed RFC 5322 address specs, preserving display-name
     * forms like 'Display Name <addr>'. Bracket/quote-aware split:
     * commas inside "..." or <...> do not terminate an entry.
     * The compose form stores addresses as a single comma-
     * separated string so the Scheduled folder UI can render them
     * without re-parsing MIME, and so existing inbound-form
     * validation can normalize before storage.
     *
     * @param string $list comma-separated address list
     * @return array list of trimmed specs (empty array if input
     *      is empty or whitespace)
     */
    protected static function parseAddressList($list)
    {
        $list = trim((string) $list);
        if ($list === '') {
            return [];
        }
        $parts = [];
        $buffer = '';
        $in_quote = false;
        $in_angle = false;
        $length = strlen($list);
        for ($i = 0; $i < $length; $i++) {
            $character = $list[$i];
            if ($character === '"' && !$in_angle) {
                $in_quote = !$in_quote;
                $buffer .= $character;
                continue;
            }
            if ($character === '<' && !$in_quote) {
                $in_angle = true;
                $buffer .= $character;
                continue;
            }
            if ($character === '>' && !$in_quote) {
                $in_angle = false;
                $buffer .= $character;
                continue;
            }
            if ($character === ',' && !$in_quote && !$in_angle) {
                $parts[] = $buffer;
                $buffer = '';
                continue;
            }
            $buffer .= $character;
        }
        if ($buffer !== '') {
            $parts[] = $buffer;
        }
        $clean = [];
        foreach ($parts as $part) {
            $trimmed = trim($part);
            if ($trimmed !== '') {
                $clean[] = $trimmed;
            }
        }
        return $clean;
    }
}
X