From 015949a7f60b92884ceed7a0f17857e15c1f0600ef98e139b2a00b1876efaaaa Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Mon, 9 Feb 2026 17:10:04 -0500 Subject: [PATCH] Add email integration for ticket system and IMAP inbound support Email integration: IMAP polling via webklex/php-imap (M365/Zoho/MIAB compatible), TicketEmailProcessor with 3-strategy ticket matching (In-Reply-To, References, subject line [EZSCALE-{id}]), signature and quote stripping, scheduled command every 2min. Outbound: 4 notification classes (TicketCreated, CustomerReply, StaffReply, StatusChanged) with email threading headers via withSymfonyMessage(). Staff replies set Reply-To: support@ezscale.cloud. Frontend: ticket reference chips on all pages, "Via Email" badges on email-originated replies. 12 new tests (163 total, 816 assertions). Co-Authored-By: Claude Opus 4.6 --- website/.env.example | 12 + .../Console/Commands/ProcessTicketEmails.php | 48 +++ .../Controllers/Account/TicketController.php | 14 +- .../Controllers/Admin/TicketController.php | 12 +- website/app/Models/SupportTicket.php | 22 ++ website/app/Models/TicketReply.php | 4 + .../TicketCreatedNotification.php | 56 ++++ .../TicketCustomerReplyNotification.php | 56 ++++ .../TicketStaffReplyNotification.php | 91 ++++++ .../TicketStatusChangedNotification.php | 57 ++++ website/app/Providers/AppServiceProvider.php | 4 + .../Tickets/EmailServiceInterface.php | 13 + .../app/Services/Tickets/ImapEmailService.php | 150 +++++++++ website/app/Services/Tickets/ParsedEmail.php | 17 ++ .../Services/Tickets/TicketEmailProcessor.php | 144 +++++++++ website/composer.json | 3 +- website/composer.lock | 83 ++++- website/config/tickets.php | 26 ++ .../factories/SupportTicketFactory.php | 2 + .../database/factories/TicketReplyFactory.php | 12 + ...0000_add_email_fields_to_ticket_system.php | 53 ++++ .../ts/Pages/Admin/Tickets/Index.vue | 9 + .../resources/ts/Pages/Admin/Tickets/Show.vue | 19 +- website/resources/ts/Pages/Tickets/Index.vue | 9 + website/resources/ts/Pages/Tickets/Show.vue | 19 +- website/resources/ts/types/index.ts | 5 + website/routes/console.php | 1 + .../Feature/TicketEmailProcessingTest.php | 285 ++++++++++++++++++ 28 files changed, 1220 insertions(+), 6 deletions(-) create mode 100644 website/app/Console/Commands/ProcessTicketEmails.php create mode 100644 website/app/Notifications/TicketCreatedNotification.php create mode 100644 website/app/Notifications/TicketCustomerReplyNotification.php create mode 100644 website/app/Notifications/TicketStaffReplyNotification.php create mode 100644 website/app/Notifications/TicketStatusChangedNotification.php create mode 100644 website/app/Services/Tickets/EmailServiceInterface.php create mode 100644 website/app/Services/Tickets/ImapEmailService.php create mode 100644 website/app/Services/Tickets/ParsedEmail.php create mode 100644 website/app/Services/Tickets/TicketEmailProcessor.php create mode 100644 website/config/tickets.php create mode 100644 website/database/migrations/2026_02_09_220000_add_email_fields_to_ticket_system.php create mode 100644 website/tests/Feature/TicketEmailProcessingTest.php diff --git a/website/.env.example b/website/.env.example index 77164dd..1af35a5 100644 --- a/website/.env.example +++ b/website/.env.example @@ -86,4 +86,16 @@ ENHANCE_API_KEY= SCREENSHOT_AUTH_ENABLED=false SCREENSHOT_TOKEN= +# Support Ticket Email Integration +SUPPORT_EMAIL=support@ezscale.cloud +TICKET_EMAIL_POLLING=false +IMAP_HOST=outlook.office365.com +IMAP_PORT=993 +IMAP_ENCRYPTION=ssl +IMAP_USERNAME= +IMAP_PASSWORD= +IMAP_VALIDATE_CERT=true +IMAP_PROTOCOL=imap +IMAP_FOLDER=INBOX + VITE_APP_NAME="${APP_NAME}" diff --git a/website/app/Console/Commands/ProcessTicketEmails.php b/website/app/Console/Commands/ProcessTicketEmails.php new file mode 100644 index 0000000..daf50e1 --- /dev/null +++ b/website/app/Console/Commands/ProcessTicketEmails.php @@ -0,0 +1,48 @@ +warn('Ticket email polling is disabled. Set TICKET_EMAIL_POLLING=true to enable.'); + + return self::SUCCESS; + } + + try { + $emails = $emailService->fetchNewEmails(); + $count = count($emails); + + foreach ($emails as $email) { + $processor->processEmail($email); + } + + $this->info("Processed {$count} email(s)."); + Log::info('Ticket email processing complete', ['count' => $count]); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error('Failed to process ticket emails: '.$e->getMessage()); + Log::error('Ticket email processing failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return self::FAILURE; + } + } +} diff --git a/website/app/Http/Controllers/Account/TicketController.php b/website/app/Http/Controllers/Account/TicketController.php index 1791896..dad9e1f 100644 --- a/website/app/Http/Controllers/Account/TicketController.php +++ b/website/app/Http/Controllers/Account/TicketController.php @@ -7,8 +7,12 @@ namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; use App\Models\SupportTicket; use App\Models\TicketReply; +use App\Models\User; +use App\Notifications\TicketCreatedNotification; +use App\Notifications\TicketCustomerReplyNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Notification; use Inertia\Inertia; use Inertia\Response; @@ -57,6 +61,10 @@ class TicketController extends Controller 'is_staff_reply' => false, ]); + // Notify all admins about the new ticket + $admins = User::role('admin')->get(); + Notification::send($admins, new TicketCreatedNotification($ticket)); + return redirect()->route('account.tickets.show', $ticket) ->with('success', 'Support ticket created successfully.'); } @@ -81,7 +89,7 @@ class TicketController extends Controller 'body' => ['required', 'string', 'max:5000'], ]); - TicketReply::query()->create([ + $reply = TicketReply::query()->create([ 'ticket_id' => $ticket->id, 'user_id' => $request->user()->id, 'body' => $validated['body'], @@ -90,6 +98,10 @@ class TicketController extends Controller $ticket->update(['last_reply_at' => now()]); + // Notify all admins about the customer reply + $admins = User::role('admin')->get(); + Notification::send($admins, new TicketCustomerReplyNotification($ticket, $reply)); + return redirect()->back()->with('success', 'Reply added successfully.'); } diff --git a/website/app/Http/Controllers/Admin/TicketController.php b/website/app/Http/Controllers/Admin/TicketController.php index 6e2168a..ed87905 100644 --- a/website/app/Http/Controllers/Admin/TicketController.php +++ b/website/app/Http/Controllers/Admin/TicketController.php @@ -8,6 +8,8 @@ use App\Http\Controllers\Controller; use App\Models\AuditLog; use App\Models\SupportTicket; use App\Models\TicketReply; +use App\Notifications\TicketStaffReplyNotification; +use App\Notifications\TicketStatusChangedNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -76,7 +78,7 @@ class TicketController extends Controller 'status' => ['nullable', 'in:open,in_progress,waiting,closed'], ]); - TicketReply::query()->create([ + $reply = TicketReply::query()->create([ 'ticket_id' => $ticket->id, 'user_id' => $request->user()->id, 'body' => $validated['body'], @@ -91,6 +93,9 @@ class TicketController extends Controller $ticket->update($updateData); + // Notify the customer about the staff reply + $ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply)); + AuditLog::query()->create([ 'user_id' => $ticket->user_id, 'admin_id' => $request->user()->id, @@ -110,8 +115,13 @@ class TicketController extends Controller 'status' => ['required', 'in:open,in_progress,waiting,closed'], ]); + $oldStatus = $ticket->status; + $ticket->update(['status' => $validated['status']]); + // Notify the customer about the status change + $ticket->user?->notify(new TicketStatusChangedNotification($ticket, $oldStatus, $validated['status'])); + AuditLog::query()->create([ 'user_id' => $ticket->user_id, 'admin_id' => $request->user()->id, diff --git a/website/app/Models/SupportTicket.php b/website/app/Models/SupportTicket.php index 0eff385..6730dad 100644 --- a/website/app/Models/SupportTicket.php +++ b/website/app/Models/SupportTicket.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,6 +16,8 @@ class SupportTicket extends Model protected $fillable = [ 'user_id', + 'ticket_reference', + 'customer_email', 'subject', 'status', 'priority', @@ -30,6 +33,25 @@ class SupportTicket extends Model ]; } + protected static function booted(): void + { + static::created(function (SupportTicket $ticket): void { + if (empty($ticket->ticket_reference)) { + $ticket->updateQuietly([ + 'ticket_reference' => sprintf( + config('tickets.ticket_reference_format'), + $ticket->id + ), + ]); + } + }); + } + + public function scopeByReference(Builder $query, string $reference): Builder + { + return $query->where('ticket_reference', $reference); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/website/app/Models/TicketReply.php b/website/app/Models/TicketReply.php index 399e386..d8e900f 100644 --- a/website/app/Models/TicketReply.php +++ b/website/app/Models/TicketReply.php @@ -17,6 +17,9 @@ class TicketReply extends Model 'user_id', 'body', 'is_staff_reply', + 'message_id', + 'from_email', + 'via_email', ]; /** @return array */ @@ -24,6 +27,7 @@ class TicketReply extends Model { return [ 'is_staff_reply' => 'boolean', + 'via_email' => 'boolean', ]; } diff --git a/website/app/Notifications/TicketCreatedNotification.php b/website/app/Notifications/TicketCreatedNotification.php new file mode 100644 index 0000000..3aee3bd --- /dev/null +++ b/website/app/Notifications/TicketCreatedNotification.php @@ -0,0 +1,56 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']'; + $adminUrl = 'https://'.config('app.domains.admin').'/tickets/'.$this->ticket->id; + + return (new MailMessage) + ->subject('New Ticket: '.$this->ticket->subject.' '.$reference) + ->greeting("Hello {$notifiable->name}!") + ->line('A new support ticket has been created.') + ->line('**Subject:** '.$this->ticket->subject) + ->line('**Priority:** '.ucfirst($this->ticket->priority)) + ->line('**Department:** '.ucfirst($this->ticket->department)) + ->line('**Customer:** '.($this->ticket->user?->name ?? $this->ticket->customer_email ?? 'Unknown')) + ->action('View Ticket', $adminUrl) + ->line('Please review and respond at your earliest convenience.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'ticket_created', + 'ticket_id' => $this->ticket->id, + 'subject' => $this->ticket->subject, + 'priority' => $this->ticket->priority, + 'department' => $this->ticket->department, + 'message' => 'New support ticket: '.$this->ticket->subject, + ]; + } +} diff --git a/website/app/Notifications/TicketCustomerReplyNotification.php b/website/app/Notifications/TicketCustomerReplyNotification.php new file mode 100644 index 0000000..aa82b5f --- /dev/null +++ b/website/app/Notifications/TicketCustomerReplyNotification.php @@ -0,0 +1,56 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']'; + $adminUrl = 'https://'.config('app.domains.admin').'/tickets/'.$this->ticket->id; + + return (new MailMessage) + ->subject('Re: '.$this->ticket->subject.' '.$reference) + ->greeting("Hello {$notifiable->name}!") + ->line('A customer has replied to a support ticket.') + ->line('**Ticket:** '.$this->ticket->subject) + ->line('**Customer:** '.($this->reply->user?->name ?? $this->reply->from_email ?? 'Unknown')) + ->line($this->reply->body) + ->action('View Ticket', $adminUrl) + ->line('Please review and respond at your earliest convenience.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'ticket_customer_reply', + 'ticket_id' => $this->ticket->id, + 'reply_id' => $this->reply->id, + 'subject' => $this->ticket->subject, + 'message' => 'Customer replied to ticket: '.$this->ticket->subject, + ]; + } +} diff --git a/website/app/Notifications/TicketStaffReplyNotification.php b/website/app/Notifications/TicketStaffReplyNotification.php new file mode 100644 index 0000000..0529eb0 --- /dev/null +++ b/website/app/Notifications/TicketStaffReplyNotification.php @@ -0,0 +1,91 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']'; + $host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'ezscale.cloud'; + $rawMessageId = 'ticket-'.$this->reply->id.'@'.$host; + $storedMessageId = '<'.$rawMessageId.'>'; + + // Store message_id on the reply for future threading + $this->reply->updateQuietly(['message_id' => $storedMessageId]); + + $accountUrl = 'https://'.config('app.domains.account').'/tickets/'.$this->ticket->id; + + $mail = (new MailMessage) + ->subject('Re: '.$this->ticket->subject.' '.$reference) + ->replyTo((string) config('tickets.support_email')) + ->greeting('Hello '.$notifiable->name.'!') + ->line($this->reply->body) + ->action('View Ticket', $accountUrl) + ->line('Reply to this email or click the button above to respond.'); + + $mail->withSymfonyMessage(function (\Symfony\Component\Mime\Email $message) use ($rawMessageId): void { + // Remove existing Message-ID header and replace with ours + $headers = $message->getHeaders(); + if ($headers->has('Message-ID')) { + $headers->remove('Message-ID'); + } + $headers->addIdHeader('Message-ID', $rawMessageId); + + $lastReply = $this->ticket->replies() + ->whereNotNull('message_id') + ->where('id', '!=', $this->reply->id) + ->latest() + ->first(); + + if ($lastReply) { + $lastId = trim((string) $lastReply->message_id, '<>'); + $headers->addIdHeader('In-Reply-To', $lastId); + + $referenceIds = $this->ticket->replies() + ->whereNotNull('message_id') + ->where('id', '!=', $this->reply->id) + ->pluck('message_id') + ->map(fn (string $id): string => trim($id, '<>')) + ->toArray(); + $headers->addIdHeader('References', $referenceIds); + } + }); + + return $mail; + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'ticket_staff_reply', + 'ticket_id' => $this->ticket->id, + 'reply_id' => $this->reply->id, + 'subject' => $this->ticket->subject, + 'message' => 'Staff replied to your ticket: '.$this->ticket->subject, + ]; + } +} diff --git a/website/app/Notifications/TicketStatusChangedNotification.php b/website/app/Notifications/TicketStatusChangedNotification.php new file mode 100644 index 0000000..deb1f1f --- /dev/null +++ b/website/app/Notifications/TicketStatusChangedNotification.php @@ -0,0 +1,57 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']'; + $accountUrl = 'https://'.config('app.domains.account').'/tickets/'.$this->ticket->id; + $formattedStatus = ucwords(str_replace('_', ' ', $this->newStatus)); + + return (new MailMessage) + ->subject($this->ticket->subject.' '.$reference.' - Status: '.$formattedStatus) + ->greeting('Hello '.$notifiable->name.'!') + ->line('The status of your support ticket has been updated.') + ->line('**Ticket:** '.$this->ticket->subject) + ->line('**New Status:** '.$formattedStatus) + ->action('View Ticket', $accountUrl) + ->line('Thank you for using EZSCALE support!'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'ticket_status_changed', + 'ticket_id' => $this->ticket->id, + 'subject' => $this->ticket->subject, + 'old_status' => $this->oldStatus, + 'new_status' => $this->newStatus, + 'message' => 'Ticket "'.$this->ticket->subject.'" status changed to '.ucwords(str_replace('_', ' ', $this->newStatus)), + ]; + } +} diff --git a/website/app/Providers/AppServiceProvider.php b/website/app/Providers/AppServiceProvider.php index 5794b9b..c738f9d 100644 --- a/website/app/Providers/AppServiceProvider.php +++ b/website/app/Providers/AppServiceProvider.php @@ -24,6 +24,10 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); $this->app->singleton(BillingServiceFactory::class); $this->app->singleton(ProvisioningFactory::class); + $this->app->bind( + \App\Services\Tickets\EmailServiceInterface::class, + \App\Services\Tickets\ImapEmailService::class + ); } public function boot(): void diff --git a/website/app/Services/Tickets/EmailServiceInterface.php b/website/app/Services/Tickets/EmailServiceInterface.php new file mode 100644 index 0000000..735a058 --- /dev/null +++ b/website/app/Services/Tickets/EmailServiceInterface.php @@ -0,0 +1,13 @@ + */ + public function fetchNewEmails(): array; + + public function markAsProcessed(mixed $message): void; +} diff --git a/website/app/Services/Tickets/ImapEmailService.php b/website/app/Services/Tickets/ImapEmailService.php new file mode 100644 index 0000000..ff1120f --- /dev/null +++ b/website/app/Services/Tickets/ImapEmailService.php @@ -0,0 +1,150 @@ +clientManager = new ClientManager; + } + + /** @return array */ + public function fetchNewEmails(): array + { + $client = $this->clientManager->make([ + 'host' => config('tickets.imap.host'), + 'port' => config('tickets.imap.port'), + 'encryption' => config('tickets.imap.encryption'), + 'validate_cert' => config('tickets.imap.validate_cert'), + 'username' => config('tickets.imap.username'), + 'password' => config('tickets.imap.password'), + 'protocol' => config('tickets.imap.protocol'), + ]); + + $client->connect(); + + $folder = $client->getFolder((string) config('tickets.imap_folder')); + + if ($folder === null) { + return []; + } + + $messages = $folder->messages()->unseen()->get(); + + $emails = []; + foreach ($messages as $message) { + /** @var Message $message */ + $from = $this->extractFromAddress($message); + $subject = (string) $message->getSubject(); + $body = $this->cleanBody($message); + $messageId = (string) $message->getMessageId(); + $inReplyTo = $this->extractHeader($message, 'in_reply_to'); + $references = $this->extractHeader($message, 'references'); + + $emails[] = new ParsedEmail( + messageId: $messageId, + from: $from, + subject: $subject, + body: $body, + inReplyTo: $inReplyTo, + references: $references, + ); + + $this->markAsProcessed($message); + } + + return $emails; + } + + public function markAsProcessed(mixed $message): void + { + if ($message instanceof Message) { + $message->setFlag('Seen'); + } + } + + private function extractFromAddress(Message $message): string + { + $from = $message->getFrom(); + if ($from && count($from) > 0) { + return (string) $from->first()->mail; + } + + return ''; + } + + private function extractHeader(Message $message, string $header): ?string + { + $value = $message->get($header); + if ($value !== null && (string) $value !== '') { + return (string) $value; + } + + return null; + } + + private function cleanBody(Message $message): string + { + $body = $message->hasTextBody() + ? (string) $message->getTextBody() + : strip_tags((string) $message->getHTMLBody()); + + return $this->stripSignaturesAndQuotes($body); + } + + public static function stripSignaturesAndQuotes(string $body): string + { + // Strip email signatures + $signaturePatterns = [ + '/^--\s*$/m', + '/^_{3,}$/m', + '/^Sent from my .+$/mi', + '/^Get Outlook for .+$/mi', + '/^Sent from .+$/mi', + ]; + + foreach ($signaturePatterns as $pattern) { + if (preg_match($pattern, $body, $matches, \PREG_OFFSET_CAPTURE)) { + $body = substr($body, 0, (int) $matches[0][1]); + } + } + + // Strip quoted text (lines starting with >) + $lines = explode("\n", $body); + $cleanLines = []; + $hitQuote = false; + + foreach ($lines as $line) { + // Check for "On wrote:" pattern + if (preg_match('/^On .+ wrote:\s*$/i', $line)) { + $hitQuote = true; + + continue; + } + + // Check for lines starting with > + if (str_starts_with(trim($line), '>')) { + $hitQuote = true; + + continue; + } + + // Once we hit quoted content, skip remaining lines + if ($hitQuote) { + continue; + } + + $cleanLines[] = $line; + } + + return trim(implode("\n", $cleanLines)); + } +} diff --git a/website/app/Services/Tickets/ParsedEmail.php b/website/app/Services/Tickets/ParsedEmail.php new file mode 100644 index 0000000..15a4a74 --- /dev/null +++ b/website/app/Services/Tickets/ParsedEmail.php @@ -0,0 +1,17 @@ +findTicketFromEmail($email); + + if ($ticket) { + $this->addReplyToTicket($ticket, $email); + } else { + $this->createNewTicket($email); + } + } + + public function findTicketFromEmail(ParsedEmail $email): ?SupportTicket + { + // Strategy 1: Match by In-Reply-To header against ticket_replies.message_id + if ($email->inReplyTo) { + $reply = TicketReply::query() + ->where('message_id', $email->inReplyTo) + ->first(); + + if ($reply) { + return $reply->ticket; + } + } + + // Strategy 2: Match by References header against ticket_replies.message_id + if ($email->references) { + $referenceIds = preg_split('/\s+/', $email->references); + if ($referenceIds) { + $reply = TicketReply::query() + ->whereIn('message_id', $referenceIds) + ->latest() + ->first(); + + if ($reply) { + return $reply->ticket; + } + } + } + + // Strategy 3: Match by subject line ticket reference [EZSCALE-{id}] + $regex = config('tickets.ticket_reference_regex'); + if (preg_match($regex, $email->subject, $matches)) { + $ticketId = (int) $matches[1]; + + return SupportTicket::query()->find($ticketId); + } + + return null; + } + + private function addReplyToTicket(SupportTicket $ticket, ParsedEmail $email): void + { + $isStaff = $this->isStaffEmail($email->from); + $user = User::query()->where('email', $email->from)->first(); + + $reply = TicketReply::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $user?->id, + 'body' => $email->body, + 'is_staff_reply' => $isStaff, + 'message_id' => $email->messageId, + 'from_email' => $email->from, + 'via_email' => true, + ]); + + $ticket->update([ + 'last_reply_at' => now(), + 'status' => $ticket->status === 'closed' ? 'open' : $ticket->status, + ]); + + // Send appropriate notifications + if ($isStaff) { + $ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply)); + } else { + $admins = User::role('admin')->get(); + Notification::send($admins, new TicketCustomerReplyNotification($ticket, $reply)); + } + + Log::info('Email reply added to ticket', [ + 'ticket_id' => $ticket->id, + 'from' => $email->from, + 'is_staff' => $isStaff, + ]); + } + + private function createNewTicket(ParsedEmail $email): void + { + $user = User::query()->where('email', $email->from)->first(); + + $ticket = SupportTicket::query()->create([ + 'user_id' => $user?->id, + 'customer_email' => $email->from, + 'subject' => $email->subject, + 'status' => 'open', + 'priority' => 'medium', + 'department' => config('tickets.default_department'), + 'last_reply_at' => now(), + ]); + + TicketReply::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $user?->id, + 'body' => $email->body, + 'is_staff_reply' => false, + 'message_id' => $email->messageId, + 'from_email' => $email->from, + 'via_email' => true, + ]); + + // Notify admins about the new ticket + $admins = User::role('admin')->get(); + Notification::send($admins, new TicketCreatedNotification($ticket)); + + Log::info('New ticket created from email', [ + 'ticket_id' => $ticket->id, + 'from' => $email->from, + 'subject' => $email->subject, + ]); + } + + public function isStaffEmail(string $email): bool + { + $user = User::query()->where('email', $email)->first(); + + return $user?->hasRole('admin') ?? false; + } +} diff --git a/website/composer.json b/website/composer.json index d4e3a50..f1d42ec 100644 --- a/website/composer.json +++ b/website/composer.json @@ -17,7 +17,8 @@ "laravel/passport": "^13.4", "laravel/tinker": "^2.10.1", "spatie/laravel-permission": "^6.24", - "srmklive/paypal": "^3.0" + "srmklive/paypal": "^3.0", + "webklex/php-imap": "^6.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/website/composer.lock b/website/composer.lock index 7812357..b55eea2 100644 --- a/website/composer.lock +++ b/website/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7166e8eccf92defcc15b9e2bdb8d8108", + "content-hash": "7ed919792a4edfaad8af686d9fdf0522", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7773,6 +7773,87 @@ } ], "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" } ], "packages-dev": [ diff --git a/website/config/tickets.php b/website/config/tickets.php new file mode 100644 index 0000000..e931518 --- /dev/null +++ b/website/config/tickets.php @@ -0,0 +1,26 @@ + env('SUPPORT_EMAIL', 'support@ezscale.cloud'), + + 'imap' => [ + 'host' => env('IMAP_HOST', 'outlook.office365.com'), + 'port' => (int) env('IMAP_PORT', 993), + 'encryption' => env('IMAP_ENCRYPTION', 'ssl'), + 'username' => env('IMAP_USERNAME'), + 'password' => env('IMAP_PASSWORD'), + 'validate_cert' => (bool) env('IMAP_VALIDATE_CERT', true), + 'protocol' => env('IMAP_PROTOCOL', 'imap'), + ], + + 'polling_enabled' => (bool) env('TICKET_EMAIL_POLLING', false), + 'imap_folder' => env('IMAP_FOLDER', 'INBOX'), + + 'ticket_reference_format' => '[EZSCALE-%d]', + 'ticket_reference_regex' => '/\[EZSCALE-(\d+)\]/i', + + 'departments' => ['billing', 'technical', 'sales', 'general'], + 'default_department' => 'general', +]; diff --git a/website/database/factories/SupportTicketFactory.php b/website/database/factories/SupportTicketFactory.php index 11d9479..97caa58 100644 --- a/website/database/factories/SupportTicketFactory.php +++ b/website/database/factories/SupportTicketFactory.php @@ -20,6 +20,8 @@ class SupportTicketFactory extends Factory { return [ 'user_id' => User::factory(), + 'ticket_reference' => null, + 'customer_email' => null, 'subject' => fake()->sentence(), 'status' => fake()->randomElement(['open', 'in_progress', 'waiting', 'closed']), 'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']), diff --git a/website/database/factories/TicketReplyFactory.php b/website/database/factories/TicketReplyFactory.php index 43508ea..4de8110 100644 --- a/website/database/factories/TicketReplyFactory.php +++ b/website/database/factories/TicketReplyFactory.php @@ -24,6 +24,9 @@ class TicketReplyFactory extends Factory 'user_id' => User::factory(), 'body' => fake()->paragraphs(2, true), 'is_staff_reply' => false, + 'message_id' => null, + 'from_email' => null, + 'via_email' => false, ]; } @@ -33,4 +36,13 @@ class TicketReplyFactory extends Factory 'is_staff_reply' => true, ]); } + + public function viaEmail(?string $fromEmail = null): static + { + return $this->state(fn (array $attributes): array => [ + 'via_email' => true, + 'from_email' => $fromEmail ?? fake()->safeEmail(), + 'message_id' => '<'.fake()->uuid().'@example.com>', + ]); + } } diff --git a/website/database/migrations/2026_02_09_220000_add_email_fields_to_ticket_system.php b/website/database/migrations/2026_02_09_220000_add_email_fields_to_ticket_system.php new file mode 100644 index 0000000..0c64694 --- /dev/null +++ b/website/database/migrations/2026_02_09_220000_add_email_fields_to_ticket_system.php @@ -0,0 +1,53 @@ +string('ticket_reference')->nullable()->unique()->after('id'); + $table->string('customer_email')->nullable()->index()->after('user_id'); + }); + + // Make user_id nullable to support tickets created via email from unknown senders + Schema::table('support_tickets', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id')->nullable()->change(); + }); + + Schema::table('ticket_replies', function (Blueprint $table): void { + $table->string('message_id')->nullable()->index()->after('is_staff_reply'); + $table->string('from_email')->nullable()->after('message_id'); + $table->boolean('via_email')->default(false)->after('from_email'); + }); + + // Make user_id nullable to support replies from unknown email senders + Schema::table('ticket_replies', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('support_tickets', function (Blueprint $table): void { + $table->dropColumn(['ticket_reference', 'customer_email']); + }); + + Schema::table('support_tickets', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id')->nullable(false)->change(); + }); + + Schema::table('ticket_replies', function (Blueprint $table): void { + $table->dropColumn(['message_id', 'from_email', 'via_email']); + }); + + Schema::table('ticket_replies', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id')->nullable(false)->change(); + }); + } +}; diff --git a/website/resources/ts/Pages/Admin/Tickets/Index.vue b/website/resources/ts/Pages/Admin/Tickets/Index.vue index d23c70a..8d3e414 100644 --- a/website/resources/ts/Pages/Admin/Tickets/Index.vue +++ b/website/resources/ts/Pages/Admin/Tickets/Index.vue @@ -173,6 +173,15 @@ function formatDate(dateStr: string): string { {{ ticket.subject }} + + {{ ticket.ticket_reference }} +
diff --git a/website/resources/ts/Pages/Admin/Tickets/Show.vue b/website/resources/ts/Pages/Admin/Tickets/Show.vue index fd3a0af..b81f9cb 100644 --- a/website/resources/ts/Pages/Admin/Tickets/Show.vue +++ b/website/resources/ts/Pages/Admin/Tickets/Show.vue @@ -97,6 +97,14 @@ function getUserInitial(name: string): string {
Ticket #{{ ticket.id }} + + {{ ticket.ticket_reference }} +
- {{ reply.user?.name ?? 'Unknown' }} + {{ reply.user?.name ?? reply.from_email ?? 'Unknown' }} Staff + + Via Email +
{{ formatDateTime(reply.created_at) }} diff --git a/website/resources/ts/Pages/Tickets/Index.vue b/website/resources/ts/Pages/Tickets/Index.vue index e7a0508..32c6a84 100644 --- a/website/resources/ts/Pages/Tickets/Index.vue +++ b/website/resources/ts/Pages/Tickets/Index.vue @@ -73,6 +73,15 @@ function formatStatus(status: string): string { {{ ticket.subject }} + + {{ ticket.ticket_reference }} +
{{ ticket.subject }} + + {{ ticket.ticket_reference }} +
- {{ reply.user?.name ?? 'Unknown' }} + {{ reply.user?.name ?? reply.from_email ?? 'Unknown' }} Staff + + Via Email +
{{ formatDateTime(reply.created_at) }} diff --git a/website/resources/ts/types/index.ts b/website/resources/ts/types/index.ts index 16eeb78..4a5e446 100644 --- a/website/resources/ts/types/index.ts +++ b/website/resources/ts/types/index.ts @@ -166,6 +166,8 @@ export interface CouponRedemption { export interface SupportTicket { id: number user_id: number + ticket_reference: string | null + customer_email: string | null subject: string status: 'open' | 'in_progress' | 'waiting' | 'closed' priority: 'low' | 'medium' | 'high' | 'urgent' @@ -189,6 +191,9 @@ export interface TicketReply { user_id: number body: string is_staff_reply: boolean + message_id: string | null + from_email: string | null + via_email: boolean created_at: string updated_at: string user?: { diff --git a/website/routes/console.php b/website/routes/console.php index 6e9a69a..b80ba4b 100644 --- a/website/routes/console.php +++ b/website/routes/console.php @@ -9,3 +9,4 @@ Artisan::command('inspire', function () { })->purpose('Display an inspiring quote'); Schedule::command('billing:process-dunning')->daily()->at('06:00'); +Schedule::command('tickets:process-emails')->everyTwoMinutes()->withoutOverlapping(); diff --git a/website/tests/Feature/TicketEmailProcessingTest.php b/website/tests/Feature/TicketEmailProcessingTest.php new file mode 100644 index 0000000..bc8626e --- /dev/null +++ b/website/tests/Feature/TicketEmailProcessingTest.php @@ -0,0 +1,285 @@ +seed(RoleAndPermissionSeeder::class); + $this->accountUrl = 'http://'.config('app.domains.account'); + $this->adminUrl = 'http://'.config('app.domains.admin'); +}); + +// --------------------------------------------------------------------------- +// Ticket Reference Auto-Generation +// --------------------------------------------------------------------------- +describe('Ticket Reference', function (): void { + it('auto-generates ticket reference on create', function (): void { + $customer = User::factory()->customer()->create(); + + $ticket = SupportTicket::factory()->create([ + 'user_id' => $customer->id, + ]); + + $ticket->refresh(); + + expect($ticket->ticket_reference)->toBe('[EZSCALE-'.$ticket->id.']'); + }); +}); + +// --------------------------------------------------------------------------- +// Email Processing - New Tickets +// --------------------------------------------------------------------------- +describe('TicketEmailProcessor - New Tickets', function (): void { + it('creates new ticket from email for known user', function (): void { + Notification::fake(); + + $customer = User::factory()->customer()->create(['email' => 'customer@example.com']); + User::factory()->admin()->create(); + + $processor = app(TicketEmailProcessor::class); + + $email = new ParsedEmail( + messageId: '', + from: 'customer@example.com', + subject: 'Need help with VPS', + body: 'I cannot connect to my VPS server.', + ); + + $processor->processEmail($email); + + $ticket = SupportTicket::where('customer_email', 'customer@example.com')->first(); + + expect($ticket)->not->toBeNull() + ->and($ticket->user_id)->toBe($customer->id) + ->and($ticket->subject)->toBe('Need help with VPS') + ->and($ticket->status)->toBe('open') + ->and($ticket->department)->toBe('general'); + + $reply = TicketReply::where('ticket_id', $ticket->id)->first(); + expect($reply->body)->toBe('I cannot connect to my VPS server.') + ->and($reply->via_email)->toBeTrue() + ->and($reply->from_email)->toBe('customer@example.com') + ->and($reply->message_id)->toBe(''); + + Notification::assertSentTo( + User::role('admin')->first(), + TicketCreatedNotification::class + ); + }); + + it('creates new ticket from email for unknown sender', function (): void { + Notification::fake(); + + User::factory()->admin()->create(); + + $processor = app(TicketEmailProcessor::class); + + $email = new ParsedEmail( + messageId: '', + from: 'stranger@example.com', + subject: 'Pricing question', + body: 'How much does a VPS cost?', + ); + + $processor->processEmail($email); + + $ticket = SupportTicket::where('customer_email', 'stranger@example.com')->first(); + + expect($ticket)->not->toBeNull() + ->and($ticket->user_id)->toBeNull() + ->and($ticket->subject)->toBe('Pricing question'); + + $reply = TicketReply::where('ticket_id', $ticket->id)->first(); + expect($reply->user_id)->toBeNull() + ->and($reply->from_email)->toBe('stranger@example.com') + ->and($reply->via_email)->toBeTrue(); + }); +}); + +// --------------------------------------------------------------------------- +// Email Processing - Reply Matching +// --------------------------------------------------------------------------- +describe('TicketEmailProcessor - Reply Matching', function (): void { + it('matches reply by subject reference [EZSCALE-{id}]', function (): void { + Notification::fake(); + + $customer = User::factory()->customer()->create(['email' => 'customer@example.com']); + User::factory()->admin()->create(); + + $ticket = SupportTicket::factory()->open()->create([ + 'user_id' => $customer->id, + 'subject' => 'Server issue', + ]); + $ticket->refresh(); // ensure ticket_reference is populated + + $processor = app(TicketEmailProcessor::class); + + $email = new ParsedEmail( + messageId: '', + from: 'customer@example.com', + subject: 'Re: Server issue '.$ticket->ticket_reference, + body: 'Here is additional info.', + ); + + $processor->processEmail($email); + + $replies = TicketReply::where('ticket_id', $ticket->id)->get(); + + expect($replies)->toHaveCount(1) + ->and($replies->first()->body)->toBe('Here is additional info.') + ->and($replies->first()->via_email)->toBeTrue(); + }); + + it('matches reply by In-Reply-To header', function (): void { + Notification::fake(); + + $customer = User::factory()->customer()->create(['email' => 'customer@example.com']); + User::factory()->admin()->create(); + + $ticket = SupportTicket::factory()->open()->create([ + 'user_id' => $customer->id, + ]); + + $existingReply = TicketReply::factory()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $customer->id, + 'message_id' => '', + ]); + + $processor = app(TicketEmailProcessor::class); + + $email = new ParsedEmail( + messageId: '', + from: 'customer@example.com', + subject: 'Re: Something totally different', + body: 'Follow-up information.', + inReplyTo: '', + ); + + $processor->processEmail($email); + + $newReplies = TicketReply::where('ticket_id', $ticket->id) + ->where('id', '!=', $existingReply->id) + ->get(); + + expect($newReplies)->toHaveCount(1) + ->and($newReplies->first()->body)->toBe('Follow-up information.'); + }); +}); + +// --------------------------------------------------------------------------- +// Body Cleaning +// --------------------------------------------------------------------------- +describe('Email Body Cleaning', function (): void { + it('strips email signatures', function (): void { + $body = "Hello, I need help with my VPS.\n\n-- \nJohn Doe\nSenior Developer"; + + $cleaned = ImapEmailService::stripSignaturesAndQuotes($body); + + expect($cleaned)->toBe('Hello, I need help with my VPS.'); + }); + + it('strips quoted content', function (): void { + $body = "Thank you for the update.\n\nOn Mon, Feb 9, 2026 John wrote:\n> Original message here\n> More quoted text"; + + $cleaned = ImapEmailService::stripSignaturesAndQuotes($body); + + expect($cleaned)->toBe('Thank you for the update.'); + }); +}); + +// --------------------------------------------------------------------------- +// Artisan Command +// --------------------------------------------------------------------------- +describe('ProcessTicketEmails Command', function (): void { + it('exits with warning when polling is disabled', function (): void { + config(['tickets.polling_enabled' => false]); + + $this->artisan('tickets:process-emails') + ->expectsOutput('Ticket email polling is disabled. Set TICKET_EMAIL_POLLING=true to enable.') + ->assertExitCode(0); + }); +}); + +// --------------------------------------------------------------------------- +// Notification Integration +// --------------------------------------------------------------------------- +describe('Ticket Notifications', function (): void { + it('sends admin notification when customer creates ticket via web', function (): void { + Notification::fake(); + + $customer = User::factory()->customer()->create(); + $admin = User::factory()->admin()->create(); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets', [ + 'subject' => 'Help with billing', + 'department' => 'billing', + 'priority' => 'medium', + 'message' => 'I need help understanding my invoice details.', + ]) + ->assertRedirect(); + + Notification::assertSentTo($admin, TicketCreatedNotification::class); + }); + + it('sends customer notification when staff replies', function (): void { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'We are looking into this right now.', + ]) + ->assertRedirect(); + + Notification::assertSentTo($customer, TicketStaffReplyNotification::class); + }); + + it('sends customer notification when status changes', function (): void { + Notification::fake(); + + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [ + 'status' => 'in_progress', + ]) + ->assertRedirect(); + + Notification::assertSentTo($customer, TicketStatusChangedNotification::class); + }); + + it('sends admin notification when customer replies', function (): void { + Notification::fake(); + + $customer = User::factory()->customer()->create(); + $admin = User::factory()->admin()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'I have more details about my issue.', + ]) + ->assertRedirect(); + + Notification::assertSentTo($admin, TicketCustomerReplyNotification::class); + }); +});