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 from your Webklex version)
* ------------------------------------------------------------------ */
function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file, $ccs) {
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;
$bad_pattern = "/do[\W_]*not[\W_]*reply|no[\W_]*reply/i"; // Email addresses to ignore
// 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);
}
}
// Add unknown guests as ticket watcher
if ($client_id == 0 && !preg_match($bad_pattern, $contact_email_esc)) {
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$contact_email_esc', watcher_ticket_id = $id");
}
// Add CCs as ticket watchers
foreach ($ccs as $cc) {
if (filter_var($cc, FILTER_VALIDATE_EMAIL) && !preg_match($bad_pattern, $cc)) {
$cc_esc = mysqli_real_escape_string($mysqli, $cc);
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$cc_esc', watcher_ticket_id = $id");
}
}
// External email
$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';
// 1) Remove the reply separator and everything below it (HTML-aware)
$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
$message = preg_replace('/]*>/i', '', $message);
$message = preg_replace('/]*>/i', '', $message);
$message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
$message = trim($message);
$message = preg_replace('/\r\n|\r|\n/', ' ', $message);
$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']);
$client_uri = $client_id ? "&client_id=$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_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 (UNCHANGED)
* ------------------------------------------------------------------ */
// 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];
}
function getGoogleAccessToken(string $username): ?string {
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 (!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)) {
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;
$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;
}
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',
]);
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 === '') {
logApp("Cron-Email-Parser", "info", "IMAP polling skipped: provider not configured.");
@unlink($lock_file_path);
exit(0);
}
/** ------------------------------------------------------------------
* ImapEngine setup (Standard / Google OAuth / Microsoft OAuth)
* ------------------------------------------------------------------ */
use DirectoryTree\ImapEngine\Mailbox;
// Defaults from settings (standard IMAP)
$validate_cert = true;
$host = $config_imap_host;
$port = (int)$config_imap_port;
$encr = !empty($config_imap_encryption) ? $config_imap_encryption : null; // 'ssl'|'starttls'|null
$user = $config_imap_username;
$pass = $config_imap_password;
$auth = 'plain'; // '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);
}
// Map "tls" / "notls" from your old settings into ImapEngine's expected values
// ImapEngine uses: 'ssl', 'starttls', or null.
$e = strtolower((string)$encr);
if ($e === 'tls') $encr = 'starttls';
if ($e === 'notls' || $e === '') $encr = null;
}
$mailbox = new Mailbox([
'host' => $host,
'port' => $port,
'username' => $user,
'password' => $pass, // OAuth token when authentication = oauth
'encryption' => $encr, // 'ssl'|'starttls'|null
'validate_cert' => (bool)$validate_cert,
'authentication'=> $auth, // 'plain'|'oauth'
'timeout' => 30,
'debug' => false,
]);
try {
$mailbox->connect();
} catch (\Throwable $e) {
echo "Error connecting to IMAP server: " . $e->getMessage() . "\n";
@unlink($lock_file_path);
exit(1);
}
// INBOX + ensure target folder exists
$inbox = $mailbox->inbox();
$targetFolderPath = 'ITFlow';
$targetFolder = $mailbox->folders()->find($targetFolderPath);
if (!$targetFolder) {
try {
$targetFolder = $mailbox->folders()->create($targetFolderPath);
} catch (\Throwable $e) {
// Race: if another process created it, find again
$targetFolder = $mailbox->folders()->find($targetFolderPath);
if (!$targetFolder) {
logApp("Cron-Email-Parser", "error", "Failed to create/find target folder [$targetFolderPath]: ".$e->getMessage());
$mailbox->disconnect();
@unlink($lock_file_path);
exit(1);
}
}
}
// Fetch unseen messages (match Webklex behavior: iterate messages, then explicitly mark/move)
$messages = $inbox->messages()
->unseen()
->withHeaders()
->withFlags()
->withBody()
->withBodyStructure()
->get();
// Counters
$processed_count = 0;
$unprocessed_count = 0;
// Process messages
foreach ($messages as $message) {
$email_processed = false;
$original_message_file = null;
// Save original message as .eml
try {
mkdirMissing('../uploads/tmp/');
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
$rawHead = $message->head() ?? '';
$rawBody = $message->body() ?? '';
// Ensure header/body separator
$raw_message = (string)$rawHead;
if ($raw_message !== '' && !preg_match("/\r?\n\r?\n$/", $raw_message)) {
$raw_message .= "\r\n\r\n";
}
$raw_message .= (string)$rawBody;
// Fallback if body() wasn't fetched / empty
if (trim($raw_message) === '') {
$raw_message = "Subject: ".($message->subject() ?? '')."\r\n\r\n".($message->html() ?: $message->text() ?: '');
}
file_put_contents("../uploads/tmp/{$original_message_file}", $raw_message);
} catch (\Throwable $e) {
logApp("Cron-Email-Parser", "warning", "Failed saving .eml (uid=".$message->uid()."): ".$e->getMessage());
// Keep processing anyway
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
@file_put_contents("../uploads/tmp/{$original_message_file}", "");
}
// From
$fromAddr = $message->from(); // Address|null
$from_email = sanitizeInput($fromAddr ? ($fromAddr->email() ?? 'itflow-guest@example.com') : 'itflow-guest@example.com');
$from_name = sanitizeInput($fromAddr ? ($fromAddr->name() ?? 'Unknown') : 'Unknown');
$from_domain = explode("@", $from_email);
$from_domain = sanitizeInput(end($from_domain));
// Subject
$subject = sanitizeInput((string)($message->subject() ?: 'No Subject'));
// CC list
$ccs = [];
try {
foreach (($message->cc() ?? []) as $ccAddr) {
if ($ccAddr && $ccAddr->email()) {
$ccs[] = $ccAddr->email();
}
}
} catch (\Throwable $e) {
// ignore
}
// Date
try {
$dt = $message->date();
$date = sanitizeInput($dt ? $dt->format('Y-m-d H:i:s') : date('Y-m-d H:i:s'));
} catch (\Throwable $e) {
$date = sanitizeInput(date('Y-m-d H:i:s'));
}
// Body (prefer HTML)
$message_body_html = '';
$message_body_text = '';
try { $message_body_html = (string)($message->html() ?? ''); } catch (\Throwable $e) {}
try { $message_body_text = (string)($message->text() ?? ''); } catch (\Throwable $e) {}
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 to raw body (may be MIME-y, but keeps parity with your old fallback)
$message_body = nl2br(htmlspecialchars((string)($message->body() ?? '')));
}
// Inline CID handling + attachments collection
$attachments = [];
// Track inline CIDs we embedded so we don't save them as attachments too
$inline_cids = [];
// Use body structure to find inline attachment parts and replace cid: refs
try {
if ($message->hasBodyStructure()) {
$structure = $message->bodyStructure();
$parts = $structure ? $structure->flatten() : [];
foreach ($parts as $part) {
// We want inline parts with a Content-ID
if (!$part) continue;
$isInline = false;
try { $isInline = (bool)$part->isInline(); } catch (\Throwable $e) { $isInline = false; }
$cid = null;
try { $cid = $part->id(); } catch (\Throwable $e) { $cid = null; }
if (!$isInline || empty($cid)) continue;
// Fetch this inline part and embed into HTML
$cid_trim = trim((string)$cid, "<> \t\r\n");
if ($cid_trim === '') continue;
$mime = 'application/octet-stream';
try { $mime = (string)($part->contentType() ?: $mime); } catch (\Throwable $e) {}
$content = null;
try {
$content = $message->bodyPart($part->partNumber()); // default peek (does not mark seen)
} catch (\Throwable $e) {
$content = null;
}
if ($content === null || $content === '') continue;
$dataUri = "data:$mime;base64," . base64_encode($content);
$message_body = str_replace(["cid:$cid_trim", "cid:<$cid_trim>", "cid:$cid"], $dataUri, $message_body);
$inline_cids[$cid_trim] = true;
}
}
} catch (\Throwable $e) {
// ignore inline replacement errors
}
// Collect non-inline attachments from the high-level Attachment API
try {
foreach ($message->attachments() as $att) {
if (!$att) continue;
$name = $att->filename() ?: 'attachment';
$content = $att->contents();
$cid = null;
try { $cid = $att->contentId(); } catch (\Throwable $e) { $cid = null; }
$cid_trim = $cid ? trim((string)$cid, "<> \t\r\n") : null;
// Skip inline ones we already embedded
if ($cid_trim && isset($inline_cids[$cid_trim])) {
continue;
}
if ($content !== null && $content !== '') {
$attachments[] = [
'name' => $name,
'content' => $content,
];
}
}
} catch (\Throwable $e) {
// ignore
}
// 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;
$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 {
$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 ($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'];
similar_text(strtolower($subject), strtolower($existing_subject), $percent);
if ($percent >= 95) {
$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, $ccs);
}
}
// 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, $ccs);
}
}
// 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, $ccs);
} 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;
// ImapEngine: check attachments for DSN blocks (similar to your Webklex approach)
try {
foreach ($message->attachments() as $attachment) {
$ctype = strtolower((string)$attachment->contentType());
$body = (string)$attachment->contents();
// 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]));
}
}
}
} catch (\Throwable $e) {
// ignore
}
// 3. Fallback: extract diagnostic from human-readable text
if (!$diagnostic_code) {
$text = $message_body_text ?: strip_tags($message_body);
if (preg_match('/\n\s{2,}(.+)/', $text, $m)) {
$diagnostic_code = sanitizeInput(trim($m[1]));
}
}
$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]);
$reply_body = "Email delivery failed.\n".
"Recipient: $failed_recipient\n".
"Status: $status_code\n".
"Diagnostic: $diagnostic_code\n";
addReply(
$from_email,
$date,
$original_subject,
$ticket_number,
$reply_body,
[]
);
}
$email_processed = true;
}
}
// Flag/move based on processing result
if ($email_processed) {
$processed_count++;
try {
$message->markSeen();
$message->move($targetFolderPath); // expunge later
} catch (\Throwable $e) {
$subj = (string)($message->subject() ?? '');
$uid = (int)$message->uid();
$path = $targetFolder ? $targetFolder->path() : $targetFolderPath;
logApp(
"Cron-Email-Parser",
"warning",
"Move failed (subject=\"$subj\", uid=$uid) to [$path]: ".$e->getMessage()
);
}
} else {
$unprocessed_count++;
try {
$message->markFlagged();
$message->unmarkSeen();
} catch (\Throwable $e) {
logApp("Cron-Email-Parser", "warning", "Flag update failed: ".$e->getMessage());
}
}
// Cleanup temp .eml if still present (e.g., reply path or addTicket moved it)
if (!empty($original_message_file)) {
$tmp_path = "../uploads/tmp/{$original_message_file}";
if (file_exists($tmp_path)) { @unlink($tmp_path); }
}
}
// Expunge inbox after moves (removes any \Deleted left by server-side MOVE implementation)
try {
$inbox->expunge();
} catch (\Throwable $e) {
// ignore
}
// Disconnect
try {
$mailbox->disconnect();
} catch (\Throwable $e) {
// ignore
}
// 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";