From 2395a3ee1602a6f38855c4d497c59a4bfcb7f74b Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 29 Jul 2026 18:10:34 -0400 Subject: [PATCH] Phase 2 of ticket attachements by agents --- admin/database_updates/2.5.7.php | 16 ++++++++++ agent/post/ticket.php | 47 +++++++++++++++++++++++----- agent/ticket.php | 2 +- cron/mail_queue.php | 36 ++++++++++++++++++++-- db.sql | 3 +- functions.php | 5 +++ functions/app.php | 22 ++++++++++++- functions/files.php | 53 +++++++++++++++++++++++++++----- 8 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 admin/database_updates/2.5.7.php diff --git a/admin/database_updates/2.5.7.php b/admin/database_updates/2.5.7.php new file mode 100644 index 00000000..820b4067 --- /dev/null +++ b/admin/database_updates/2.5.7.php @@ -0,0 +1,16 @@ + $contact_email, 'recipient_name' => $contact_name, 'subject' => $subject, - 'body' => $body + 'body' => $body, + 'attachments' => $emailable_attachments['send'] ]; } @@ -174,7 +175,8 @@ if (isset($_POST['add_ticket'])) { 'recipient' => $watcher_email, 'recipient_name' => $watcher_email, 'subject' => $subject, - 'body' => $body + 'body' => $body, + 'attachments' => $emailable_attachments['send'] ]; } addToMailQueue($data); @@ -190,6 +192,15 @@ if (isset($_POST['add_ticket'])) { flashAlert("Ticket $config_ticket_prefix$ticket_number created"); + // Tell the agent about anything too large for the mail queue to carry + if (!empty($emailable_attachments['skipped'])) { + $skipped_names = []; + foreach ($emailable_attachments['skipped'] as $skipped_attachment) { + $skipped_names[] = escapeHtml($skipped_attachment['name']); + } + flashAlert("Stored on the ticket but too large to email: " . implode(', ', $skipped_names) . "", 'error'); + } + redirect("ticket.php?client_id=$client_id&ticket_id=$ticket_id"); } @@ -1840,6 +1851,11 @@ if (isset($_POST['add_ticket_reply'])) { $ticket_reply_id = mysqli_insert_id($mysqli); + // Store any attached files against this reply before the email is built, so + // a public reply can carry them + $reply_attachments = saveTicketAttachments($ticket_id, $ticket_reply_id); + $emailable_attachments = filterEmailableAttachments($reply_attachments); + // Get Ticket Details $ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_status, ticket_status_name, ticket_url_key, ticket_first_response_at, ticket_created_by, ticket_assigned_to, ticket_client_id FROM tickets @@ -1907,7 +1923,8 @@ if (isset($_POST['add_ticket_reply'])) { 'recipient' => $contact_email, 'recipient_name' => $contact_name, 'subject' => $subject, - 'body' => $body + 'body' => $body, + 'attachments' => $emailable_attachments['send'] ]; } @@ -1924,7 +1941,8 @@ if (isset($_POST['add_ticket_reply'])) { 'recipient' => $watcher_email, 'recipient_name' => $watcher_email, 'subject' => $subject, - 'body' => $body + 'body' => $body, + 'attachments' => $emailable_attachments['send'] ]; } addToMailQueue($data); @@ -1960,9 +1978,22 @@ if (isset($_POST['add_ticket_reply'])) { flashAlert("Ticket updated"); } - // Store any attached files. They hang off the reply when there is one, and off - // the ticket itself when a file was uploaded without any accompanying text. - saveTicketAttachments($ticket_id, $ticket_reply_id ?: null); + // A file uploaded without any accompanying text has no reply to hang off, so it + // attaches to the ticket itself. With reply text the files were already stored + // above, before the email was composed. + if (empty($ticket_reply_id)) { + $emailable_attachments = filterEmailableAttachments(saveTicketAttachments($ticket_id, null)); + } + + // Tell the agent about anything too large for the mail queue to carry, rather + // than letting them assume the recipient got it + if (!empty($emailable_attachments['skipped'])) { + $skipped_names = []; + foreach ($emailable_attachments['skipped'] as $skipped_attachment) { + $skipped_names[] = escapeHtml($skipped_attachment['name']); + } + flashAlert("Stored on the ticket but too large to email: " . implode(', ', $skipped_names) . "", 'error'); + } logAudit("Ticket", "Reply", "$session_name replied to ticket $ticket_prefix$ticket_number - $ticket_subject and was a $ticket_reply_type reply", $client_id, $ticket_id); diff --git a/agent/ticket.php b/agent/ticket.php index 6c44647a..d1d3dd3d 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -645,7 +645,7 @@ if (isset($_GET['ticket_id'])) {
- Stored against this reply, or against the ticket itself if you submit without any text. Attachments are not included in the emailed copy of a public reply. + Stored against this reply, or against the ticket itself if you submit without any text. A Public + Email reply sends them to the contact and any watchers.
diff --git a/cron/mail_queue.php b/cron/mail_queue.php index a0a0fc21..39790ef2 100644 --- a/cron/mail_queue.php +++ b/cron/mail_queue.php @@ -207,6 +207,7 @@ function sendQueueEmail( string $subject, string $html_body, string $ics_str, + string $attachments_json, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, @@ -285,6 +286,33 @@ function sendQueueEmail( $mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar'); } + // File attachments arrive as a manifest of app-root-relative paths. Each one is + // resolved and confirmed to still be inside uploads/ before it is attached - the + // same guard the ticket attachment download endpoints apply - and a file that + // has been deleted since the message was queued is simply skipped. + if (!empty($attachments_json)) { + + $attachment_manifest = json_decode($attachments_json, true); + $uploads_base = realpath(__DIR__ . '/../uploads'); + + if (is_array($attachment_manifest) && $uploads_base !== false) { + foreach ($attachment_manifest as $attachment) { + + if (empty($attachment['path']) || empty($attachment['name'])) { + continue; + } + + $attachment_path = realpath(__DIR__ . '/../' . $attachment['path']); + + if ($attachment_path === false || strpos($attachment_path, $uploads_base) !== 0) { + continue; + } + + $mail->addAttachment($attachment_path, basename($attachment['name'])); + } + } + } + $mail->send(); return true; } @@ -324,6 +352,7 @@ if (mysqli_num_rows($sql_queue) > 0) { $email_subject = $rowq['email_subject']; $email_content = $rowq['email_content']; $email_ics_str = $rowq['email_cal_str']; + $email_attachments = $rowq['email_attachments']; // Check sender if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) { @@ -377,6 +406,7 @@ if (mysqli_num_rows($sql_queue) > 0) { (string)$email_subject, (string)$email_content, (string)$email_ics_str, + (string)$email_attachments, (string)$config_mail_oauth_client_id, (string)$config_mail_oauth_client_secret, (string)$config_mail_oauth_tenant_id, @@ -386,7 +416,7 @@ if (mysqli_num_rows($sql_queue) > 0) { ); // Scrub the body on delivery - it can carry share decryption keys and temporary passwords - mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '' WHERE email_id = $email_id"); + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '', email_attachments = '' WHERE email_id = $email_id"); } catch (Exception $e) { mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id"); @@ -424,6 +454,7 @@ if (mysqli_num_rows($sql_failed_queue) > 0) { $email_subject = $rowf['email_subject']; $email_content = $rowf['email_content']; $email_ics_str = $rowf['email_cal_str']; + $email_attachments = $rowf['email_attachments']; $email_attempts = (int)$rowf['email_attempts'] + 1; // Claim the row - same lock as the send path, from the failed state this time. @@ -452,6 +483,7 @@ if (mysqli_num_rows($sql_failed_queue) > 0) { (string)$email_subject, (string)$email_content, (string)$email_ics_str, + (string)$email_attachments, (string)$config_mail_oauth_client_id, (string)$config_mail_oauth_client_secret, (string)$config_mail_oauth_tenant_id, @@ -461,7 +493,7 @@ if (mysqli_num_rows($sql_failed_queue) > 0) { ); // Scrub the body on delivery - it can carry share decryption keys and temporary passwords - mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '' WHERE email_id = $email_id"); + mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '', email_attachments = '' WHERE email_id = $email_id"); } catch (Exception $e) { mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id"); diff --git a/db.sql b/db.sql index 4a1c22ab..95b44ea6 100644 --- a/db.sql +++ b/db.sql @@ -1175,6 +1175,7 @@ CREATE TABLE `email_queue` ( `email_subject` varchar(255) NOT NULL, `email_content` longtext NOT NULL, `email_cal_str` varchar(1024) DEFAULT NULL, + `email_attachments` text DEFAULT NULL, `email_queued_at` datetime NOT NULL DEFAULT current_timestamp(), `email_failed_at` datetime DEFAULT NULL, `email_attempts` tinyint(1) NOT NULL DEFAULT 0, @@ -3086,4 +3087,4 @@ CREATE TABLE `vendors` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-07-29 17:47:46 +-- Dump completed on 2026-07-29 18:09:44 diff --git a/functions.php b/functions.php index 8f9be24d..43eeb44f 100644 --- a/functions.php +++ b/functions.php @@ -3,6 +3,11 @@ // Role check failed wording DEFINE("WORDING_ROLECHECK_FAILED", "You are not permitted to do that!"); +// Ceiling on what the mail queue will attach to a single message. Uploads are +// allowed up to 500 MB, which no mail server will accept - anything over this is +// left on the ticket for the recipient to download instead of being emailed. +DEFINE("MAX_EMAIL_ATTACHMENT_BYTES", 10 * 1024 * 1024); + // functions.php is now a loader. Helper functions live in topical files // under functions/ - see each file's header comment for scope. diff --git a/functions/app.php b/functions/app.php index 55311571..d5a25e8f 100644 --- a/functions/app.php +++ b/functions/app.php @@ -373,6 +373,26 @@ function addToMailQueue($data) { $cal_str = mysqli_escape_string($mysqli, $email['cal_str']); } + // Attachments travel as a manifest of app-root-relative paths rather than + // file contents, so the queue table stays small. cron/mail_queue.php + // re-checks each path is inside uploads/ before attaching it. + $attachments = ''; + if (!empty($email['attachments']) && is_array($email['attachments'])) { + $attachment_manifest = []; + foreach ($email['attachments'] as $attachment) { + if (empty($attachment['path']) || empty($attachment['name'])) { + continue; + } + $attachment_manifest[] = [ + 'path' => strval($attachment['path']), + 'name' => strval($attachment['name']) + ]; + } + if ($attachment_manifest) { + $attachments = mysqli_escape_string($mysqli, json_encode($attachment_manifest)); + } + } + // Check if 'email_queued_at' is set and not empty if (isset($email['queued_at']) && !empty($email['queued_at'])) { $queued_at = "'" . escapeSql($email['queued_at']) . "'"; @@ -381,7 +401,7 @@ function addToMailQueue($data) { $queued_at = 'CURRENT_TIMESTAMP()'; } - mysqli_query($mysqli, "INSERT INTO email_queue SET email_recipient = '$recipient', email_recipient_name = '$recipient_name', email_from = '$from', email_from_name = '$from_name', email_subject = '$subject', email_content = '$body', email_queued_at = $queued_at, email_cal_str = '$cal_str'"); + mysqli_query($mysqli, "INSERT INTO email_queue SET email_recipient = '$recipient', email_recipient_name = '$recipient_name', email_from = '$from', email_from_name = '$from_name', email_subject = '$subject', email_content = '$body', email_queued_at = $queued_at, email_cal_str = '$cal_str', email_attachments = '$attachments'"); } return true; diff --git a/functions/files.php b/functions/files.php index 93d67f60..2ade5011 100644 --- a/functions/files.php +++ b/functions/files.php @@ -181,8 +181,10 @@ function cleanupUnusedImages(string $html, string $folderFsPath, string $folderW * Pass $reply_id to attach to a specific reply, or null to attach to the ticket * itself - the ticket page reads reply_id IS NULL as "belongs to the ticket". * - * Returns the number of files stored. Anything the extension allow-list or - * checkFileUpload() rejects is skipped silently, as it always has been. + * Returns a list of what was stored - ['name' => original name, 'path' => path + * relative to the app root, 'size' => bytes] - so a caller can pass the same + * files to the mail queue. Anything the extension allow-list or checkFileUpload() + * rejects is skipped silently, as it always has been. */ function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'attachments') { @@ -190,8 +192,10 @@ function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'atta $ticket_id = intval($ticket_id); + $stored_attachments = []; + if (!$ticket_id || empty($_FILES[$field_name]) || !isset($_FILES[$field_name]['name'])) { - return 0; + return $stored_attachments; } $allowed_extensions = array( @@ -203,7 +207,7 @@ function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'atta // A single-file input posts scalars, a multiple one posts arrays - normalize $names = $_FILES[$field_name]['name']; if (!is_array($names)) { - return 0; + return $stored_attachments; } mkdirMissing('../uploads/tickets/'); @@ -216,8 +220,6 @@ function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'atta $reply_id_sql = intval($reply_id); } - $files_stored = 0; - for ($i = 0; $i < count($names); $i++) { $single_file = [ @@ -244,8 +246,43 @@ function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'atta mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$attachment_name', ticket_attachment_reference_name = '$attachment_reference_name', ticket_attachment_reply_id = $reply_id_sql, ticket_attachment_ticket_id = $ticket_id"); - $files_stored++; + // Path is relative to the app root, not the caller, so the mail cron can + // resolve it from its own directory + $stored_attachments[] = [ + 'name' => $single_file['name'], + 'path' => "uploads/tickets/$ticket_id/$attachment_reference_name", + 'size' => (int) $single_file['size'] + ]; } - return $files_stored; + return $stored_attachments; +} + +/* + * Splits a stored attachment list into what the mail queue will carry and what it + * will not, against MAX_EMAIL_ATTACHMENT_BYTES applied to the message as a whole. + * + * Oversized files stay on the ticket - the recipient can still download them from + * the portal - and the caller is expected to say so rather than leaving the agent + * to assume everything went out. + * + * Returns ['send' => [...], 'skipped' => [...]] in the input order. + */ +function filterEmailableAttachments($attachments) { + + $result = ['send' => [], 'skipped' => []]; + $running_total = 0; + + foreach ($attachments as $attachment) { + $size = intval($attachment['size'] ?? 0); + + if ($size > 0 && $running_total + $size <= MAX_EMAIL_ATTACHMENT_BYTES) { + $running_total += $size; + $result['send'][] = $attachment; + } else { + $result['skipped'][] = $attachment; + } + } + + return $result; }