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