diff --git a/app/Jobs/ProcessIncomingEmail.php b/app/Jobs/ProcessIncomingEmail.php index b834a45..4cf91e6 100644 --- a/app/Jobs/ProcessIncomingEmail.php +++ b/app/Jobs/ProcessIncomingEmail.php @@ -107,10 +107,16 @@ class ProcessIncomingEmail implements ShouldQueue } if (! empty($bodyHtml)) { - $stripped = strip_tags($bodyHtml); - $stripped = preg_replace('/\s+/', ' ', $stripped); + // Replace all HTML tags with spaces to prevent words from running together + $html = preg_replace('/<[^>]*>/', ' ', $bodyHtml); - return mb_substr(trim($stripped), 0, 500); + // Decode HTML entities (e.g.  , &) + $decoded = html_entity_decode($html ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Collapse multiple spaces into a single space + $stripped = preg_replace('/\s+/', ' ', $decoded); + + return mb_substr(trim($stripped ?? ''), 0, 500); } return ''; @@ -126,12 +132,13 @@ class ProcessIncomingEmail implements ShouldQueue Cache::rememberForever('mongodb_ttl_index_ensured', function () { $ttlSeconds = config('services.mailops.email_body_ttl_seconds', 259200); - /** @var Collection $collection */ - $collection = (new EmailBody)->getCollection(); - $collection->createIndex( - ['created_at' => 1], - ['expireAfterSeconds' => $ttlSeconds, 'name' => 'ttl_created_at'] - ); + EmailBody::raw(function ($collection) use ($ttlSeconds) { + /* @var \MongoDB\Collection $collection */ + $collection->createIndex( + ['created_at' => 1], + ['expireAfterSeconds' => $ttlSeconds, 'name' => 'ttl_created_at'] + ); + }); return true; }); diff --git a/tests/Feature/ProcessIncomingEmailTest.php b/tests/Feature/ProcessIncomingEmailTest.php new file mode 100644 index 0000000..3e214fd --- /dev/null +++ b/tests/Feature/ProcessIncomingEmailTest.php @@ -0,0 +1,91 @@ + $hash, + 'metadata' => [ + 'recipientEmail' => 'test@imail.app', + 'recipientName' => 'Test User', + 'senderEmail' => 'sender@example.com', + 'senderName' => 'Sender Name', + 'domain' => 'imail.app', + 'subject' => 'Test Subject', + 'received_at' => now()->toIso8601String(), + 'attachmentSize' => 1024, + 'attachments' => [ + ['filename' => 'test.pdf', 'mimeType' => 'application/pdf', 'size' => 1024], + ], + ], + 'bodyText' => 'This is the plain text body format.', + 'bodyHtml' => '

This is the HTML body format.

', + ]; + + $job = new ProcessIncomingEmail($payload); + $job->handle(); + + // Verify MariaDB storage + $this->assertDatabaseHas('emails', [ + 'unique_id_hash' => $hash, + 'recipient_email' => 'test@imail.app', + 'domain' => 'imail.app', + 'subject' => 'Test Subject', + 'preview' => 'This is the plain text body format.', + 'attachment_size' => 1024, + ]); + + $email = Email::where('unique_id_hash', $hash)->first(); + expect($email->attachments_json)->toHaveCount(1) + ->and($email->attachments_json[0]['filename'])->toBe('test.pdf'); + + // Verify MongoDB storage + $body = EmailBody::where('unique_id_hash', $hash)->first(); + expect($body)->not->toBeNull() + ->and($body->body_text)->toBe('This is the plain text body format.') + ->and($body->body_html)->toBe('

This is the HTML body format.

'); + + // Verify Broadcast Event + Event::assertDispatched(NewEmailReceived::class, function ($event) use ($hash) { + return $event->email->unique_id_hash === $hash; + }); + + // Cleanup MongoDB (MariaDB is handled by RefreshDatabase if used, but let's be safe) + $body->delete(); +}); + +it('generates preview from stripped HTML if text body is missing', function () { + Event::fake(); + + $hash = 'test-hash-html-only-'.time(); + $payload = [ + 'hash' => $hash, + 'metadata' => [ + 'recipientEmail' => 'test2@imail.app', + 'senderEmail' => 'sender2@example.com', + 'domain' => 'imail.app', + 'received_at' => now()->toIso8601String(), + ], + 'bodyText' => null, + 'bodyHtml' => '

Welcome

This is a strong test.


Footer

', + ]; + + $job = new ProcessIncomingEmail($payload); + $job->handle(); + + // Verify MariaDB storage preview logic + $this->assertDatabaseHas('emails', [ + 'unique_id_hash' => $hash, + 'preview' => 'Welcome This is a strong test. Footer', + ]); + + // Cleanup MongoDB + EmailBody::where('unique_id_hash', $hash)->delete(); +}); diff --git a/tests/Feature/WebhookIngestionTest.php b/tests/Feature/WebhookIngestionTest.php new file mode 100644 index 0000000..b06ff0e --- /dev/null +++ b/tests/Feature/WebhookIngestionTest.php @@ -0,0 +1,78 @@ +postJson('/api/webhooks/incoming_email', [ + 'hash' => 'dummy-hash', + ]); + + $response->assertStatus(401) + ->assertJson(['error' => 'Unauthorized']); +}); + +it('rejects webhooks with an invalid secret token', function () { + $response = $this->postJson('/api/webhooks/incoming_email', [ + 'hash' => 'dummy-hash', + ], [ + 'Authorization' => 'Bearer wrong-secret', + ]); + + $response->assertStatus(401); +}); + +it('accepts valid webhooks and dispatches the processing job', function () { + Queue::fake(); + + $payload = [ + 'hash' => 'test-unique-hash-12345', + 'metadata' => [ + 'recipientEmail' => 'test@imail.app', + 'recipientName' => 'Test User', + 'senderEmail' => 'sender@example.com', + 'senderName' => 'Sender Name', + 'domain' => 'imail.app', + 'subject' => 'Test Subject', + 'received_at' => now()->toIso8601String(), + 'attachmentSize' => 0, + 'attachments' => [], + ], + 'bodyText' => 'This is a test email body.', + 'bodyHtml' => '

This is a test email body.

', + ]; + + $response = $this->postJson('/api/webhooks/incoming_email', $payload, [ + 'Authorization' => 'Bearer test-secret', + ]); + + $response->assertStatus(200) + ->assertJson(['status' => 'queued']); + + Queue::assertPushed(ProcessIncomingEmail::class, function ($job) use ($payload) { + return $job->payload['hash'] === $payload['hash']; + }); +}); + +it('validates required payload fields', function () { + Queue::fake(); + + $response = $this->postJson('/api/webhooks/incoming_email', [ + // Missing hash and other required fields + 'metadata' => [ + 'recipientEmail' => 'test@imail.app', + ], + ], [ + 'Authorization' => 'Bearer test-secret', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['hash', 'metadata.senderEmail', 'metadata.domain', 'metadata.received_at']); + + Queue::assertNothingPushed(); +});