From 6b6d847756b4146044e98f48156e3d839d0597ef Mon Sep 17 00:00:00 2001 From: cs2000 Date: Wed, 4 Feb 2026 13:23:03 +0000 Subject: [PATCH 01/14] Changes for M365 oAuth - Added web-based Microsoft OAuth onboarding UI in Mail settings, including a Connect Microsoft 365 button and auto-generated callback URI display. - Added Test OAuth Token Refresh UI section. - Updated visibility logic so Test Email Sending and Test IMAP Connection show correctly for OAuth-based configs (not only host/password configs). --- admin/settings_mail.php | 1085 +++++++++++++++++++++------------------ 1 file changed, 590 insertions(+), 495 deletions(-) diff --git a/admin/settings_mail.php b/admin/settings_mail.php index eb0dee02..923739cd 100644 --- a/admin/settings_mail.php +++ b/admin/settings_mail.php @@ -1,495 +1,590 @@ - - -
-
-

SMTP Mail Settings (For Sending Email)

-
-
-
- - - -
- -
-
- -
- -
- - Choose your SMTP provider. OAuth options ignore the SMTP password here. - -
- - - -
-
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
-
- -
-
- -
- -
- -
-
-
-
- -
- -
- - - -
-
-
- -
-
-

IMAP Mail Settings (For Monitoring Ticket Inbox)

-
-
-
- - -
- -
-
- -
- -
- - Select your mailbox provider. OAuth options ignore the IMAP password here. - -
- - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
- -
-
-
- - - - -
- - - -
-
-
- -
-
-

Mail From Configuration

-
-
-
- - -

Each of the "From Email" Addresses need to be able to send email on behalf of the SMTP user configured above -

System Default
-

(used for system tasks such as sending share links)

-
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
Invoices
-

(used for when invoice emails are sent)

- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
Quotes
-

(used for when quote emails are sent)

- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
Tickets
-

(used for when tickets are created and emailed to a client)

- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- - - -
-
-
- - - -
-
-

Test Email Sending

-
-
-
- - -
- - -
- -
-
-
-
-
- - - - - -
-
-

Test IMAP Connection

-
-
-
- - -
- -
-
-
-
- - - - - - + +
+
+

SMTP Mail Settings (For Sending Email)

+
+
+
+ + + +
+ +
+
+ +
+ +
+ + Choose your SMTP provider. OAuth options ignore the SMTP password here. + +
+ + + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ + + +
+
+
+ +
+
+

IMAP Mail Settings (For Monitoring Ticket Inbox)

+
+
+
+ + +
+ +
+
+ +
+ +
+ + Select your mailbox provider. OAuth options ignore the IMAP password here. + +
+ + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + + + + + +
+ +
+
+ +
+ +
+ +
+
+ + Add this callback URI in Entra App Registration, then click Connect to authorize and store refresh token automatically. + +
+ +
+ + + +
+
+
+ +
+
+

Mail From Configuration

+
+
+
+ + +

Each of the "From Email" Addresses need to be able to send email on behalf of the SMTP user configured above +

System Default
+

(used for system tasks such as sending share links)

+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
Invoices
+

(used for when invoice emails are sent)

+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
Quotes
+

(used for when quote emails are sent)

+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
Tickets
+

(used for when tickets are created and emailed to a client)

+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + + +
+
+
+ + + + + +
+
+

Test Email Sending

+
+
+
+ + +
+ + +
+ +
+
+
+
+
+ + + + + + + +
+
+

Test IMAP Connection

+
+
+
+ + +
+ +
+
+
+
+ + + + + + + +
+
+

Test OAuth Token Refresh

+
+
+
+ + + +

+ This validates your refresh token and stores a new access token for + . +

+ + +
+
+
+ + + + + + Date: Wed, 4 Feb 2026 13:23:53 +0000 Subject: [PATCH 02/14] Changes for M365 oAuth - Added handler to start Microsoft OAuth Authorization Code flow (oauth_connect_microsoft_mail) with state generation/validation prep. - Added handler to test OAuth token refresh from admin UI and persist refreshed tokens/expiry. - Updated IMAP test handler to support OAuth token refresh + XOAUTH2 authentication (in addition to legacy LOGIN). --- admin/post/settings_mail.php | 865 +++++++++++++++++++++++------------ 1 file changed, 578 insertions(+), 287 deletions(-) diff --git a/admin/post/settings_mail.php b/admin/post/settings_mail.php index 34d9dc0e..7e5f1d04 100644 --- a/admin/post/settings_mail.php +++ b/admin/post/settings_mail.php @@ -1,287 +1,578 @@ - $email_from, - 'from_name' => $email_from_name, - 'recipient' => $email_to, - 'recipient_name' => 'Chap', - 'subject' => $subject, - 'body' => $body - ] - ]; - - $mail = addToMailQueue($data); - - if ($mail === true) { - flash_alert("Test email queued! Check Admin > Mail queue"); - } else { - flash_alert("Failed to add test mail to queue", 'error'); - } - - redirect(); - -} - -if (isset($_POST['test_email_imap'])) { - - validateCSRFToken($_POST['csrf_token']); - - $host = $config_imap_host; - $port = (int) $config_imap_port; - $encryption = strtolower(trim($config_imap_encryption)); // e.g. "ssl", "tls", "none" - $username = $config_imap_username; - $password = $config_imap_password; - - // Build remote socket (implicit SSL vs plain TCP) - $transport = 'tcp'; - if ($encryption === 'ssl') { - $transport = 'ssl'; - } - - $remote_socket = $transport . '://' . $host . ':' . $port; - - // Stream context (you can tighten these if you want strict validation) - $contextOptions = []; - if (in_array($encryption, ['ssl', 'tls'], true)) { - $contextOptions['ssl'] = [ - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true, - ]; - } - - $context = stream_context_create($contextOptions); - - try { - $errno = 0; - $errstr = ''; - - // 10-second timeout, adjust as needed - $fp = @stream_socket_client( - $remote_socket, - $errno, - $errstr, - 10, - STREAM_CLIENT_CONNECT, - $context - ); - - if (!$fp) { - throw new Exception("Could not connect to IMAP server: [$errno] $errstr"); - } - - stream_set_timeout($fp, 10); - - // Read server greeting (IMAP servers send something like: * OK Dovecot ready) - $greeting = fgets($fp, 1024); - if ($greeting === false || strpos($greeting, '* OK') !== 0) { - fclose($fp); - throw new Exception("Invalid IMAP greeting: " . trim((string) $greeting)); - } - - // If you really want STARTTLS for "tls" (port 143), you can do it here - if ($encryption === 'tls' && stripos($greeting, 'STARTTLS') !== false) { - // Request STARTTLS - fwrite($fp, "A0001 STARTTLS\r\n"); - $line = fgets($fp, 1024); - if ($line === false || stripos($line, 'A0001 OK') !== 0) { - fclose($fp); - throw new Exception("STARTTLS failed: " . trim((string) $line)); - } - - // Enable crypto on the stream - if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - fclose($fp); - throw new Exception("Unable to enable TLS encryption on IMAP connection."); - } - } - - // --- Do LOGIN command --- - $tag = 'A0002'; - - // Simple quoting; this may fail with some special chars in username/password. - $loginCmd = sprintf( - "%s LOGIN \"%s\" \"%s\"\r\n", - $tag, - addcslashes($username, "\\\""), - addcslashes($password, "\\\"") - ); - - fwrite($fp, $loginCmd); - - $success = false; - $errorLine = ''; - - while (!feof($fp)) { - $line = fgets($fp, 2048); - if ($line === false) { - break; - } - - // Look for tagged response for our LOGIN - if (strpos($line, $tag . ' ') === 0) { - if (stripos($line, $tag . ' OK') === 0) { - $success = true; - } else { - $errorLine = trim($line); - } - break; - } - } - - // Always logout / close - fwrite($fp, "A0003 LOGOUT\r\n"); - fclose($fp); - - if ($success) { - flash_alert("Connected successfully"); - } else { - if (!$errorLine) { - $errorLine = 'Unknown IMAP authentication error'; - } - throw new Exception($errorLine); - } - - } catch (Exception $e) { - flash_alert("IMAP connection failed: " . htmlspecialchars($e->getMessage()), 'error'); - } - - redirect(); -} + $config_mail_oauth_client_id, + 'response_type' => 'code', + 'redirect_uri' => $redirect_uri, + 'response_mode' => 'query', + 'scope' => $scope, + 'state' => $state, + 'prompt' => 'consent', + ], '', '&', PHP_QUERY_RFC3986); + + logAction("Settings", "Edit", "$session_name started Microsoft OAuth connect flow for mail settings"); + + redirect($authorize_url); +} + +if (isset($_POST['edit_mail_smtp_settings'])) { + + validateCSRFToken($_POST['csrf_token']); + + $config_smtp_provider = sanitizeInput($_POST['config_smtp_provider']); + $config_smtp_host = sanitizeInput($_POST['config_smtp_host']); + $config_smtp_port = intval($_POST['config_smtp_port'] ?? 0); + $config_smtp_encryption = sanitizeInput($_POST['config_smtp_encryption']); + $config_smtp_username = sanitizeInput($_POST['config_smtp_username']); + $config_smtp_password = sanitizeInput($_POST['config_smtp_password']); + + // Shared OAuth fields + $config_mail_oauth_client_id = sanitizeInput($_POST['config_mail_oauth_client_id']); + $config_mail_oauth_client_secret = sanitizeInput($_POST['config_mail_oauth_client_secret']); + $config_mail_oauth_tenant_id = sanitizeInput($_POST['config_mail_oauth_tenant_id']); + $config_mail_oauth_refresh_token = sanitizeInput($_POST['config_mail_oauth_refresh_token']); + $config_mail_oauth_access_token = sanitizeInput($_POST['config_mail_oauth_access_token']); + + mysqli_query($mysqli, " + UPDATE settings SET + config_smtp_provider = '$config_smtp_provider', + config_smtp_host = '$config_smtp_host', + config_smtp_port = $config_smtp_port, + config_smtp_encryption = '$config_smtp_encryption', + config_smtp_username = '$config_smtp_username', + config_smtp_password = '$config_smtp_password', + config_mail_oauth_client_id = '$config_mail_oauth_client_id', + config_mail_oauth_client_secret = '$config_mail_oauth_client_secret', + config_mail_oauth_tenant_id = '$config_mail_oauth_tenant_id', + config_mail_oauth_refresh_token = '$config_mail_oauth_refresh_token', + config_mail_oauth_access_token = '$config_mail_oauth_access_token' + WHERE company_id = 1 + "); + + logAction("Settings", "Edit", "$session_name edited SMTP settings"); + + flash_alert("SMTP Mail Settings updated"); + + redirect(); + +} + +if (isset($_POST['edit_mail_imap_settings'])) { + + validateCSRFToken($_POST['csrf_token']); + + $config_imap_provider = sanitizeInput($_POST['config_imap_provider']); + $config_imap_host = sanitizeInput($_POST['config_imap_host']); + $config_imap_port = intval($_POST['config_imap_port'] ?? 0); + $config_imap_encryption = sanitizeInput($_POST['config_imap_encryption']); + $config_imap_username = sanitizeInput($_POST['config_imap_username']); + $config_imap_password = sanitizeInput($_POST['config_imap_password']); + + // Shared OAuth fields + $config_mail_oauth_client_id = sanitizeInput($_POST['config_mail_oauth_client_id']); + $config_mail_oauth_client_secret = sanitizeInput($_POST['config_mail_oauth_client_secret']); + $config_mail_oauth_tenant_id = sanitizeInput($_POST['config_mail_oauth_tenant_id']); + $config_mail_oauth_refresh_token = sanitizeInput($_POST['config_mail_oauth_refresh_token']); + $config_mail_oauth_access_token = sanitizeInput($_POST['config_mail_oauth_access_token']); + + mysqli_query($mysqli, " + UPDATE settings SET + config_imap_provider = '$config_imap_provider', + config_imap_host = '$config_imap_host', + config_imap_port = $config_imap_port, + config_imap_encryption = '$config_imap_encryption', + config_imap_username = '$config_imap_username', + config_imap_password = '$config_imap_password', + config_mail_oauth_client_id = '$config_mail_oauth_client_id', + config_mail_oauth_client_secret = '$config_mail_oauth_client_secret', + config_mail_oauth_tenant_id = '$config_mail_oauth_tenant_id', + config_mail_oauth_refresh_token = '$config_mail_oauth_refresh_token', + config_mail_oauth_access_token = '$config_mail_oauth_access_token' + WHERE company_id = 1 + "); + + logAction("Settings", "Edit", "$session_name edited IMAP settings"); + + flash_alert("IMAP Mail Settings updated"); + + redirect(); + +} + +if (isset($_POST['edit_mail_from_settings'])) { + + validateCSRFToken($_POST['csrf_token']); + + $config_mail_from_email = sanitizeInput(filter_var($_POST['config_mail_from_email'], FILTER_VALIDATE_EMAIL)); + $config_mail_from_name = sanitizeInput(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_mail_from_name'])); + + $config_invoice_from_email = sanitizeInput(filter_var($_POST['config_invoice_from_email'], FILTER_VALIDATE_EMAIL)); + $config_invoice_from_name = sanitizeInput(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_invoice_from_name'])); + + $config_quote_from_email = sanitizeInput(filter_var($_POST['config_quote_from_email'], FILTER_VALIDATE_EMAIL)); + $config_quote_from_name = sanitizeInput(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_quote_from_name'])); + + $config_ticket_from_email = sanitizeInput(filter_var($_POST['config_ticket_from_email'], FILTER_VALIDATE_EMAIL)); + $config_ticket_from_name = sanitizeInput(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_ticket_from_name'])); + + mysqli_query($mysqli,"UPDATE settings SET config_mail_from_email = '$config_mail_from_email', config_mail_from_name = '$config_mail_from_name', config_invoice_from_email = '$config_invoice_from_email', config_invoice_from_name = '$config_invoice_from_name', config_quote_from_email = '$config_quote_from_email', config_quote_from_name = '$config_quote_from_name', config_ticket_from_email = '$config_ticket_from_email', config_ticket_from_name = '$config_ticket_from_name' WHERE company_id = 1"); + + logAction("Settings", "Edit", "$session_name edited mail from settings"); + + flash_alert("Mail From Settings updated"); + + redirect(); + +} + +if (isset($_POST['test_email_smtp'])) { + + validateCSRFToken($_POST['csrf_token']); + + $test_email = intval($_POST['test_email']); + + if($test_email == 1) { + $email_from = sanitizeInput($config_mail_from_email); + $email_from_name = sanitizeInput($config_mail_from_name); + } elseif ($test_email == 2) { + $email_from = sanitizeInput($config_invoice_from_email); + $email_from_name = sanitizeInput($config_invoice_from_name); + } elseif ($test_email == 3) { + $email_from = sanitizeInput($config_quote_from_email); + $email_from_name = sanitizeInput($config_quote_from_name); + } else { + $email_from = sanitizeInput($config_ticket_from_email); + $email_from_name = sanitizeInput($config_ticket_from_name); + } + + $email_to = sanitizeInput($_POST['email_to']); + $subject = "Test email from ITFlow"; + $body = "This is a test email from ITFlow. If you are reading this, it worked!"; + + $data = [ + [ + 'from' => $email_from, + 'from_name' => $email_from_name, + 'recipient' => $email_to, + 'recipient_name' => 'Chap', + 'subject' => $subject, + 'body' => $body + ] + ]; + + $mail = addToMailQueue($data); + + if ($mail === true) { + flash_alert("Test email queued! Check Admin > Mail queue"); + } else { + flash_alert("Failed to add test mail to queue", 'error'); + } + + redirect(); + +} + +if (isset($_POST['test_email_imap'])) { + + validateCSRFToken($_POST['csrf_token']); + + $provider = sanitizeInput($config_imap_provider ?? ''); + + $host = $config_imap_host; + $port = (int) $config_imap_port; + $encryption = strtolower(trim($config_imap_encryption)); // e.g. "ssl", "tls", "none" + $username = $config_imap_username; + $password = $config_imap_password; + + // Shared OAuth fields + $config_mail_oauth_client_id = $config_mail_oauth_client_id ?? ''; + $config_mail_oauth_client_secret = $config_mail_oauth_client_secret ?? ''; + $config_mail_oauth_tenant_id = $config_mail_oauth_tenant_id ?? ''; + $config_mail_oauth_refresh_token = $config_mail_oauth_refresh_token ?? ''; + $config_mail_oauth_access_token = $config_mail_oauth_access_token ?? ''; + $config_mail_oauth_access_token_expires_at = $config_mail_oauth_access_token_expires_at ?? ''; + + $is_oauth = ($provider === 'google_oauth' || $provider === 'microsoft_oauth'); + + if ($provider === 'google_oauth') { + if (empty($host)) { + $host = 'imap.gmail.com'; + } + if (empty($port)) { + $port = 993; + } + if (empty($encryption)) { + $encryption = 'ssl'; + } + } elseif ($provider === 'microsoft_oauth') { + if (empty($host)) { + $host = 'outlook.office365.com'; + } + if (empty($port)) { + $port = 993; + } + if (empty($encryption)) { + $encryption = 'ssl'; + } + } + + if (empty($host) || empty($port) || empty($username)) { + flash_alert("IMAP connection failed: Missing host, port, or username.", 'error'); + redirect(); + } + + $token_is_expired = function (?string $expires_at): bool { + if (empty($expires_at)) { + return true; + } + + $ts = strtotime($expires_at); + + if ($ts === false) { + return true; + } + + return ($ts - 60) <= time(); + }; + + $http_form_post = function (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, + ]; + }; + + if ($is_oauth) { + if (!empty($config_mail_oauth_access_token) && !$token_is_expired($config_mail_oauth_access_token_expires_at)) { + $password = $config_mail_oauth_access_token; + } else { + if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token)) { + flash_alert("IMAP OAuth failed: Missing OAuth client credentials or refresh token.", 'error'); + redirect(); + } + + if ($provider === 'google_oauth') { + $response = $http_form_post('https://oauth2.googleapis.com/token', [ + 'client_id' => $config_mail_oauth_client_id, + 'client_secret' => $config_mail_oauth_client_secret, + 'refresh_token' => $config_mail_oauth_refresh_token, + 'grant_type' => 'refresh_token', + ]); + } else { + if (empty($config_mail_oauth_tenant_id)) { + flash_alert("IMAP OAuth failed: Microsoft tenant ID is required.", 'error'); + redirect(); + } + + $token_url = "https://login.microsoftonline.com/" . rawurlencode($config_mail_oauth_tenant_id) . "/oauth2/v2.0/token"; + $response = $http_form_post($token_url, [ + 'client_id' => $config_mail_oauth_client_id, + 'client_secret' => $config_mail_oauth_client_secret, + 'refresh_token' => $config_mail_oauth_refresh_token, + 'grant_type' => 'refresh_token', + ]); + } + + if (!$response['ok']) { + flash_alert("IMAP OAuth failed: Could not refresh access token.", 'error'); + redirect(); + } + + $json = json_decode($response['body'], true); + if (!is_array($json) || empty($json['access_token'])) { + flash_alert("IMAP OAuth failed: Token response did not include an access token.", 'error'); + redirect(); + } + + $password = $json['access_token']; + $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600)); + $refresh_token_to_save = $json['refresh_token'] ?? null; + + $token_esc = mysqli_real_escape_string($mysqli, $password); + $expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at); + + $refresh_sql = ''; + if (!empty($refresh_token_to_save)) { + $refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token_to_save); + $refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'"; + } + + mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1"); + } + } + + // Build remote socket (implicit SSL vs plain TCP) + $transport = 'tcp'; + if ($encryption === 'ssl') { + $transport = 'ssl'; + } + + $remote_socket = $transport . '://' . $host . ':' . $port; + + // Stream context (you can tighten these if you want strict validation) + $context_options = []; + if (in_array($encryption, ['ssl', 'tls'], true)) { + $context_options['ssl'] = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]; + } + + $context = stream_context_create($context_options); + + try { + $errno = 0; + $errstr = ''; + + // 10-second timeout, adjust as needed + $fp = @stream_socket_client( + $remote_socket, + $errno, + $errstr, + 10, + STREAM_CLIENT_CONNECT, + $context + ); + + if (!$fp) { + throw new Exception("Could not connect to IMAP server: [$errno] $errstr"); + } + + stream_set_timeout($fp, 10); + + // Read server greeting (IMAP servers send something like: * OK Dovecot ready) + $greeting = fgets($fp, 1024); + if ($greeting === false || strpos($greeting, '* OK') !== 0) { + fclose($fp); + throw new Exception("Invalid IMAP greeting: " . trim((string) $greeting)); + } + // If you really want STARTTLS for "tls" (port 143), you can do it here + if ($encryption === 'tls' && stripos($greeting, 'STARTTLS') !== false) { + fwrite($fp, "A0001 STARTTLS\r\n"); + $line = fgets($fp, 1024); + if ($line === false || stripos($line, 'A0001 OK') !== 0) { + fclose($fp); + throw new Exception("STARTTLS failed: " . trim((string) $line)); + } + + if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + fclose($fp); + throw new Exception("Unable to enable TLS encryption on IMAP connection."); + } + } + + $tag = 'A0002'; + + if ($is_oauth) { + $oauth_b64 = base64_encode("user={$username}\x01auth=Bearer {$password}\x01\x01"); + $auth_cmd = sprintf("%s AUTHENTICATE XOAUTH2 %s\r\n", $tag, $oauth_b64); + fwrite($fp, $auth_cmd); + } else { + $login_cmd = sprintf( + "%s LOGIN \"%s\" \"%s\"\r\n", + $tag, + addcslashes($username, "\\\""), + addcslashes($password, "\\\"") + ); + + fwrite($fp, $login_cmd); + } + + $success = false; + $error_line = ''; + + while (!feof($fp)) { + $line = fgets($fp, 2048); + if ($line === false) { + break; + } + + if (strpos($line, $tag . ' ') === 0) { + if (stripos($line, $tag . ' OK') === 0) { + $success = true; + } else { + $error_line = trim($line); + } + break; + } + } + + // Always logout / close + fwrite($fp, "A0003 LOGOUT\r\n"); + fclose($fp); + + if ($success) { + if ($is_oauth) { + flash_alert("Connected successfully using OAuth"); + } else { + flash_alert("Connected successfully"); + } + } else { + if (!$error_line) { + $error_line = 'Unknown IMAP authentication error'; + } + throw new Exception($error_line); + } + + } catch (Exception $e) { + flash_alert("IMAP connection failed: " . htmlspecialchars($e->getMessage()), 'error'); + } + + redirect(); +} + + +if (isset($_POST['test_oauth_token_refresh'])) { + + validateCSRFToken($_POST['csrf_token']); + + $provider = sanitizeInput($_POST['oauth_provider'] ?? ''); + + if ($provider !== 'google_oauth' && $provider !== 'microsoft_oauth') { + flash_alert("OAuth token test failed: unsupported provider.", 'error'); + redirect(); + } + + $oauth_client_id = sanitizeInput($config_mail_oauth_client_id ?? ''); + $oauth_client_secret = sanitizeInput($config_mail_oauth_client_secret ?? ''); + $oauth_tenant_id = sanitizeInput($config_mail_oauth_tenant_id ?? ''); + $oauth_refresh_token = sanitizeInput($config_mail_oauth_refresh_token ?? ''); + + if (empty($oauth_client_id) || empty($oauth_client_secret) || empty($oauth_refresh_token)) { + flash_alert("OAuth token test failed: missing client ID, client secret, or refresh token.", 'error'); + redirect(); + } + + if ($provider === 'microsoft_oauth' && empty($oauth_tenant_id)) { + flash_alert("OAuth token test failed: Microsoft tenant ID is required.", 'error'); + redirect(); + } + + $token_url = 'https://oauth2.googleapis.com/token'; + if ($provider === 'microsoft_oauth') { + $token_url = "https://login.microsoftonline.com/" . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token"; + } + + $post_fields = http_build_query([ + 'client_id' => $oauth_client_id, + 'client_secret' => $oauth_client_secret, + 'refresh_token' => $oauth_refresh_token, + 'grant_type' => 'refresh_token', + ]); + + $ch = curl_init($token_url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields); + 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) { + $err_msg = !empty($curl_err) ? $curl_err : "HTTP $http_code"; + flash_alert("OAuth token test failed: $err_msg", 'error'); + redirect(); + } + + $json = json_decode($raw_body, true); + + if (!is_array($json) || empty($json['access_token'])) { + flash_alert("OAuth token test failed: access token missing in provider response.", 'error'); + redirect(); + } + + $new_access_token = sanitizeInput($json['access_token']); + $new_expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600)); + $new_refresh_token = !empty($json['refresh_token']) ? sanitizeInput($json['refresh_token']) : ''; + + $new_access_token_esc = mysqli_real_escape_string($mysqli, $new_access_token); + $new_expires_at_esc = mysqli_real_escape_string($mysqli, $new_expires_at); + + $refresh_sql = ''; + if (!empty($new_refresh_token)) { + $new_refresh_token_esc = mysqli_real_escape_string($mysqli, $new_refresh_token); + $refresh_sql = ", config_mail_oauth_refresh_token = '$new_refresh_token_esc'"; + } + + mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '$new_access_token_esc', config_mail_oauth_access_token_expires_at = '$new_expires_at_esc'$refresh_sql WHERE company_id = 1"); + + $provider_label = $provider === 'microsoft_oauth' ? 'Microsoft 365' : 'Google Workspace'; + logAction("Settings", "Edit", "$session_name tested OAuth token refresh for $provider_label mail settings"); + + flash_alert("OAuth token refresh successful for $provider_label. Access token expires at $new_expires_at."); + redirect(); +} From f6845a046fddaa9c56977115b9f7a74fadfbf274 Mon Sep 17 00:00:00 2001 From: cs2000 Date: Wed, 4 Feb 2026 13:24:50 +0000 Subject: [PATCH 03/14] Changes for M365 oAuth - New callback endpoint to complete Microsoft OAuth web flow. - Validates admin session + OAuth state, exchanges authorization code for tokens, stores refresh/access tokens and expiry, and redirects with success/error feedback. --- admin/oauth_microsoft_mail_callback.php | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 admin/oauth_microsoft_mail_callback.php diff --git a/admin/oauth_microsoft_mail_callback.php b/admin/oauth_microsoft_mail_callback.php new file mode 100644 index 00000000..9c7e6cac --- /dev/null +++ b/admin/oauth_microsoft_mail_callback.php @@ -0,0 +1,101 @@ + $session_state_expires) { + flash_alert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error'); + redirect('/admin/settings_mail.php'); +} + +if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) { + flash_alert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error'); + redirect('/admin/settings_mail.php'); +} + +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"; + flash_alert("Microsoft OAuth token exchange failed: $reason", 'error'); + redirect('/admin/settings_mail.php'); +} + +$json = json_decode($raw_body, true); +if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) { + flash_alert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error'); + redirect('/admin/settings_mail.php'); +} + +$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 +"); + +logAction("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings"); +flash_alert("Microsoft OAuth connected successfully. Token expires at $expires_at."); +redirect('/admin/settings_mail.php'); From f3f9d0dd71dfaa1dd5773020b7047370385b781d Mon Sep 17 00:00:00 2001 From: cs2000 Date: Wed, 4 Feb 2026 13:25:32 +0000 Subject: [PATCH 04/14] Changes for M365 oAuth - Added OAuth token lifecycle helpers (expiry check, refresh, persistence). - Updated SMTP XOAUTH2 send path to automatically refresh expired/missing access tokens for Microsoft/Google providers before sending queued mail. --- cron/mail_queue.php | 819 +++++++++++++++++++++++++------------------- 1 file changed, 462 insertions(+), 357 deletions(-) diff --git a/cron/mail_queue.php b/cron/mail_queue.php index c052f55a..07a9542a 100644 --- a/cron/mail_queue.php +++ b/cron/mail_queue.php @@ -1,357 +1,462 @@ -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); -} - -/** ======================================================================= - * 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_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 = 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"); - - // 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 = sanitizeInput($email_recipient); - $email_subject_logging = sanitizeInput($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 = sanitizeInput(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 = sanitizeInput($email_recipient); - $email_subject_logging = sanitizeInput($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 - ); - - 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_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; - - 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); +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' | '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); +} + +/** ======================================================================= + * 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 OAuth helpers + sender function + * ======================================================================= */ +function token_is_expired(?string $expires_at): bool { + if (empty($expires_at)) { + return true; + } + + $ts = strtotime($expires_at); + + if ($ts === false) { + return true; + } + + return ($ts - 60) <= time(); +} + +function http_form_post(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 persist_mail_oauth_tokens(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 refresh_mail_oauth_access_token(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array { + if (empty($oauth_client_id) || empty($oauth_client_secret) || empty($oauth_refresh_token)) { + return null; + } + + if ($provider === 'google_oauth') { + $response = http_form_post('https://oauth2.googleapis.com/token', [ + 'client_id' => $oauth_client_id, + 'client_secret' => $oauth_client_secret, + 'refresh_token' => $oauth_refresh_token, + 'grant_type' => 'refresh_token', + ]); + } elseif ($provider === 'microsoft_oauth') { + if (empty($oauth_tenant_id)) { + return null; + } + + $token_url = "https://login.microsoftonline.com/" . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token"; + $response = http_form_post($token_url, [ + 'client_id' => $oauth_client_id, + 'client_secret' => $oauth_client_secret, + 'refresh_token' => $oauth_refresh_token, + 'grant_type' => 'refresh_token', + ]); + } else { + return null; + } + + if (!$response['ok']) { + return null; + } + + $json = json_decode($response['body'], true); + + if (!is_array($json) || empty($json['access_token'])) { + return null; + } + + $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600)); + + return [ + 'access_token' => $json['access_token'], + 'expires_at' => $expires_at, + 'refresh_token' => $json['refresh_token'] ?? null, + ]; +} + +function resolve_mail_oauth_access_token(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) && !token_is_expired($oauth_access_token_expires_at)) { + return $oauth_access_token; + } + + $tokens = refresh_mail_oauth_access_token($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; + } + + persist_mail_oauth_tokens($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; + + $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 = resolve_mail_oauth_access_token( + $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; +} + +/** ======================================================================= + * 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']; + + // Check sender + 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"); + + // 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 = sanitizeInput($email_recipient); + $email_subject_logging = sanitizeInput($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 = sanitizeInput(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 = sanitizeInput($email_recipient); + $email_subject_logging = sanitizeInput($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 + ); + + 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); From d2e8dc1439877d4886569a92b6485a9439f0f1c7 Mon Sep 17 00:00:00 2001 From: cs2000 Date: Wed, 4 Feb 2026 13:26:17 +0000 Subject: [PATCH 05/14] Changes for M365 oAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed email queue gating for OAuth SMTP setups by treating configured config_smtp_provider as mail-enabled, even when config_smtp_host is blank. - Restores queueing for public ticket reply emails (including “Public Comment & Email”) and related ticket notification paths. --- agent/ticket.php | 360 +++++++++++++++++++++-------------------------- 1 file changed, 158 insertions(+), 202 deletions(-) diff --git a/agent/ticket.php b/agent/ticket.php index c1cf0793..266f01db 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -48,7 +48,7 @@ if (isset($_GET['ticket_id'])) { require_once "../includes/footer.php"; } else { - $row = mysqli_fetch_assoc($sql); + $row = mysqli_fetch_array($sql); $client_id = intval($row['client_id']); $client_name = nullable_htmlentities($row['client_name']); $client_type = nullable_htmlentities($row['client_type']); @@ -80,11 +80,11 @@ if (isset($_GET['ticket_id'])) { //Set Ticket Badge Color based of priority if ($ticket_priority == "High") { - $ticket_priority_display = "$ticket_priority"; + $ticket_priority_display = "$ticket_priority"; } elseif ($ticket_priority == "Medium") { - $ticket_priority_display = "$ticket_priority"; + $ticket_priority_display = "$ticket_priority"; } elseif ($ticket_priority == "Low") { - $ticket_priority_display = "$ticket_priority"; + $ticket_priority_display = "$ticket_priority"; } else { $ticket_priority_display = ""; } @@ -113,7 +113,7 @@ if (isset($_GET['ticket_id'])) { $ticket_assigned_to = intval($row['ticket_assigned_to']); if (empty($ticket_assigned_to)) { - $ticket_assigned_to_display = "Unassigned"; + $ticket_assigned_to_display = "Not Assigned"; } else { $ticket_assigned_to_display = nullable_htmlentities($row['user_name']); } @@ -149,8 +149,7 @@ if (isset($_GET['ticket_id'])) { $vendor_description = nullable_htmlentities($row['vendor_description']); $vendor_account_number = nullable_htmlentities($row['vendor_account_number']); $vendor_contact_name = nullable_htmlentities($row['vendor_contact_name']); - $vendor_phone_country_code = nullable_htmlentities($row['vendor_phone_country_code']); - $vendor_phone = nullable_htmlentities(formatPhoneNumber($row['vendor_phone'], $vendor_phone_country_code)); + $vendor_phone = formatPhoneNumber($row['vendor_phone']); $vendor_extension = nullable_htmlentities($row['vendor_extension']); $vendor_email = nullable_htmlentities($row['vendor_email']); $vendor_website = nullable_htmlentities($row['vendor_website']); @@ -187,49 +186,73 @@ if (isset($_GET['ticket_id'])) { if($project_manager) { $sql_project_manager = mysqli_query($mysqli,"SELECT * FROM users WHERE user_id = $project_manager"); - $row = mysqli_fetch_assoc($sql_project_manager); + $row = mysqli_fetch_array($sql_project_manager); $project_manager_name = nullable_htmlentities($row['user_name']); } if ($contact_id) { //Get Contact Ticket Stats $ticket_related_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_open FROM tickets WHERE ticket_status != 'Closed' AND ticket_contact_id = $contact_id "); - $row = mysqli_fetch_assoc($ticket_related_open); + $row = mysqli_fetch_array($ticket_related_open); $ticket_related_open = intval($row['ticket_related_open']); $ticket_related_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_closed FROM tickets WHERE ticket_status = 'Closed' AND ticket_contact_id = $contact_id "); - $row = mysqli_fetch_assoc($ticket_related_closed); + $row = mysqli_fetch_array($ticket_related_closed); $ticket_related_closed = intval($row['ticket_related_closed']); $ticket_related_total = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS ticket_related_total FROM tickets WHERE ticket_contact_id = $contact_id "); - $row = mysqli_fetch_assoc($ticket_related_total); + $row = mysqli_fetch_array($ticket_related_total); $ticket_related_total = intval($row['ticket_related_total']); } //Get Total Ticket Time $ticket_total_reply_time = mysqli_query($mysqli, "SELECT SEC_TO_TIME(SUM(TIME_TO_SEC(ticket_reply_time_worked))) AS ticket_total_reply_time FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_total_reply_time); + $row = mysqli_fetch_array($ticket_total_reply_time); $ticket_total_reply_time = nullable_htmlentities($row['ticket_total_reply_time']); + + // Client Tags + $client_tag_name_display_array = array(); + $client_tag_id_array = array(); + $sql_client_tags = mysqli_query($mysqli, "SELECT * FROM client_tags LEFT JOIN tags ON client_tags.tag_id = tags.tag_id WHERE client_id = $client_id ORDER BY tag_name ASC"); + while ($row = mysqli_fetch_array($sql_client_tags)) { + + $client_tag_id = intval($row['tag_id']); + $client_tag_name = nullable_htmlentities($row['tag_name']); + $client_tag_color = nullable_htmlentities($row['tag_color']); + if (empty($client_tag_color)) { + $client_tag_color = "dark"; + } + $client_tag_icon = nullable_htmlentities($row['tag_icon']); + if (empty($client_tag_icon)) { + $client_tag_icon = "tag"; + } + + $client_tag_id_array[] = $client_tag_id; + $client_tag_name_display_array[] = "$client_tag_name"; + } + $client_tags_display = implode(' ', $client_tag_name_display_array); + + // Get the number of ticket Responses $ticket_responses_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_responses FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_responses_sql); + $row = mysqli_fetch_array($ticket_responses_sql); $ticket_responses = intval($row['ticket_responses']); $ticket_all_comments_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_all_comments_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_ticket_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_all_comments_sql); + $row = mysqli_fetch_array($ticket_all_comments_sql); $ticket_all_comments_count = intval($row['ticket_all_comments_count']); $ticket_internal_notes_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_internal_notes_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND ticket_reply_type = 'Internal' AND ticket_reply_ticket_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_internal_notes_sql); + $row = mysqli_fetch_array($ticket_internal_notes_sql); $ticket_internal_notes_count = intval($row['ticket_internal_notes_count']); $ticket_public_comments_sql = mysqli_query($mysqli, "SELECT COUNT(ticket_reply_id) AS ticket_public_comments_count FROM ticket_replies WHERE ticket_reply_archived_at IS NULL AND (ticket_reply_type = 'Public' OR ticket_reply_type = 'Client') AND ticket_reply_ticket_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_public_comments_sql); + $row = mysqli_fetch_array($ticket_public_comments_sql); $ticket_public_comments_count = intval($row['ticket_public_comments_count']); $ticket_events_sql = mysqli_query($mysqli, "SELECT COUNT(log_id) AS ticket_events_count FROM logs WHERE log_type = 'Ticket' AND log_entity_id = $ticket_id"); - $row = mysqli_fetch_assoc($ticket_events_sql); + $row = mysqli_fetch_array($ticket_events_sql); $ticket_events_count = intval($row['ticket_events_count']); @@ -353,7 +376,7 @@ if (isset($_GET['ticket_id'])) { - + @@ -397,7 +420,7 @@ if (isset($_GET['ticket_id'])) { Summarize - Merge Ticket + Merge @@ -440,38 +463,35 @@ if (isset($_GET['ticket_id'])) {
- Updated: ($ticket_updated_at_ago)" ?> + Updated:
- Agent: +
-
- -
- Billable: + Ticket is Yes"; + echo "Billable"; } else { - echo "No"; + echo "Not Billable"; } ?> @@ -519,11 +539,16 @@ if (isset($_GET['ticket_id'])) {
-
Tasks
-
-
+ Tasks Completed + % +
+
/
+ +
+ +
@@ -534,9 +559,9 @@ if (isset($_GET['ticket_id'])) {
-
-
- Description / Comments +
+
+ Ticket Details
@@ -545,14 +570,14 @@ if (isset($_GET['ticket_id'])) {
-
+
$name [View][Download]"; + echo "
$name | Download | View"; } ?>
@@ -562,24 +587,26 @@ if (isset($_GET['ticket_id'])) { = 2 && empty($ticket_resolved_at) && empty($ticket_closed_at)) { ?> + +
-
+
-
+
@@ -593,10 +620,11 @@ if (isset($_GET['ticket_id'])) {
-
-
-
-
+
+ +
+
+
-
- -
-
-
+ +
+
@@ -640,14 +666,14 @@ if (isset($_GET['ticket_id'])) {
-
-
-
- +
+
+ +
-
+
@@ -658,7 +684,7 @@ if (isset($_GET['ticket_id'])) { purify($row['ticket_reply']); $ticket_reply_type = nullable_htmlentities($row['ticket_reply_type']); @@ -679,7 +705,7 @@ if (isset($_GET['ticket_id'])) { $user_avatar = nullable_htmlentities($row['user_avatar']); $user_initials = initials($row['user_name']); $avatar_link = "../uploads/users/$user_id/$user_avatar"; - $ticket_reply_time_worked = $row['ticket_reply_time_worked']; + $ticket_reply_time_worked = date_create($row['ticket_reply_time_worked']); } $sql_ticket_reply_attachments = mysqli_query( @@ -706,19 +732,12 @@ if (isset($_GET['ticket_id'])) { -
+

- +
-
- - - Time worked: - - - - +
Time worked:
@@ -770,7 +789,7 @@ if (isset($_GET['ticket_id'])) { $name | Download | View"; @@ -790,10 +809,10 @@ if (isset($_GET['ticket_id'])) {
- -
-
-
Activity Summary
+ +
"> +
+
Ticket Details
-
+
-
- Created: - () +
+ Created:
- -
- Created by: +
+ Created by:
- -
- Source: + +
+ Source:
- -
- Category: + 0) { ?> +
+ Category:
-
- 1st resp: +
+ FR:
-
- Total time: +
+ Time worked:
@@ -857,34 +875,34 @@ if (isset($_GET['ticket_id'])) { - +
-
- Resolved: +
+ Resolved:
- -
- Closed by: +
+ Closed by:
-
- Closed: +
+ Closed:
-
- Feedback: +
+ Feedback:
@@ -898,8 +916,8 @@ if (isset($_GET['ticket_id'])) { 0)) { ?>
-
-
Tasks
+
+
Tasks
= 2) { ?>