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 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 17:10:04 -05:00
parent a4cf7026bc
commit 015949a7f6
28 changed files with 1220 additions and 6 deletions

View File

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

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Tickets\EmailServiceInterface;
use App\Services\Tickets\TicketEmailProcessor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ProcessTicketEmails extends Command
{
protected $signature = 'tickets:process-emails';
protected $description = 'Fetch and process incoming support ticket emails via IMAP';
public function handle(EmailServiceInterface $emailService, TicketEmailProcessor $processor): int
{
if (! config('tickets.polling_enabled')) {
$this->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;
}
}
}

View File

@@ -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.');
}

View File

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

View File

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

View File

@@ -17,6 +17,9 @@ class TicketReply extends Model
'user_id',
'body',
'is_staff_reply',
'message_id',
'from_email',
'via_email',
];
/** @return array<string, string> */
@@ -24,6 +27,7 @@ class TicketReply extends Model
{
return [
'is_staff_reply' => 'boolean',
'via_email' => 'boolean',
];
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketCreatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public SupportTicket $ticket,
) {}
/** @return array<int, string> */
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<string, mixed> */
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,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketCustomerReplyNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public SupportTicket $ticket,
public TicketReply $reply,
) {}
/** @return array<int, string> */
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<string, mixed> */
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,
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketStaffReplyNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public SupportTicket $ticket,
public TicketReply $reply,
) {}
/** @return array<int, string> */
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<string, mixed> */
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,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TicketStatusChangedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public SupportTicket $ticket,
public string $oldStatus,
public string $newStatus,
) {}
/** @return array<int, string> */
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<string, mixed> */
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)),
];
}
}

View File

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

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\Tickets;
interface EmailServiceInterface
{
/** @return array<int, ParsedEmail> */
public function fetchNewEmails(): array;
public function markAsProcessed(mixed $message): void;
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Services\Tickets;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Message;
class ImapEmailService implements EmailServiceInterface
{
private ClientManager $clientManager;
public function __construct()
{
$this->clientManager = new ClientManager;
}
/** @return array<int, ParsedEmail> */
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 <date> <person> 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));
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Tickets;
class ParsedEmail
{
public function __construct(
public readonly string $messageId,
public readonly string $from,
public readonly string $subject,
public readonly string $body,
public readonly ?string $inReplyTo = null,
public readonly ?string $references = null,
) {}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Services\Tickets;
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use App\Notifications\TicketCreatedNotification;
use App\Notifications\TicketCustomerReplyNotification;
use App\Notifications\TicketStaffReplyNotification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class TicketEmailProcessor
{
public function processEmail(ParsedEmail $email): void
{
$ticket = $this->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;
}
}

View File

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

83
website/composer.lock generated
View File

@@ -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": [

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
return [
'support_email' => 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',
];

View File

@@ -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']),

View File

@@ -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>',
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_tickets', function (Blueprint $table): void {
$table->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();
});
}
};

View File

@@ -173,6 +173,15 @@ function formatDate(dateStr: string): string {
</td>
<td class="text-body-2 font-weight-medium">
{{ ticket.subject }}
<VChip
v-if="ticket.ticket_reference"
size="x-small"
color="secondary"
variant="tonal"
class="ms-2"
>
{{ ticket.ticket_reference }}
</VChip>
</td>
<td>
<div class="d-flex flex-column">

View File

@@ -97,6 +97,14 @@ function getUserInitial(name: string): string {
<div>
<div class="d-flex align-center gap-2">
<span class="text-h4 font-weight-bold">Ticket #{{ ticket.id }}</span>
<VChip
v-if="ticket.ticket_reference"
size="small"
color="secondary"
variant="tonal"
>
{{ ticket.ticket_reference }}
</VChip>
<VChip
:color="resolveTicketStatusColor(ticket.status)"
size="small"
@@ -162,7 +170,7 @@ function getUserInitial(name: string): string {
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ reply.user?.name ?? 'Unknown' }}
{{ reply.user?.name ?? reply.from_email ?? 'Unknown' }}
<VChip
v-if="reply.is_staff_reply"
size="x-small"
@@ -171,6 +179,15 @@ function getUserInitial(name: string): string {
>
Staff
</VChip>
<VChip
v-if="reply.via_email"
size="x-small"
color="info"
variant="tonal"
class="ms-2"
>
Via Email
</VChip>
</div>
<div class="text-caption text-medium-emphasis">
{{ formatDateTime(reply.created_at) }}

View File

@@ -73,6 +73,15 @@ function formatStatus(status: string): string {
<tr v-for="ticket in tickets.data" :key="ticket.id">
<td class="text-body-2 font-weight-medium">
{{ ticket.subject }}
<VChip
v-if="ticket.ticket_reference"
size="x-small"
color="secondary"
variant="tonal"
class="ms-2"
>
{{ ticket.ticket_reference }}
</VChip>
</td>
<td>
<VChip

View File

@@ -76,6 +76,14 @@ function getUserInitial(name: string): string {
<div>
<div class="d-flex align-center ga-3 mb-2">
<span class="text-h5 font-weight-bold">{{ ticket.subject }}</span>
<VChip
v-if="ticket.ticket_reference"
size="small"
color="secondary"
variant="tonal"
>
{{ ticket.ticket_reference }}
</VChip>
<VChip
:color="resolveTicketStatusColor(ticket.status)"
size="small"
@@ -134,7 +142,7 @@ function getUserInitial(name: string): string {
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ reply.user?.name ?? 'Unknown' }}
{{ reply.user?.name ?? reply.from_email ?? 'Unknown' }}
<VChip
v-if="reply.is_staff_reply"
size="x-small"
@@ -143,6 +151,15 @@ function getUserInitial(name: string): string {
>
Staff
</VChip>
<VChip
v-if="reply.via_email"
size="x-small"
color="info"
variant="tonal"
class="ms-2"
>
Via Email
</VChip>
</div>
<div class="text-caption text-medium-emphasis">
{{ formatDateTime(reply.created_at) }}

View File

@@ -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?: {

View File

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

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
use App\Models\SupportTicket;
use App\Models\TicketReply;
use App\Models\User;
use App\Notifications\TicketCreatedNotification;
use App\Notifications\TicketCustomerReplyNotification;
use App\Notifications\TicketStaffReplyNotification;
use App\Notifications\TicketStatusChangedNotification;
use App\Services\Tickets\ImapEmailService;
use App\Services\Tickets\ParsedEmail;
use App\Services\Tickets\TicketEmailProcessor;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
$this->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: '<msg-001@example.com>',
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('<msg-001@example.com>');
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: '<msg-002@example.com>',
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: '<msg-reply-001@example.com>',
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' => '<original-msg@example.com>',
]);
$processor = app(TicketEmailProcessor::class);
$email = new ParsedEmail(
messageId: '<msg-reply-002@example.com>',
from: 'customer@example.com',
subject: 'Re: Something totally different',
body: 'Follow-up information.',
inReplyTo: '<original-msg@example.com>',
);
$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);
});
});