mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57:14 +00:00
Phase 2 of ticket attachements by agents
This commit is contained in:
16
admin/database_updates/2.5.7.php
Normal file
16
admin/database_updates/2.5.7.php
Normal 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`");
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
3
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user