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:
285
website/tests/Feature/TicketEmailProcessingTest.php
Normal file
285
website/tests/Feature/TicketEmailProcessingTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user