diff --git a/cron/mail_queue.php b/cron/mail_queue.php
index 07a9542a..a892869b 100644
--- a/cron/mail_queue.php
+++ b/cron/mail_queue.php
@@ -43,7 +43,7 @@ class StaticTokenProvider implements OAuthTokenProvider {
* Load settings
* ======================================================================= */
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1");
-$row = mysqli_fetch_array($sql_settings);
+$row = mysqli_fetch_assoc($sql_settings);
$config_enable_cron = intval($row['config_enable_cron']);
@@ -307,7 +307,7 @@ function sendQueueEmail(
$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)) {
+ 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'];
@@ -401,7 +401,7 @@ $sql_failed_queue = mysqli_query(
);
if (mysqli_num_rows($sql_failed_queue) > 0) {
- while ($rowf = mysqli_fetch_array($sql_failed_queue)) {
+ 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'];
diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php
index d4bf66da..e0903ef8 100644
--- a/cron/ticket_email_parser.php
+++ b/cron/ticket_email_parser.php
@@ -1,804 +1,914 @@
- Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
-}
-
-// System temp directory & lock
-$temp_dir = sys_get_temp_dir();
-$lock_file_path = "{$temp_dir}/itflow_email_parser_{$installation_id}.lock";
-
-if (file_exists($lock_file_path)) {
- $file_age = time() - filemtime($lock_file_path);
- if ($file_age > 300) {
- unlink($lock_file_path);
- logApp("Cron-Email-Parser", "warning", "Cron Email Parser detected a lock file was present but was over 5 minutes old so it removed it.");
- } else {
- logApp("Cron-Email-Parser", "warning", "Lock file present. Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
- exit("Script is already running. Exiting.");
- }
-}
-file_put_contents($lock_file_path, "Locked");
-
-// Ensure lock gets removed even on fatal error
-register_shutdown_function(function() use ($lock_file_path) {
- if (file_exists($lock_file_path)) {
- @unlink($lock_file_path);
- }
-});
-
-// Allowed attachment extensions
-$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx', 'csv', 'xls', 'xlsx', 'xlsm', 'zip', 'tar', 'gz');
-
-/** ------------------------------------------------------------------
- * Ticket / Reply helpers (unchanged)
- * ------------------------------------------------------------------ */
-function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file) {
- global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_ticket_new_ticket_notification_email, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_ticket_default_billable, $allowed_extensions;
-
- // Atomically increment and get the new ticket number
- mysqli_query($mysqli, "
- UPDATE settings
- SET
- config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number),
- config_ticket_next_number = config_ticket_next_number + 1
- WHERE company_id = 1
- ");
-
- $ticket_number = mysqli_insert_id($mysqli);
-
- // Clean up the message
- $message = trim($message);
- // Remove DOCTYPE and meta tags
- $message = preg_replace('/]*>/i', '', $message);
- $message = preg_replace('/]*>/i', '', $message);
- // Remove ,
, and their closing tags
- $message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
- // Collapse excess whitespace
- $message = preg_replace('/\s+/', ' ', $message);
- // Convert newlines to
- $message = nl2br($message);
- // Wrap final formatted message
- $message = "Email from: $contact_name <$contact_email> at $date:-
$message
";
-
- $ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
- $message_esc = mysqli_real_escape_string($mysqli, $message);
- $contact_email_esc = mysqli_real_escape_string($mysqli, $contact_email);
- $client_id = intval($client_id);
-
- $url_key = randomString(156);
-
- mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix_esc', ticket_number = $ticket_number, ticket_source = 'Email', ticket_subject = '$subject', ticket_details = '$message_esc', ticket_priority = 'Low', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id");
- $id = mysqli_insert_id($mysqli);
-
- // Logging
- logAction("Ticket", "Create", "Email parser: Client contact $contact_email_esc created ticket $ticket_prefix_esc$ticket_number ($subject) ($id)", $client_id, $id);
-
- mkdirMissing('../uploads/tickets/');
- $att_dir = "../uploads/tickets/" . $id . "/";
- mkdirMissing($att_dir);
-
- // Move original .eml into the ticket folder
- rename("../uploads/tmp/{$original_message_file}", "{$att_dir}/{$original_message_file}");
- $original_message_file_esc = mysqli_real_escape_string($mysqli, $original_message_file);
- mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = 'Original-parsed-email.eml', ticket_attachment_reference_name = '$original_message_file_esc', ticket_attachment_ticket_id = $id");
-
- // Save non-inline attachments
- foreach ($attachments as $attachment) {
- $att_name = $attachment['name'];
- $att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
-
- if (in_array($att_extension, $allowed_extensions)) {
- $att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
- $att_saved_path = $att_dir . $att_saved_filename;
- file_put_contents($att_saved_path, $attachment['content']);
-
- $ticket_attachment_name = sanitizeInput($att_name);
- $ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
-
- $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
- $ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
- mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_ticket_id = $id");
- } else {
- $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
- logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $contact_email_esc for ticket $ticket_prefix_esc$ticket_number", $client_id, $id);
- }
- }
-
- // Guest ticket watchers
- if ($client_id == 0) {
- mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$contact_email_esc', watcher_ticket_id = $id");
- }
-
- $data = [];
- if ($config_ticket_client_general_notifications == 1) {
- $subject_email = "Ticket created - [$config_ticket_prefix$ticket_number] - $subject";
- $body = "##- Please type your reply above this line -##
Hello $contact_name,
Thank you for your email. A ticket regarding \"$subject\" has been automatically created for you.
Ticket: $config_ticket_prefix$ticket_number
Subject: $subject
Status: New
Portal: View ticket
--
$company_name - Support
$config_ticket_from_email
$company_phone";
- $data[] = [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $contact_email,
- 'recipient_name' => $contact_name,
- 'subject' => $subject_email,
- 'body' => mysqli_real_escape_string($mysqli, $body)
- ];
- }
-
- if ($config_ticket_new_ticket_notification_email) {
- if ($client_id == 0) {
- $client_name = "Guest";
- } else {
- $client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id");
- $client_row = mysqli_fetch_array($client_sql);
- $client_name = sanitizeInput($client_row['client_name']);
- }
- $email_subject = "$config_app_name - New Ticket - $client_name: $subject";
- $email_body = "Hello,
This is a notification that a new ticket has been raised in ITFlow.
Client: $client_name
Priority: Low (email parsed)
Link: https://$config_base_url/agent/ticket.php?ticket_id=$id
--------------------------------
$subject
$message";
-
- $data[] = [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $config_ticket_new_ticket_notification_email,
- 'recipient_name' => $config_ticket_from_name,
- 'subject' => $email_subject,
- 'body' => mysqli_real_escape_string($mysqli, $email_body)
- ];
- }
-
- addToMailQueue($data);
- customAction('ticket_create', $id);
-
- return true;
-}
-
-function addReply($from_email, $date, $subject, $ticket_number, $message, $attachments) {
- global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $allowed_extensions;
-
- $ticket_reply_type = 'Client';
- // $message contains the raw HTML body from IMAP
-
- // 1) Remove the reply separator and everything below it (HTML-aware)
- // This matches: ##- Please type your reply above this line -## and EVERYTHING after it
- $message = preg_replace(
- '/]*>##-\s*Please\s+type\s+your\s+reply\s+above\s+this\s+line\s*-##<\/i>.*$/is',
- '',
- $message
- );
-
- // 2) Clean up the remaining message
-
- // Remove DOCTYPE and meta tags
- $message = preg_replace('/]*>/i', '', $message);
- $message = preg_replace('/]*>/i', '', $message);
-
- // Remove , , and their closing tags
- $message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
-
- // Trim leading/trailing whitespace
- $message = trim($message);
-
- // Normalize line breaks to spaces
- $message = preg_replace('/\r\n|\r|\n/', ' ', $message);
-
- // Convert to
for HTML display
- $message = nl2br($message);
-
- // 3) Final wrapper
- $message = "Email from: $from_email at $date:-
- $message
";
-
- $ticket_number_esc = intval($ticket_number);
- $message_esc = mysqli_real_escape_string($mysqli, $message);
- $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
-
- $row = mysqli_fetch_array(mysqli_query($mysqli, "SELECT ticket_id, ticket_subject, ticket_status, ticket_contact_id, ticket_client_id, contact_email, client_name
- FROM tickets
- LEFT JOIN contacts on tickets.ticket_contact_id = contacts.contact_id
- LEFT JOIN clients on tickets.ticket_client_id = clients.client_id
- WHERE ticket_number = $ticket_number_esc LIMIT 1"));
-
- if ($row) {
- $ticket_id = intval($row['ticket_id']);
- $ticket_subject = sanitizeInput($row['ticket_subject']);
- $ticket_status = sanitizeInput($row['ticket_status']);
- $ticket_reply_contact = intval($row['ticket_contact_id']);
- $ticket_contact_email = sanitizeInput($row['contact_email']);
- $client_id = intval($row['ticket_client_id']);
- $client_name = sanitizeInput($row['client_name']);
-
- if ($ticket_status == 5) {
- $config_ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
- $ticket_number_esc2 = mysqli_real_escape_string($mysqli, $ticket_number);
-
- appNotify("Ticket", "Email parser: $from_email attempted to re-open ticket $config_ticket_prefix_esc$ticket_number_esc2 (ID $ticket_id) - check inbox manually to see email", "/agent/ticket.php?ticket_id=$ticket_id", $client_id);
-
- $email_subject = "Action required: This ticket is already closed";
- $email_body = "Hi there,
You've tried to reply to a ticket that is closed - we won't see your response.
Please raise a new ticket by sending a new e-mail to our support address below.
--
$company_name - Support
$config_ticket_from_email
$company_phone";
-
- $data = [
- [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $from_email,
- 'recipient_name' => $from_email,
- 'subject' => $email_subject,
- 'body' => mysqli_real_escape_string($mysqli, $email_body)
- ]
- ];
-
- addToMailQueue($data);
- return true;
- }
-
- if (empty($ticket_contact_email) || $ticket_contact_email !== $from_email) {
- $from_email_esc2 = mysqli_real_escape_string($mysqli, $from_email);
- $row2 = mysqli_fetch_array(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_email = '$from_email_esc2' AND contact_client_id = $client_id LIMIT 1"));
- if ($row2) {
- $ticket_reply_contact = intval($row2['contact_id']);
- } else {
- $ticket_reply_type = 'Internal';
- $ticket_reply_contact = '0';
- $message = "WARNING: Contact email mismatch
$message";
- $message_esc = mysqli_real_escape_string($mysqli, $message);
- }
- }
-
- mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$message_esc', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $ticket_reply_contact, ticket_reply_ticket_id = $ticket_id");
- $reply_id = mysqli_insert_id($mysqli);
-
- $ticket_dir = "../uploads/tickets/" . $ticket_id . "/";
- mkdirMissing($ticket_dir);
-
- foreach ($attachments as $attachment) {
- $att_name = $attachment['name'];
- $att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
-
- if (in_array($att_extension, $allowed_extensions)) {
- $att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
- $att_saved_path = $ticket_dir . $att_saved_filename;
- file_put_contents($att_saved_path, $attachment['content']);
-
- $ticket_attachment_name = sanitizeInput($att_name);
- $ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
-
- $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
- $ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
- mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_reply_id = $reply_id, ticket_attachment_ticket_id = $ticket_id");
- } else {
- $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
- logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $from_email_esc for ticket $config_ticket_prefix$ticket_number_esc", $client_id, $ticket_id);
- }
- }
-
- $ticket_assigned_to_sql = mysqli_query($mysqli, "SELECT ticket_assigned_to FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
- if ($ticket_assigned_to_sql) {
- $row3 = mysqli_fetch_array($ticket_assigned_to_sql);
- $ticket_assigned_to = intval($row3['ticket_assigned_to']);
-
- if ($ticket_assigned_to) {
- $tech_sql = mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1");
- $tech_row = mysqli_fetch_array($tech_sql);
- $tech_email = sanitizeInput($tech_row['user_email']);
- $tech_name = sanitizeInput($tech_row['user_name']);
-
- $email_subject = "$config_app_name - Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
- $email_body = "Hello $tech_name,
A new reply has been added to the below ticket.
Client: $client_name
Ticket: $config_ticket_prefix$ticket_number
Subject: $ticket_subject
Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id
--------------------------------
$message_esc";
-
- $data = [
- [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $tech_email,
- 'recipient_name' => $tech_name,
- 'subject' => mysqli_real_escape_string($mysqli, $email_subject),
- 'body' => mysqli_real_escape_string($mysqli, $email_body)
- ]
- ];
- addToMailQueue($data);
- }
- }
-
- mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
-
- logAction("Ticket", "Edit", "Email parser: Client contact $from_email_esc updated ticket $config_ticket_prefix$ticket_number_esc ($subject)", $client_id, $ticket_id);
- customAction('ticket_reply_client', $ticket_id);
- return true;
- } else {
- return false;
- }
-}
-
-/** ------------------------------------------------------------------
- * OAuth helpers + provider guard
- * ------------------------------------------------------------------ */
-
-// returns true if expires_at ('Y-m-d H:i:s') is in the past (or missing)
-function tokenExpired(?string $expires_at): bool {
- if (empty($expires_at)) return true;
- $ts = strtotime($expires_at);
- if ($ts === false) return true;
- // refresh a little early (60s) to avoid race
- return ($ts - 60) <= time();
-}
-
-// very small form-encoded POST helper using curl
-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];
-}
-
-/**
- * Get a valid access token for Google Workspace IMAP via refresh token if needed.
- * Uses settings: config_mail_oauth_client_id / _client_secret / _refresh_token / _access_token / _access_token_expires_at
- * Updates globals if refreshed (so later logging can reflect it if you want to persist).
- */
-function getGoogleAccessToken(string $username): ?string {
- // pull from global settings variables you already load
- global $mysqli,
- $config_mail_oauth_client_id,
- $config_mail_oauth_client_secret,
- $config_mail_oauth_refresh_token,
- $config_mail_oauth_access_token,
- $config_mail_oauth_access_token_expires_at;
-
- // If we have a not-expired token, use it
- if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
- return $config_mail_oauth_access_token;
- }
-
- // Need to refresh?
- if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token)) {
- // Nothing we can do
- return null;
- }
-
- $resp = httpFormPost(
- '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',
- ]
- );
-
- if (!$resp['ok']) return null;
-
- $json = json_decode($resp['body'], true);
- if (!is_array($json) || empty($json['access_token'])) return null;
-
- // Calculate new expiry
- $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
-
- // Update in-memory globals (and persist to DB)
- $config_mail_oauth_access_token = $json['access_token'];
- $config_mail_oauth_access_token_expires_at = $expires_at;
-
- $at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
- $exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
- mysqli_query($mysqli, "UPDATE settings SET
- config_mail_oauth_access_token = '{$at_esc}',
- config_mail_oauth_access_token_expires_at = '{$exp_esc}'
- WHERE company_id = 1
- ");
-
- return $config_mail_oauth_access_token;
-}
-
-/**
- * Get a valid access token for Microsoft 365 IMAP via refresh token if needed.
- * Uses settings: config_mail_oauth_client_id / _client_secret / _tenant_id / _refresh_token / _access_token / _access_token_expires_at
- */
-function getMicrosoftAccessToken(string $username): ?string {
- global $mysqli,
- $config_mail_oauth_client_id,
- $config_mail_oauth_client_secret,
- $config_mail_oauth_tenant_id,
- $config_mail_oauth_refresh_token,
- $config_mail_oauth_access_token,
- $config_mail_oauth_access_token_expires_at;
-
- if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
- return $config_mail_oauth_access_token;
- }
-
- if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token) || empty($config_mail_oauth_tenant_id)) {
- return null;
- }
-
- $url = "https://login.microsoftonline.com/".rawurlencode($config_mail_oauth_tenant_id)."/oauth2/v2.0/token";
-
- $resp = httpFormPost($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',
- // IMAP/SMTP scopes typically included at initial consent; not needed for refresh
- ]);
-
- if (!$resp['ok']) return null;
-
- $json = json_decode($resp['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));
-
- $config_mail_oauth_access_token = $json['access_token'];
- $config_mail_oauth_access_token_expires_at = $expires_at;
-
- $at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
- $exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
- mysqli_query($mysqli, "UPDATE settings SET
- config_mail_oauth_access_token = '{$at_esc}',
- config_mail_oauth_access_token_expires_at = '{$exp_esc}'
- WHERE company_id = 1
- ");
-
- return $config_mail_oauth_access_token;
-}
-
-// Provider from settings (may be NULL/empty to disable IMAP polling)
-$imap_provider = $config_imap_provider ?? '';
-if ($imap_provider === null) $imap_provider = '';
-
-if ($imap_provider === '') {
- // IMAP disabled by admin: exit cleanly
- logApp("Cron-Email-Parser", "info", "IMAP polling skipped: provider not configured.");
- @unlink($lock_file_path);
- exit(0);
-}
-
-/** ------------------------------------------------------------------
- * Webklex IMAP setup (supports Standard / Google OAuth / Microsoft OAuth)
- * ------------------------------------------------------------------ */
-use Webklex\PHPIMAP\ClientManager;
-
-$validate_cert = true;
-
-// Defaults from settings (standard IMAP)
-$host = $config_imap_host;
-$port = (int)$config_imap_port;
-$encr = !empty($config_imap_encryption) ? $config_imap_encryption : 'notls'; // 'ssl'|'tls'|'notls'
-$user = $config_imap_username;
-$pass = $config_imap_password;
-$auth = null; // 'oauth' for OAuth providers
-
-if ($imap_provider === 'google_oauth') {
- $host = 'imap.gmail.com';
- $port = 993;
- $encr = 'ssl';
- $auth = 'oauth';
- $pass = getGoogleAccessToken($user);
- if (empty($pass)) {
- logApp("Cron-Email-Parser", "error", "Google OAuth: no usable access token (check refresh token/client credentials).");
- @unlink($lock_file_path);
- exit(1);
- }
-} elseif ($imap_provider === 'microsoft_oauth') {
- $host = 'outlook.office365.com';
- $port = 993;
- $encr = 'ssl';
- $auth = 'oauth';
- $pass = getMicrosoftAccessToken($user);
- if (empty($pass)) {
- logApp("Cron-Email-Parser", "error", "Microsoft OAuth: no usable access token (check refresh token/client credentials/tenant).");
- @unlink($lock_file_path);
- exit(1);
- }
-} else {
- // standard_imap (username/password)
- if (empty($host) || empty($port) || empty($user)) {
- logApp("Cron-Email-Parser", "error", "Standard IMAP: missing host/port/username.");
- @unlink($lock_file_path);
- exit(1);
- }
-}
-
-$cm = new ClientManager();
-
-$client = $cm->make(array_filter([
- 'host' => $host,
- 'port' => $port,
- 'encryption' => $encr, // 'ssl' | 'tls' | null
- 'validate_cert' => (bool)$validate_cert,
- 'username' => $user, // full mailbox address (OAuth uses user as principal)
- 'password' => $pass, // access token when $auth === 'oauth'
- 'authentication' => $auth, // 'oauth' or null
- 'protocol' => 'imap',
-]));
-
-try {
- $client->connect();
-} catch (\Throwable $e) {
- echo "Error connecting to IMAP server: " . $e->getMessage();
- @unlink($lock_file_path);
- exit(1);
-}
-
-$inbox = $client->getFolderByPath('INBOX');
-
-$targetFolderPath = 'ITFlow';
-try {
- $targetFolder = $client->getFolderByPath($targetFolderPath);
-} catch (\Throwable $e) {
- $client->createFolder($targetFolderPath);
- $targetFolder = $client->getFolderByPath($targetFolderPath);
-}
-
-// Fetch unseen messages
-$messages = $inbox->messages()->leaveUnread()->unseen()->get();
-
-// Counters
-$processed_count = 0;
-$unprocessed_count = 0;
-
-// Process messages
-foreach ($messages as $message) {
- $email_processed = false;
-
- // Save original message as .eml (getRawMessage() doesn't seem to work properly)
- mkdirMissing('../uploads/tmp/');
- $original_message_file = "processed-eml-" . randomString(200) . ".eml";
- $raw_message = (string)$message->getHeader()->raw . "\r\n\r\n" . ($message->getRawBody() ?? $message->getHTMLBody() ?? $message->getTextBody());
- file_put_contents("../uploads/tmp/{$original_message_file}", $raw_message);
-
- // From
- $fromCol = $message->getFrom();
- $fromFirst = ($fromCol && $fromCol->count()) ? $fromCol->first() : null;
- $from_email = sanitizeInput($fromFirst->mail ?? 'itflow-guest@example.com');
- $from_name = sanitizeInput($fromFirst->personal ?? 'Unknown');
-
- $from_domain = explode("@", $from_email);
- $from_domain = sanitizeInput(end($from_domain));
-
- // Subject
- $subject = sanitizeInput((string)$message->getSubject() ?: 'No Subject');
-
- // Date (string)
- $dateAttr = $message->getDate(); // Attribute
- $dateRaw = $dateAttr ? (string)$dateAttr : ''; // e.g. "Tue, 10 Sep 2025 13:22:05 +0000"
- $ts = $dateRaw ? strtotime($dateRaw) : false;
- $date = sanitizeInput($ts !== false ? date('Y-m-d H:i:s', $ts) : date('Y-m-d H:i:s'));
-
- // Body (prefer HTML)
- $message_body_html = $message->getHTMLBody();
- $message_body_text = $message->getTextBody();
- $message_body = $message_body_html ?: nl2br(htmlspecialchars((string)$message_body_text));
-
- // Handle attachments (inline vs regular)
- $attachments = [];
- foreach ($message->getAttachments() as $att) {
- $attrs = $att->getAttributes(); // v6.2: canonical source
- $dispo = strtolower((string)($attrs['disposition'] ?? ''));
- $cid = $attrs['id'] ?? null; // Content-ID
- $content = $attrs['content'] ?? null; // binary
- $mime = $att->getMimeType();
- $name = $att->getName() ?: 'attachment';
-
- $is_inline = false;
- if ($dispo === 'inline' && $cid && $content !== null) {
- $cid_trim = trim($cid, '<>');
- $dataUri = "data:$mime;base64,".base64_encode($content);
- $message_body = str_replace(["cid:$cid_trim", "cid:$cid"], $dataUri, $message_body);
- $is_inline = true;
- }
-
- if (!$is_inline && $content !== null) {
- $attachments[] = ['name' => $name, 'content' => $content];
- }
- }
-
- // 1. Reply to existing ticket with the number in subject
- if (preg_match("/\[$config_ticket_prefix(\d+)\]/", $subject, $ticket_number_matches)) {
- $ticket_number = intval($ticket_number_matches[1]);
- $email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
- }
-
- // 2. Fuzzy duplicate check using a known contact/domain and similar_text subject
- if (!$email_processed && strlen(trim($subject)) > 10) {
- $contact_id = 0;
- $client_id = 0;
-
- // First: check if sender is a registered contact
- $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
- $contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
- $contact_row = mysqli_fetch_array($contact_sql);
-
- if ($contact_row) {
- $contact_id = intval($contact_row['contact_id']);
- $client_id = intval($contact_row['contact_client_id']);
- } else {
- // Else: check if sender domain is registered
- $from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
- $domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
- $domain_row = mysqli_fetch_assoc($domain_sql);
-
- if ($domain_row && $from_domain == $domain_row['domain_name']) {
- $client_id = intval($domain_row['domain_client_id']);
- }
- }
-
- // If we found either a contact or a domain, check recent tickets for a matching subject
- if ($client_id) {
- $recent_tickets_sql = mysqli_query($mysqli,
- "SELECT ticket_id, ticket_number, ticket_subject
- FROM tickets
- WHERE ticket_client_id = $client_id AND ticket_resolved_at IS NULL
- AND ticket_created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
- );
-
- while ($rowt = mysqli_fetch_assoc($recent_tickets_sql)) {
- $ticket_number = intval($rowt['ticket_number']);
- $existing_subject = $rowt['ticket_subject'];
-
- // Calculate similarity percentage
- similar_text(strtolower($subject), strtolower($existing_subject), $percent);
-
- if ($percent >= 95) {
- // Treat as a reply/duplicate
- $email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
- break;
- }
- }
- }
- }
-
- // 3. A known, registered contact?
- if (!$email_processed) {
- $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
- $any_contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
- $rowc = mysqli_fetch_array($any_contact_sql);
-
- if ($rowc) {
- $contact_name = sanitizeInput($rowc['contact_name']);
- $contact_id = intval($rowc['contact_id']);
- $contact_email = sanitizeInput($rowc['contact_email']);
- $client_id = intval($rowc['contact_client_id']);
-
- $email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file);
- }
- }
-
- // 4. A known domain?
- if (!$email_processed) {
- $from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
- $domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
- $rowd = mysqli_fetch_assoc($domain_sql);
-
- if ($rowd && $from_domain == $rowd['domain_name']) {
- $client_id = intval($rowd['domain_client_id']);
-
- // Create a new contact
- $contact_name = $from_name;
- $contact_email = $from_email;
- mysqli_query($mysqli, "INSERT INTO contacts SET contact_name = '".mysqli_real_escape_string($mysqli, $contact_name)."', contact_email = '".mysqli_real_escape_string($mysqli, $contact_email)."', contact_notes = 'Added automatically via email parsing.', contact_client_id = $client_id");
- $contact_id = mysqli_insert_id($mysqli);
-
- logAction("Contact", "Create", "Email parser: created contact " . mysqli_real_escape_string($mysqli, $contact_name), $client_id, $contact_id);
- customAction('contact_create', $contact_id);
-
- $email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file);
- }
- }
-
- // 5. Unknown sender allowed?
- if (!$email_processed && $config_ticket_email_parse_unknown_senders) {
- $bad_from_pattern = "/daemon|postmaster/i";
- if (!preg_match($bad_from_pattern, $from_email)) {
- $email_processed = addTicket(0, $from_name, $from_email, 0, $date, $subject, $message_body, $attachments, $original_message_file);
- }
- }
-
-
- // Flag/move based on processing result
- if ($email_processed) {
- $processed_count++; // increment first so a move failure doesn't hide the success
- try {
- $message->setFlag('Seen');
- // Move using the Folder object (top-level "ITFlow")
- $message->move($targetFolderPath);
- // optional: logApp("Cron-Email-Parser", "info", "Moved message to ITFlow");
- } catch (\Throwable $e) {
- // >>> Put the extra logging RIGHT HERE
- $subj = (string)$message->getSubject();
- $uid = method_exists($message, 'getUid') ? $message->getUid() : 'n/a';
- $path = (is_object($targetFolder) && property_exists($targetFolder, 'path'))
- ? (string)$targetFolder->path
- : $targetFolderPath;
- logApp(
- "Cron-Email-Parser",
- "warning",
- "Move failed (subject=\"$subj\", uid=$uid) to [$path]: ".$e->getMessage()
- );
- }
- } else {
- $unprocessed_count++;
- try {
- $message->setFlag('Flagged');
- $message->unsetFlag('Seen');
- } catch (\Throwable $e) {
- logApp("Cron-Email-Parser", "warning", "Flag update failed: ".$e->getMessage());
- }
- }
-
- // Cleanup temp .eml if still present (e.g., reply path)
- if (isset($original_message_file)) {
- $tmp_path = "../uploads/tmp/{$original_message_file}";
- if (file_exists($tmp_path)) { @unlink($tmp_path); }
- }
-}
-
-// Expunge & disconnect
-try {
- $client->expunge();
-} catch (\Throwable $e) {
- // ignore
-}
-$client->disconnect();
-
-// Execution timing (optional)
-$script_end_time = microtime(true);
-$execution_time = $script_end_time - $script_start_time;
-$execution_time_formatted = number_format($execution_time, 2);
-
-$processed_info = "Processed: $processed_count email(s), Unprocessed: $unprocessed_count email(s)";
-// logAction("Cron-Email-Parser", "Execution", "Cron Email Parser executed in $execution_time_formatted seconds. $processed_info");
-
-// Remove the lock file
-unlink($lock_file_path);
-
-// DEBUG
-echo "\nLock File Path: $lock_file_path\n";
-if (file_exists($lock_file_path)) {
- echo "\nLock is present\n\n";
-}
-echo "Processed Emails into tickets: $processed_count\n";
-echo "Unprocessed Emails: $unprocessed_count\n";
+ Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
+}
+
+// System temp directory & lock
+$temp_dir = sys_get_temp_dir();
+$lock_file_path = "{$temp_dir}/itflow_email_parser_{$installation_id}.lock";
+
+if (file_exists($lock_file_path)) {
+ $file_age = time() - filemtime($lock_file_path);
+ if ($file_age > 300) {
+ unlink($lock_file_path);
+ logApp("Cron-Email-Parser", "warning", "Cron Email Parser detected a lock file was present but was over 5 minutes old so it removed it.");
+ } else {
+ logApp("Cron-Email-Parser", "warning", "Lock file present. Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
+ exit("Script is already running. Exiting.");
+ }
+}
+file_put_contents($lock_file_path, "Locked");
+
+// Ensure lock gets removed even on fatal error
+register_shutdown_function(function() use ($lock_file_path) {
+ if (file_exists($lock_file_path)) {
+ @unlink($lock_file_path);
+ }
+});
+
+// Allowed attachment extensions
+$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'txt', 'md', 'doc', 'docx', 'csv', 'xls', 'xlsx', 'xlsm', 'zip', 'tar', 'gz');
+
+/** ------------------------------------------------------------------
+ * Ticket / Reply helpers (unchanged)
+ * ------------------------------------------------------------------ */
+function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file) {
+ global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_ticket_new_ticket_notification_email, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_ticket_default_billable, $allowed_extensions;
+
+ // Atomically increment and get the new ticket number
+ mysqli_query($mysqli, "
+ UPDATE settings
+ SET
+ config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number),
+ config_ticket_next_number = config_ticket_next_number + 1
+ WHERE company_id = 1
+ ");
+
+ $ticket_number = mysqli_insert_id($mysqli);
+
+ // Clean up the message
+ $message = trim($message);
+ // Remove DOCTYPE and meta tags
+ $message = preg_replace('/]*>/i', '', $message);
+ $message = preg_replace('/]*>/i', '', $message);
+ // Remove , , and their closing tags
+ $message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
+ // Collapse excess whitespace
+ $message = preg_replace('/\s+/', ' ', $message);
+ // Convert newlines to
+ $message = nl2br($message);
+ // Wrap final formatted message
+ $message = "Email from: $contact_name <$contact_email> at $date:-
$message
";
+
+ $ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
+ $message_esc = mysqli_real_escape_string($mysqli, $message);
+ $contact_email_esc = mysqli_real_escape_string($mysqli, $contact_email);
+ $client_id = intval($client_id);
+
+ $url_key = randomString(32);
+
+ mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix_esc', ticket_number = $ticket_number, ticket_source = 'Email', ticket_subject = '$subject', ticket_details = '$message_esc', ticket_priority = 'Low', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id");
+ $id = mysqli_insert_id($mysqli);
+
+ // Logging
+ logAction("Ticket", "Create", "Email parser: Client contact $contact_email_esc created ticket $ticket_prefix_esc$ticket_number ($subject) ($id)", $client_id, $id);
+
+ mkdirMissing('../uploads/tickets/');
+ $att_dir = "../uploads/tickets/" . $id . "/";
+ mkdirMissing($att_dir);
+
+ // Move original .eml into the ticket folder
+ rename("../uploads/tmp/{$original_message_file}", "{$att_dir}/{$original_message_file}");
+ $original_message_file_esc = mysqli_real_escape_string($mysqli, $original_message_file);
+ mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = 'Original-parsed-email.eml', ticket_attachment_reference_name = '$original_message_file_esc', ticket_attachment_ticket_id = $id");
+
+ // Save non-inline attachments
+ foreach ($attachments as $attachment) {
+ $att_name = $attachment['name'];
+ $att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
+
+ if (in_array($att_extension, $allowed_extensions)) {
+ $att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
+ $att_saved_path = $att_dir . $att_saved_filename;
+ file_put_contents($att_saved_path, $attachment['content']);
+
+ $ticket_attachment_name = sanitizeInput($att_name);
+ $ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
+
+ $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
+ $ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
+ mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_ticket_id = $id");
+ } else {
+ $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
+ logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $contact_email_esc for ticket $ticket_prefix_esc$ticket_number", $client_id, $id);
+ }
+ }
+
+ // Guest ticket watchers
+ if ($client_id == 0) {
+ mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$contact_email_esc', watcher_ticket_id = $id");
+ }
+
+ // External email
+ $bad_pattern = "/do[\W_]*not[\W_]*reply|no[\W_]*reply/i";
+ $data = [];
+ if ($config_ticket_client_general_notifications == 1 && !preg_match($bad_pattern, $contact_email)) {
+ $subject_email = "Ticket created - [$config_ticket_prefix$ticket_number] - $subject";
+ $body = "##- Please type your reply above this line -##
Hello $contact_name,
Thank you for your email. A ticket regarding \"$subject\" has been automatically created for you.
Ticket: $config_ticket_prefix$ticket_number
Subject: $subject
Status: New
Portal: View ticket
--
$company_name - Support
$config_ticket_from_email
$company_phone";
+ $data[] = [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $contact_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $subject_email,
+ 'body' => mysqli_real_escape_string($mysqli, $body)
+ ];
+ }
+
+ // Internal email
+ if ($config_ticket_new_ticket_notification_email) {
+ if ($client_id == 0) {
+ $client_name = "Guest";
+ $client_uri = '';
+ } else {
+ $client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id");
+ $client_row = mysqli_fetch_assoc($client_sql);
+ $client_name = sanitizeInput($client_row['client_name']);
+ $client_uri = "&client_id=$client_id";
+ }
+ $email_subject = "$config_app_name - New Ticket - $client_name: $subject";
+ $email_body = "Hello,
This is a notification that a new ticket has been raised in ITFlow.
Client: $client_name
Priority: Low (email parsed)
Link: https://$config_base_url/agent/ticket.php?ticket_id=$id$client_uri
--------------------------------
$subject
$message";
+
+ $data[] = [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $config_ticket_new_ticket_notification_email,
+ 'recipient_name' => $config_ticket_from_name,
+ 'subject' => $email_subject,
+ 'body' => mysqli_real_escape_string($mysqli, $email_body)
+ ];
+ }
+
+ addToMailQueue($data);
+ customAction('ticket_create', $id);
+
+ return true;
+}
+
+function addReply($from_email, $date, $subject, $ticket_number, $message, $attachments) {
+ global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $allowed_extensions;
+
+ $ticket_reply_type = 'Client';
+ // $message contains the raw HTML body from IMAP
+
+ // 1) Remove the reply separator and everything below it (HTML-aware)
+ // This matches: ##- Please type your reply above this line -## and EVERYTHING after it
+ $message = preg_replace(
+ '/]*>##-\s*Please\s+type\s+your\s+reply\s+above\s+this\s+line\s*-##<\/i>.*$/is',
+ '',
+ $message
+ );
+
+ // 2) Clean up the remaining message
+
+ // Remove DOCTYPE and meta tags
+ $message = preg_replace('/]*>/i', '', $message);
+ $message = preg_replace('/]*>/i', '', $message);
+
+ // Remove , , and their closing tags
+ $message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
+
+ // Trim leading/trailing whitespace
+ $message = trim($message);
+
+ // Normalize line breaks to spaces
+ $message = preg_replace('/\r\n|\r|\n/', ' ', $message);
+
+ // Convert to
for HTML display
+ $message = nl2br($message);
+
+ // 3) Final wrapper
+ $message = "Email from: $from_email at $date:-
$message
";
+
+ $ticket_number_esc = intval($ticket_number);
+ $message_esc = mysqli_real_escape_string($mysqli, $message);
+ $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
+
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_subject, ticket_status, ticket_contact_id, ticket_client_id, contact_email, client_name
+ FROM tickets
+ LEFT JOIN contacts on tickets.ticket_contact_id = contacts.contact_id
+ LEFT JOIN clients on tickets.ticket_client_id = clients.client_id
+ WHERE ticket_number = $ticket_number_esc LIMIT 1"));
+
+ if ($row) {
+ $ticket_id = intval($row['ticket_id']);
+ $ticket_subject = sanitizeInput($row['ticket_subject']);
+ $ticket_status = sanitizeInput($row['ticket_status']);
+ $ticket_reply_contact = intval($row['ticket_contact_id']);
+ $ticket_contact_email = sanitizeInput($row['contact_email']);
+ $client_id = intval($row['ticket_client_id']);
+ if ($client_id) {
+ $client_uri = "&client_id=$client_id";
+ } else {
+ $client_uri = '';
+ }
+ $client_name = sanitizeInput($row['client_name']);
+
+ if ($ticket_status == 5) {
+ $config_ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
+ $ticket_number_esc2 = mysqli_real_escape_string($mysqli, $ticket_number);
+
+ appNotify("Ticket", "Email parser: $from_email attempted to re-open ticket $config_ticket_prefix_esc$ticket_number_esc2 (ID $ticket_id) - check inbox manually to see email", "/agent/ticket.php?ticket_id=$ticket_id$client_uri", $client_id);
+
+ $email_subject = "Action required: This ticket is already closed";
+ $email_body = "Hi there,
You've tried to reply to a ticket that is closed - we won't see your response.
Please raise a new ticket by sending a new e-mail to our support address below.
--
$company_name - Support
$config_ticket_from_email
$company_phone";
+
+ $data = [
+ [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $from_email,
+ 'recipient_name' => $from_email,
+ 'subject' => $email_subject,
+ 'body' => mysqli_real_escape_string($mysqli, $email_body)
+ ]
+ ];
+
+ addToMailQueue($data);
+ return true;
+ }
+
+ if (empty($ticket_contact_email) || $ticket_contact_email !== $from_email) {
+ $from_email_esc2 = mysqli_real_escape_string($mysqli, $from_email);
+ $row2 = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_email = '$from_email_esc2' AND contact_client_id = $client_id LIMIT 1"));
+ if ($row2) {
+ $ticket_reply_contact = intval($row2['contact_id']);
+ } else {
+ $ticket_reply_type = 'Internal';
+ $ticket_reply_contact = '0';
+ $message = "WARNING: Contact email mismatch
$message";
+ $message_esc = mysqli_real_escape_string($mysqli, $message);
+ }
+ }
+
+ mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$message_esc', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $ticket_reply_contact, ticket_reply_ticket_id = $ticket_id");
+ $reply_id = mysqli_insert_id($mysqli);
+
+ $ticket_dir = "../uploads/tickets/" . $ticket_id . "/";
+ mkdirMissing($ticket_dir);
+
+ foreach ($attachments as $attachment) {
+ $att_name = $attachment['name'];
+ $att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
+
+ if (in_array($att_extension, $allowed_extensions)) {
+ $att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
+ $att_saved_path = $ticket_dir . $att_saved_filename;
+ file_put_contents($att_saved_path, $attachment['content']);
+
+ $ticket_attachment_name = sanitizeInput($att_name);
+ $ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
+
+ $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
+ $ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
+ mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_reply_id = $reply_id, ticket_attachment_ticket_id = $ticket_id");
+ } else {
+ $ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
+ logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $from_email_esc for ticket $config_ticket_prefix$ticket_number_esc", $client_id, $ticket_id);
+ }
+ }
+
+ $ticket_assigned_to_sql = mysqli_query($mysqli, "SELECT ticket_assigned_to FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
+ if ($ticket_assigned_to_sql) {
+ $row3 = mysqli_fetch_assoc($ticket_assigned_to_sql);
+ $ticket_assigned_to = intval($row3['ticket_assigned_to']);
+
+ if ($ticket_assigned_to) {
+ $tech_sql = mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1");
+ $tech_row = mysqli_fetch_assoc($tech_sql);
+ $tech_email = sanitizeInput($tech_row['user_email']);
+ $tech_name = sanitizeInput($tech_row['user_name']);
+
+ $email_subject = "$config_app_name - Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
+ $email_body = "Hello $tech_name,
A new reply has been added to the below ticket.
Client: $client_name
Ticket: $config_ticket_prefix$ticket_number
Subject: $ticket_subject
Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri
--------------------------------
$message_esc";
+
+ $data = [
+ [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $tech_email,
+ 'recipient_name' => $tech_name,
+ 'subject' => mysqli_real_escape_string($mysqli, $email_subject),
+ 'body' => mysqli_real_escape_string($mysqli, $email_body)
+ ]
+ ];
+ addToMailQueue($data);
+ }
+ }
+
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
+
+ logAction("Ticket", "Edit", "Email parser: Client contact $from_email_esc updated ticket $config_ticket_prefix$ticket_number_esc ($subject)", $client_id, $ticket_id);
+ customAction('ticket_reply_client', $ticket_id);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+/** ------------------------------------------------------------------
+ * OAuth helpers + provider guard
+ * ------------------------------------------------------------------ */
+
+// returns true if expires_at ('Y-m-d H:i:s') is in the past (or missing)
+function tokenExpired(?string $expires_at): bool {
+ if (empty($expires_at)) return true;
+ $ts = strtotime($expires_at);
+ if ($ts === false) return true;
+ // refresh a little early (60s) to avoid race
+ return ($ts - 60) <= time();
+}
+
+// very small form-encoded POST helper using curl
+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];
+}
+
+/**
+ * Get a valid access token for Google Workspace IMAP via refresh token if needed.
+ * Uses settings: config_mail_oauth_client_id / _client_secret / _refresh_token / _access_token / _access_token_expires_at
+ * Updates globals if refreshed (so later logging can reflect it if you want to persist).
+ */
+function getGoogleAccessToken(string $username): ?string {
+ // pull from global settings variables you already load
+ global $mysqli,
+ $config_mail_oauth_client_id,
+ $config_mail_oauth_client_secret,
+ $config_mail_oauth_refresh_token,
+ $config_mail_oauth_access_token,
+ $config_mail_oauth_access_token_expires_at;
+
+ // If we have a not-expired token, use it
+ if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
+ return $config_mail_oauth_access_token;
+ }
+
+ // Need to refresh?
+ if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token)) {
+ // Nothing we can do
+ return null;
+ }
+
+ $resp = httpFormPost(
+ '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',
+ ]
+ );
+
+ if (!$resp['ok']) return null;
+
+ $json = json_decode($resp['body'], true);
+ if (!is_array($json) || empty($json['access_token'])) return null;
+
+ // Calculate new expiry
+ $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
+
+ // Update in-memory globals (and persist to DB)
+ $config_mail_oauth_access_token = $json['access_token'];
+ $config_mail_oauth_access_token_expires_at = $expires_at;
+
+ $at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
+ $exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
+ mysqli_query($mysqli, "UPDATE settings SET
+ config_mail_oauth_access_token = '{$at_esc}',
+ config_mail_oauth_access_token_expires_at = '{$exp_esc}'
+ WHERE company_id = 1
+ ");
+
+ return $config_mail_oauth_access_token;
+}
+
+/**
+ * Get a valid access token for Microsoft 365 IMAP via refresh token if needed.
+ * Uses settings: config_mail_oauth_client_id / _client_secret / _tenant_id / _refresh_token / _access_token / _access_token_expires_at
+ */
+function getMicrosoftAccessToken(string $username): ?string {
+ global $mysqli,
+ $config_mail_oauth_client_id,
+ $config_mail_oauth_client_secret,
+ $config_mail_oauth_tenant_id,
+ $config_mail_oauth_refresh_token,
+ $config_mail_oauth_access_token,
+ $config_mail_oauth_access_token_expires_at;
+
+ if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
+ return $config_mail_oauth_access_token;
+ }
+
+ if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token) || empty($config_mail_oauth_tenant_id)) {
+ return null;
+ }
+
+ $url = "https://login.microsoftonline.com/".rawurlencode($config_mail_oauth_tenant_id)."/oauth2/v2.0/token";
+
+ $resp = httpFormPost($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',
+ // IMAP/SMTP scopes typically included at initial consent; not needed for refresh
+ ]);
+
+ if (!$resp['ok']) return null;
+
+ $json = json_decode($resp['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));
+
+ $config_mail_oauth_access_token = $json['access_token'];
+ $config_mail_oauth_access_token_expires_at = $expires_at;
+
+ $at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
+ $exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
+ mysqli_query($mysqli, "UPDATE settings SET
+ config_mail_oauth_access_token = '{$at_esc}',
+ config_mail_oauth_access_token_expires_at = '{$exp_esc}'
+ WHERE company_id = 1
+ ");
+
+ return $config_mail_oauth_access_token;
+}
+
+// Provider from settings (may be NULL/empty to disable IMAP polling)
+$imap_provider = $config_imap_provider ?? '';
+if ($imap_provider === null) $imap_provider = '';
+
+if ($imap_provider === '') {
+ // IMAP disabled by admin: exit cleanly
+ logApp("Cron-Email-Parser", "info", "IMAP polling skipped: provider not configured.");
+ @unlink($lock_file_path);
+ exit(0);
+}
+
+/** ------------------------------------------------------------------
+ * Webklex IMAP setup (supports Standard / Google OAuth / Microsoft OAuth)
+ * ------------------------------------------------------------------ */
+use Webklex\PHPIMAP\ClientManager;
+
+$validate_cert = true;
+
+// Defaults from settings (standard IMAP)
+$host = $config_imap_host;
+$port = (int)$config_imap_port;
+$encr = !empty($config_imap_encryption) ? $config_imap_encryption : 'notls'; // 'ssl'|'tls'|'notls'
+$user = $config_imap_username;
+$pass = $config_imap_password;
+$auth = null; // 'oauth' for OAuth providers
+
+if ($imap_provider === 'google_oauth') {
+ $host = 'imap.gmail.com';
+ $port = 993;
+ $encr = 'ssl';
+ $auth = 'oauth';
+ $pass = getGoogleAccessToken($user);
+ if (empty($pass)) {
+ logApp("Cron-Email-Parser", "error", "Google OAuth: no usable access token (check refresh token/client credentials).");
+ @unlink($lock_file_path);
+ exit(1);
+ }
+} elseif ($imap_provider === 'microsoft_oauth') {
+ $host = 'outlook.office365.com';
+ $port = 993;
+ $encr = 'ssl';
+ $auth = 'oauth';
+ $pass = getMicrosoftAccessToken($user);
+ if (empty($pass)) {
+ logApp("Cron-Email-Parser", "error", "Microsoft OAuth: no usable access token (check refresh token/client credentials/tenant).");
+ @unlink($lock_file_path);
+ exit(1);
+ }
+} else {
+ // standard_imap (username/password)
+ if (empty($host) || empty($port) || empty($user)) {
+ logApp("Cron-Email-Parser", "error", "Standard IMAP: missing host/port/username.");
+ @unlink($lock_file_path);
+ exit(1);
+ }
+}
+
+$cm = new ClientManager();
+
+$client = $cm->make(array_filter([
+ 'host' => $host,
+ 'port' => $port,
+ 'encryption' => $encr, // 'ssl' | 'tls' | null
+ 'validate_cert' => (bool)$validate_cert,
+ 'username' => $user, // full mailbox address (OAuth uses user as principal)
+ 'password' => $pass, // access token when $auth === 'oauth'
+ 'authentication' => $auth, // 'oauth' or null
+ 'protocol' => 'imap',
+]));
+
+try {
+ $client->connect();
+} catch (\Throwable $e) {
+ echo "Error connecting to IMAP server: " . $e->getMessage();
+ @unlink($lock_file_path);
+ exit(1);
+}
+
+$inbox = $client->getFolderByPath('INBOX');
+
+$targetFolderPath = 'ITFlow';
+try {
+ $targetFolder = $client->getFolderByPath($targetFolderPath);
+} catch (\Throwable $e) {
+ $client->createFolder($targetFolderPath);
+ $targetFolder = $client->getFolderByPath($targetFolderPath);
+}
+
+// Fetch unseen messages
+$messages = $inbox->messages()->leaveUnread()->unseen()->get();
+
+// Counters
+$processed_count = 0;
+$unprocessed_count = 0;
+
+// Process messages
+foreach ($messages as $message) {
+ $email_processed = false;
+
+ // Save original message as .eml (getRawMessage() doesn't seem to work properly)
+ mkdirMissing('../uploads/tmp/');
+ $original_message_file = "processed-eml-" . randomString(200) . ".eml";
+ $raw_message = (string)$message->getHeader()->raw . "\r\n\r\n" . ($message->getRawBody() ?? $message->getHTMLBody() ?? $message->getTextBody());
+ file_put_contents("../uploads/tmp/{$original_message_file}", $raw_message);
+
+ // From
+ $fromCol = $message->getFrom();
+ $fromFirst = ($fromCol && $fromCol->count()) ? $fromCol->first() : null;
+ $from_email = sanitizeInput($fromFirst->mail ?? 'itflow-guest@example.com');
+ $from_name = sanitizeInput($fromFirst->personal ?? 'Unknown');
+
+ $from_domain = explode("@", $from_email);
+ $from_domain = sanitizeInput(end($from_domain));
+
+ // Subject
+ $subject = sanitizeInput((string)$message->getSubject() ?: 'No Subject');
+
+ // Date (string)
+ $dateAttr = $message->getDate(); // Attribute
+ $dateRaw = $dateAttr ? (string)$dateAttr : ''; // e.g. "Tue, 10 Sep 2025 13:22:05 +0000"
+ $ts = $dateRaw ? strtotime($dateRaw) : false;
+ $date = sanitizeInput($ts !== false ? date('Y-m-d H:i:s', $ts) : date('Y-m-d H:i:s'));
+
+ // Body (prefer HTML)
+ $message_body_html = $message->getHTMLBody();
+ $message_body_text = $message->getTextBody();
+ $message_body_raw = $message->getRawBody();
+
+ if (!empty($message_body_html)) {
+ $message_body = $message_body_html;
+ } elseif (!empty($message_body_text)) {
+ $message_body = nl2br(htmlspecialchars($message_body_text));
+ } else {
+ // Final fallback
+ $message_body = nl2br(htmlspecialchars($message_body_raw));
+ }
+
+ // Handle attachments (inline vs regular)
+ $attachments = [];
+ foreach ($message->getAttachments() as $att) {
+ $attrs = $att->getAttributes(); // v6.2: canonical source
+ $dispo = strtolower((string)($attrs['disposition'] ?? ''));
+ $cid = $attrs['id'] ?? null; // Content-ID
+ $content = $attrs['content'] ?? null; // binary
+ $mime = $att->getMimeType();
+ $name = $att->getName() ?: 'attachment';
+
+ $is_inline = false;
+ if ($dispo === 'inline' && $cid && $content !== null) {
+ $cid_trim = trim($cid, '<>');
+ $dataUri = "data:$mime;base64,".base64_encode($content);
+ $message_body = str_replace(["cid:$cid_trim", "cid:$cid"], $dataUri, $message_body);
+ $is_inline = true;
+ }
+
+ if (!$is_inline && $content !== null) {
+ $attachments[] = ['name' => $name, 'content' => $content];
+ }
+ }
+
+ // 1. Reply to existing ticket with the number in subject
+ if (preg_match("/\[$config_ticket_prefix(\d+)\]/", $subject, $ticket_number_matches)) {
+ $ticket_number = intval($ticket_number_matches[1]);
+ $email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
+ }
+
+ // 2. Fuzzy duplicate check using a known contact/domain and similar_text subject
+ if (!$email_processed && strlen(trim($subject)) > 10) {
+ $contact_id = 0;
+ $client_id = 0;
+
+ // First: check if sender is a registered contact
+ $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
+ $contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
+ $contact_row = mysqli_fetch_assoc($contact_sql);
+
+ if ($contact_row) {
+ $contact_id = intval($contact_row['contact_id']);
+ $client_id = intval($contact_row['contact_client_id']);
+ } else {
+ // Else: check if sender domain is registered
+ $from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
+ $domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
+ $domain_row = mysqli_fetch_assoc($domain_sql);
+
+ if ($domain_row && $from_domain == $domain_row['domain_name']) {
+ $client_id = intval($domain_row['domain_client_id']);
+ }
+ }
+
+ // If we found either a contact or a domain, check recent tickets for a matching subject
+ if ($client_id) {
+ $recent_tickets_sql = mysqli_query($mysqli,
+ "SELECT ticket_id, ticket_number, ticket_subject
+ FROM tickets
+ WHERE ticket_client_id = $client_id AND ticket_resolved_at IS NULL
+ AND ticket_created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
+ );
+
+ while ($rowt = mysqli_fetch_assoc($recent_tickets_sql)) {
+ $ticket_number = intval($rowt['ticket_number']);
+ $existing_subject = $rowt['ticket_subject'];
+
+ // Calculate similarity percentage
+ similar_text(strtolower($subject), strtolower($existing_subject), $percent);
+
+ if ($percent >= 95) {
+ // Treat as a reply/duplicate
+ $email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
+ break;
+ }
+ }
+ }
+ }
+
+ // 3. A known, registered contact?
+ if (!$email_processed) {
+ $from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
+ $any_contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
+ $rowc = mysqli_fetch_assoc($any_contact_sql);
+
+ if ($rowc) {
+ $contact_name = sanitizeInput($rowc['contact_name']);
+ $contact_id = intval($rowc['contact_id']);
+ $contact_email = sanitizeInput($rowc['contact_email']);
+ $client_id = intval($rowc['contact_client_id']);
+
+ $email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file);
+ }
+ }
+
+ // 4. A known domain?
+ if (!$email_processed) {
+ $from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
+ $domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
+ $rowd = mysqli_fetch_assoc($domain_sql);
+
+ if ($rowd && $from_domain == $rowd['domain_name']) {
+ $client_id = intval($rowd['domain_client_id']);
+
+ // Create a new contact
+ $contact_name = $from_name;
+ $contact_email = $from_email;
+ mysqli_query($mysqli, "INSERT INTO contacts SET contact_name = '".mysqli_real_escape_string($mysqli, $contact_name)."', contact_email = '".mysqli_real_escape_string($mysqli, $contact_email)."', contact_notes = 'Added automatically via email parsing.', contact_client_id = $client_id");
+ $contact_id = mysqli_insert_id($mysqli);
+
+ logAction("Contact", "Create", "Email parser: created contact " . mysqli_real_escape_string($mysqli, $contact_name), $client_id, $contact_id);
+ customAction('contact_create', $contact_id);
+
+ $email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file);
+ }
+ }
+
+ // 5. Unknown sender allowed?
+ if (!$email_processed && $config_ticket_email_parse_unknown_senders) {
+
+ $bad_from_pattern = "/daemon|postmaster|bounce|mta/i"; // Stop NDRs with bad subjects raising new tickets
+ if (!preg_match($bad_from_pattern, $from_email)) {
+ $email_processed = addTicket(0, $from_name, $from_email, 0, $date, $subject, $message_body, $attachments, $original_message_file);
+
+ } else {
+
+ // Probably an NDR message without a ticket ref in the subject
+
+ $failed_recipient = null;
+ $diagnostic_code = null;
+ $status_code = null;
+ $original_subject = null;
+ $original_to = null;
+
+ // Webklex stores DSN info in attachments, not parts
+ foreach ($message->getAttachments() as $attachment) {
+
+ $ctype = strtolower($attachment->getContentType());
+ $body = $attachment->getContent() ?? '';
+
+ // 1. Delivery status block
+ if (strpos($ctype, 'delivery-status') !== false) {
+
+ if (preg_match('/Final-Recipient:\s*rfc822;\s*(.+)/i', $body, $m)) {
+ $failed_recipient = sanitizeInput(trim($m[1]));
+ }
+
+ if (preg_match('/Diagnostic-Code:\s*(.+)/i', $body, $m)) {
+ $diagnostic_code = sanitizeInput(trim($m[1]));
+ }
+
+ if (preg_match('/Status:\s*([0-9\.]+)/i', $body, $m)) {
+ $status_code = sanitizeInput(trim($m[1]));
+ }
+ }
+
+ // 2. Original message headers
+ if (strpos($ctype, 'message/rfc822') !== false) {
+
+ if (preg_match('/^To:\s*(.+)$/mi', $body, $m)) {
+ $original_to = sanitizeInput(trim($m[1]));
+ }
+
+ if (preg_match('/^Subject:\s*(.+)$/mi', $body, $m)) {
+ $original_subject = sanitizeInput(trim($m[1]));
+ }
+ }
+ }
+
+ // 3. Fallback: extract diagnostic from human-readable text/plain
+ if (!$diagnostic_code) {
+ $text = $message->getTextBody() ?? '';
+
+ // Exim puts diagnostics on an indented line
+ if (preg_match('/\n\s{2,}(.+)/', $text, $m)) {
+ $diagnostic_code = sanitizeInput(trim($m[1]));
+ }
+ }
+
+ // Fallbacks
+ $failed_recipient = $failed_recipient ?: 'unknown recipient';
+ $diagnostic_code = $diagnostic_code ?: 'unknown diagnostic code';
+ $status_code = $status_code ?: 'unknown status code';
+ $original_subject = $original_subject ?: $subject;
+
+ appNotify(
+ "Ticket",
+ "Email parser NDR: Message to $failed_recipient bounced. Subject: $original_subject Diagnostics: $status_code / $diagnostic_code - check ITFlow folder manually to see email",
+ "",
+ 0
+ );
+
+ // If the original subject has a ticket, add the NDR there too
+ if (preg_match("/\[$config_ticket_prefix(\d+)\]/", $original_subject, $ticket_number_matches)) {
+
+ $ticket_number = intval($ticket_number_matches[1]);
+
+ // Craft a clean bounce message
+ $reply_body = "Email delivery failed.\n".
+ "Recipient: $failed_recipient\n".
+ "Status: $status_code\n".
+ "Diagnostic: $diagnostic_code\n";
+
+ // No attachments
+ addReply(
+ $from_email,
+ $date,
+ $original_subject,
+ $ticket_number,
+ $reply_body,
+ []
+ );
+
+ }
+
+ $email_processed = true;
+ }
+ }
+
+
+ // Flag/move based on processing result
+ if ($email_processed) {
+ $processed_count++; // increment first so a move failure doesn't hide the success
+ try {
+ $message->setFlag('Seen');
+ // Move using the Folder object (top-level "ITFlow")
+ $message->move($targetFolderPath);
+ // optional: logApp("Cron-Email-Parser", "info", "Moved message to ITFlow");
+ } catch (\Throwable $e) {
+ // >>> Put the extra logging RIGHT HERE
+ $subj = (string)$message->getSubject();
+ $uid = method_exists($message, 'getUid') ? $message->getUid() : 'n/a';
+ $path = (is_object($targetFolder) && property_exists($targetFolder, 'path')) ? (string)$targetFolder->path : $targetFolderPath;
+ logApp(
+ "Cron-Email-Parser",
+ "warning",
+ "Move failed (subject=\"$subj\", uid=$uid) to [$path]: ".$e->getMessage()
+ );
+ }
+ } else {
+ $unprocessed_count++;
+ try {
+ $message->setFlag('Flagged');
+ $message->unsetFlag('Seen');
+ } catch (\Throwable $e) {
+ logApp("Cron-Email-Parser", "warning", "Flag update failed: ".$e->getMessage());
+ }
+ }
+
+ // Cleanup temp .eml if still present (e.g., reply path)
+ if (isset($original_message_file)) {
+ $tmp_path = "../uploads/tmp/{$original_message_file}";
+ if (file_exists($tmp_path)) { @unlink($tmp_path); }
+ }
+}
+
+// Expunge & disconnect
+try {
+ $client->expunge();
+} catch (\Throwable $e) {
+ // ignore
+}
+$client->disconnect();
+
+// Execution timing (optional)
+$script_end_time = microtime(true);
+$execution_time = $script_end_time - $script_start_time;
+$execution_time_formatted = number_format($execution_time, 2);
+
+$processed_info = "Processed: $processed_count email(s), Unprocessed: $unprocessed_count email(s)";
+// logAction("Cron-Email-Parser", "Execution", "Cron Email Parser executed in $execution_time_formatted seconds. $processed_info");
+
+// Remove the lock file
+unlink($lock_file_path);
+
+// DEBUG
+echo "\nLock File Path: $lock_file_path\n";
+if (file_exists($lock_file_path)) {
+ echo "\nLock is present\n\n";
+}
+echo "Processed Emails: $processed_count\n";
+echo "Unprocessed Emails: $unprocessed_count\n";