Files
itflow/cron/ticket_email_parser.php
johnnyq 9457442804 Emails stage 3: every email now goes through emailQueue and the shared layout - the client portal invite sends a link to set a password (or to sign in with Microsoft) instead of the password itself, offered on New Contact as well as Edit through portalMailInvite, with Forgot password, password changed and two-step sign-in emails through portalMailPasswordReset and portalMailAccountChange; shared link and code emails with the note quoted and the whole link printed under the button, replacing shareMailHtml and shareQueueMail; calendar invitations, updates and cancellations with when, repeats, where, the description and a join button; Email on the contacts and clients lists in the layout from one of the configured mailboxes with an optional greeting and send time, leaving out archived contacts and contacts without an address, each address once, nothing queued without mail set up, through emailBulkSend and emailBulkPost with a shared bulk_email_fields window; Admin > Mail > Previews shows all 52 emails built by their real senders on sample records inside a rolled-back transaction with emailQueue capturing and custom actions off, with computer and phone widths and Send me a copy; emailHtml gains content and button show_url, emailQueue gains queued_at and always so billing still queues as before, plus emailMailboxes and emailTestSend; ticketMailCreated, ticketMailReply, ticketMailWatchersAdded, ticketMailTaskApproval, ticketMailClosedReply, ticketSlaAlert and billingMailCardSaved moved into functions; fixes Email on the contacts list queuing mail to contacts with no address, the clients list including archived contacts, and both taking any From address posted; changelog updated
2026-10-17 23:26:25 -04:00

953 lines
43 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 company_name, company_phone, company_phone_country_code 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 cron is enabled
if ($config_enable_cron == 0) {
logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - cron not enabled in admin settings.");
cronJobStop("Cron: is not enabled -- Quitting..");
}
// 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.");
cronJobStop("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
}
// Overlapping runs are prevented by cron/includes/cron_lock.php. This script used to keep a
// lock file of its own alongside that one, which needed a five minute age heuristic to
// recover from a killed run and could only end itself with exit() - fatal to a dispatched
// job. flock covers the same ground and the kernel drops it however the process ends.
// Allowed attachment extensions
$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', '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_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> &lt;$contact_email&gt; 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 = 'Medium', 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");
}
}
// The sender is told it is a ticket now - unless they asked for replies only (Your account in the client portal)
$contact_wants = !$contact_id || ticketContactWantsEmail(getFieldById('contacts', intval($contact_id), 'contact_ticket_emails'), 'created');
if ($config_ticket_client_general_notifications == 1 && !preg_match($bad_pattern, $contact_email) && $contact_wants && ($new = ticketRow($id))) {
ticketMailCreated($id, 'Ticket created -', [], false, 'email', [['email' => stripslashes($contact_email), 'name' => stripslashes($contact_name)]]);
}
// The new-ticket address (Admin > Ticket), when there is one
ticketMailNewTicketAddress($id, 'email');
// The client's primary and technical contacts who asked to hear about new tickets
ticketMailColleaguesNew($id);
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_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 = '';
}
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);
// They are told it didn't reach anyone, and how to raise a new one
ticketMailClosedReply($ticket_id, stripslashes($from_email));
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);
}
}
// The agent it is assigned to, with what they wrote
$replied_by = intval($ticket_reply_contact) ? (string) getFieldById('contacts', intval($ticket_reply_contact), 'contact_name') : '';
ticketMailAssigneeReplied($ticket_id, 'email', (string) getFieldById('ticket_replies', $reply_id, 'ticket_reply'), $replied_by !== '' ? $replied_by : stripslashes($from_email));
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);
// Only record the reopen when the ticket was not already open
if (intval($ticket_status) !== 2) {
logTicketHistory($ticket_id, "$from_email_esc replied by email, reopening the ticket");
}
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
/**
* 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.");
cronJobStop();
}
/** ------------------------------------------------------------------
* 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).");
cronJobStop('', 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).");
cronJobStop('', 1);
}
} else {
// standard_imap (username/password)
if (empty($host) || empty($port) || empty($user)) {
logApp("Cron-Email-Parser", "error", "Standard IMAP: missing host/port/username.");
cronJobStop('', 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();
cronJobStop('', 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());
cronJobStop('', 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)
// Some* NDRs use "auto-generated" and are still handled by the NDR logic below
// Todo: Combine with the NDR logic so we can update the ticket too
$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)");
appNotify(
"Mail",
"Email parser: Skipped auto-responder message from $from_email. Subject: $subject",
"",
0
);
$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 domain_client_id, domain_name 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 domain_client_id, domain_name 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");
}
// DEBUG
echo "Processed Emails: $processed_count\n";
echo "Unprocessed Emails: $unprocessed_count\n";