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

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