From c769bbc405eae99bebb4bc7a80a440359407a0bc Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 16 Sep 2025 15:43:54 -0400 Subject: [PATCH] Created new Mail Queuer to take advantage of OAUTH2 for M365 and Google Workspaces not enabled by default --- cron/mail_queue.php | 339 ++++++++++++++++++++++++++++++++++++++++++++ functions.php | 20 --- 2 files changed, 339 insertions(+), 20 deletions(-) create mode 100644 cron/mail_queue.php diff --git a/cron/mail_queue.php b/cron/mail_queue.php new file mode 100644 index 00000000..11993ef7 --- /dev/null +++ b/cron/mail_queue.php @@ -0,0 +1,339 @@ +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 * FROM settings WHERE company_id = 1"); +$row = mysqli_fetch_array($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'; // '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."); + exit("Cron: is not enabled -- Quitting.."); +} + +/** ======================================================================= + * Lock file + * ======================================================================= */ +$temp_dir = sys_get_temp_dir(); +$lock_file_path = "{$temp_dir}/itflow_mail_queue_{$installation_id}.lock"; + +if (file_exists($lock_file_path)) { + $file_age = time() - filemtime($lock_file_path); + if ($file_age > 600) { + unlink($lock_file_path); + logApp("Cron-Mail-Queue", "warning", "Cron Mail Queue detected a lock file was present but was over 10 minutes old so it removed it."); + } else { + logApp("Cron-Mail-Queue", "info", "Cron Mail Queue attempted to execute but was already executing so instead it terminated."); + exit("Script is already running. Exiting."); + } +} + +file_put_contents($lock_file_path, "Locked"); + +/** ======================================================================= + * Mail sender function (defined inside this cron) + * - Handles standard SMTP and XOAUTH2 for Google/Microsoft + * - Reuses shared OAuth settings + * ======================================================================= */ +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 $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 +) { + // Sensible defaults for OAuth providers if fields were left blank + if ($provider === 'google_oauth') { + if (!$host) $host = 'smtp.gmail.com'; + if (!$port) $port = 587; + if (!$encryption) $encryption = 'tls'; + if (!$username) $username = $from_email; + } elseif ($provider === 'microsoft_oauth') { + if (!$host) $host = 'smtp.office365.com'; + if (!$port) $port = 587; + if (!$encryption) $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; + + $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; + + // Pick/refresh access token + $accessToken = trim($oauth_access_token); + $needsRefresh = empty($accessToken); + if (!$needsRefresh && !empty($oauth_access_token_expires_at)) { + $expTs = strtotime($oauth_access_token_expires_at); + if ($expTs && $expTs <= time() + 60) $needsRefresh = true; + } + + if ($needsRefresh) { + if ($provider === 'google_oauth' && function_exists('getGoogleAccessToken')) { + $accessToken = getGoogleAccessToken($username); + } elseif ($provider === 'microsoft_oauth' && function_exists('getMicrosoftAccessToken')) { + $accessToken = getMicrosoftAccessToken($username); + } + } + + if (empty($accessToken)) { + throw new Exception("Missing OAuth access token for XOAUTH2 SMTP."); + } + + $mail->setOAuth(new StaticTokenProvider($username, $accessToken)); + } 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}
"; + + if (!empty($ics_str)) { + $mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar'); + } + + $mail->send(); + return true; +} + +/** ======================================================================= + * SEND: status = 0 (Queued) + * ======================================================================= */ +$sql_queue = mysqli_query($mysqli, "SELECT * FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()"); + +if (mysqli_num_rows($sql_queue) > 0) { + while ($rowq = mysqli_fetch_array($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']; + + if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) { + $email_from_logging = sanitizeInput($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; + } + + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id"); + + 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_subject_logging = sanitizeInput($rowq['email_subject']); + logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id due to invalid recipient address. 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)$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 + ); + + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1 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 = sanitizeInput($rowq['email_recipient']); + $email_subject_logging = sanitizeInput($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 * 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_array($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_attempts = (int)$rowf['email_attempts'] + 1; + + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id"); + + 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)$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 + ); + + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts 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 = sanitizeInput($rowf['email_recipient']); + $email_subject_logging = sanitizeInput($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"); + } + } +} + +/** ======================================================================= + * Unlock + * ======================================================================= */ +unlink($lock_file_path); diff --git a/functions.php b/functions.php index 272bd397..70c9117a 100644 --- a/functions.php +++ b/functions.php @@ -7,13 +7,10 @@ DEFINE("WORDING_ROLECHECK_FAILED", "You are not permitted to do that!"); require_once "plugins/PHPMailer/src/Exception.php"; require_once "plugins/PHPMailer/src/PHPMailer.php"; require_once "plugins/PHPMailer/src/SMTP.php"; -require_once "plugins/PHPMailer/src/OAuthTokenProvider.php"; -require_once "plugins/PHPMailer/src/OAuth.php"; // Initiate PHPMailer use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception; -use PHPMailer\PHPMailer\OAuthTokenProvider; // Function to generate both crypto & URL safe random strings function randomString($length = 16) { @@ -691,23 +688,6 @@ function validateAccountantRole() { } } -/** - * Minimal token provider for PHPMailer XOAUTH2 without external deps. - */ -class StaticTokenProvider implements OAuthTokenProvider { - private $email; - private $accessToken; - public function __construct(string $email, string $accessToken) { - $this->email = $email; - $this->accessToken = $accessToken; - } - public function getOauth64(): string { - // XOAUTH2 SASL string: "user=\x01auth=Bearer \x01\x01" - $authString = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01"; - return base64_encode($authString); - } -} - // Send a single email to a single recipient function sendSingleEmail($config_smtp_host, $config_smtp_username, $config_smtp_password, $config_smtp_encryption, $config_smtp_port, $from_email, $from_name, $to_email, $to_name, $subject, $body, $ics_str) {