Normalize line endings to LF; add .gitattributes and .editorconfig

This commit is contained in:
johnnyq
2026-07-28 17:45:52 -04:00
parent 7f7880a4a7
commit c5ff3e2a3f
10 changed files with 5156 additions and 5032 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Editor defaults for ITFlow - see CONTRIBUTING.md ("Style")
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
# Vendored - do not reformat
[libs/**]
indent_style = unset
indent_size = unset
trim_trailing_whitespace = false
insert_final_newline = false

44
.gitattributes vendored Normal file
View File

@@ -0,0 +1,44 @@
# ITFlow line-ending policy
#
# Everything ITFlow ships is LF in the repository and LF in the working tree.
# Contributors on Windows get LF too - this is deliberate. ITFlow is deployed
# to Linux/Apache and edited over sftp/ssh as often as it is cloned, so a
# checkout must be byte-identical everywhere.
* text=auto eol=lf
# Explicit for the file types we author, so nothing depends on git's guess.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.sql text eol=lf
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.xsd text eol=lf
*.svg text eol=lf
*.txt text eol=lf
*.ini text eol=lf
.htaccess text eol=lf
# Binary assets: never touched, never diffed as text.
*.png binary
*.gif binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ico binary
*.icc binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.crt binary
*.ser binary
*.z binary
# Vendored third-party code is preserved byte-for-byte as shipped upstream.
# Per CONTRIBUTING.md libs/ is never edited in place - it is replaced wholesale -
# so normalizing it here would create spurious diffs on the next library update.
libs/** -text

View File

@@ -1,103 +1,103 @@
<?php
require_once "../config.php";
require_once "../functions.php";
require_once "../includes/check_login.php";
$settings_mail_path = '/admin/settings_mail.php?tab=oauth';
if (!isset($session_is_admin) || !$session_is_admin) {
flashAlert("Admin access required.", 'error');
redirect($settings_mail_path);
}
$state = escapeSql($_GET['state'] ?? '');
$code = $_GET['code'] ?? '';
$error = escapeSql($_GET['error'] ?? '');
$error_description = escapeSql($_GET['error_description'] ?? '');
$session_state = $_SESSION['mail_oauth_state'] ?? '';
$session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0);
unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']);
if (!empty($error)) {
$msg = "Microsoft OAuth authorization failed: $error";
if (!empty($error_description)) {
$msg .= " ($error_description)";
}
flashAlert($msg, 'error');
redirect($settings_mail_path);
}
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error');
redirect($settings_mail_path);
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error');
redirect($settings_mail_path);
}
if (defined('BASE_URL') && !empty(BASE_URL)) {
$base_url = rtrim((string) BASE_URL, '/');
} else {
$base_url = 'https://' . rtrim((string) $config_base_url, '/');
}
$redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php';
$token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token';
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
$ch = curl_init($token_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirect_uri,
'scope' => $scope,
], '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw_body = curl_exec($ch);
$curl_err = curl_error($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw_body === false || $http_code < 200 || $http_code >= 300) {
$reason = !empty($curl_err) ? $curl_err : "HTTP $http_code";
flashAlert("Microsoft OAuth token exchange failed: $reason", 'error');
redirect($settings_mail_path);
}
$json = json_decode($raw_body, true);
if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) {
flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error');
redirect($settings_mail_path);
}
$refresh_token = (string) $json['refresh_token'];
$access_token = (string) $json['access_token'];
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
mysqli_query($mysqli, "UPDATE settings SET
config_imap_provider = 'microsoft_oauth',
config_smtp_provider = 'microsoft_oauth',
config_mail_oauth_refresh_token = '$refresh_token_esc',
config_mail_oauth_access_token = '$access_token_esc',
config_mail_oauth_access_token_expires_at = '$expires_at_esc'
WHERE company_id = 1
");
logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
redirect($settings_mail_path);
<?php
require_once "../config.php";
require_once "../functions.php";
require_once "../includes/check_login.php";
$settings_mail_path = '/admin/settings_mail.php?tab=oauth';
if (!isset($session_is_admin) || !$session_is_admin) {
flashAlert("Admin access required.", 'error');
redirect($settings_mail_path);
}
$state = escapeSql($_GET['state'] ?? '');
$code = $_GET['code'] ?? '';
$error = escapeSql($_GET['error'] ?? '');
$error_description = escapeSql($_GET['error_description'] ?? '');
$session_state = $_SESSION['mail_oauth_state'] ?? '';
$session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0);
unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']);
if (!empty($error)) {
$msg = "Microsoft OAuth authorization failed: $error";
if (!empty($error_description)) {
$msg .= " ($error_description)";
}
flashAlert($msg, 'error');
redirect($settings_mail_path);
}
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error');
redirect($settings_mail_path);
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error');
redirect($settings_mail_path);
}
if (defined('BASE_URL') && !empty(BASE_URL)) {
$base_url = rtrim((string) BASE_URL, '/');
} else {
$base_url = 'https://' . rtrim((string) $config_base_url, '/');
}
$redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php';
$token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token';
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
$ch = curl_init($token_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirect_uri,
'scope' => $scope,
], '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw_body = curl_exec($ch);
$curl_err = curl_error($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw_body === false || $http_code < 200 || $http_code >= 300) {
$reason = !empty($curl_err) ? $curl_err : "HTTP $http_code";
flashAlert("Microsoft OAuth token exchange failed: $reason", 'error');
redirect($settings_mail_path);
}
$json = json_decode($raw_body, true);
if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) {
flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error');
redirect($settings_mail_path);
}
$refresh_token = (string) $json['refresh_token'];
$access_token = (string) $json['access_token'];
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
mysqli_query($mysqli, "UPDATE settings SET
config_imap_provider = 'microsoft_oauth',
config_smtp_provider = 'microsoft_oauth',
config_mail_oauth_refresh_token = '$refresh_token_esc',
config_mail_oauth_access_token = '$access_token_esc',
config_mail_oauth_access_token_expires_at = '$expires_at_esc'
WHERE company_id = 1
");
logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
redirect($settings_mail_path);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,476 +1,476 @@
<?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 * 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.");
exit("Cron: is not enabled -- Quitting..");
}
if (empty($config_smtp_provider)) {
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
exit(0);
}
/** =======================================================================
* 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 httpFormPost(string $url, array $fields): array {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'ok' => ($raw !== false && $code >= 200 && $code < 300),
'body' => $raw,
'code' => $code,
'err' => $err,
];
}
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 $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;
// 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;
if (!empty($ics_str)) {
$mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar');
}
$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 * 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'];
// 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)$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 = '' 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 * 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_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)$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 = '' 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");
}
}
}
<?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 * 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.");
exit("Cron: is not enabled -- Quitting..");
}
if (empty($config_smtp_provider)) {
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
exit(0);
}
/** =======================================================================
* 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 httpFormPost(string $url, array $fields): array {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'ok' => ($raw !== false && $code >= 200 && $code < 300),
'body' => $raw,
'code' => $code,
'err' => $err,
];
}
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 $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;
// 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;
if (!empty($ics_str)) {
$mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar');
}
$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 * 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'];
// 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)$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 = '' 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 * 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_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)$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 = '' 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");
}
}
}

824
js/app.js
View File

@@ -1,412 +1,412 @@
$(document).ready(function() {
// Prevents resubmit on forms
if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href);
}
// Slide alert up after 4 secs
$("#alert").fadeTo(5000, 500).slideUp(500, function() {
$("#alert").slideUp(500);
});
// Initialize Select2 Elements
$('.select2').select2({
theme: 'bootstrap4',
});
// Initialize TinyMCE
tinymce.init({
selector: '.tinymce-simple',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
setup: function (editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
},
license_key: 'gpl'
});
// Initialize TinyMCE with AI
tinymce.init({
selector: '.tinymce',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
var rewordButtonApi;
editor.ui.registry.addButton('reword', {
icon: 'ai',
tooltip: 'Reword Text',
onAction: function() {
var content = editor.getContent();
// Disable the Reword button
rewordButtonApi.setEnabled(false);
// Show the progress indicator
editor.setProgressState(true);
fetch('ajax.php?ai_reword', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: content }),
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'Text reworded successfully!',
type: 'success',
timeout: 3000
});
})
.catch(error => {
console.error('Error:', error);
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'An error occurred while rewording the text.',
type: 'error',
timeout: 5000
});
});
},
onSetup: function(buttonApi) {
rewordButtonApi = buttonApi;
return function() {};
}
});
}
});
// Initialize TinyMCE AI for Tickets
tinymce.init({
selector: '.tinymceTicket',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 200,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
mobile: {
menubar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize code',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
var rewordButtonApi;
editor.ui.registry.addButton('reword', {
icon: 'ai',
tooltip: 'Reword Text',
onAction: function() {
var content = editor.getContent();
rewordButtonApi.setEnabled(false);
editor.setProgressState(true);
fetch('ajax.php?ai_reword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'Text reworded successfully!',
type: 'success',
timeout: 3000
});
})
.catch(error => {
console.error('Error:', error);
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'An error occurred while rewording the text.',
type: 'error',
timeout: 5000
});
});
},
onSetup: function(buttonApi) {
rewordButtonApi = buttonApi;
return function() {};
}
});
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact Text',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// Initialize TinyMCE Redact-only
tinymce.init({
selector: '.tinymceRedact',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: 'redactButton',
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'redactButton'
},
convert_urls: false,
plugins: 'link image lists table code fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
editor.on('keydown', function(e) {
e.preventDefault();
});
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact',
text: 'REDACT',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// DateTime
$('.datetimepicker').datetimepicker();
// Data Input Mask
$('[data-mask]').inputmask();
// ClipboardJS fix for Bootstrap modals
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
// Tooltip
$('button').tooltip({
trigger: 'click',
placement: 'bottom'
});
function setTooltip(btn, message) {
$(btn).tooltip('hide')
.attr('data-original-title', message)
.tooltip('show');
}
function hideTooltip(btn) {
setTimeout(function() {
$(btn).tooltip('hide');
}, 1000);
}
// Clipboard
var clipboard = new ClipboardJS('.clipboardjs');
clipboard.on('success', function(e) {
setTooltip(e.trigger, 'Copied!');
hideTooltip(e.trigger);
});
clipboard.on('error', function(e) {
setTooltip(e.trigger, 'Failed!');
hideTooltip(e.trigger);
});
// Enable Popovers
$(function() {
$('[data-toggle="popover"]').popover();
});
// Data Tables
new DataTable('.dataTables');
});
$(document).ready(function() {
// Prevents resubmit on forms
if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href);
}
// Slide alert up after 4 secs
$("#alert").fadeTo(5000, 500).slideUp(500, function() {
$("#alert").slideUp(500);
});
// Initialize Select2 Elements
$('.select2').select2({
theme: 'bootstrap4',
});
// Initialize TinyMCE
tinymce.init({
selector: '.tinymce-simple',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
setup: function (editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
},
license_key: 'gpl'
});
// Initialize TinyMCE with AI
tinymce.init({
selector: '.tinymce',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
var rewordButtonApi;
editor.ui.registry.addButton('reword', {
icon: 'ai',
tooltip: 'Reword Text',
onAction: function() {
var content = editor.getContent();
// Disable the Reword button
rewordButtonApi.setEnabled(false);
// Show the progress indicator
editor.setProgressState(true);
fetch('ajax.php?ai_reword', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: content }),
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'Text reworded successfully!',
type: 'success',
timeout: 3000
});
})
.catch(error => {
console.error('Error:', error);
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'An error occurred while rewording the text.',
type: 'error',
timeout: 5000
});
});
},
onSetup: function(buttonApi) {
rewordButtonApi = buttonApi;
return function() {};
}
});
}
});
// Initialize TinyMCE AI for Tickets
tinymce.init({
selector: '.tinymceTicket',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 200,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
mobile: {
menubar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize code',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
var rewordButtonApi;
editor.ui.registry.addButton('reword', {
icon: 'ai',
tooltip: 'Reword Text',
onAction: function() {
var content = editor.getContent();
rewordButtonApi.setEnabled(false);
editor.setProgressState(true);
fetch('ajax.php?ai_reword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'Text reworded successfully!',
type: 'success',
timeout: 3000
});
})
.catch(error => {
console.error('Error:', error);
editor.setProgressState(false);
rewordButtonApi.setEnabled(true);
editor.notificationManager.open({
text: 'An error occurred while rewording the text.',
type: 'error',
timeout: 5000
});
});
},
onSetup: function(buttonApi) {
rewordButtonApi = buttonApi;
return function() {};
}
});
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact Text',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// Initialize TinyMCE Redact-only
tinymce.init({
selector: '.tinymceRedact',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: 'redactButton',
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'redactButton'
},
convert_urls: false,
plugins: 'link image lists table code fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
editor.on('keydown', function(e) {
e.preventDefault();
});
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact',
text: 'REDACT',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// DateTime
$('.datetimepicker').datetimepicker();
// Data Input Mask
$('[data-mask]').inputmask();
// ClipboardJS fix for Bootstrap modals
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
// Tooltip
$('button').tooltip({
trigger: 'click',
placement: 'bottom'
});
function setTooltip(btn, message) {
$(btn).tooltip('hide')
.attr('data-original-title', message)
.tooltip('show');
}
function hideTooltip(btn) {
setTimeout(function() {
$(btn).tooltip('hide');
}, 1000);
}
// Clipboard
var clipboard = new ClipboardJS('.clipboardjs');
clipboard.on('success', function(e) {
setTooltip(e.trigger, 'Copied!');
hideTooltip(e.trigger);
});
clipboard.on('error', function(e) {
setTooltip(e.trigger, 'Failed!');
hideTooltip(e.trigger);
});
// Enable Popovers
$(function() {
$('[data-toggle="popover"]').popover();
});
// Data Tables
new DataTable('.dataTables');
});

30
normalize_eol.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# ITFlow - one-shot CRLF -> LF normalization.
#
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
# wholesale, never edited), and binary assets are skipped outright.
#
# Run once, from the repo root, alongside adding .gitattributes. After that
# .gitattributes keeps new files in line and this script should be a no-op.
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
changed=0
for f in "${candidates[@]}"; do
[ -f "$f" ] || continue
# only touch files that actually contain a CR
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
LC_ALL=C sed -i 's/\r$//' "$f"
changed=$((changed + 1))
fi
done
echo "normalized $changed file(s)"

30
scripts/normalize_eol.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# ITFlow - one-shot CRLF -> LF normalization.
#
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
# wholesale, never edited), and binary assets are skipped outright.
#
# Run once, from the repo root, alongside adding .gitattributes. After that
# .gitattributes keeps new files in line and this script should be a no-op.
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
changed=0
for f in "${candidates[@]}"; do
[ -f "$f" ] || continue
# only touch files that actually contain a CR
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
LC_ALL=C sed -i 's/\r$//' "$f"
changed=$((changed + 1))
fi
done
echo "normalized $changed file(s)"