Phase 2 of ticket attachements by agents

This commit is contained in:
johnnyq
2026-07-29 18:10:34 -04:00
parent 7c8c93c01c
commit 2395a3ee16
8 changed files with 163 additions and 21 deletions

View File

@@ -0,0 +1,16 @@
<?php
/*
* ITFlow - Database update to version 2.5.7 (from 2.5.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Queued mail can now carry file attachments. The column holds a JSON manifest
// of app-root-relative paths and display names rather than file contents, so
// the queue table stays small and the files stay in uploads/ where the ticket
// attachment endpoints already serve them from. It is scrubbed on delivery
// alongside the body, like email_cal_str.
mysqli_query($mysqli, "ALTER TABLE `email_queue`
ADD COLUMN `email_attachments` text DEFAULT NULL AFTER `email_cal_str`");

View File

@@ -90,7 +90,7 @@ if (isset($_POST['add_ticket'])) {
}
// Store any attached files against the ticket itself
saveTicketAttachments($ticket_id, null);
$emailable_attachments = filterEmailableAttachments(saveTicketAttachments($ticket_id, null));
// Add Watchers
if (isset($_POST['watchers'])) {
@@ -157,7 +157,8 @@ if (isset($_POST['add_ticket'])) {
'recipient' => $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 <strong>$config_ticket_prefix$ticket_number</strong> 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: <strong>" . implode(', ', $skipped_names) . "</strong>", '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: <strong>" . implode(', ', $skipped_names) . "</strong>", '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);

View File

@@ -645,7 +645,7 @@ if (isset($_GET['ticket_id'])) {
<div class="form-group">
<label class="text-muted small mb-1"><i class="fa fa-fw fa-paperclip mr-1"></i>Attachments</label>
<input type="file" class="form-control-file" name="attachments[]" multiple accept=".jpg, .jpeg, .gif, .png, .webp, .pdf, .txt, .md, .doc, .docx, .odt, .csv, .xls, .xlsx, .ods, .pptx, .odp, .zip, .tar, .gz, .xml, .msg, .json, .wav, .mp3, .ogg, .mov, .mp4, .av1, .ovpn">
<small class="form-text text-muted">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.</small>
<small class="form-text text-muted">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.</small>
</div>
<div class="form-row">

View File

@@ -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");

3
db.sql
View File

@@ -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

View File

@@ -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.

View File

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

View File

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