From 84dfc4411369f0b5fb238dee19b9e4b66540a1f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 14:06:23 +0000 Subject: [PATCH] Add helpdesk attachments, internal notes, followers, and email threading. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend tickets with comment files, technician-only notes, CC followers, category-based team transfer, and inbound/outbound email reply matching so support conversations stay attached to the same ticket. Co-authored-by: Arda Çetin --- .gitignore | 3 + app/Controllers/TicketController.php | 408 ++++++++++- app/Middleware/RoleMiddleware.php | 25 + app/Models/Ticket.php | 653 +++++++++++++++++- app/Services/DatabaseInitializer.php | 134 ++++ app/Services/Mail/ImapInboxFetcher.php | 145 +++- .../Mail/InboundEmailTicketService.php | 328 ++++++--- app/Services/Mail/MailService.php | 133 +++- .../Mail/TicketNotificationService.php | 147 +++- .../TicketAttachmentStorageService.php | 224 ++++++ cli.php | 10 +- config/bootstrap.php | 9 + ...t_comments_attachments_followers_email.sql | 74 ++ lang/en.php | 38 +- lang/tr.php | 38 +- storage/ticket_attachments/.gitkeep | 0 storage/ticket_attachments/.htaccess | 1 + views/dashboard.php | 386 ++++++++++- views/partials/end_user_ticket_modals.php | 20 + 19 files changed, 2603 insertions(+), 173 deletions(-) create mode 100644 app/Services/TicketAttachmentStorageService.php create mode 100644 database/migrations/034_ticket_comments_attachments_followers_email.sql create mode 100644 storage/ticket_attachments/.gitkeep create mode 100644 storage/ticket_attachments/.htaccess diff --git a/.gitignore b/.gitignore index e47295a..d4f1eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ composer.phar /storage/documents/* !/storage/documents/.htaccess !/storage/documents/.gitkeep +/storage/ticket_attachments/* +!/storage/ticket_attachments/.htaccess +!/storage/ticket_attachments/.gitkeep # IDE and editor files /.vscode/ diff --git a/app/Controllers/TicketController.php b/app/Controllers/TicketController.php index 407b5e2..57303cb 100644 --- a/app/Controllers/TicketController.php +++ b/app/Controllers/TicketController.php @@ -13,8 +13,11 @@ use App\Services\EndUserContextService; use App\Services\ListPagination; use App\Services\Mail\TicketNotificationService; +use App\Services\TicketAttachmentStorageService; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UploadedFileInterface; +use Slim\Psr7\Stream; class TicketController { @@ -25,6 +28,7 @@ public function __construct( private readonly SessionAuthService $sessionAuthService, private readonly EndUserContextService $endUserContextService, private readonly TicketNotificationService $ticketNotificationService, + private readonly TicketAttachmentStorageService $ticketAttachmentStorageService, private readonly AuditLogger $auditLogger ) { } @@ -356,7 +360,9 @@ public function addComment(ServerRequestInterface $request, ResponseInterface $r ]); } - if ($this->endUserContextService->isEndUser()) { + $isEndUser = $this->endUserContextService->isEndUser(); + + if ($isEndUser) { $personnelId = $this->endUserContextService->resolvePersonnelId(); if ($personnelId === null || !$this->ticketModel->belongsToPersonnel($ticketId, $personnelId)) { @@ -367,16 +373,13 @@ public function addComment(ServerRequestInterface $request, ResponseInterface $r } } - $payload = $this->resolvePayload($request); - - if ($payload === null) { - return $this->jsonResponse($response, 400, [ - 'status' => 'error', - 'message' => __('ticket_invalid_payload'), - ]); - } - + $payload = $this->resolvePayload($request) ?? []; $body = trim((string) ($payload['body'] ?? '')); + $isInternal = !$isEndUser && ( + (string) ($payload['is_internal'] ?? '') === '1' + || (string) ($payload['is_internal'] ?? '') === 'true' + || ($payload['is_internal'] ?? false) === true + ); if ($body === '') { return $this->jsonResponse($response, 422, [ @@ -387,9 +390,39 @@ public function addComment(ServerRequestInterface $request, ResponseInterface $r $userId = $this->endUserContextService->resolveLegacyUserId(); $authorName = $this->resolveAuthorName($userId); + $uploadedFiles = $this->collectUploadedFiles($request); try { - $comment = $this->ticketModel->addComment($ticketId, $body, $userId, $authorName); + $comment = $this->ticketModel->addComment( + $ticketId, + $body, + $userId, + $authorName, + $isInternal + ); + + if ($uploadedFiles !== []) { + $stored = []; + + foreach ($uploadedFiles as $file) { + $stored[] = $this->ticketAttachmentStorageService->storeUploadedFile($file); + } + + $attachments = $this->ticketModel->addCommentAttachments( + $ticketId, + (int) $comment['id'], + array_map(static fn (array $item): array => [ + 'original_filename' => $item['original_filename'], + 'stored_filename' => $item['stored_filename'], + 'file_path' => $item['relative_path'], + 'file_size' => $item['file_size'], + 'mime_type' => $item['mime_type'] ?? null, + ], $stored), + $userId + ); + + $comment['attachments'] = $attachments; + } } catch (\InvalidArgumentException $exception) { return $this->jsonResponse($response, 422, [ 'status' => 'error', @@ -402,7 +435,7 @@ public function addComment(ServerRequestInterface $request, ResponseInterface $r ]); } - if (!$this->endUserContextService->isEndUser()) { + if (!$isEndUser && !$isInternal) { $ticket = $this->ticketModel->findById($ticketId); if ($ticket !== null) { @@ -418,6 +451,314 @@ public function addComment(ServerRequestInterface $request, ResponseInterface $r ]); } + public function downloadAttachment( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + $ticketId = (int) ($args['id'] ?? 0); + $attachmentId = (int) ($args['attachmentId'] ?? 0); + + if ($ticketId <= 0 || $attachmentId <= 0) { + return $this->jsonResponse($response, 400, [ + 'status' => 'error', + 'message' => __('ticket_invalid_id'), + ]); + } + + if (!$this->canAccessTicket($ticketId)) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_not_found'), + ]); + } + + $attachment = $this->ticketModel->findAttachmentById($attachmentId); + + if ($attachment === null || (int) ($attachment['ticket_id'] ?? 0) !== $ticketId) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_attachment_not_found'), + ]); + } + + if ($this->endUserContextService->isEndUser()) { + $comment = $this->ticketModel->findCommentById((int) ($attachment['comment_id'] ?? 0)); + + if ($comment === null || !empty($comment['is_internal'])) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_attachment_not_found'), + ]); + } + } + + try { + $absolutePath = $this->ticketAttachmentStorageService->resolveAbsolutePath( + (string) ($attachment['file_path'] ?? '') + ); + } catch (\Throwable) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_attachment_not_found'), + ]); + } + + $stream = fopen($absolutePath, 'rb'); + + if ($stream === false) { + return $this->jsonResponse($response, 500, [ + 'status' => 'error', + 'message' => __('ticket_attachment_not_found'), + ]); + } + + $filename = (string) ($attachment['original_filename'] ?? 'attachment'); + $mime = (string) ($attachment['mime_type'] ?? 'application/octet-stream'); + + return $response + ->withBody(new Stream($stream)) + ->withHeader('Content-Type', $mime) + ->withHeader( + 'Content-Disposition', + 'attachment; filename="' . str_replace('"', '', $filename) . '"' + ); + } + + public function followers( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + $ticketId = (int) ($args['id'] ?? 0); + + if ($ticketId <= 0) { + return $this->jsonResponse($response, 400, [ + 'status' => 'error', + 'message' => __('ticket_invalid_id'), + ]); + } + + if ($this->endUserContextService->isEndUser()) { + return $this->jsonResponse($response, 403, [ + 'status' => 'error', + 'message' => __('portal_action_not_allowed'), + ]); + } + + if ($this->ticketModel->findById($ticketId) === null) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_not_found'), + ]); + } + + return $this->jsonResponse($response, 200, [ + 'status' => 'success', + 'data' => $this->ticketModel->findFollowersByTicketId($ticketId), + ]); + } + + public function updateFollowers( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + if ($this->endUserContextService->isEndUser()) { + return $this->jsonResponse($response, 403, [ + 'status' => 'error', + 'message' => __('portal_action_not_allowed'), + ]); + } + + $ticketId = (int) ($args['id'] ?? 0); + + if ($ticketId <= 0) { + return $this->jsonResponse($response, 400, [ + 'status' => 'error', + 'message' => __('ticket_invalid_id'), + ]); + } + + $payload = $this->resolvePayload($request) ?? []; + $items = $payload['items'] ?? []; + + if (!is_array($items)) { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => __('ticket_followers_invalid_payload'), + ]); + } + + try { + $followers = $this->ticketModel->replaceFollowers($ticketId, $items); + } catch (\InvalidArgumentException $exception) { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]); + } catch (\Throwable) { + return $this->jsonResponse($response, 500, [ + 'status' => 'error', + 'message' => __('ticket_followers_update_error'), + ]); + } + + return $this->jsonResponse($response, 200, [ + 'status' => 'success', + 'message' => __('ticket_followers_update_success'), + 'data' => $followers, + ]); + } + + public function transfer( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + if ($this->endUserContextService->isEndUser()) { + return $this->jsonResponse($response, 403, [ + 'status' => 'error', + 'message' => __('portal_action_not_allowed'), + ]); + } + + $ticketId = (int) ($args['id'] ?? 0); + $payload = $this->resolvePayload($request) ?? []; + $categoryId = (int) ($payload['category_id'] ?? 0); + $note = isset($payload['note']) ? trim((string) $payload['note']) : null; + + if ($ticketId <= 0 || $categoryId <= 0) { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => __('ticket_transfer_invalid_payload'), + ]); + } + + $userId = $this->sessionAuthService->userId(); + $authorName = $this->resolveAuthorName($userId); + + try { + $ticket = $this->ticketModel->transferToCategory( + $ticketId, + $categoryId, + $userId, + $authorName, + $note + ); + } catch (\InvalidArgumentException $exception) { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]); + } catch (\Throwable) { + return $this->jsonResponse($response, 500, [ + 'status' => 'error', + 'message' => __('ticket_transfer_error'), + ]); + } + + return $this->jsonResponse($response, 200, [ + 'status' => 'success', + 'message' => __('ticket_transfer_success'), + 'data' => $ticket, + ]); + } + + public function attachEmail( + ServerRequestInterface $request, + ResponseInterface $response, + array $args + ): ResponseInterface { + if ($this->endUserContextService->isEndUser()) { + return $this->jsonResponse($response, 403, [ + 'status' => 'error', + 'message' => __('portal_action_not_allowed'), + ]); + } + + $ticketId = (int) ($args['id'] ?? 0); + $payload = $this->resolvePayload($request) ?? []; + + if ($ticketId <= 0) { + return $this->jsonResponse($response, 400, [ + 'status' => 'error', + 'message' => __('ticket_invalid_id'), + ]); + } + + $ticket = $this->ticketModel->findById($ticketId); + + if ($ticket === null) { + return $this->jsonResponse($response, 404, [ + 'status' => 'error', + 'message' => __('ticket_not_found'), + ]); + } + + $body = trim((string) ($payload['body'] ?? '')); + $fromAddress = trim((string) ($payload['from_address'] ?? '')); + $subject = trim((string) ($payload['subject'] ?? '')); + $messageId = trim((string) ($payload['message_id'] ?? '')); + $inReplyTo = trim((string) ($payload['in_reply_to'] ?? '')); + + if ($body === '') { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => __('ticket_comment_required'), + ]); + } + + if ($messageId !== '' && $this->ticketModel->emailMessageExists($messageId)) { + return $this->jsonResponse($response, 409, [ + 'status' => 'error', + 'message' => __('ticket_email_already_attached'), + ]); + } + + $authorName = $fromAddress !== '' ? $fromAddress : __('ticket_email_author'); + + try { + $comment = $this->ticketModel->addComment( + $ticketId, + $body, + null, + $authorName, + false, + $messageId !== '' ? $messageId : null, + $inReplyTo !== '' ? $inReplyTo : null + ); + + if ($messageId !== '') { + $this->ticketModel->recordEmailMessage( + $ticketId, + $messageId, + Ticket::EMAIL_DIRECTION_INBOUND, + $inReplyTo !== '' ? $inReplyTo : null, + (int) $comment['id'], + $fromAddress !== '' ? $fromAddress : null, + $subject !== '' ? $subject : null + ); + } + } catch (\InvalidArgumentException $exception) { + return $this->jsonResponse($response, 422, [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]); + } catch (\Throwable) { + return $this->jsonResponse($response, 500, [ + 'status' => 'error', + 'message' => __('ticket_email_attach_error'), + ]); + } + + return $this->jsonResponse($response, 201, [ + 'status' => 'success', + 'message' => __('ticket_email_attach_success'), + 'data' => $this->ticketModel->findById($ticketId, true), + ]); + } + /** * @param array $ticket */ @@ -566,6 +907,49 @@ private function normalizeOptionalId(mixed $value): ?int return $id > 0 ? $id : null; } + private function canAccessTicket(int $ticketId): bool + { + if ($ticketId <= 0) { + return false; + } + + if ($this->endUserContextService->isEndUser()) { + $personnelId = $this->endUserContextService->resolvePersonnelId(); + + return $personnelId !== null + && $this->ticketModel->belongsToPersonnel($ticketId, $personnelId); + } + + return $this->ticketModel->findById($ticketId) !== null; + } + + /** + * @return list + */ + private function collectUploadedFiles(ServerRequestInterface $request): array + { + $uploaded = $request->getUploadedFiles(); + $files = $uploaded['files'] ?? $uploaded['files[]'] ?? []; + + if ($files instanceof UploadedFileInterface) { + return [$files]; + } + + if (!is_array($files)) { + return []; + } + + $list = []; + + foreach ($files as $file) { + if ($file instanceof UploadedFileInterface) { + $list[] = $file; + } + } + + return $list; + } + /** * @param array $ticket * diff --git a/app/Middleware/RoleMiddleware.php b/app/Middleware/RoleMiddleware.php index d7ca161..1e9405e 100644 --- a/app/Middleware/RoleMiddleware.php +++ b/app/Middleware/RoleMiddleware.php @@ -427,6 +427,31 @@ public static function defaultRules(): array 'pattern' => '/api/tickets/{id}/comments', 'roles' => $withEndUser, ], + [ + 'methods' => ['GET'], + 'pattern' => '/api/tickets/{id}/attachments/{attachmentId}/download', + 'roles' => $withEndUser, + ], + [ + 'methods' => ['GET'], + 'pattern' => '/api/tickets/{id}/followers', + 'roles' => $operational, + ], + [ + 'methods' => ['PUT'], + 'pattern' => '/api/tickets/{id}/followers', + 'roles' => $operational, + ], + [ + 'methods' => ['POST'], + 'pattern' => '/api/tickets/{id}/transfer', + 'roles' => $operational, + ], + [ + 'methods' => ['POST'], + 'pattern' => '/api/tickets/{id}/attach-email', + 'roles' => $operational, + ], [ 'methods' => ['GET'], 'pattern' => '/api/personnel', diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index aaaa93e..ab39942 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -32,6 +32,12 @@ class Ticket public const PRIORITY_HIGH = 'high'; public const PRIORITY_CRITICAL = 'critical'; + public const SOURCE_WEB = 'web'; + public const SOURCE_EMAIL = 'email'; + + public const EMAIL_DIRECTION_INBOUND = 'inbound'; + public const EMAIL_DIRECTION_OUTBOUND = 'outbound'; + public function __construct( private readonly DatabaseService $databaseService, private readonly AssetsGlobalRegistry $assetsGlobalRegistry, @@ -152,7 +158,8 @@ public function findById(int $id, bool $withComments = false): ?array $ticket = $this->normalizeRow($rows[0]); if ($withComments) { - $ticket['comments'] = $this->findCommentsByTicketId($id); + $ticket['comments'] = $this->findCommentsByTicketId($id, true); + $ticket['followers'] = $this->findFollowersByTicketId($id); } return $ticket; @@ -161,30 +168,67 @@ public function findById(int $id, bool $withComments = false): ?array /** * @return list> */ - public function findCommentsByTicketId(int $ticketId): array + public function findCommentsByTicketId(int $ticketId, bool $includeInternal = true): array { - $rows = $this->db()->select('ticket_comments', [ + $conditions = [ + 'ticket_id' => $ticketId, + 'ORDER' => ['created_at' => 'ASC', 'id' => 'ASC'], + ]; + + if (!$includeInternal) { + $conditions['is_internal'] = 0; + } + + $columns = [ 'id', 'ticket_id', 'user_id', 'author_name', 'body', + 'is_internal', + 'email_message_id', + 'email_in_reply_to', 'created_at', - ], [ - 'ticket_id' => $ticketId, - 'ORDER' => ['created_at' => 'ASC', 'id' => 'ASC'], - ]); + ]; - return array_map( - static function (array $row): array { - $row['id'] = (int) $row['id']; - $row['ticket_id'] = (int) $row['ticket_id']; - $row['user_id'] = $row['user_id'] !== null ? (int) $row['user_id'] : null; + try { + $rows = $this->db()->select('ticket_comments', $columns, $conditions); + } catch (\Throwable) { + $rows = $this->db()->select('ticket_comments', [ + 'id', + 'ticket_id', + 'user_id', + 'author_name', + 'body', + 'created_at', + ], [ + 'ticket_id' => $ticketId, + 'ORDER' => ['created_at' => 'ASC', 'id' => 'ASC'], + ]); + } - return $row; - }, - $rows - ); + if (!is_array($rows)) { + return []; + } + + $comments = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $comment = $this->normalizeCommentRow($row); + + if (!$includeInternal && !empty($comment['is_internal'])) { + continue; + } + + $comment['attachments'] = $this->findAttachmentsByCommentId((int) $comment['id']); + $comments[] = $comment; + } + + return $comments; } /** @@ -309,8 +353,15 @@ public function update(int $id, array $fields): ?array /** * @return array */ - public function addComment(int $ticketId, string $body, ?int $userId, string $authorName): array - { + public function addComment( + int $ticketId, + string $body, + ?int $userId, + string $authorName, + bool $isInternal = false, + ?string $emailMessageId = null, + ?string $emailInReplyTo = null + ): array { if ($this->findById($ticketId) === null) { throw new \InvalidArgumentException(__('ticket_not_found')); } @@ -326,34 +377,459 @@ public function addComment(int $ticketId, string $body, ?int $userId, string $au throw new \InvalidArgumentException(__('ticket_comment_author_required')); } - $this->db()->insert('ticket_comments', [ + $payload = [ 'ticket_id' => $ticketId, 'user_id' => $userId, 'author_name' => $trimmedAuthor, 'body' => $trimmedBody, - ]); + 'is_internal' => $isInternal ? 1 : 0, + 'email_message_id' => $this->normalizeOptionalMessageId($emailMessageId), + 'email_in_reply_to' => $this->normalizeOptionalMessageId($emailInReplyTo), + ]; + + $this->db()->insert('ticket_comments', $payload); $commentId = (int) $this->db()->id(); - $comment = $this->db()->get('ticket_comments', [ - 'id', - 'ticket_id', - 'user_id', - 'author_name', - 'body', - 'created_at', - ], ['id' => $commentId]); + $comment = $this->findCommentById($commentId); - if (!is_array($comment) || $comment === []) { + if ($comment === null) { throw new \RuntimeException(__('ticket_comment_create_error')); } - $comment['id'] = (int) $comment['id']; - $comment['ticket_id'] = (int) $comment['ticket_id']; - $comment['user_id'] = $comment['user_id'] !== null ? (int) $comment['user_id'] : null; - return $comment; } + /** + * @return array|null + */ + public function findCommentById(int $commentId): ?array + { + try { + $comment = $this->db()->get('ticket_comments', [ + 'id', + 'ticket_id', + 'user_id', + 'author_name', + 'body', + 'is_internal', + 'email_message_id', + 'email_in_reply_to', + 'created_at', + ], ['id' => $commentId]); + } catch (\Throwable) { + $comment = $this->db()->get('ticket_comments', [ + 'id', + 'ticket_id', + 'user_id', + 'author_name', + 'body', + 'created_at', + ], ['id' => $commentId]); + } + + if (!is_array($comment) || $comment === []) { + return null; + } + + $normalized = $this->normalizeCommentRow($comment); + $normalized['attachments'] = $this->findAttachmentsByCommentId((int) $normalized['id']); + + return $normalized; + } + + /** + * @param list $attachments + * + * @return list> + */ + public function addCommentAttachments( + int $ticketId, + int $commentId, + array $attachments, + ?int $uploadedByUserId + ): array { + $created = []; + + foreach ($attachments as $attachment) { + $this->db()->insert('ticket_comment_attachments', [ + 'comment_id' => $commentId, + 'ticket_id' => $ticketId, + 'original_filename' => (string) ($attachment['original_filename'] ?? 'file'), + 'stored_filename' => (string) ($attachment['stored_filename'] ?? ''), + 'file_path' => (string) ($attachment['file_path'] ?? ''), + 'file_size' => (string) ($attachment['file_size'] ?? ''), + 'mime_type' => $attachment['mime_type'] ?? null, + 'uploaded_by_user_id' => $uploadedByUserId, + ]); + + $row = $this->findAttachmentById((int) $this->db()->id()); + + if ($row !== null) { + $created[] = $row; + } + } + + return $created; + } + + /** + * @return list> + */ + public function findAttachmentsByCommentId(int $commentId): array + { + if (!$this->collaborationTablesReady()) { + return []; + } + + $rows = $this->db()->select('ticket_comment_attachments', '*', [ + 'comment_id' => $commentId, + 'ORDER' => ['id' => 'ASC'], + ]); + + if (!is_array($rows)) { + return []; + } + + return array_map(fn (array $row): array => $this->normalizeAttachmentRow($row), $rows); + } + + /** + * @return array|null + */ + public function findAttachmentById(int $attachmentId): ?array + { + if (!$this->collaborationTablesReady() || $attachmentId <= 0) { + return null; + } + + $row = $this->db()->get('ticket_comment_attachments', '*', ['id' => $attachmentId]); + + if (!is_array($row) || $row === []) { + return null; + } + + return $this->normalizeAttachmentRow($row); + } + + /** + * @return list> + */ + public function findFollowersByTicketId(int $ticketId): array + { + if (!$this->collaborationTablesReady()) { + return []; + } + + $rows = $this->db()->select('ticket_followers', '*', [ + 'ticket_id' => $ticketId, + 'ORDER' => ['id' => 'ASC'], + ]); + + if (!is_array($rows)) { + return []; + } + + return array_map(fn (array $row): array => $this->normalizeFollowerRow($row), $rows); + } + + /** + * @param list $items + * + * @return list> + */ + public function replaceFollowers(int $ticketId, array $items): array + { + if ($this->findById($ticketId) === null) { + throw new \InvalidArgumentException(__('ticket_not_found')); + } + + if (!$this->collaborationTablesReady()) { + throw new \RuntimeException(__('ticket_followers_unavailable')); + } + + $this->db()->delete('ticket_followers', ['ticket_id' => $ticketId]); + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $personnelId = isset($item['personnel_id']) && (int) $item['personnel_id'] > 0 + ? (int) $item['personnel_id'] + : null; + $userId = isset($item['user_id']) && (int) $item['user_id'] > 0 + ? (int) $item['user_id'] + : null; + $email = isset($item['email']) ? strtolower(trim((string) $item['email'])) : ''; + + if ($personnelId === null && $userId === null && $email === '') { + continue; + } + + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException(__('ticket_follower_email_invalid')); + } + + if ($personnelId !== null) { + $this->assertPersonnelExists($personnelId); + } + + if ($userId !== null) { + $this->assertUserExists($userId); + } + + $this->db()->insert('ticket_followers', [ + 'ticket_id' => $ticketId, + 'personnel_id' => $personnelId, + 'user_id' => $userId, + 'email' => $email !== '' ? $email : null, + 'notify_email' => !array_key_exists('notify_email', $item) || (bool) $item['notify_email'] ? 1 : 0, + ]); + } + + return $this->findFollowersByTicketId($ticketId); + } + + /** + * @return list + */ + public function followerEmailsForTicket(int $ticketId): array + { + $emails = []; + + foreach ($this->findFollowersByTicketId($ticketId) as $follower) { + if (!(bool) ($follower['notify_email'] ?? true)) { + continue; + } + + $email = trim((string) ($follower['email'] ?? '')); + + if ($email === '' && !empty($follower['personnel_id'])) { + $row = $this->db()->get('personnel', 'email', ['id' => (int) $follower['personnel_id']]); + $email = is_string($row) ? $row : (string) ($row['email'] ?? ''); + } + + if ($email === '' && !empty($follower['user_id'])) { + $row = $this->db()->get('users', 'email', ['id' => (int) $follower['user_id']]); + $email = is_string($row) ? $row : (string) ($row['email'] ?? ''); + } + + $email = strtolower(trim($email)); + + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[] = $email; + } + } + + return array_values(array_unique($emails)); + } + + /** + * @return array + */ + public function transferToCategory( + int $ticketId, + int $categoryId, + ?int $actorUserId, + string $actorName, + ?string $note = null + ): array { + $existing = $this->findById($ticketId); + + if ($existing === null) { + throw new \InvalidArgumentException(__('ticket_not_found')); + } + + $this->assertCategoryExists($categoryId); + + $oldCategoryName = trim((string) ($existing['category_name'] ?? '')) ?: __('ticket_transfer_uncategorized'); + $category = $this->db()->get('ticket_categories', ['id', 'name'], ['id' => $categoryId]); + $newCategoryName = is_array($category) + ? trim((string) ($category['name'] ?? '')) + : ''; + + if ($newCategoryName === '') { + throw new \InvalidArgumentException(__('ticket_category_not_found')); + } + + $this->db()->update('tickets', ['category_id' => $categoryId], ['id' => $ticketId]); + + $systemBody = sprintf( + __('ticket_transfer_system_note'), + $oldCategoryName, + $newCategoryName + ); + + if ($note !== null && trim($note) !== '') { + $systemBody .= "\n\n" . trim($note); + } + + $this->addComment( + $ticketId, + $systemBody, + $actorUserId, + $actorName !== '' ? $actorName : __('ticket_system_author'), + true + ); + + $updated = $this->findById($ticketId, true); + + if ($updated === null) { + throw new \RuntimeException(__('ticket_update_error')); + } + + return $updated; + } + + /** + * @return array|null + */ + public function findByEmailMessageId(string $messageId): ?array + { + $normalized = $this->normalizeOptionalMessageId($messageId); + + if ($normalized === null) { + return null; + } + + if ($this->collaborationTablesReady()) { + $emailRow = $this->db()->get('ticket_email_messages', ['ticket_id'], [ + 'message_id' => $normalized, + ]); + + if (is_array($emailRow) && (int) ($emailRow['ticket_id'] ?? 0) > 0) { + return $this->findById((int) $emailRow['ticket_id']); + } + } + + $ticket = $this->db()->get('tickets', 'id', [ + 'email_message_id' => $normalized, + ]); + + $ticketId = is_array($ticket) ? (int) ($ticket['id'] ?? 0) : (int) $ticket; + + return $ticketId > 0 ? $this->findById($ticketId) : null; + } + + /** + * @return array|null + */ + public function findByTicketNumber(string $ticketNumber): ?array + { + $normalized = strtoupper(trim($ticketNumber)); + + if ($normalized === '') { + return null; + } + + $rows = $this->selectRows(['tickets.ticket_number' => $normalized], 1); + + return $rows === [] ? null : $this->normalizeRow($rows[0]); + } + + public function recordEmailMessage( + int $ticketId, + string $messageId, + string $direction, + ?string $inReplyTo = null, + ?int $commentId = null, + ?string $fromAddress = null, + ?string $subject = null + ): void { + if (!$this->collaborationTablesReady()) { + return; + } + + $normalizedMessageId = $this->normalizeOptionalMessageId($messageId); + + if ($normalizedMessageId === null) { + return; + } + + if ($this->db()->has('ticket_email_messages', ['message_id' => $normalizedMessageId])) { + return; + } + + $this->db()->insert('ticket_email_messages', [ + 'ticket_id' => $ticketId, + 'comment_id' => $commentId, + 'message_id' => $normalizedMessageId, + 'in_reply_to' => $this->normalizeOptionalMessageId($inReplyTo), + 'direction' => $direction === self::EMAIL_DIRECTION_OUTBOUND + ? self::EMAIL_DIRECTION_OUTBOUND + : self::EMAIL_DIRECTION_INBOUND, + 'from_address' => $fromAddress !== null ? trim($fromAddress) : null, + 'subject' => $subject !== null ? trim($subject) : null, + ]); + } + + public function emailMessageExists(string $messageId): bool + { + $normalized = $this->normalizeOptionalMessageId($messageId); + + if ($normalized === null || !$this->collaborationTablesReady()) { + return false; + } + + return $this->db()->has('ticket_email_messages', ['message_id' => $normalized]); + } + + /** + * @return list + */ + public function emailReferenceChain(int $ticketId): array + { + if (!$this->collaborationTablesReady()) { + return []; + } + + $rows = $this->db()->select('ticket_email_messages', ['message_id'], [ + 'ticket_id' => $ticketId, + 'ORDER' => ['id' => 'ASC'], + ]); + + if (!is_array($rows)) { + return []; + } + + $ids = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $messageId = $this->normalizeOptionalMessageId((string) ($row['message_id'] ?? '')); + + if ($messageId !== null) { + $ids[] = $messageId; + } + } + + return $ids; + } + + public function setEmailThreadRoot(int $ticketId, string $messageId, ?string $references = null): void + { + $normalized = $this->normalizeOptionalMessageId($messageId); + + if ($normalized === null) { + return; + } + + $this->db()->update('tickets', [ + 'source' => self::SOURCE_EMAIL, + 'email_message_id' => $normalized, + 'email_references' => $references, + ], ['id' => $ticketId]); + } + /** * @return list> */ @@ -387,7 +863,7 @@ public function findByIdForPersonnel(int $id, int $personnelId, bool $withCommen $ticket = $this->normalizeRow($rows[0]); if ($withComments) { - $ticket['comments'] = $this->findCommentsByTicketId($id); + $ticket['comments'] = $this->findCommentsByTicketId($id, false); } return $ticket; @@ -463,6 +939,9 @@ private function selectRows( 'tickets.category_id', 'tickets.assigned_user_id', 'tickets.created_by_user_id', + 'tickets.source', + 'tickets.email_message_id', + 'tickets.email_references', 'tickets.resolved_at', 'tickets.created_at', 'tickets.updated_at', @@ -507,6 +986,9 @@ private function normalizeRow(array $row): array $row['category_id'] = $row['category_id'] !== null ? (int) $row['category_id'] : null; $row['status'] = (string) $row['status']; $row['priority'] = (string) $row['priority']; + $row['source'] = trim((string) ($row['source'] ?? self::SOURCE_WEB)) ?: self::SOURCE_WEB; + $row['email_message_id'] = trim((string) ($row['email_message_id'] ?? '')) ?: null; + $row['email_references'] = trim((string) ($row['email_references'] ?? '')) ?: null; $row['is_open'] = !in_array($row['status'], [self::STATUS_RESOLVED, self::STATUS_CLOSED], true); if ($row['asset_id'] === null) { @@ -525,6 +1007,109 @@ private function normalizeRow(array $row): array return $row; } + /** + * @param array $row + * + * @return array + */ + private function normalizeCommentRow(array $row): array + { + $row['id'] = (int) ($row['id'] ?? 0); + $row['ticket_id'] = (int) ($row['ticket_id'] ?? 0); + $row['user_id'] = isset($row['user_id']) && $row['user_id'] !== null ? (int) $row['user_id'] : null; + $row['author_name'] = (string) ($row['author_name'] ?? ''); + $row['body'] = (string) ($row['body'] ?? ''); + $row['is_internal'] = (bool) ((int) ($row['is_internal'] ?? 0)); + $row['email_message_id'] = trim((string) ($row['email_message_id'] ?? '')) ?: null; + $row['email_in_reply_to'] = trim((string) ($row['email_in_reply_to'] ?? '')) ?: null; + $row['created_at'] = (string) ($row['created_at'] ?? ''); + $row['attachments'] = $row['attachments'] ?? []; + + return $row; + } + + /** + * @param array $row + * + * @return array + */ + private function normalizeAttachmentRow(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'comment_id' => (int) ($row['comment_id'] ?? 0), + 'ticket_id' => (int) ($row['ticket_id'] ?? 0), + 'original_filename' => (string) ($row['original_filename'] ?? ''), + 'stored_filename' => (string) ($row['stored_filename'] ?? ''), + 'file_path' => (string) ($row['file_path'] ?? ''), + 'file_size' => (string) ($row['file_size'] ?? ''), + 'mime_type' => trim((string) ($row['mime_type'] ?? '')) ?: null, + 'uploaded_by_user_id' => isset($row['uploaded_by_user_id']) && $row['uploaded_by_user_id'] !== null + ? (int) $row['uploaded_by_user_id'] + : null, + 'created_at' => (string) ($row['created_at'] ?? ''), + ]; + } + + /** + * @param array $row + * + * @return array + */ + private function normalizeFollowerRow(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'ticket_id' => (int) ($row['ticket_id'] ?? 0), + 'personnel_id' => isset($row['personnel_id']) && $row['personnel_id'] !== null + ? (int) $row['personnel_id'] + : null, + 'user_id' => isset($row['user_id']) && $row['user_id'] !== null + ? (int) $row['user_id'] + : null, + 'email' => trim((string) ($row['email'] ?? '')) ?: null, + 'notify_email' => (bool) ((int) ($row['notify_email'] ?? 1)), + 'created_at' => (string) ($row['created_at'] ?? ''), + ]; + } + + private function normalizeOptionalMessageId(?string $messageId): ?string + { + if ($messageId === null) { + return null; + } + + $trimmed = trim($messageId); + + if ($trimmed === '') { + return null; + } + + if ($trimmed[0] !== '<') { + $trimmed = '<' . trim($trimmed, '<>') . '>'; + } + + return $trimmed; + } + + private function collaborationTablesReady(): bool + { + static $ready = null; + + if ($ready !== null) { + return $ready; + } + + try { + $statement = $this->db()->query("SHOW TABLES LIKE 'ticket_comment_attachments'"); + $ready = $statement !== false && $statement->rowCount() > 0; + } catch (\Throwable) { + $ready = false; + } + + return $ready; + } + private function generateTicketNumber(): string { $year = date('Y'); diff --git a/app/Services/DatabaseInitializer.php b/app/Services/DatabaseInitializer.php index c0baec7..b5d7063 100644 --- a/app/Services/DatabaseInitializer.php +++ b/app/Services/DatabaseInitializer.php @@ -109,6 +109,10 @@ public function initialize(): DatabaseInitializationResult $warnings[] = $warning; } + foreach ($this->patchTicketCollaboration($connection) as $warning) { + $warnings[] = $warning; + } + foreach ($this->patchKnowledgeBase($connection) as $warning) { $warnings[] = $warning; } @@ -1173,6 +1177,136 @@ private function patchTicketCategories(object $connection): array return $warnings; } + /** + * Self-heal ticket attachments, followers, internal notes, and email threading. + * + * @param object $connection Medoo instance + * + * @return list + */ + private function patchTicketCollaboration(object $connection): array + { + $warnings = []; + + if (!$this->tableExists($connection, 'tickets') || !$this->tableExists($connection, 'ticket_comments')) { + return $warnings; + } + + if (!$this->columnExists($connection, 'tickets', 'source')) { + $connection->query( + "ALTER TABLE tickets + ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'web' AFTER created_by_user_id" + ); + $warnings[] = 'Self-healed database: added tickets.source column.'; + } + + if (!$this->columnExists($connection, 'tickets', 'email_message_id')) { + $connection->query( + 'ALTER TABLE tickets + ADD COLUMN email_message_id VARCHAR(255) NULL DEFAULT NULL AFTER source, + ADD COLUMN email_references TEXT NULL DEFAULT NULL AFTER email_message_id' + ); + $warnings[] = 'Self-healed database: added tickets email threading columns.'; + } + + if ( + $this->columnExists($connection, 'tickets', 'email_message_id') + && !$this->indexExists($connection, 'tickets', 'uq_tickets_email_message_id') + ) { + $connection->query( + 'ALTER TABLE tickets ADD UNIQUE KEY uq_tickets_email_message_id (email_message_id)' + ); + $warnings[] = 'Self-healed database: added unique index on tickets.email_message_id.'; + } + + if (!$this->columnExists($connection, 'ticket_comments', 'is_internal')) { + $connection->query( + 'ALTER TABLE ticket_comments + ADD COLUMN is_internal TINYINT(1) NOT NULL DEFAULT 0 AFTER body, + ADD COLUMN email_message_id VARCHAR(255) NULL DEFAULT NULL AFTER is_internal, + ADD COLUMN email_in_reply_to VARCHAR(255) NULL DEFAULT NULL AFTER email_message_id' + ); + $warnings[] = 'Self-healed database: added ticket_comments internal/email columns.'; + } + + if (!$this->tableExists($connection, 'ticket_comment_attachments')) { + $connection->query( + 'CREATE TABLE IF NOT EXISTS ticket_comment_attachments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + comment_id BIGINT UNSIGNED NOT NULL, + ticket_id BIGINT UNSIGNED NOT NULL, + original_filename VARCHAR(255) NOT NULL, + stored_filename VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + file_size VARCHAR(64) NOT NULL, + mime_type VARCHAR(128) DEFAULT NULL, + uploaded_by_user_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ticket_comment_attachments_comment_id (comment_id), + KEY idx_ticket_comment_attachments_ticket_id (ticket_id), + CONSTRAINT fk_ticket_comment_attachments_comment_id + FOREIGN KEY (comment_id) REFERENCES ticket_comments (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ticket_comment_attachments_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + $warnings[] = 'Self-healed database: created ticket_comment_attachments table.'; + } + + if (!$this->tableExists($connection, 'ticket_followers')) { + $connection->query( + 'CREATE TABLE IF NOT EXISTS ticket_followers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_id BIGINT UNSIGNED NOT NULL, + personnel_id BIGINT UNSIGNED DEFAULT NULL, + user_id BIGINT UNSIGNED DEFAULT NULL, + email VARCHAR(255) DEFAULT NULL, + notify_email TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ticket_followers_ticket_id (ticket_id), + KEY idx_ticket_followers_email (email), + CONSTRAINT fk_ticket_followers_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + $warnings[] = 'Self-healed database: created ticket_followers table.'; + } + + if (!$this->tableExists($connection, 'ticket_email_messages')) { + $connection->query( + 'CREATE TABLE IF NOT EXISTS ticket_email_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_id BIGINT UNSIGNED NOT NULL, + comment_id BIGINT UNSIGNED DEFAULT NULL, + message_id VARCHAR(255) NOT NULL, + in_reply_to VARCHAR(255) DEFAULT NULL, + direction VARCHAR(16) NOT NULL, + from_address VARCHAR(255) DEFAULT NULL, + subject VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_ticket_email_messages_message_id (message_id), + KEY idx_ticket_email_messages_ticket_id (ticket_id), + KEY idx_ticket_email_messages_in_reply_to (in_reply_to), + CONSTRAINT fk_ticket_email_messages_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ticket_email_messages_comment_id + FOREIGN KEY (comment_id) REFERENCES ticket_comments (id) + ON DELETE SET NULL ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + $warnings[] = 'Self-healed database: created ticket_email_messages table.'; + } + + return $warnings; + } + private function getTicketCategoriesMigrationPath(): string { return dirname($this->schemaPath) . '/migrations/019_create_ticket_categories.sql'; diff --git a/app/Services/Mail/ImapInboxFetcher.php b/app/Services/Mail/ImapInboxFetcher.php index b0767be..06dbe6b 100644 --- a/app/Services/Mail/ImapInboxFetcher.php +++ b/app/Services/Mail/ImapInboxFetcher.php @@ -8,6 +8,9 @@ class ImapInboxFetcher { + /** @var resource|null */ + private $activeConnection = null; + public function __construct( private readonly ImapConfigResolver $imapConfigResolver, private readonly AppLogger $appLogger @@ -20,12 +23,21 @@ public function __construct( * skipped: bool, * message: string, * fetched: int, - * messages: list + * messages: list, + * from: string, + * subject: string, + * body: string + * }> * } */ public function fetchUnreadMessages(): array { $this->logStep('mail.imap.start', []); + $this->closeActiveConnection(); if (!function_exists('imap_open')) { $message = 'PHP IMAP extension is not loaded (ext-imap). Inbound email fetching is unavailable.'; @@ -84,11 +96,12 @@ public function fetchUnreadMessages(): array imap_errors(); } + // Writable connection so processed messages can be marked \\Seen. $connection = @imap_open( $mailboxPath, $config['username'], $config['password'], - OP_READONLY, + 0, 1, [ 'DISABLE_AUTHENTICATOR' => 'GSSAPI', @@ -119,6 +132,8 @@ public function fetchUnreadMessages(): array ]; } + $this->activeConnection = $connection; + $this->logStep('mail.imap.connected', [ 'stage' => 'mailbox_selected', 'mailbox_path' => $mailboxPath, @@ -138,7 +153,7 @@ public function fetchUnreadMessages(): array 'matched' => 0, ]); - imap_close($connection); + $this->closeActiveConnection(); return [ 'success' => true, @@ -164,14 +179,24 @@ public function fetchUnreadMessages(): array continue; } + $rawHeaders = imap_fetchheader($connection, $uid, FT_UID); + $headerMap = is_string($rawHeaders) ? $this->parseRawHeaders($rawHeaders) : []; + $from = $this->parseEmailAddress(isset($overview->from) ? (string) $overview->from : ''); $subject = $this->decodeMimeHeader(isset($overview->subject) ? (string) $overview->subject : ''); - $messageId = trim(isset($overview->message_id) ? (string) $overview->message_id : ''); + $messageId = $this->normalizeMessageId( + $headerMap['message-id'] + ?? (isset($overview->message_id) ? (string) $overview->message_id : '') + ); + $inReplyTo = $this->normalizeMessageId($headerMap['in-reply-to'] ?? ''); + $references = $this->parseReferences($headerMap['references'] ?? ''); $body = $this->extractMessageBody($connection, $uid); $messages[] = [ 'uid' => $uid, 'message_id' => $messageId, + 'in_reply_to' => $inReplyTo, + 'references' => $references, 'from' => $from, 'subject' => $subject, 'body' => $body, @@ -182,6 +207,7 @@ public function fetchUnreadMessages(): array 'from' => $from, 'subject' => $subject, 'message_id' => $messageId, + 'in_reply_to' => $inReplyTo, ]); } @@ -189,8 +215,6 @@ public function fetchUnreadMessages(): array 'fetched' => count($messages), ]); - imap_close($connection); - return [ 'success' => true, 'skipped' => false, @@ -205,9 +229,7 @@ public function fetchUnreadMessages(): array 'trace' => $exception->getTraceAsString(), ]); - if (is_resource($connection)) { - imap_close($connection); - } + $this->closeActiveConnection(); return [ 'success' => false, @@ -219,6 +241,47 @@ public function fetchUnreadMessages(): array } } + /** + * @param list $uids + */ + public function markMessagesSeen(array $uids): void + { + if ($this->activeConnection === null || $uids === []) { + return; + } + + $uniqueUids = array_values(array_unique(array_filter( + array_map(static fn ($uid): int => (int) $uid, $uids), + static fn (int $uid): bool => $uid > 0 + ))); + + if ($uniqueUids === []) { + return; + } + + $sequence = implode(',', $uniqueUids); + $result = @imap_setflag_full($this->activeConnection, $sequence, '\\Seen', ST_UID); + + $this->logStep('mail.imap.mark_seen', [ + 'uids' => $uniqueUids, + 'success' => $result === true, + 'last_error' => $result === true ? null : (imap_last_error() ?: null), + ]); + } + + public function closeActiveConnection(): void + { + if ($this->activeConnection === null) { + return; + } + + if (is_resource($this->activeConnection)) { + imap_close($this->activeConnection); + } + + $this->activeConnection = null; + } + /** * @param resource $connection */ @@ -289,6 +352,70 @@ private function decodePartBody($connection, int $uid, object $part, string $sec return ''; } + /** + * @return array + */ + private function parseRawHeaders(string $rawHeaders): array + { + $normalized = preg_replace("/\r\n[ \t]+/", ' ', str_replace("\r\n", "\n", $rawHeaders)) ?? $rawHeaders; + $map = []; + + foreach (explode("\n", $normalized) as $line) { + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $key = strtolower(trim($name)); + + if ($key === '') { + continue; + } + + $map[$key] = trim($value); + } + + return $map; + } + + /** + * @return list + */ + private function parseReferences(string $raw): array + { + $parts = preg_split('/\s+/', trim($raw)) ?: []; + $ids = []; + + foreach ($parts as $part) { + $messageId = $this->normalizeMessageId($part); + + if ($messageId !== '') { + $ids[$messageId] = true; + } + } + + return array_keys($ids); + } + + private function normalizeMessageId(string $value): string + { + $trimmed = trim($value); + + if ($trimmed === '') { + return ''; + } + + if ($trimmed[0] !== '<') { + $trimmed = '<' . $trimmed; + } + + if (!str_ends_with($trimmed, '>')) { + $trimmed .= '>'; + } + + return $trimmed; + } + private function parseEmailAddress(string $raw): string { $raw = trim($raw); diff --git a/app/Services/Mail/InboundEmailTicketService.php b/app/Services/Mail/InboundEmailTicketService.php index 679d317..4d82bdf 100644 --- a/app/Services/Mail/InboundEmailTicketService.php +++ b/app/Services/Mail/InboundEmailTicketService.php @@ -25,6 +25,7 @@ public function __construct( * message: string, * fetched: int, * created: int, + * replied: int, * skipped_messages: int * } */ @@ -32,120 +33,247 @@ public function run(): array { $this->appLogger->log('mail.inbound.start', []); - $fetchResult = $this->imapInboxFetcher->fetchUnreadMessages(); + try { + $fetchResult = $this->imapInboxFetcher->fetchUnreadMessages(); - if ($fetchResult['skipped']) { - return [ - 'success' => false, - 'skipped' => true, - 'message' => $fetchResult['message'], - 'fetched' => 0, - 'created' => 0, - 'skipped_messages' => 0, - ]; - } + if ($fetchResult['skipped']) { + return [ + 'success' => false, + 'skipped' => true, + 'message' => $fetchResult['message'], + 'fetched' => 0, + 'created' => 0, + 'replied' => 0, + 'skipped_messages' => 0, + ]; + } + + if (!$fetchResult['success']) { + return [ + 'success' => false, + 'skipped' => false, + 'message' => $fetchResult['message'], + 'fetched' => 0, + 'created' => 0, + 'replied' => 0, + 'skipped_messages' => 0, + ]; + } + + $created = 0; + $replied = 0; + $skippedMessages = 0; + $seenUids = []; + + foreach ($fetchResult['messages'] as $message) { + $uid = (int) ($message['uid'] ?? 0); + $from = strtolower(trim((string) ($message['from'] ?? ''))); + $subject = trim((string) ($message['subject'] ?? '')); + $body = trim((string) ($message['body'] ?? '')); + $messageId = trim((string) ($message['message_id'] ?? '')); + $inReplyTo = trim((string) ($message['in_reply_to'] ?? '')); + /** @var list $references */ + $references = is_array($message['references'] ?? null) ? $message['references'] : []; + + if ($messageId !== '' && $this->ticketModel->emailMessageExists($messageId)) { + $skippedMessages++; + $seenUids[] = $uid; + $this->appLogger->log('mail.inbound.message_skipped', [ + 'reason' => 'duplicate_message_id', + 'from' => $from, + 'uid' => $uid, + 'message_id' => $messageId, + ]); + continue; + } + + if ($from === '' || filter_var($from, FILTER_VALIDATE_EMAIL) === false) { + $skippedMessages++; + $seenUids[] = $uid; + $this->appLogger->error('mail.inbound.message_skipped', [ + 'reason' => 'invalid_sender', + 'uid' => $uid, + 'message_id' => $messageId, + ]); + continue; + } + + if ($subject === '') { + $subject = __('ticket_email_default_subject'); + } + + if ($body === '') { + $body = $subject; + } + + $matchedTicket = $this->resolveMatchedTicket($inReplyTo, $references, $subject); + + try { + if ($matchedTicket !== null) { + $ticketId = (int) ($matchedTicket['id'] ?? 0); + $comment = $this->ticketModel->addComment( + $ticketId, + $body, + null, + $from, + false, + $messageId !== '' ? $messageId : null, + $inReplyTo !== '' ? $inReplyTo : null + ); + + if ($messageId !== '') { + $this->ticketModel->recordEmailMessage( + $ticketId, + $messageId, + Ticket::EMAIL_DIRECTION_INBOUND, + $inReplyTo !== '' ? $inReplyTo : null, + (int) ($comment['id'] ?? 0), + $from, + $subject + ); + } + + $replied++; + $seenUids[] = $uid; + $this->appLogger->log('mail.inbound.ticket_replied', [ + 'ticket_id' => $ticketId, + 'ticket_number' => $matchedTicket['ticket_number'] ?? null, + 'from' => $from, + 'uid' => $uid, + 'message_id' => $messageId, + ]); + continue; + } + + $person = $this->personnelModel->findByEmail($from); + + if ($person === null) { + $skippedMessages++; + $seenUids[] = $uid; + $this->appLogger->error('mail.inbound.message_skipped', [ + 'reason' => 'unknown_sender', + 'from' => $from, + 'subject' => $subject, + 'uid' => $uid, + 'message_id' => $messageId, + ]); + continue; + } + + $ticket = $this->ticketModel->create( + $subject, + $body, + (int) ($person['id'] ?? 0), + null, + Ticket::PRIORITY_MEDIUM, + null + ); + + $ticketId = (int) ($ticket['id'] ?? 0); + + if ($messageId !== '' && $ticketId > 0) { + $referenceHeader = trim(implode(' ', $references)); + $this->ticketModel->setEmailThreadRoot( + $ticketId, + $messageId, + $referenceHeader !== '' ? $referenceHeader : null + ); + $this->ticketModel->recordEmailMessage( + $ticketId, + $messageId, + Ticket::EMAIL_DIRECTION_INBOUND, + $inReplyTo !== '' ? $inReplyTo : null, + null, + $from, + $subject + ); + } + + $created++; + $seenUids[] = $uid; + $this->appLogger->log('mail.inbound.ticket_created', [ + 'ticket_id' => $ticket['id'] ?? null, + 'ticket_number' => $ticket['ticket_number'] ?? null, + 'from' => $from, + 'uid' => $uid, + 'message_id' => $messageId, + ]); + } catch (\Throwable $exception) { + $skippedMessages++; + $this->appLogger->error('mail.inbound.ticket_create_failed', [ + 'from' => $from, + 'subject' => $subject, + 'uid' => $uid, + 'message_id' => $messageId, + 'error' => $exception->getMessage(), + ]); + } + } + + $this->imapInboxFetcher->markMessagesSeen($seenUids); + + $message = sprintf( + 'Inbound email fetch complete. Fetched %d message(s), created %d ticket(s), replied %d, skipped %d message(s).', + $fetchResult['fetched'], + $created, + $replied, + $skippedMessages + ); + + $this->appLogger->log('mail.inbound.complete', [ + 'fetched' => $fetchResult['fetched'], + 'created' => $created, + 'replied' => $replied, + 'skipped_messages' => $skippedMessages, + ]); - if (!$fetchResult['success']) { return [ - 'success' => false, + 'success' => true, 'skipped' => false, - 'message' => $fetchResult['message'], - 'fetched' => 0, - 'created' => 0, - 'skipped_messages' => 0, + 'message' => $message, + 'fetched' => $fetchResult['fetched'], + 'created' => $created, + 'replied' => $replied, + 'skipped_messages' => $skippedMessages, ]; + } finally { + $this->imapInboxFetcher->closeActiveConnection(); } + } - $created = 0; - $skippedMessages = 0; - - foreach ($fetchResult['messages'] as $message) { - $from = strtolower(trim((string) ($message['from'] ?? ''))); - $subject = trim((string) ($message['subject'] ?? '')); - $body = trim((string) ($message['body'] ?? '')); - - if ($from === '' || filter_var($from, FILTER_VALIDATE_EMAIL) === false) { - $skippedMessages++; - $this->appLogger->error('mail.inbound.message_skipped', [ - 'reason' => 'invalid_sender', - 'uid' => $message['uid'] ?? null, - 'message_id' => $message['message_id'] ?? null, - ]); - continue; - } + /** + * @param list $references + * + * @return array|null + */ + private function resolveMatchedTicket(string $inReplyTo, array $references, string $subject): ?array + { + $candidateIds = []; - $person = $this->personnelModel->findByEmail($from); - - if ($person === null) { - $skippedMessages++; - $this->appLogger->error('mail.inbound.message_skipped', [ - 'reason' => 'unknown_sender', - 'from' => $from, - 'subject' => $subject, - 'uid' => $message['uid'] ?? null, - 'message_id' => $message['message_id'] ?? null, - ]); - continue; - } + if ($inReplyTo !== '') { + $candidateIds[] = $inReplyTo; + } - if ($subject === '') { - $subject = __('ticket_email_default_subject'); - } + foreach ($references as $reference) { + $reference = trim((string) $reference); - if ($body === '') { - $body = $subject; + if ($reference !== '') { + $candidateIds[] = $reference; } + } + + foreach ($candidateIds as $candidateId) { + $ticket = $this->ticketModel->findByEmailMessageId($candidateId); - try { - $ticket = $this->ticketModel->create( - $subject, - $body, - (int) ($person['id'] ?? 0), - null, - Ticket::PRIORITY_MEDIUM, - null - ); - - $created++; - $this->appLogger->log('mail.inbound.ticket_created', [ - 'ticket_id' => $ticket['id'] ?? null, - 'ticket_number' => $ticket['ticket_number'] ?? null, - 'from' => $from, - 'uid' => $message['uid'] ?? null, - 'message_id' => $message['message_id'] ?? null, - ]); - } catch (\Throwable $exception) { - $skippedMessages++; - $this->appLogger->error('mail.inbound.ticket_create_failed', [ - 'from' => $from, - 'subject' => $subject, - 'uid' => $message['uid'] ?? null, - 'message_id' => $message['message_id'] ?? null, - 'error' => $exception->getMessage(), - ]); + if ($ticket !== null) { + return $ticket; } } - $message = sprintf( - 'Inbound email fetch complete. Fetched %d message(s), created %d ticket(s), skipped %d message(s).', - $fetchResult['fetched'], - $created, - $skippedMessages - ); - - $this->appLogger->log('mail.inbound.complete', [ - 'fetched' => $fetchResult['fetched'], - 'created' => $created, - 'skipped_messages' => $skippedMessages, - ]); - - return [ - 'success' => true, - 'skipped' => false, - 'message' => $message, - 'fetched' => $fetchResult['fetched'], - 'created' => $created, - 'skipped_messages' => $skippedMessages, - ]; + if (preg_match('/HD-\d{4}-\d+/i', $subject, $matches) === 1) { + return $this->ticketModel->findByTicketNumber(strtoupper($matches[0])); + } + + return null; } } diff --git a/app/Services/Mail/MailService.php b/app/Services/Mail/MailService.php index f1f6a56..2cf0437 100644 --- a/app/Services/Mail/MailService.php +++ b/app/Services/Mail/MailService.php @@ -14,12 +14,19 @@ class MailService /** @var list */ private array $smtpTrace = []; + private ?string $lastMessageId = null; + public function __construct( private readonly MailConfigResolver $configResolver, private readonly AppLogger $appLogger ) { } + public function getLastMessageId(): ?string + { + return $this->lastMessageId; + } + /** * @param array|null $configOverride */ @@ -41,10 +48,23 @@ public function isConfigured(?array $configOverride = null): bool /** * @param list $recipients * @param array|null $configOverride + * @param array{ + * cc?: list|null, + * messageId?: string|null, + * inReplyTo?: string|null, + * references?: string|list|null, + * replyTo?: string|null + * } $options */ - public function sendHtml(array $recipients, string $subject, string $htmlBody, ?array $configOverride = null): bool - { + public function sendHtml( + array $recipients, + string $subject, + string $htmlBody, + ?array $configOverride = null, + array $options = [] + ): bool { $this->smtpTrace = []; + $this->lastMessageId = null; $config = $this->configResolver->resolve($configOverride); $this->appLogger->log('mail.dispatch.start', [ @@ -75,11 +95,19 @@ public function sendHtml(array $recipients, string $subject, string $htmlBody, ? } $normalizedRecipients = $this->normalizeRecipients($recipients); + $normalizedCc = $this->normalizeRecipients( + is_array($options['cc'] ?? null) ? $options['cc'] : [] + ); + $normalizedCc = array_values(array_filter( + $normalizedCc, + static fn (string $email): bool => !in_array($email, $normalizedRecipients, true) + )); $this->appLogger->log('mail.dispatch.recipients', [ 'subject' => $subject, 'stage' => 'recipients_normalized', 'recipients' => $normalizedRecipients, + 'cc' => $normalizedCc, ]); if ($normalizedRecipients === []) { @@ -92,6 +120,11 @@ public function sendHtml(array $recipients, string $subject, string $htmlBody, ? return false; } + $messageId = $this->normalizeMessageId( + isset($options['messageId']) ? (string) $options['messageId'] : null, + $config['from_address'] + ); + try { $mailer = $this->createMailer($config); @@ -105,6 +138,36 @@ public function sendHtml(array $recipients, string $subject, string $htmlBody, ? $mailer->addAddress($recipient); } + foreach ($normalizedCc as $cc) { + $mailer->addCC($cc); + } + + $replyTo = trim((string) ($options['replyTo'] ?? '')); + + if ($replyTo === '' && $config['support_addresses'] !== []) { + $replyTo = (string) $config['support_addresses'][0]; + } + + if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL) !== false) { + $mailer->addReplyTo($replyTo); + } + + $mailer->MessageID = $messageId; + + $inReplyTo = $this->normalizeOptionalMessageId( + isset($options['inReplyTo']) ? (string) $options['inReplyTo'] : null + ); + + if ($inReplyTo !== null) { + $mailer->addCustomHeader('In-Reply-To', $inReplyTo); + } + + $references = $this->normalizeReferences($options['references'] ?? null); + + if ($references !== '') { + $mailer->addCustomHeader('References', $references); + } + $mailer->Subject = $subject; $mailer->Body = $htmlBody; $mailer->AltBody = trim(strip_tags(str_replace(['
', '
', '
'], "\n", $htmlBody))); @@ -112,13 +175,17 @@ public function sendHtml(array $recipients, string $subject, string $htmlBody, ? $this->appLogger->log('mail.dispatch.sending', [ 'subject' => $subject, 'stage' => 'smtp_send', + 'message_id' => $messageId, ]); $mailer->send(); + $this->lastMessageId = $messageId; $this->appLogger->log('mail.sent', [ 'subject' => $subject, 'recipients' => $normalizedRecipients, + 'cc' => $normalizedCc, + 'message_id' => $messageId, 'stage' => 'completed', ]); @@ -226,4 +293,66 @@ private function normalizeRecipients(array $recipients): array return array_keys($unique); } + + private function normalizeMessageId(?string $messageId, string $fromAddress): string + { + $normalized = $this->normalizeOptionalMessageId($messageId); + + if ($normalized !== null) { + return $normalized; + } + + $domain = 'localhost'; + + if (str_contains($fromAddress, '@')) { + $domain = substr($fromAddress, (int) strrpos($fromAddress, '@') + 1) ?: 'localhost'; + } + + return '<' . bin2hex(random_bytes(12)) . '.' . time() . '@' . $domain . '>'; + } + + private function normalizeOptionalMessageId(?string $messageId): ?string + { + $value = trim((string) $messageId); + + if ($value === '') { + return null; + } + + if ($value[0] !== '<') { + $value = '<' . $value; + } + + if (!str_ends_with($value, '>')) { + $value .= '>'; + } + + return $value; + } + + /** + * @param mixed $references + */ + private function normalizeReferences(mixed $references): string + { + if (is_string($references)) { + $parts = preg_split('/\s+/', trim($references)) ?: []; + } elseif (is_array($references)) { + $parts = $references; + } else { + return ''; + } + + $normalized = []; + + foreach ($parts as $part) { + $messageId = $this->normalizeOptionalMessageId(is_string($part) ? $part : null); + + if ($messageId !== null) { + $normalized[$messageId] = true; + } + } + + return implode(' ', array_keys($normalized)); + } } diff --git a/app/Services/Mail/TicketNotificationService.php b/app/Services/Mail/TicketNotificationService.php index 6ae5dba..ad69c4b 100644 --- a/app/Services/Mail/TicketNotificationService.php +++ b/app/Services/Mail/TicketNotificationService.php @@ -4,6 +4,7 @@ namespace App\Services\Mail; +use App\Models\Ticket; use App\Models\User; use App\Services\AppLogger; use App\Services\DeferredTaskRunner; @@ -20,6 +21,7 @@ public function __construct( private readonly MailConfigResolver $mailConfigResolver, private readonly ViewRenderer $viewRenderer, private readonly User $userModel, + private readonly Ticket $ticketModel, private readonly AppLogger $appLogger, private readonly string $appUrl ) { @@ -92,7 +94,14 @@ private function sendNewTicketAlert(array $ticket): void 'footer' => __('mail_ticket_footer'), ], 'emails/layout'); - $sent = $this->mailService->sendHtml($recipients, $subject, $html); + $sent = $this->dispatchTicketMail( + $ticket, + $recipients, + $subject, + $html, + [], + null + ); if (!$sent) { $this->appLogger->error('mail.ticket_new.failed', [ @@ -146,7 +155,15 @@ private function sendStatusChangeAlert(array $ticket, string $previousStatus): v 'detailHtml' => '', ], 'emails/layout'); - $sent = $this->mailService->sendHtml([$recipient], $subject, $html); + $followerCc = $this->followerCcForTicket((int) ($ticket['id'] ?? 0), [$recipient]); + $sent = $this->dispatchTicketMail( + $ticket, + [$recipient], + $subject, + $html, + $followerCc, + null + ); if (!$sent) { $this->appLogger->error('mail.ticket_status.failed', [ @@ -170,6 +187,15 @@ private function sendStatusChangeAlert(array $ticket, string $previousStatus): v private function sendStaffReplyAlert(array $ticket, array $comment): void { try { + if (!empty($comment['is_internal'])) { + $this->appLogger->log('mail.ticket_reply.skipped', [ + 'ticket_id' => $ticket['id'] ?? null, + 'reason' => 'internal_comment', + ]); + + return; + } + $recipient = strtolower(trim((string) ($ticket['personnel_email'] ?? ''))); if ($recipient === '' || filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) { @@ -207,7 +233,15 @@ private function sendStaffReplyAlert(array $ticket, array $comment): void ], null), ], 'emails/layout'); - $sent = $this->mailService->sendHtml([$recipient], $subject, $html); + $followerCc = $this->followerCcForTicket((int) ($ticket['id'] ?? 0), [$recipient]); + $sent = $this->dispatchTicketMail( + $ticket, + [$recipient], + $subject, + $html, + $followerCc, + isset($comment['id']) ? (int) $comment['id'] : null + ); if (!$sent) { $this->appLogger->error('mail.ticket_reply.failed', [ @@ -224,6 +258,113 @@ private function sendStaffReplyAlert(array $ticket, array $comment): void } } + /** + * @param array $ticket + * @param list $recipients + * @param list $cc + */ + private function dispatchTicketMail( + array $ticket, + array $recipients, + string $subject, + string $html, + array $cc, + ?int $commentId + ): bool { + $ticketId = (int) ($ticket['id'] ?? 0); + $thread = $this->resolveThreadHeaders($ticket); + $config = $this->mailConfigResolver->resolve(); + $replyTo = $config['support_addresses'][0] ?? null; + + $sent = $this->mailService->sendHtml($recipients, $subject, $html, null, [ + 'cc' => $cc, + 'inReplyTo' => $thread['in_reply_to'], + 'references' => $thread['references'], + 'replyTo' => is_string($replyTo) ? $replyTo : null, + ]); + + if (!$sent || $ticketId <= 0) { + return $sent; + } + + $messageId = $this->mailService->getLastMessageId(); + + if ($messageId === null || $messageId === '') { + return true; + } + + $this->ticketModel->recordEmailMessage( + $ticketId, + $messageId, + Ticket::EMAIL_DIRECTION_OUTBOUND, + $thread['in_reply_to'], + $commentId, + $config['from_address'] ?? null, + $subject + ); + + return true; + } + + /** + * @param array $ticket + * + * @return array{in_reply_to: ?string, references: list} + */ + private function resolveThreadHeaders(array $ticket): array + { + $ticketId = (int) ($ticket['id'] ?? 0); + $chain = $ticketId > 0 ? $this->ticketModel->emailReferenceChain($ticketId) : []; + $rootMessageId = trim((string) ($ticket['email_message_id'] ?? '')); + + if ($rootMessageId !== '' && !in_array($rootMessageId, $chain, true)) { + array_unshift($chain, $rootMessageId); + } + + $inReplyTo = $chain !== [] ? $chain[array_key_last($chain)] : ($rootMessageId !== '' ? $rootMessageId : null); + + return [ + 'in_reply_to' => $inReplyTo, + 'references' => $chain, + ]; + } + + /** + * @param list $exclude + * + * @return list + */ + private function followerCcForTicket(int $ticketId, array $exclude = []): array + { + if ($ticketId <= 0) { + return []; + } + + $excludeMap = []; + + foreach ($exclude as $email) { + $normalized = strtolower(trim($email)); + + if ($normalized !== '') { + $excludeMap[$normalized] = true; + } + } + + $cc = []; + + foreach ($this->ticketModel->followerEmailsForTicket($ticketId) as $email) { + $normalized = strtolower(trim($email)); + + if ($normalized === '' || isset($excludeMap[$normalized])) { + continue; + } + + $cc[] = $normalized; + } + + return array_values(array_unique($cc)); + } + /** * @return list */ diff --git a/app/Services/TicketAttachmentStorageService.php b/app/Services/TicketAttachmentStorageService.php new file mode 100644 index 0000000..9fdb5d7 --- /dev/null +++ b/app/Services/TicketAttachmentStorageService.php @@ -0,0 +1,224 @@ + */ + private const ALLOWED_EXTENSIONS = [ + 'pdf', 'docx', 'xlsx', 'pptx', 'txt', 'csv', + 'jpg', 'jpeg', 'png', 'gif', 'webp', + ]; + + /** @var list */ + private const BLOCKED_EXTENSIONS = [ + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', + 'sh', 'bash', 'zsh', 'js', 'mjs', 'cjs', 'exe', 'bat', 'cmd', 'com', 'htaccess', + ]; + + private const MAX_BYTES = 26214400; + + private readonly string $storageDirectory; + + public function __construct(string $projectRoot) + { + $this->storageDirectory = rtrim($projectRoot, '/') . '/storage/ticket_attachments'; + } + + /** + * @return array{ + * stored_filename: string, + * relative_path: string, + * absolute_path: string, + * original_filename: string, + * file_size: string, + * mime_type: string|null + * } + */ + public function storeUploadedFile(UploadedFileInterface $file): array + { + if ($file->getError() !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException(__('ticket_attachment_upload_error')); + } + + $originalFilename = basename((string) ($file->getClientFilename() ?? 'attachment')); + $extension = $this->resolveSafeExtension($originalFilename); + + if (!$this->isAllowedExtension($extension)) { + throw new InvalidArgumentException(__('ticket_attachment_extension_not_allowed')); + } + + $this->assertNotBlockedFilename($originalFilename); + + $sizeBytes = $file->getSize(); + + if ($sizeBytes === null || $sizeBytes <= 0) { + throw new InvalidArgumentException(__('ticket_attachment_upload_error')); + } + + if ($sizeBytes > self::MAX_BYTES) { + throw new InvalidArgumentException(__('ticket_attachment_file_too_large')); + } + + $this->ensureStorageDirectoryExists(); + + $storedFilename = $this->generateStoredFilename($extension); + $absolutePath = $this->storageDirectory . '/' . $storedFilename; + $relativePath = 'storage/ticket_attachments/' . $storedFilename; + + $file->moveTo($absolutePath); + + $this->assertStoredFileSafe($absolutePath, $extension); + + $mimeType = null; + + if (function_exists('mime_content_type')) { + $mimeType = mime_content_type($absolutePath) ?: null; + } + + return [ + 'stored_filename' => $storedFilename, + 'relative_path' => $relativePath, + 'absolute_path' => $absolutePath, + 'original_filename' => $originalFilename, + 'file_size' => QualityDocumentStorageService::formatBytes((int) $sizeBytes), + 'mime_type' => $mimeType, + ]; + } + + public function resolveAbsolutePath(string $relativePath): string + { + $normalizedRelative = str_replace('\\', '/', trim($relativePath)); + $normalizedRelative = ltrim($normalizedRelative, '/'); + + if ($normalizedRelative === '' + || str_contains($normalizedRelative, '..') + || !str_starts_with($normalizedRelative, 'storage/ticket_attachments/')) { + throw new InvalidArgumentException(__('ticket_attachment_invalid_path')); + } + + $basename = basename($normalizedRelative); + $candidate = $this->storageDirectory . '/' . $basename; + $storageRealPath = realpath($this->storageDirectory); + $fileRealPath = realpath($candidate); + + if ($storageRealPath === false + || $fileRealPath === false + || !str_starts_with($fileRealPath, $storageRealPath)) { + throw new InvalidArgumentException(__('ticket_attachment_invalid_path')); + } + + if (!is_file($fileRealPath)) { + throw new RuntimeException(__('ticket_attachment_not_found')); + } + + return $fileRealPath; + } + + public function deleteStoredFile(string $relativePath): void + { + try { + $absolutePath = $this->resolveAbsolutePath($relativePath); + } catch (InvalidArgumentException) { + return; + } + + if (is_file($absolutePath)) { + unlink($absolutePath); + } + } + + private function ensureStorageDirectoryExists(): void + { + if (is_dir($this->storageDirectory)) { + return; + } + + if (!mkdir($this->storageDirectory, 0750, true) && !is_dir($this->storageDirectory)) { + throw new RuntimeException(__('ticket_attachment_storage_unavailable')); + } + } + + private function generateStoredFilename(string $extension): string + { + return bin2hex(random_bytes(16)) . '.' . $extension; + } + + private function resolveSafeExtension(string $filename): string + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if ($extension === 'jpeg') { + return 'jpg'; + } + + return $extension; + } + + private function isAllowedExtension(string $extension): bool + { + return $extension !== '' && in_array($extension, self::ALLOWED_EXTENSIONS, true); + } + + private function assertNotBlockedFilename(string $filename): void + { + $lowerName = strtolower($filename); + $segments = array_filter(explode('.', $lowerName)); + + foreach ($segments as $segment) { + if (in_array($segment, self::BLOCKED_EXTENSIONS, true)) { + throw new InvalidArgumentException(__('ticket_attachment_extension_not_allowed')); + } + } + } + + private function assertStoredFileSafe(string $absolutePath, string $extension): void + { + if (!is_file($absolutePath)) { + throw new RuntimeException(__('ticket_attachment_upload_error')); + } + + $detectedExtension = $this->resolveSafeExtension(basename($absolutePath)); + + if (!$this->isAllowedExtension($detectedExtension) || $detectedExtension !== $extension) { + unlink($absolutePath); + throw new InvalidArgumentException(__('ticket_attachment_extension_not_allowed')); + } + + if (function_exists('mime_content_type')) { + $mimeType = mime_content_type($absolutePath) ?: ''; + $allowedMimes = $this->allowedMimeTypesForExtension($extension); + + if ($allowedMimes !== [] && !in_array($mimeType, $allowedMimes, true)) { + unlink($absolutePath); + throw new InvalidArgumentException(__('ticket_attachment_extension_not_allowed')); + } + } + } + + /** + * @return list + */ + private function allowedMimeTypesForExtension(string $extension): array + { + return match ($extension) { + 'pdf' => ['application/pdf'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'], + 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'], + 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'], + 'txt' => ['text/plain'], + 'csv' => ['text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel'], + 'jpg' => ['image/jpeg'], + 'png' => ['image/png'], + 'gif' => ['image/gif'], + 'webp' => ['image/webp'], + default => [], + }; + } +} diff --git a/cli.php b/cli.php index 6198f7b..0439168 100755 --- a/cli.php +++ b/cli.php @@ -11,6 +11,8 @@ require __DIR__ . '/vendor/autoload.php'; use App\Commands\FetchEmailsCommand; +use App\Models\AssetRegistry; +use App\Models\AssetsGlobalRegistry; use App\Models\Consumable; use App\Models\IpNetwork; use App\Models\License; @@ -79,6 +81,10 @@ exit(0); } +$assetsGlobalRegistryModel = new AssetsGlobalRegistry($databaseService); +$assetRegistryModel = new AssetRegistry($databaseService); +$ticketModel = new Ticket($databaseService, $assetsGlobalRegistryModel, $assetRegistryModel); + if ($command === 'mail:fetch_inbox') { $settingModel = new Setting($databaseService); $mailConfigResolver = new MailConfigResolver($settingModel); @@ -87,7 +93,7 @@ $service = new InboundEmailTicketService( $imapInboxFetcher, new Personnel($databaseService), - new Ticket($databaseService), + $ticketModel, $appLogger ); $commandRunner = new FetchEmailsCommand($service); @@ -103,7 +109,7 @@ $service = new DailySummaryNotificationService( new License($databaseService), new Consumable($databaseService), - new Ticket($databaseService), + $ticketModel, $settingModel, new Personnel($databaseService), $mailService, diff --git a/config/bootstrap.php b/config/bootstrap.php index 0314e26..c2b3c36 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -93,6 +93,7 @@ use App\Services\Mail\TicketNotificationService; use App\Services\NetworkPortMappingService; use App\Services\QualityDocumentStorageService; +use App\Services\TicketAttachmentStorageService; use App\Services\QrCodeService; use App\Services\Translator; use App\Services\TurnstileVerifier; @@ -218,6 +219,7 @@ $knowledgeBaseArticleModel = new KnowledgeBaseArticle($databaseService); $qualityDocumentStorageService = new QualityDocumentStorageService($rootPath); $qualityDocumentModel = new QualityDocument($databaseService, $qualityDocumentStorageService); +$ticketAttachmentStorageService = new TicketAttachmentStorageService($rootPath); $ticketCategoryModel = new TicketCategory($databaseService); $userIntegrationFactory = new UserIntegrationFactory($databaseService, $settingModel); $qrCodeService = new QrCodeService($appConfig['url']); @@ -320,6 +322,7 @@ $mailConfigResolver, $viewRenderer, $userModel, + $ticketModel, $appLogger, $appConfig['url'] ); @@ -330,6 +333,7 @@ $sessionAuthService, $endUserContextService, $ticketNotificationService, + $ticketAttachmentStorageService, $auditLogger ); $endUserController = new EndUserController($assetModel, $endUserContextService); @@ -368,6 +372,7 @@ $group->post('/api/tickets', [$ticketController, 'store']); $group->get('/api/tickets/{id}', [$ticketController, 'show']); $group->post('/api/tickets/{id}/comments', [$ticketController, 'addComment']); + $group->get('/api/tickets/{id}/attachments/{attachmentId}/download', [$ticketController, 'downloadAttachment']); $group->get('/api/assets/{id}/tutanak', [$assetTutanakController, 'show']); $group->get('/api/assets/{id}/history', [$assetController, 'history']); }); @@ -480,6 +485,10 @@ $group->delete('/api/quality-documents/{id}', [$qualityDocumentController, 'destroy']); $group->put('/api/tickets/{id}', [$ticketController, 'update']); $group->delete('/api/tickets/{id}', [$ticketController, 'destroy']); + $group->get('/api/tickets/{id}/followers', [$ticketController, 'followers']); + $group->put('/api/tickets/{id}/followers', [$ticketController, 'updateFollowers']); + $group->post('/api/tickets/{id}/transfer', [$ticketController, 'transfer']); + $group->post('/api/tickets/{id}/attach-email', [$ticketController, 'attachEmail']); $group->get('/api/assets/{id}/licenses', [$licenseController, 'forAsset']); $group->get('/api/personnel', [$userController, 'personnelIndex']); $group->post('/api/personnel', [$userController, 'storePersonnel']); diff --git a/database/migrations/034_ticket_comments_attachments_followers_email.sql b/database/migrations/034_ticket_comments_attachments_followers_email.sql new file mode 100644 index 0000000..4673a75 --- /dev/null +++ b/database/migrations/034_ticket_comments_attachments_followers_email.sql @@ -0,0 +1,74 @@ +-- Ticket comment attachments, internal notes, followers/CC, and email threading + +ALTER TABLE tickets + ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'web' AFTER created_by_user_id, + ADD COLUMN email_message_id VARCHAR(255) NULL DEFAULT NULL AFTER source, + ADD COLUMN email_references TEXT NULL DEFAULT NULL AFTER email_message_id; + +ALTER TABLE tickets + ADD UNIQUE KEY uq_tickets_email_message_id (email_message_id); + +ALTER TABLE ticket_comments + ADD COLUMN is_internal TINYINT(1) NOT NULL DEFAULT 0 AFTER body, + ADD COLUMN email_message_id VARCHAR(255) NULL DEFAULT NULL AFTER is_internal, + ADD COLUMN email_in_reply_to VARCHAR(255) NULL DEFAULT NULL AFTER email_message_id; + +CREATE TABLE IF NOT EXISTS ticket_comment_attachments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + comment_id BIGINT UNSIGNED NOT NULL, + ticket_id BIGINT UNSIGNED NOT NULL, + original_filename VARCHAR(255) NOT NULL, + stored_filename VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + file_size VARCHAR(64) NOT NULL, + mime_type VARCHAR(128) DEFAULT NULL, + uploaded_by_user_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ticket_comment_attachments_comment_id (comment_id), + KEY idx_ticket_comment_attachments_ticket_id (ticket_id), + CONSTRAINT fk_ticket_comment_attachments_comment_id + FOREIGN KEY (comment_id) REFERENCES ticket_comments (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ticket_comment_attachments_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ticket_followers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_id BIGINT UNSIGNED NOT NULL, + personnel_id BIGINT UNSIGNED DEFAULT NULL, + user_id BIGINT UNSIGNED DEFAULT NULL, + email VARCHAR(255) DEFAULT NULL, + notify_email TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ticket_followers_ticket_id (ticket_id), + KEY idx_ticket_followers_email (email), + CONSTRAINT fk_ticket_followers_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ticket_email_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_id BIGINT UNSIGNED NOT NULL, + comment_id BIGINT UNSIGNED DEFAULT NULL, + message_id VARCHAR(255) NOT NULL, + in_reply_to VARCHAR(255) DEFAULT NULL, + direction VARCHAR(16) NOT NULL, + from_address VARCHAR(255) DEFAULT NULL, + subject VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_ticket_email_messages_message_id (message_id), + KEY idx_ticket_email_messages_ticket_id (ticket_id), + KEY idx_ticket_email_messages_in_reply_to (in_reply_to), + CONSTRAINT fk_ticket_email_messages_ticket_id + FOREIGN KEY (ticket_id) REFERENCES tickets (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ticket_email_messages_comment_id + FOREIGN KEY (comment_id) REFERENCES ticket_comments (id) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/lang/en.php b/lang/en.php index 9fbc03b..438f092 100644 --- a/lang/en.php +++ b/lang/en.php @@ -1108,7 +1108,39 @@ 'ticket_comment_create_success' => 'Reply added.', 'ticket_comment_create_error' => 'Reply could not be added.', 'ticket_comment_system_author' => 'IT Support', + 'ticket_system_author' => 'System', 'ticket_delete_confirm' => 'Delete this ticket and all replies?', + 'ticket_internal_note_label' => 'Internal note (technicians only)', + 'ticket_internal_badge' => 'Internal', + 'ticket_attachments_label' => 'Attachments', + 'ticket_attachment_download' => 'Download', + 'ticket_attachment_not_found' => 'Attachment not found.', + 'ticket_attachment_upload_error' => 'Attachment could not be uploaded.', + 'ticket_attachment_extension_not_allowed' => 'This file type is not allowed.', + 'ticket_attachment_file_too_large' => 'Attachment exceeds the 25 MB limit.', + 'ticket_followers_title' => 'Followers / CC', + 'ticket_followers_subtitle' => 'People and email addresses copied on public ticket updates.', + 'ticket_follower_email_placeholder' => 'Add email address', + 'ticket_follower_add_email' => 'Add', + 'ticket_follower_search_placeholder' => 'Search personnel…', + 'ticket_followers_save' => 'Save followers', + 'ticket_followers_update_success' => 'Followers updated.', + 'ticket_followers_update_error' => 'Followers could not be updated.', + 'ticket_followers_invalid_payload' => 'Invalid followers payload.', + 'ticket_followers_unavailable' => 'Followers are unavailable until the database is updated.', + 'ticket_follower_email_invalid' => 'One or more follower email addresses are invalid.', + 'ticket_transfer_note_label' => 'Transfer note (optional)', + 'ticket_transfer_action' => 'Transfer to team', + 'ticket_transfer_confirm' => 'Transfer this ticket to the selected team/queue?', + 'ticket_transfer_success' => 'Ticket transferred.', + 'ticket_transfer_error' => 'Ticket could not be transferred.', + 'ticket_transfer_invalid_payload' => 'Select a valid team/queue to transfer to.', + 'ticket_transfer_uncategorized' => 'Uncategorized', + 'ticket_transfer_system_note' => 'Ticket transferred from "%s" to "%s".', + 'ticket_email_author' => 'Email sender', + 'ticket_email_already_attached' => 'This email message is already attached to a ticket.', + 'ticket_email_attach_success' => 'Email attached to the ticket.', + 'ticket_email_attach_error' => 'Email could not be attached to the ticket.', 'action_view_ticket' => 'View', 'action_add_ticket_reply' => 'Send Reply', 'action_delete_ticket' => 'Delete Ticket', @@ -1171,9 +1203,9 @@ 'portal_ticket_for_asset' => 'You are reporting an issue for :name (:tag).', 'ticket_linked_asset_title' => 'Linked Asset', 'action_view_linked_asset' => 'View Asset Details', - 'mail_ticket_new_subject' => 'New support ticket :ticket_number', - 'mail_ticket_status_subject' => 'Ticket :ticket_number status updated', - 'mail_ticket_reply_subject' => 'New reply on ticket :ticket_number', + 'mail_ticket_new_subject' => '[:ticket_number] New support ticket', + 'mail_ticket_status_subject' => '[:ticket_number] Ticket status updated', + 'mail_ticket_reply_subject' => '[:ticket_number] New reply on ticket', 'mail_ticket_new_heading' => 'New Support Ticket', 'mail_ticket_new_intro' => 'A new support ticket has been submitted and requires your attention.', 'mail_ticket_status_heading' => 'Your Ticket Status Changed', diff --git a/lang/tr.php b/lang/tr.php index 9b6e52b..661a327 100644 --- a/lang/tr.php +++ b/lang/tr.php @@ -1145,7 +1145,39 @@ 'ticket_comment_create_success' => 'Yanıt eklendi.', 'ticket_comment_create_error' => 'Yanıt eklenemedi.', 'ticket_comment_system_author' => 'BT Destek', + 'ticket_system_author' => 'Sistem', 'ticket_delete_confirm' => 'Bu talep ve tüm yanıtları silinsin mi?', + 'ticket_internal_note_label' => 'İç not (yalnız teknisyen)', + 'ticket_internal_badge' => 'İç not', + 'ticket_attachments_label' => 'Ekler', + 'ticket_attachment_download' => 'İndir', + 'ticket_attachment_not_found' => 'Ek bulunamadı.', + 'ticket_attachment_upload_error' => 'Dosya eki yüklenemedi.', + 'ticket_attachment_extension_not_allowed' => 'Bu dosya türüne izin verilmiyor.', + 'ticket_attachment_file_too_large' => 'Dosya eki 25 MB sınırını aşıyor.', + 'ticket_followers_title' => 'Takipçiler / CC', + 'ticket_followers_subtitle' => 'Herkese açık talep güncellemelerinde CC olarak eklenecek kişiler ve e-postalar.', + 'ticket_follower_email_placeholder' => 'E-posta adresi ekle', + 'ticket_follower_add_email' => 'Ekle', + 'ticket_follower_search_placeholder' => 'Personel ara…', + 'ticket_followers_save' => 'Takipçileri kaydet', + 'ticket_followers_update_success' => 'Takipçiler güncellendi.', + 'ticket_followers_update_error' => 'Takipçiler güncellenemedi.', + 'ticket_followers_invalid_payload' => 'Geçersiz takipçi verisi.', + 'ticket_followers_unavailable' => 'Veritabanı güncellenene kadar takipçiler kullanılamaz.', + 'ticket_follower_email_invalid' => 'Bir veya daha fazla takipçi e-posta adresi geçersiz.', + 'ticket_transfer_note_label' => 'Devretme notu (isteğe bağlı)', + 'ticket_transfer_action' => 'Ekibe devret', + 'ticket_transfer_confirm' => 'Bu talep seçilen ekibe/kuyruğa devredilsin mi?', + 'ticket_transfer_success' => 'Talep devredildi.', + 'ticket_transfer_error' => 'Talep devredilemedi.', + 'ticket_transfer_invalid_payload' => 'Devretmek için geçerli bir ekip/kuyruk seçin.', + 'ticket_transfer_uncategorized' => 'Kategorisiz', + 'ticket_transfer_system_note' => 'Talep "%s" kuyruğundan "%s" kuyruğuna devredildi.', + 'ticket_email_author' => 'E-posta göndereni', + 'ticket_email_already_attached' => 'Bu e-posta mesajı zaten bir talebe bağlı.', + 'ticket_email_attach_success' => 'E-posta talebe eklendi.', + 'ticket_email_attach_error' => 'E-posta talebe eklenemedi.', 'action_view_ticket' => 'Görüntüle', 'action_add_ticket_reply' => 'Yanıt Gönder', 'action_delete_ticket' => 'Talebi Sil', @@ -1197,9 +1229,9 @@ 'portal_ticket_for_asset' => ':name (:tag) için arıza bildiriyorsunuz.', 'ticket_linked_asset_title' => 'Bağlı Envanter', 'action_view_linked_asset' => 'Envanter Detayına Git', - 'mail_ticket_new_subject' => 'Yeni destek talebi :ticket_number', - 'mail_ticket_status_subject' => ':ticket_number talebinin durumu güncellendi', - 'mail_ticket_reply_subject' => ':ticket_number talebine yeni yanıt', + 'mail_ticket_new_subject' => '[:ticket_number] Yeni destek talebi', + 'mail_ticket_status_subject' => '[:ticket_number] Talep durumu güncellendi', + 'mail_ticket_reply_subject' => '[:ticket_number] Talebe yeni yanıt', 'mail_ticket_new_heading' => 'Yeni Destek Talebi', 'mail_ticket_new_intro' => 'Yeni bir destek talebi oluşturuldu ve incelemeniz gerekiyor.', 'mail_ticket_status_heading' => 'Talebinizin Durumu Güncellendi', diff --git a/storage/ticket_attachments/.gitkeep b/storage/ticket_attachments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/ticket_attachments/.htaccess b/storage/ticket_attachments/.htaccess new file mode 100644 index 0000000..3a42882 --- /dev/null +++ b/storage/ticket_attachments/.htaccess @@ -0,0 +1 @@ +Deny from all diff --git a/views/dashboard.php b/views/dashboard.php index abc553f..030c86f 100644 --- a/views/dashboard.php +++ b/views/dashboard.php @@ -379,6 +379,17 @@ 'quality_document_delete_confirm' => __('quality_document_delete_confirm'), 'ticket_comment_create_success' => __('ticket_comment_create_success'), 'ticket_comment_create_error' => __('ticket_comment_create_error'), + 'ticket_internal_note_label' => __('ticket_internal_note_label'), + 'ticket_internal_badge' => __('ticket_internal_badge'), + 'ticket_attachments_label' => __('ticket_attachments_label'), + 'ticket_attachment_download' => __('ticket_attachment_download'), + 'ticket_followers_update_success' => __('ticket_followers_update_success'), + 'ticket_followers_update_error' => __('ticket_followers_update_error'), + 'ticket_follower_email_invalid' => __('ticket_follower_email_invalid'), + 'ticket_transfer_success' => __('ticket_transfer_success'), + 'ticket_transfer_error' => __('ticket_transfer_error'), + 'ticket_transfer_confirm' => __('ticket_transfer_confirm'), + 'ticket_transfer_invalid_payload' => __('ticket_transfer_invalid_payload'), 'helpdesk_filter_all' => __('helpdesk_filter_all'), 'helpdesk_filter_active' => __('helpdesk_filter_active'), 'helpdesk_filter_closed' => __('helpdesk_filter_closed'), @@ -2089,19 +2100,113 @@ class="shrink-0 rounded-lg border border-sky-300 bg-white px-3 py-1.5 text-xs fo + +
+ +
+ +
+

+

+
+ +
+
+
+ +
+ +
+
+
+ + +
+
+

+
+ +
+
+

@@ -2110,6 +2215,20 @@ class="shrink-0 rounded-lg border border-sky-300 bg-white px-3 py-1.5 text-xs fo + +

+
+

+ +
@@ -96,6 +106,16 @@ class="fixed inset-0 z-[60] flex items-center justify-center px-4" +