Files
itflow/cron/mail_queue.php
johnnyq 3ec52974db Calendar modernization: calendar page rebuilt to load only the dates on screen with show or hide toggles for each calendar and for built-in sources (scheduled tickets, project start and due dates, service reviews, recurring tickets, renewals, invoices due), a filter for one agent, quick looks on click, drag to reschedule tickets and events that do not repeat, and a To schedule panel for dragging open unscheduled tickets onto the calendar; events get a shared add/edit form with repeats every N days/weeks/months/years with an optional end date and single skipped occurrences, a client site, location or meeting link, agents, client contacts and guest emails as attendees, iCalendar invitations with updates and cancellations sent to invited attendees, an event quick look with map and join links, .ics download and resend; each agent gets a personal subscription link for their schedule (their own or the whole team, busy only option, respects role and client access, stops when the account is disabled) with setup help for Google, Outlook, Apple, Nextcloud and Thunderbird; shared calendar feeds and invites now publish timed events with TZID and VTIMEZONE so repeating events keep their time across DST, with INTERVAL, UNTIL and EXDATE support; managing calendars is limited to admins; ticket schedule logic moved into functions/ticket.php and shared with the calendar, the schedule dialog shows the assigned agent's day, and ticket visit calendar files are rebuilt without zapcal as one-hour published events; mail queue sends invitations and cancellations as a text/calendar part and other calendar files as invite.ics; dashboard schedule shows only events the agent is on or added, client overview Coming up and client sidebar count use upcoming events, sites and contacts get New event shortcuts, demo data uses repeating events and attendees; database update 2.8.8 adds event repeat interval, until, skip, sequence and created by, attendee user and invited at, user_calendar_feeds, and widens email_queue.email_cal_str to text
2026-10-15 19:17:17 -04:00

513 lines
23 KiB
PHP

<?php
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
// Ensure we're running from command line
if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "includes/cron_lock.php";
require_once "../config.php";
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
require_once "../libs/vendor/autoload.php";
// PHP Mailer Libs
require_once "../libs/PHPMailer/src/Exception.php";
require_once "../libs/PHPMailer/src/PHPMailer.php";
require_once "../libs/PHPMailer/src/SMTP.php";
require_once "../libs/PHPMailer/src/OAuthTokenProvider.php";
require_once "../libs/PHPMailer/src/OAuth.php";
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\OAuthTokenProvider;
if (!defined('GOOGLE_OAUTH_TOKEN_URL')) {
define('GOOGLE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token');
}
if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
}
/** =======================================================================
* XOAUTH2 Token Provider for PHPMailer (simple “static” provider)
* ======================================================================= */
class StaticTokenProvider implements OAuthTokenProvider {
private string $email;
private string $accessToken;
public function __construct(string $email, string $accessToken) {
$this->email = $email;
$this->accessToken = $accessToken;
}
public function getOauth64(): string {
$auth = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01";
return base64_encode($auth);
}
}
/** =======================================================================
* Load settings
* ======================================================================= */
$sql_settings = mysqli_query($mysqli, "SELECT config_enable_cron, config_mail_oauth_access_token,
config_mail_oauth_access_token_expires_at, config_mail_oauth_client_id,
config_mail_oauth_client_secret, config_mail_oauth_refresh_token,
config_mail_oauth_tenant_id, config_smtp_encryption, config_smtp_host,
config_smtp_password, config_smtp_port, config_smtp_provider, config_smtp_username FROM settings WHERE company_id = 1");
$row = mysqli_fetch_assoc($sql_settings);
$config_enable_cron = intval($row['config_enable_cron']);
// SMTP baseline
$config_smtp_host = $row['config_smtp_host'];
$config_smtp_username = $row['config_smtp_username'];
$config_smtp_password = $row['config_smtp_password'];
$config_smtp_port = intval($row['config_smtp_port']);
$config_smtp_encryption = $row['config_smtp_encryption'];
// SMTP provider + shared OAuth fields
$config_smtp_provider = $row['config_smtp_provider']; // 'standard_smtp' | 'google_oauth' | 'microsoft_oauth'
$config_mail_oauth_client_id = $row['config_mail_oauth_client_id'] ?? '';
$config_mail_oauth_client_secret = $row['config_mail_oauth_client_secret'] ?? '';
$config_mail_oauth_tenant_id = $row['config_mail_oauth_tenant_id'] ?? '';
$config_mail_oauth_refresh_token = $row['config_mail_oauth_refresh_token'] ?? '';
$config_mail_oauth_access_token = $row['config_mail_oauth_access_token'] ?? '';
$config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_token_expires_at'] ?? '';
if ($config_enable_cron == 0) {
logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings.");
cronJobStop("Cron: is not enabled -- Quitting..");
}
if (empty($config_smtp_provider)) {
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
cronJobStop();
}
/** =======================================================================
* Mail OAuth helpers + sender function
* ======================================================================= */
function tokenIsExpired(?string $expires_at): bool {
if (empty($expires_at)) {
return true;
}
$ts = strtotime($expires_at);
if ($ts === false) {
return true;
}
return ($ts - 60) <= time();
}
function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void {
global $mysqli;
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
$refresh_sql = '';
if (!empty($refresh_token)) {
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
$refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'";
}
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$access_token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1");
}
function refreshMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array {
$result = null;
$response = null;
if (!empty($oauth_client_id) && !empty($oauth_client_secret) && !empty($oauth_refresh_token)) {
if ($provider === 'google_oauth') {
$response = httpFormPost(GOOGLE_OAUTH_TOKEN_URL, [
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
'refresh_token' => $oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
} elseif ($provider === 'microsoft_oauth' && !empty($oauth_tenant_id)) {
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token";
$response = httpFormPost($token_url, [
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
'refresh_token' => $oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
}
}
if (is_array($response) && !empty($response['ok'])) {
$json = json_decode($response['body'], true);
if (is_array($json) && !empty($json['access_token'])) {
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$result = [
'access_token' => $json['access_token'],
'expires_at' => $expires_at,
'refresh_token' => $json['refresh_token'] ?? null,
];
}
}
return $result;
}
function resolveMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token, string $oauth_access_token, string $oauth_access_token_expires_at): ?string {
if (!empty($oauth_access_token) && !tokenIsExpired($oauth_access_token_expires_at)) {
return $oauth_access_token;
}
$tokens = refreshMailOauthAccessToken($provider, $oauth_client_id, $oauth_client_secret, $oauth_tenant_id, $oauth_refresh_token);
if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_at'])) {
return null;
}
persistMailOauthTokens($tokens['access_token'], $tokens['expires_at'], $tokens['refresh_token'] ?? null);
return $tokens['access_token'];
}
function sendQueueEmail(
string $provider,
string $host,
int $port,
string $encryption,
string $username,
string $password,
string $from_email,
string $from_name,
string $to_email,
string $to_name,
string $subject,
string $html_body,
string $ics_str,
string $attachments_json,
string $oauth_client_id,
string $oauth_client_secret,
string $oauth_tenant_id,
string $oauth_refresh_token,
string $oauth_access_token,
string $oauth_access_token_expires_at
) {
// Google and Microsoft each expose exactly one XOAUTH2 SMTP endpoint, so the
// stored host/port/encryption are overridden rather than used as a fallback -
// the same thing cron/ticket_email_parser.php already does on the IMAP side.
//
// These used to apply only when the columns were blank, which quietly broke
// every install that moved from Standard SMTP to OAuth: the old server is
// still sitting in config_smtp_host/port/encryption, the settings page hides
// those fields for OAuth providers so there is no way to clear them, and the
// send then pointed an M365 bearer token at the previous mail host. Only a
// fresh install, where the columns happened to be empty, ever worked.
if ($provider === 'google_oauth') {
$host = 'smtp.gmail.com';
$port = 587;
$encryption = 'tls';
if (!$username) $username = $from_email;
} elseif ($provider === 'microsoft_oauth') {
$host = 'smtp.office365.com';
$port = 587;
$encryption = 'tls';
if (!$username) $username = $from_email;
}
$mail = new PHPMailer(true);
$mail->CharSet = "UTF-8";
$mail->SMTPDebug = 0;
$mail->isSMTP();
$mail->Host = $host;
$mail->Port = $port;
// Bound the SMTP conversation. Without this an unresponsive mail server can
// hold the cron lock open indefinitely and stall the whole queue.
$mail->Timeout = 30;
$enc = strtolower($encryption);
if ($enc === '' || $enc === 'none') {
$mail->SMTPAutoTLS = false;
$mail->SMTPSecure = false;
$mail->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
} else {
$mail->SMTPSecure = $enc; // 'tls' | 'ssl'
}
if ($provider === 'google_oauth' || $provider === 'microsoft_oauth') {
// XOAUTH2
$mail->SMTPAuth = true;
$mail->AuthType = 'XOAUTH2';
$mail->Username = $username;
$access_token = resolveMailOauthAccessToken(
$provider,
trim($oauth_client_id),
trim($oauth_client_secret),
trim($oauth_tenant_id),
trim($oauth_refresh_token),
trim($oauth_access_token),
trim($oauth_access_token_expires_at)
);
if (empty($access_token)) {
throw new Exception("Missing OAuth access token for XOAUTH2 SMTP.");
}
$mail->setOAuth(new StaticTokenProvider($username, $access_token));
} else {
// Standard SMTP (with or without auth)
$mail->SMTPAuth = !empty($username);
$mail->Username = $username ?: '';
$mail->Password = $password ?: '';
}
// Recipients & content
$mail->setFrom($from_email, $from_name);
$mail->addAddress($to_email, $to_name);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $html_body;
// A calendar invitation or cancellation goes in as its own text/calendar part
// beside the HTML - the way calendar apps send them, and the only way Outlook
// shows one as a meeting rather than a file. PHPMailer only builds that part
// when there are no file attachments, so anything else (and a published
// event to add) travels as an attached invite.ics.
if (!empty($ics_str)) {
if (preg_match('/^METHOD:(REQUEST|CANCEL)\r?$/m', $ics_str) && empty($attachments_json)) {
$mail->Ical = $ics_str;
$mail->AltBody = $mail->html2text($html_body);
} else {
$mail->addStringAttachment($ics_str, 'invite.ics', 'base64', 'text/calendar');
}
}
// File attachments arrive as a manifest of app-root-relative paths. Each one is
// resolved and confirmed to still be inside uploads/ before it is attached - the
// same guard the ticket attachment download endpoints apply - and a file that
// has been deleted since the message was queued is simply skipped.
if (!empty($attachments_json)) {
$attachment_manifest = json_decode($attachments_json, true);
$uploads_base = realpath(__DIR__ . '/../uploads');
if (is_array($attachment_manifest) && $uploads_base !== false) {
foreach ($attachment_manifest as $attachment) {
if (empty($attachment['path']) || empty($attachment['name'])) {
continue;
}
$attachment_path = realpath(__DIR__ . '/../' . $attachment['path']);
if ($attachment_path === false || strpos($attachment_path, $uploads_base) !== 0) {
continue;
}
$mail->addAttachment($attachment_path, basename($attachment['name']));
}
}
}
$mail->send();
return true;
}
/** =======================================================================
* RECOVER: status = 1 (Sending) left behind by a run that died
*
* Nothing else in the codebase ever selects status 1, so without this a row
* claimed by a run that was killed mid-send stays 'Sending' forever and is never
* delivered. The cron lock above guarantees no other run of this script is in
* progress, so any row still sitting at status 1 is by definition orphaned and
* safe to reclaim. It is moved to failed rather than queued so it inherits the
* retry pass's 30 minute backoff and attempt cap instead of retrying instantly.
*
* This can re-send a message that did go out but died before being marked sent.
* That trade is deliberate: a duplicate is recoverable, an invoice that silently
* never arrives is not.
* ======================================================================= */
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1");
$orphaned_emails = mysqli_affected_rows($mysqli);
if ($orphaned_emails > 0) {
logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry.");
}
/** =======================================================================
* SEND: status = 0 (Queued)
* ======================================================================= */
$sql_queue = mysqli_query($mysqli, "SELECT email_attachments, email_cal_str, email_content, email_from, email_from_name, email_id,
email_recipient, email_recipient_name, email_subject FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()");
if (mysqli_num_rows($sql_queue) > 0) {
while ($rowq = mysqli_fetch_assoc($sql_queue)) {
$email_id = (int)$rowq['email_id'];
$email_from = $rowq['email_from'];
$email_from_name = $rowq['email_from_name'];
$email_recipient = $rowq['email_recipient'];
$email_recipient_name = $rowq['email_recipient_name'];
$email_subject = $rowq['email_subject'];
$email_content = $rowq['email_content'];
$email_ics_str = $rowq['email_cal_str'];
$email_attachments = $rowq['email_attachments'];
// Check sender
if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) {
$email_from_logging = escapeSql($rowq['email_from']);
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
logApp("Cron-Mail-Queue", "Error", "Failed to send email #$email_id due to invalid sender address: $email_from_logging - check configuration in settings.");
appNotify("Mail", "Failed to send email #$email_id due to invalid sender address");
continue;
}
// Claim the row - the conditional UPDATE is the lock. If another run already took
// this email, skip it rather than sending the client a second copy.
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 0");
if (mysqli_affected_rows($mysqli) !== 1) {
continue;
}
// Basic recipient syntax check
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
$email_to_logging = escapeSql($email_recipient);
$email_subject_logging = escapeSql($rowq['email_subject']);
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient address. Email subject was: $email_subject_logging");
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient address: Email subject was: $email_subject_logging");
continue;
}
// More intelligent recipient MX check (if not disabled with --no-mx-validation)
$domain = escapeSql(substr($email_recipient, strpos($email_recipient, '@') + 1));
if (!in_array('--no-mx-validation', $argv) && !checkdnsrr($domain, 'MX')) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
$email_to_logging = escapeSql($email_recipient);
$email_subject_logging = escapeSql($rowq['email_subject']);
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient domain (no MX). Email subject was: $email_subject_logging");
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient domain (no MX): Email subject was: $email_subject_logging");
continue;
}
try {
sendQueueEmail(
($config_smtp_provider ?: 'standard_smtp'),
$config_smtp_host,
(int)$config_smtp_port,
(string)$config_smtp_encryption,
(string)$config_smtp_username,
(string)$config_smtp_password,
(string)$email_from,
(string)$email_from_name,
(string)$email_recipient,
(string)$email_recipient_name,
(string)$email_subject,
(string)$email_content,
(string)$email_ics_str,
(string)$email_attachments,
(string)$config_mail_oauth_client_id,
(string)$config_mail_oauth_client_secret,
(string)$config_mail_oauth_tenant_id,
(string)$config_mail_oauth_refresh_token,
(string)$config_mail_oauth_access_token,
(string)$config_mail_oauth_access_token_expires_at
);
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '', email_attachments = '' WHERE email_id = $email_id");
} catch (Exception $e) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id");
$email_recipient_logging = escapeSql($rowq['email_recipient']);
$email_subject_logging = escapeSql($rowq['email_subject']);
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
appNotify("Cron-Mail-Queue", "Failed to send email #$email_id to $email_recipient_logging");
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_recipient_logging regarding $email_subject_logging. $err");
}
}
}
/** =======================================================================
* RETRIES: status = 2 (Failed), attempts < 4, wait 30 min
* NOTE: Backoff is `email_failed_at <= NOW() - INTERVAL 30 MINUTE`
* =======================================================================
*/
$sql_failed_queue = mysqli_query(
$mysqli,
"SELECT email_attachments, email_attempts, email_cal_str, email_content, email_from,
email_from_name, email_id, email_recipient, email_recipient_name, email_subject FROM email_queue
WHERE email_status = 2
AND email_attempts < 4
AND email_failed_at <= NOW() - INTERVAL 30 MINUTE"
);
if (mysqli_num_rows($sql_failed_queue) > 0) {
while ($rowf = mysqli_fetch_assoc($sql_failed_queue)) {
$email_id = (int)$rowf['email_id'];
$email_from = $rowf['email_from'];
$email_from_name = $rowf['email_from_name'];
$email_recipient = $rowf['email_recipient'];
$email_recipient_name = $rowf['email_recipient_name'];
$email_subject = $rowf['email_subject'];
$email_content = $rowf['email_content'];
$email_ics_str = $rowf['email_cal_str'];
$email_attachments = $rowf['email_attachments'];
$email_attempts = (int)$rowf['email_attempts'] + 1;
// Claim the row - same lock as the send path, from the failed state this time.
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 2");
if (mysqli_affected_rows($mysqli) !== 1) {
continue;
}
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = $email_attempts WHERE email_id = $email_id");
continue;
}
try {
sendQueueEmail(
($config_smtp_provider ?: 'standard_smtp'),
$config_smtp_host,
(int)$config_smtp_port,
(string)$config_smtp_encryption,
(string)$config_smtp_username,
(string)$config_smtp_password,
(string)$email_from,
(string)$email_from_name,
(string)$email_recipient,
(string)$email_recipient_name,
(string)$email_subject,
(string)$email_content,
(string)$email_ics_str,
(string)$email_attachments,
(string)$config_mail_oauth_client_id,
(string)$config_mail_oauth_client_secret,
(string)$config_mail_oauth_tenant_id,
(string)$config_mail_oauth_refresh_token,
(string)$config_mail_oauth_access_token,
(string)$config_mail_oauth_access_token_expires_at
);
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '', email_attachments = '' WHERE email_id = $email_id");
} catch (Exception $e) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id");
$email_recipient_logging = escapeSql($rowf['email_recipient']);
$email_subject_logging = escapeSql($rowf['email_subject']);
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
logApp("Cron-Mail-Queue", "Error", "Failed to re-send email #$email_id to $email_recipient_logging regarding $email_subject_logging. $err");
}
}
}