<?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;
}
}