mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
Statuses can be flagged to pause the resolution clock; sla_history records the intervals a ticket's clock actually ran and the deadline is re-based on remaining budget when it resumes. Adds SLA Summary and SLA by Client reports, an SLA state filter on the ticket list, SLA colouring on kanban cards, and an Urgent column on the Tickets by Client report. DB update 2.5.1. Also fixes resolution SLA verdicts being skipped when resolving via kanban or the client portal.
1050 lines
47 KiB
PHP
1050 lines
47 KiB
PHP
<?php
|
|
/*
|
|
* CRON - Email Parser (DirectoryTree ImapEngine)
|
|
* Process emails and create/update tickets using DirectoryTree\ImapEngine (no PHP IMAP extension required)
|
|
*/
|
|
|
|
// Start the timer
|
|
$script_start_time = microtime(true);
|
|
|
|
// Set working directory to the directory this cron script lives at.
|
|
chdir(dirname(__FILE__));
|
|
|
|
// Ensure we're running from command line
|
|
if (php_sapi_name() !== 'cli') {
|
|
die("This script must be run from the command line.\n");
|
|
}
|
|
|
|
// Prevent overlapping runs of this script
|
|
$cron_lock_script = __FILE__;
|
|
require_once "../includes/cron_lock.php";
|
|
|
|
// Autoload (Webklex & any composer deps)
|
|
require_once "../libs/vendor/autoload.php";
|
|
|
|
// Get ITFlow config & helper functions
|
|
require_once "../config.php";
|
|
|
|
// Set Timezone
|
|
require_once "../includes/inc_set_timezone.php";
|
|
require_once "../functions.php";
|
|
|
|
// Get settings for the "default" company
|
|
require_once "../includes/load_global_settings.php";
|
|
|
|
$config_ticket_prefix = escapeSql($config_ticket_prefix);
|
|
$config_ticket_from_name = escapeSql($config_ticket_from_name);
|
|
$config_ticket_email_parse_unknown_senders = intval($row['config_ticket_email_parse_unknown_senders']);
|
|
|
|
// Get company name & phone & timezone
|
|
$sql = mysqli_query($mysqli, "SELECT * FROM companies, settings WHERE companies.company_id = settings.company_id AND companies.company_id = 1");
|
|
$row = mysqli_fetch_assoc($sql);
|
|
$company_name = escapeSql($row['company_name']);
|
|
$company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
|
|
|
|
// Check setting enabled
|
|
if ($config_ticket_email_parse == 0) {
|
|
logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - not enabled in admin settings.");
|
|
exit("Email Parser: Feature is not enabled - check Settings > 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.");
|
|
}
|
|
}
|
|
// Atomically create the lock ('x' fails if another process beat us to it)
|
|
if (@fopen($lock_file_path, 'x') === false) {
|
|
logApp("Cron-Email-Parser", "warning", "Lock file present (race). Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
|
|
exit("Script is already running. Exiting.");
|
|
}
|
|
|
|
// 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');
|
|
|
|
// Processing limits
|
|
$max_emails_per_run = 50; // Cap per cron run to bound memory usage (cron catches up on the next run)
|
|
$max_attachment_bytes = 15728640; // 15 MB - larger attachments are skipped & logged
|
|
$max_inline_embed_bytes = 2097152; // 2 MB - larger inline images are saved as regular attachments instead of base64-embedded in the ticket body
|
|
|
|
/** ------------------------------------------------------------------
|
|
* Ticket / Reply helpers (unchanged)
|
|
* ------------------------------------------------------------------ */
|
|
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('/<!DOCTYPE[^>]*>/i', '', $message);
|
|
$message = preg_replace('/<meta[^>]*>/i', '', $message);
|
|
// Remove <html>, <head>, <body> and their closing tags
|
|
$message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
|
|
// Collapse excess whitespace
|
|
$message = preg_replace('/\s+/', ' ', $message);
|
|
// Convert newlines to <br>
|
|
$message = nl2br($message);
|
|
// Wrap final formatted message
|
|
$message = "<i>Email from: <b>$contact_name</b> <$contact_email> at $date:-</i> <br><br><div style='line-height:1.5;'>$message</div>";
|
|
|
|
$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);
|
|
applyTicketSla($id);
|
|
|
|
// Logging
|
|
logAudit("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 = escapeSql($att_name);
|
|
$ticket_attachment_reference_name = escapeSql($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);
|
|
logAudit("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 = "<i style='color: #808080'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Thank you for your email. A ticket regarding \"$subject\" has been automatically created for you.<br><br>Ticket: $config_ticket_prefix$ticket_number<br>Subject: $subject<br>Status: New<br>Portal: <a href='https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$id&url_key=$url_key'>View ticket</a><br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$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 = escapeSql($client_row['client_name']);
|
|
$client_uri = "&client_id=$client_id";
|
|
}
|
|
$email_subject = "$config_app_name - New Ticket - $client_name: $subject";
|
|
$email_body = "Hello, <br><br>This is a notification that a new ticket has been raised in ITFlow. <br>Client: $client_name<br>Priority: Low (email parsed)<br>Link: https://$config_base_url/agent/ticket.php?ticket_id=$id$client_uri <br><br>--------------------------------<br><br><b>$subject</b><br>$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);
|
|
triggerCustomAction('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: <i ...>##- Please type your reply above this line -##</i> and EVERYTHING after it
|
|
$message = preg_replace(
|
|
'/<i[^>]*>##-\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('/<!DOCTYPE[^>]*>/i', '', $message);
|
|
$message = preg_replace('/<meta[^>]*>/i', '', $message);
|
|
|
|
// Remove <html>, <head>, <body> 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 <br> for HTML display
|
|
$message = nl2br($message);
|
|
|
|
// 3) Final wrapper
|
|
$message = "<i>Email from: $from_email at $date:-</i><br><br><div style='line-height:1.5;'>$message</div>";
|
|
|
|
$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 = escapeSql($row['ticket_subject']);
|
|
$ticket_status = escapeSql($row['ticket_status']);
|
|
$ticket_reply_contact = intval($row['ticket_contact_id']);
|
|
$ticket_contact_email = escapeSql($row['contact_email']);
|
|
$client_id = intval($row['ticket_client_id']);
|
|
if ($client_id) {
|
|
$client_uri = "&client_id=$client_id";
|
|
} else {
|
|
$client_uri = '';
|
|
}
|
|
$client_name = escapeSql($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, <br><br>You've tried to reply to a ticket that is closed - we won't see your response. <br><br>Please raise a new ticket by sending a new e-mail to our support address below. <br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$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 = "<b>WARNING: Contact email mismatch</b><br>$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 = escapeSql($att_name);
|
|
$ticket_attachment_reference_name = escapeSql($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);
|
|
logAudit("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 = escapeSql($tech_row['user_email']);
|
|
$tech_name = escapeSql($tech_row['user_name']);
|
|
|
|
$email_subject = "$config_app_name - Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
|
|
$email_body = "Hello $tech_name,<br><br>A new reply has been added to the below ticket.<br><br>Client: $client_name<br>Ticket: $config_ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri<br><br>--------------------------------<br>$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");
|
|
resetTicketResolutionSla($ticket_id);
|
|
|
|
logAudit("Ticket", "Edit", "Email parser: Client contact $from_email_esc updated ticket $config_ticket_prefix$ticket_number_esc ($subject)", $client_id, $ticket_id);
|
|
triggerCustomAction('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);
|
|
}
|
|
|
|
/** ------------------------------------------------------------------
|
|
* ImapEngine setup (supports Standard / Google OAuth / Microsoft OAuth)
|
|
* ------------------------------------------------------------------ */
|
|
use DirectoryTree\ImapEngine\Mailbox;
|
|
|
|
$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 = '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 Webklex-style encryption values to ImapEngine transports
|
|
// Webklex 'tls' = STARTTLS (port 143), ImapEngine 'tls' = implicit TLS, so translate
|
|
$encryption = match (strtolower($encr)) {
|
|
'ssl' => 'ssl', // implicit TLS (993)
|
|
'tls' => 'starttls', // STARTTLS upgrade (143) - Webklex semantics
|
|
'starttls' => 'starttls',
|
|
default => '', // 'notls' / plain tcp
|
|
};
|
|
|
|
$mailbox = new Mailbox([
|
|
'host' => $host,
|
|
'port' => $port,
|
|
'encryption' => $encryption,
|
|
'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 'plain'
|
|
]);
|
|
|
|
try {
|
|
$mailbox->connect();
|
|
} catch (\Throwable $e) {
|
|
echo "Error connecting to IMAP server: " . $e->getMessage();
|
|
@unlink($lock_file_path);
|
|
exit(1);
|
|
}
|
|
|
|
$inbox = $mailbox->inbox();
|
|
|
|
// Resolve the processed-mail folder in a namespace-aware way.
|
|
// Most servers (Gmail, M365, Dovecot with an empty namespace prefix) allow
|
|
// root-level folders, so "ITFlow" is preferred. cPanel-style Dovecot uses a
|
|
// Maildir++ layout where the personal namespace prefix is "INBOX." - root
|
|
// CREATE fails there with "Client tried to access nonexistent namespace",
|
|
// and the folder must be addressed as "INBOX.ITFlow" (clients still display
|
|
// it as a top-level folder alongside Inbox).
|
|
$targetFolderName = 'ITFlow';
|
|
try {
|
|
// INBOX always exists (RFC 3501) - use it to learn the hierarchy delimiter
|
|
// (ImapEngine returns the literal string "NIL" for servers with a flat namespace)
|
|
$delimiter = $mailbox->folders()->findOrFail('INBOX')->delimiter();
|
|
if ($delimiter === '' || strcasecmp($delimiter, 'NIL') === 0) {
|
|
$delimiter = '.';
|
|
}
|
|
|
|
// Candidate paths, in order of preference
|
|
$candidates = [
|
|
$targetFolderName, // Root-level (empty namespace prefix)
|
|
'INBOX' . $delimiter . $targetFolderName, // INBOX-prefixed namespace (e.g. cPanel Dovecot)
|
|
];
|
|
|
|
// Re-use the folder if it already exists at either location
|
|
$targetFolder = null;
|
|
foreach ($candidates as $candidate) {
|
|
if ($targetFolder = $mailbox->folders()->find($candidate)) {
|
|
$targetFolderPath = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Otherwise create it, falling back to the namespace-prefixed path if the
|
|
// server rejects root-level creation
|
|
if (!$targetFolder) {
|
|
$creation_errors = [];
|
|
foreach ($candidates as $candidate) {
|
|
try {
|
|
$targetFolder = $mailbox->folders()->create($candidate);
|
|
$targetFolderPath = $candidate;
|
|
break;
|
|
} catch (\Throwable $e) {
|
|
$creation_errors[] = "[$candidate]: " . $e->getMessage();
|
|
}
|
|
}
|
|
if (!$targetFolder) {
|
|
throw new \Exception("CREATE rejected for all candidate paths - " . implode(' / ', $creation_errors));
|
|
}
|
|
|
|
// Subscribe to the newly created folder so it's visible in mail clients
|
|
// that only display subscribed folders (common with Dovecot/Roundcube).
|
|
// Non-fatal - some servers ignore or reject SUBSCRIBE entirely.
|
|
try {
|
|
$mailbox->connection()->subscribe(
|
|
\DirectoryTree\ImapEngine\Support\Str::toImapUtf7($targetFolderPath)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
logApp("Cron-Email-Parser", "warning", "Created folder [$targetFolderPath] but could not subscribe to it: " . $e->getMessage());
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
logApp("Cron-Email-Parser", "error", "Unable to find/create target folder [$targetFolderName]: " . $e->getMessage());
|
|
@unlink($lock_file_path);
|
|
exit(1);
|
|
}
|
|
|
|
// Fetch unseen messages (headers, body & flags; BODY.PEEK so they stay unread)
|
|
// unflagged() skips messages a previous run flagged for manual attention,
|
|
// so they aren't pointlessly re-processed every run forever
|
|
$messages = $inbox->messages()
|
|
->withHeaders()
|
|
->withBody()
|
|
->withFlags()
|
|
->leaveUnread()
|
|
->unseen()
|
|
->unflagged()
|
|
->limit($max_emails_per_run)
|
|
->get();
|
|
|
|
// Counters
|
|
$processed_count = 0;
|
|
$unprocessed_count = 0;
|
|
|
|
// Process messages
|
|
foreach ($messages as $message) {
|
|
try {
|
|
$email_processed = false;
|
|
|
|
// From
|
|
$from_addr = $message->from(); // ?Address
|
|
$from_email = escapeSql($from_addr?->email() ?: 'itflow-guest@example.com');
|
|
$from_name = escapeSql($from_addr?->name() ?: 'Unknown');
|
|
|
|
$from_domain = explode("@", $from_email);
|
|
$from_domain = escapeSql(end($from_domain));
|
|
|
|
// Subject
|
|
$subject = escapeSql((string)$message->subject() ?: 'No Subject');
|
|
|
|
// Skip vacation/out-of-office auto-responders to prevent mail loops (RFC 3834)
|
|
// NDRs use "auto-generated" and are still handled by the NDR logic below
|
|
$auto_submitted = strtolower((string)($message->header('Auto-Submitted')?->getValue() ?? ''));
|
|
$precedence = strtolower((string)($message->header('Precedence')?->getValue() ?? ''));
|
|
if (str_starts_with($auto_submitted, 'auto-replied') || $precedence === 'auto_reply') {
|
|
logApp("Cron-Email-Parser", "info", "Email parser skipped auto-responder from $from_email ($subject)");
|
|
$processed_count++;
|
|
$message->markSeen();
|
|
$message->move($targetFolderPath);
|
|
continue;
|
|
}
|
|
|
|
// Save original message as .eml (ImapEngine: raw headers + raw body)
|
|
mkdirMissing('../uploads/tmp/');
|
|
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
|
|
$raw_message = (string)$message; // head + body, CRLF separated
|
|
file_put_contents("../uploads/tmp/{$original_message_file}", $raw_message);
|
|
|
|
// CC (deduplicated, excluding the sender)
|
|
$ccs = array();
|
|
foreach ($message->cc() as $cc_addr) {
|
|
$cc_mail = strtolower($cc_addr->email());
|
|
if ($cc_mail && $cc_mail !== strtolower($from_email) && !in_array($cc_mail, $ccs)) {
|
|
$ccs[] = $cc_mail;
|
|
}
|
|
}
|
|
|
|
// Date (string)
|
|
$dateObj = $message->date(); // ?CarbonInterface
|
|
$date = escapeSql($dateObj ? $dateObj->setTimezone(date_default_timezone_get())->format('Y-m-d H:i:s') : date('Y-m-d H:i:s'));
|
|
|
|
// Body (prefer HTML)
|
|
$message_body_html = $message->html();
|
|
$message_body_text = $message->text();
|
|
|
|
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 - raw body
|
|
$message_body = nl2br(htmlspecialchars($message->body()));
|
|
}
|
|
|
|
// Handle attachments (inline vs regular)
|
|
$attachments = [];
|
|
foreach ($message->attachments() as $att) {
|
|
$dispo = strtolower((string)$att->contentDisposition());
|
|
$cid = $att->contentId(); // Content-ID (without <>)
|
|
$content = $att->contents(); // binary
|
|
$mime = $att->contentType();
|
|
$name = $att->filename() ?: 'attachment';
|
|
$size = strlen($content);
|
|
|
|
// Skip oversized attachments entirely
|
|
if ($size > $max_attachment_bytes) {
|
|
logApp("Cron-Email-Parser", "warning", "Email parser skipped oversized attachment " . escapeSql($name) . " (" . round($size / 1048576, 1) . " MB) from $from_email ($subject)");
|
|
continue;
|
|
}
|
|
|
|
// Embed small inline images as data URIs; oversized inline images fall through and are saved as regular attachments
|
|
$is_inline = false;
|
|
if ($dispo === 'inline' && $cid && $content !== '' && $size <= $max_inline_embed_bytes) {
|
|
$cid_trim = trim($cid, '<>');
|
|
$dataUri = "data:$mime;base64,".base64_encode($content);
|
|
$message_body = str_replace(["cid:$cid_trim", "cid:<$cid_trim>"], $dataUri, $message_body);
|
|
$is_inline = true;
|
|
}
|
|
|
|
if (!$is_inline && $content !== '') {
|
|
$attachments[] = ['name' => $name, 'content' => $content];
|
|
}
|
|
}
|
|
|
|
// 1. Reply to existing ticket with the number in subject
|
|
if (preg_match("/\[" . preg_quote($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 = escapeSql($rowc['contact_name']);
|
|
$contact_id = intval($rowc['contact_id']);
|
|
$contact_email = escapeSql($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);
|
|
|
|
logAudit("Contact", "Create", "Email parser: created contact " . mysqli_real_escape_string($mysqli, $contact_name), $client_id, $contact_id);
|
|
triggerCustomAction('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: walk the parsed MIME parts to find DSN info
|
|
foreach ($message->parse()->getAllParts() as $part) {
|
|
|
|
$ctype = strtolower((string)$part->getContentType());
|
|
$body = $part->getContent() ?? '';
|
|
|
|
// 1. Delivery status block
|
|
if (strpos($ctype, 'delivery-status') !== false) {
|
|
|
|
if (preg_match('/Final-Recipient:\s*rfc822;\s*(.+)/i', $body, $m)) {
|
|
$failed_recipient = escapeSql(trim($m[1]));
|
|
}
|
|
|
|
if (preg_match('/Diagnostic-Code:\s*(.+)/i', $body, $m)) {
|
|
$diagnostic_code = escapeSql(trim($m[1]));
|
|
}
|
|
|
|
if (preg_match('/Status:\s*([0-9\.]+)/i', $body, $m)) {
|
|
$status_code = escapeSql(trim($m[1]));
|
|
}
|
|
}
|
|
|
|
// 2. Original message headers
|
|
if (strpos($ctype, 'message/rfc822') !== false) {
|
|
|
|
if (preg_match('/^To:\s*(.+)$/mi', $body, $m)) {
|
|
$original_to = escapeSql(trim($m[1]));
|
|
}
|
|
|
|
if (preg_match('/^Subject:\s*(.+)$/mi', $body, $m)) {
|
|
$original_subject = escapeSql(trim($m[1]));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: extract diagnostic from human-readable text/plain
|
|
if (!$diagnostic_code) {
|
|
$text = $message->text() ?? '';
|
|
|
|
// Exim puts diagnostics on an indented line
|
|
if (preg_match('/\n\s{2,}(.+)/', $text, $m)) {
|
|
$diagnostic_code = escapeSql(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("/\[" . preg_quote($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->markSeen();
|
|
// Move to the top-level "ITFlow" folder
|
|
$message->move($targetFolderPath);
|
|
// optional: logApp("Cron-Email-Parser", "info", "Moved message to ITFlow");
|
|
} catch (\Throwable $e) {
|
|
$subj = (string)$message->subject();
|
|
$uid = $message->uid();
|
|
$path = $targetFolder->path();
|
|
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());
|
|
}
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
// One bad message must not kill the whole run - flag it for manual attention and continue
|
|
$unprocessed_count++;
|
|
logApp("Cron-Email-Parser", "warning", "Email parser failed to process message UID " . $message->uid() . ": " . $e->getMessage());
|
|
try {
|
|
$message->markFlagged();
|
|
$message->unmarkSeen();
|
|
} catch (\Throwable $e2) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// 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); }
|
|
unset($original_message_file);
|
|
}
|
|
}
|
|
|
|
// Expunge & disconnect
|
|
try {
|
|
$inbox->expunge();
|
|
} catch (\Throwable $e) {
|
|
// ignore
|
|
}
|
|
$mailbox->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)";
|
|
if ($processed_count || $unprocessed_count) {
|
|
logApp("Cron-Email-Parser", "info", "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";
|