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";