diff --git a/.github/workflows/docker-dev-image.yml b/.github/workflows/docker-dev-image.yml index ad57324a..e904990d 100644 --- a/.github/workflows/docker-dev-image.yml +++ b/.github/workflows/docker-dev-image.yml @@ -43,7 +43,7 @@ jobs: push: true build-args: | APP_VERSION=${{ steps.previoustag.outputs.tag }} - FRONTEND_IMAGE_TAG=dev + FRONTEND_IMAGE_TAG=latest BRANCH=${{ steps.previoustag.outputs.tag }} tags: ghcr.io/${{ github.repository }}:dev, ghcr.io/${{ github.repository }}:${{ steps.previoustag.outputs.tag }} diff --git a/.github/workflows/phpunit-mysql.yml b/.github/workflows/phpunit-mysql.yml index 60684039..9ac19282 100644 --- a/.github/workflows/phpunit-mysql.yml +++ b/.github/workflows/phpunit-mysql.yml @@ -62,7 +62,7 @@ jobs: run: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Restore Composer Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.github/workflows/phpunit-pgsql.yml b/.github/workflows/phpunit-pgsql.yml index 6ba0b070..22f5b5c5 100644 --- a/.github/workflows/phpunit-pgsql.yml +++ b/.github/workflows/phpunit-pgsql.yml @@ -60,7 +60,7 @@ jobs: run: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Restore Composer Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.gitignore b/.gitignore index 7712ec9b..4a51fa04 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ protoc-gen-php-grpc* .db .sqlhistory *Zone.Identifier +.context \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 4d64c2d2..6d80dfaf 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -15,6 +15,6 @@ (new PhpCsFixer\Finder()) ->files() ->name('*.php') - ->in([__DIR__ . '/app/src', __DIR__ . '/app/modules']), + ->in([__DIR__ . '/app/src', __DIR__ . '/app/modules', __DIR__ . '/tests']), ) ->setCacheFile('.cache/.php-cs-fixer.cache'); diff --git a/app/modules/Smtp/Application/Mail/Message.php b/app/modules/Smtp/Application/Mail/Message.php index 39b272f0..b94f091f 100644 --- a/app/modules/Smtp/Application/Mail/Message.php +++ b/app/modules/Smtp/Application/Mail/Message.php @@ -36,7 +36,8 @@ public function getBccs(): array return \array_values( \array_filter($this->allRecipients, function (string $recipient) { foreach (\array_merge($this->recipients, $this->ccs) as $publicRecipient) { - if (\str_contains($publicRecipient, $recipient)) { + // Use email from the public recipient array + if (isset($publicRecipient['email']) && \str_contains($publicRecipient['email'], $recipient)) { return false; } } diff --git a/app/modules/Smtp/Application/Storage/Message.php b/app/modules/Smtp/Application/Storage/Message.php index 8b71cbdd..46218766 100644 --- a/app/modules/Smtp/Application/Storage/Message.php +++ b/app/modules/Smtp/Application/Storage/Message.php @@ -4,8 +4,6 @@ namespace Modules\Smtp\Application\Storage; -use Modules\Smtp\Application\Mail\Parser; - final class Message { public function __construct( @@ -45,21 +43,32 @@ public function setFrom(string $from): void public function appendBody(string $body): void { - $this->body .= \preg_replace("/^(\.\.)/m", '.', $body); + // Handle escaped periods at the beginning of lines per SMTP spec + $safeBody = \preg_replace("/^(\.\.)/m", '.', $body); + + // Ensure body is properly appended even with multi-byte characters + $this->body .= $safeBody; } public function bodyHasEos(): bool { - return \str_ends_with($this->body, "\r\n.\r\n"); + // More robust check for end of stream marker + // This handles potential encoding issues with multi-byte characters + return \mb_substr($this->body, -5) === "\r\n.\r\n"; } public function getBody(): string { - return \str_replace("\r\n.\r\n", '', $this->body); + // Remove the end of stream marker in a way that's safe for multi-byte strings + if ($this->bodyHasEos()) { + return \mb_substr($this->body, 0, \mb_strlen($this->body) - 5); + } + + return $this->body; } public function parse(): \Modules\Smtp\Application\Mail\Message { - return (new Parser())->parse($this->getBody()); + return ParserFactory::getInstance()->create()->parse($this->getBody(), $this->recipients); } } diff --git a/app/modules/Smtp/Application/Storage/ParserFactory.php b/app/modules/Smtp/Application/Storage/ParserFactory.php new file mode 100644 index 00000000..22c166df --- /dev/null +++ b/app/modules/Smtp/Application/Storage/ParserFactory.php @@ -0,0 +1,63 @@ +parser = $parser; + } + + /** + * Create a Parser instance + * + * @return Parser The Parser instance + */ + public function create(): Parser + { + return $this->parser ?? new Parser(); + } +} diff --git a/app/modules/Smtp/Interfaces/TCP/Service.php b/app/modules/Smtp/Interfaces/TCP/Service.php index e58f8e2e..22a78f2e 100644 --- a/app/modules/Smtp/Interfaces/TCP/Service.php +++ b/app/modules/Smtp/Interfaces/TCP/Service.php @@ -65,6 +65,9 @@ public function handle(Request $request): ResponseInterface $response = $this->makeResponse(ResponseMessage::closing(), close: true); $message = $this->emailBodyStorage->cleanup($request->connectionUuid); } elseif ($request->body === "DATA\r\n") { + // Reset the body to empty string when starting a new DATA command + // This prevents confusion between multiple DATA commands in the same session + $message->body = ''; $response = $this->makeResponse(ResponseMessage::provideBody()); $message->waitBody = true; } elseif ($request->body === "RSET\r\n") { @@ -75,13 +78,16 @@ public function handle(Request $request): ResponseInterface } elseif ($message->waitBody) { $message->appendBody($request->body); - $response = $this->makeResponse(ResponseMessage::ok()); - + // FIX: Only send one response when data ends if ($message->bodyHasEos()) { $uuid = $this->dispatchMessage($message->parse(), project: $message->username); - $response = $this->makeResponse(ResponseMessage::accepted($uuid)); $dispatched = true; + // Reset the waitBody flag to false since we've processed the message + $message->waitBody = false; + } else { + // Only send "OK" response if we're not at the end of data + $response = $this->makeResponse(ResponseMessage::ok()); } } diff --git a/app/modules/context.yaml b/app/modules/context.yaml new file mode 100644 index 00000000..3feea4ec --- /dev/null +++ b/app/modules/context.yaml @@ -0,0 +1,17 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +documents: + - description: Events module + outputPath: module/events.md + sources: + - type: file + sourcePaths: + - ./Events + + - description: SMTP module + outputPath: module/smtp.md + sources: + - type: file + sourcePaths: + - ./Smtp + - ../../docs/smtp.md \ No newline at end of file diff --git a/context.yaml b/context.yaml new file mode 100644 index 00000000..6a3d201c --- /dev/null +++ b/context.yaml @@ -0,0 +1,15 @@ +$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' + +import: + - path: app/**/context.yaml + - type: url + url: https://gist.githubusercontent.com/butschster/29e84fb9c976ac837181141f88049a35/raw/e869d8dfc210c70ae6e31278b1322b98e1e575cb/dev-prompts.yaml + +documents: + - description: 'Project structure overview' + outputPath: project-structure.md + sources: + - type: tree + sourcePaths: + - app + showCharCount: true diff --git a/docs/smtp.md b/docs/smtp.md new file mode 100644 index 00000000..1d314433 --- /dev/null +++ b/docs/smtp.md @@ -0,0 +1,134 @@ +# SMTP Module + +## Purpose of the Component + +The SMTP module provides email handling functionality, allowing the application to receive, parse, store, and retrieve +emails with attachments. It handles the full lifecycle of email messages from SMTP protocol interaction to storage and +retrieval of email content and attachments. + +## Detailed Description for Business Analyst and Marketing + +The SMTP module serves as an email receiver and processor within the application, enabling automated email capture and +storage. It solves the business problem of needing to automatically process incoming emails in a structured way, making +them available through the application's API. + +This component adds significant value by: + +- Capturing email communications automatically without manual intervention +- Storing emails systematically for future reference and processing +- Making email content and attachments available through a standardized API +- Supporting business workflows that are triggered by or depend on email communication + +The module integrates with the rest of the application to ensure emails become first-class entities within the system, +enabling email-driven processes and workflows. + +## Mermaid Sequence Diagram for Business Analyst and Marketing + +```mermaid +sequenceDiagram + participant EmailClient as Email Client + participant SMTPService as SMTP Service + participant Parser as Email Parser + participant Storage as Storage System + participant API as Application API + participant Client as API Client + EmailClient ->> SMTPService: Send email + SMTPService ->> Parser: Pass raw email + Parser ->> Parser: Parse email content + Parser ->> Storage: Store email body + Parser ->> Storage: Store attachments + SMTPService ->> SMTPService: Generate event UUID + SMTPService ->> API: Dispatch email received event + Note over SMTPService, API: Email is now available in the system + Client ->> API: Request email content + API ->> Storage: Retrieve email + Storage ->> API: Return email data + API ->> Client: Send email content + Client ->> API: Request attachment + API ->> Storage: Retrieve attachment + Storage ->> API: Return attachment data + API ->> Client: Send attachment +``` + +## List of Actors + +1. **Email Sender** - Any system or person sending emails to the application's SMTP server. +2. **API Client** - Applications or services that consume the email data through the HTTP API. +3. **Event Listeners** - Internal system components that react to email-related events. +4. **Storage System** - Handles persistence of email bodies and attachments. +5. **Event System** - Distributes events related to emails throughout the application. + +## List of Business Rules + +### SMTP Protocol Rules + +- The system must support standard SMTP commands (HELO/EHLO, MAIL FROM, RCPT TO, DATA, etc.) +- Authentication is required for sending emails (AUTH LOGIN supported) +- Multiple recipients (TO, CC, BCC) must be properly tracked + +### Email Processing Rules + +- All emails must be assigned a unique UUID for tracking +- Both HTML and plain text email bodies must be preserved +- Email metadata (sender, recipients, subject) must be extracted and stored +- All attachments must be properly stored with correct MIME types + +### Attachment Handling Rules + +- Attachments must be stored securely with access control +- Attachments must be accessible via HTTP API only by authorized clients +- Inline attachments (content-id) must be properly linked in HTML content +- Each attachment must have a unique UUID independent of its filename + +### Data Retention Rules + +- Email bodies are temporarily cached (1 minute) during the SMTP session +- Attachments are persisted until the associated event is deleted + +## Domain Ubiquitous Terminology + +- **SMTP** - Simple Mail Transfer Protocol, the standard protocol for email transmission +- **Attachment** - A file attached to an email message +- **Content-ID** - A unique identifier for inline attachments referenced in HTML email content +- **Event** - A record of an action that occurred in the system, in this case receiving an email +- **MIME Type** - The format identifier for a file (e.g., image/jpeg, application/pdf) +- **Parser** - Component that extracts structured data from raw email content +- **Repository** - A storage abstraction for accessing and managing domain objects + +## Simple Use Cases + +### Use Case 1: Receiving a Customer Support Email + +1. A customer sends an email to support@company.com +2. The SMTP service receives the email and authenticates the session +3. The email content and any attachments are parsed and stored +4. The system generates a unique event for this email +5. The support team is notified of the new support request +6. Support staff can view the email content and download attachments via the application + +### Use Case 2: Automated Document Processing + +1. A business partner sends documents as email attachments +2. The SMTP module receives the email and processes it +3. Attachments are stored with appropriate metadata +4. Another system component is notified of new documents +5. The documents are automatically processed according to business rules +6. Results of processing are made available through the API + +### Use Case 3: Email-Triggered Workflow + +1. A specific email address receives status updates from an external system +2. The SMTP module captures these emails +3. The system parses the email subject and body for status information +4. Based on the content, specific workflow actions are triggered +5. The email and its metadata remain available for audit purposes + +## Known Limitations / TODO + +- The module currently doesn't support outbound email sending +- No support for email encryption (PGP, S/MIME) +- Limited email thread tracking functionality +- Potential improvement: Add full-text search for email content +- Consideration: Implement IMAP support for accessing remote mailboxes +- TODO: Enhance performance for handling large attachments +- TODO: Add spam filtering capabilities diff --git a/tests/App/Broadcasting/BroadcastFaker.php b/tests/App/Broadcasting/BroadcastFaker.php index 138fb349..54906447 100644 --- a/tests/App/Broadcasting/BroadcastFaker.php +++ b/tests/App/Broadcasting/BroadcastFaker.php @@ -12,8 +12,7 @@ { public function __construct( private Container $container, - ) { - } + ) {} public function dump(): self { @@ -31,7 +30,7 @@ public function reset(): self public function assertPushedTimes(string|\Stringable $topic, int $times = 1): array { - $messages = $this->filterMessages((string)$topic); + $messages = $this->filterMessages((string) $topic); TestCase::assertCount( $times, @@ -50,7 +49,7 @@ public function assertPushedTimes(string|\Stringable $topic, int $times = 1): ar public function assertPushed(string|\Stringable $topic, \Closure $callback = null): self { - $messages = $this->filterMessages((string)$topic, $callback); + $messages = $this->filterMessages((string) $topic, $callback); TestCase::assertTrue( $messages !== [], @@ -62,7 +61,7 @@ public function assertPushed(string|\Stringable $topic, \Closure $callback = nul public function assertNotPushed(string|\Stringable $topic, \Closure $callback = null): self { - $messages = $this->filterMessages((string)$topic, $callback); + $messages = $this->filterMessages((string) $topic, $callback); TestCase::assertCount( 0, diff --git a/tests/App/Console/SpyConsoleInvoker.php b/tests/App/Console/SpyConsoleInvoker.php index a897dee8..6fca3746 100644 --- a/tests/App/Console/SpyConsoleInvoker.php +++ b/tests/App/Console/SpyConsoleInvoker.php @@ -4,7 +4,6 @@ namespace Tests\App\Console; - use PHPUnit\Framework\TestCase; use Spiral\Console\Command; use Spiral\Core\InvokerInterface; diff --git a/tests/App/Events/EventExpectation.php b/tests/App/Events/EventExpectation.php index 2b73b04d..20625919 100644 --- a/tests/App/Events/EventExpectation.php +++ b/tests/App/Events/EventExpectation.php @@ -12,8 +12,7 @@ final class EventExpectation { public function __construct( private CompositeExpectation $expectation, - ) { - } + ) {} public function andReturnEvent(Event $event): void { diff --git a/tests/App/Events/EventsMocker.php b/tests/App/Events/EventsMocker.php index 49f9e5c6..997e5dd5 100644 --- a/tests/App/Events/EventsMocker.php +++ b/tests/App/Events/EventsMocker.php @@ -14,14 +14,13 @@ { public function __construct( private MockInterface&EventRepositoryInterface $events, - ) { - } + ) {} public function eventShouldBeFound(Uuid|string $uuid, ?Event $result): self { $this->events ->shouldReceive('findByPK') - ->with((string)$uuid) + ->with((string) $uuid) ->once() ->andReturn($result); @@ -33,7 +32,7 @@ public function shouldRequestEventByUuid(Uuid $uuid): EventExpectation return new EventExpectation( $this->events ->shouldReceive('findByPK') - ->with((string)$uuid) + ->with((string) $uuid) ->once(), ); } @@ -42,7 +41,7 @@ public function eventShouldBeDeleted(Uuid $uuid, bool $status = true): void { $this->events ->shouldReceive('deleteByPK') - ->with((string)$uuid) + ->with((string) $uuid) ->once() ->andReturn($status); } @@ -51,7 +50,7 @@ public function eventShouldNotBeDeleted(Uuid $uuid): void { $this->events ->shouldNotReceive('deleteByPK') - ->with((string)$uuid); + ->with((string) $uuid); } public function eventShouldBeClear(?string $type = null, ?string $project = null, ?array $uuids = null): void diff --git a/tests/App/Sentry/FakeTransport.php b/tests/App/Sentry/FakeTransport.php index 4d30ba0a..9e785f55 100644 --- a/tests/App/Sentry/FakeTransport.php +++ b/tests/App/Sentry/FakeTransport.php @@ -18,12 +18,11 @@ final class FakeTransport implements TransportInterface public function __construct( private readonly PayloadSerializerInterface $payloadSerializer, - ) { - } + ) {} public function send(Event $event): Result { - $this->events[(string)$event->getId()] = $this->payloadSerializer->serialize($event); + $this->events[(string) $event->getId()] = $this->payloadSerializer->serialize($event); return new Result(ResultStatus::success(), $event); } @@ -35,6 +34,6 @@ public function close(?int $timeout = null): Result public function findEvent(EventId $id): string { - return $this->events[(string)$id] ?? throw new \InvalidArgumentException('Event not found'); + return $this->events[(string) $id] ?? throw new \InvalidArgumentException('Event not found'); } } diff --git a/tests/DatabaseTestCase.php b/tests/DatabaseTestCase.php index c1d8fca1..57148240 100644 --- a/tests/DatabaseTestCase.php +++ b/tests/DatabaseTestCase.php @@ -20,7 +20,10 @@ abstract class DatabaseTestCase extends TestCase { - use Transactions, Helper, DatabaseAsserts, ShowQueries; + use Transactions; + use Helper; + use DatabaseAsserts; + use ShowQueries; private DriverEnum $dbDriver; @@ -29,7 +32,7 @@ protected function setUp(): void parent::setUp(); $this->dbDriver = $this->get(DriverEnum::class); -// $this->getRefreshStrategy()->enableRefreshAttribute(); + // $this->getRefreshStrategy()->enableRefreshAttribute(); } protected function tearDown(): void diff --git a/tests/Feature/Interfaces/Console/RegisterModulesTest.php b/tests/Feature/Interfaces/Console/RegisterModulesTest.php index c77d78e1..4bf76ef7 100644 --- a/tests/Feature/Interfaces/Console/RegisterModulesTest.php +++ b/tests/Feature/Interfaces/Console/RegisterModulesTest.php @@ -9,17 +9,17 @@ final class RegisterModulesTest extends TestCase { -// #[Env('PERSISTENCE_DRIVER', 'mongodb')] -// public function testCommandWithMongoDriver(): void -// { -// $this->spyConsole(function () { -// $this->getConsole()->run('register:modules'); -// }, ['register:modules']) -// ->assertCommandNotRun('migrate') -// ->assertCommandRun('webhooks:register') -// ->assertCommandRun('metrics:declare') -// ->assertCommandRun('projects:register'); -// } + // #[Env('PERSISTENCE_DRIVER', 'mongodb')] + // public function testCommandWithMongoDriver(): void + // { + // $this->spyConsole(function () { + // $this->getConsole()->run('register:modules'); + // }, ['register:modules']) + // ->assertCommandNotRun('migrate') + // ->assertCommandRun('webhooks:register') + // ->assertCommandRun('metrics:declare') + // ->assertCommandRun('projects:register'); + // } #[Env('PERSISTENCE_DRIVER', 'database')] public function testCommandWithDatabaseDriver(): void diff --git a/tests/Feature/Interfaces/Http/Events/ClearActionTest.php b/tests/Feature/Interfaces/Http/Events/ClearActionTest.php index 43a480d5..7f2cb4ce 100644 --- a/tests/Feature/Interfaces/Http/Events/ClearActionTest.php +++ b/tests/Feature/Interfaces/Http/Events/ClearActionTest.php @@ -65,10 +65,10 @@ public function testClearEventsByUuids(): void $this->http ->clearEvents(uuids: [ - (string)$event1->getUuid(), - (string)$event2->getUuid(), - (string)$event3->getUuid(), - (string)$event4->getUuid(), + (string) $event1->getUuid(), + (string) $event2->getUuid(), + (string) $event3->getUuid(), + (string) $event4->getUuid(), ]) ->assertSuccessResource(); @@ -87,9 +87,9 @@ public function testClearEventsByTypeAndUuids(): void $this->http ->clearEvents(type: 'foo', uuids: [ - (string)$event1->getUuid(), - (string)$event2->getUuid(), - (string)$event3->getUuid(), + (string) $event1->getUuid(), + (string) $event2->getUuid(), + (string) $event3->getUuid(), ]) ->assertSuccessResource(); diff --git a/tests/Feature/Interfaces/Http/Events/ShowActionTest.php b/tests/Feature/Interfaces/Http/Events/ShowActionTest.php index 7e2883d9..2c4a5d03 100644 --- a/tests/Feature/Interfaces/Http/Events/ShowActionTest.php +++ b/tests/Feature/Interfaces/Http/Events/ShowActionTest.php @@ -29,7 +29,7 @@ public function testNotFoundShowEvent(): void ->showEvent($uuid) ->assertNotFound() ->assertJsonResponseSame([ - 'message' => 'Event with given uuid ['.$uuid.'] was not found.', + 'message' => 'Event with given uuid [' . $uuid . '] was not found.', 'code' => 404, ]); } diff --git a/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php b/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php index 85fb04bf..6d62e96f 100644 --- a/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php +++ b/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php @@ -62,7 +62,7 @@ public function testSendDataWithProject(): void $this->http ->post( - uri: 'http://inspector:'.$project.'@localhost/', + uri: 'http://inspector:' . $project . '@localhost/', data: Stream::create(self::PAYLOAD), headers: [ 'X-Inspector-Key' => 'test', @@ -105,7 +105,7 @@ public function testSendDataWithWrongSecretKey(): void public function assertEvent(string $project = 'default'): void { - $this->broadcastig->assertPushed((string) new EventsChannel($project), function (array $data) use($project) { + $this->broadcastig->assertPushed((string) new EventsChannel($project), function (array $data) use ($project) { $this->assertSame('event.received', $data['event']); $this->assertSame('inspector', $data['data']['type']); $this->assertSame($project, $data['data']['project']); diff --git a/tests/Feature/Interfaces/Http/Profiler/ProfilerActionTest.php b/tests/Feature/Interfaces/Http/Profiler/ProfilerActionTest.php index e817d29a..d2c6794a 100644 --- a/tests/Feature/Interfaces/Http/Profiler/ProfilerActionTest.php +++ b/tests/Feature/Interfaces/Http/Profiler/ProfilerActionTest.php @@ -61,7 +61,7 @@ public function testSendInvalidPayload(): void public function assertEvent(?string $project = null): void { - $this->broadcastig->assertPushed((string)new EventsChannel($project), function (array $data) use ($project) { + $this->broadcastig->assertPushed((string) new EventsChannel($project), function (array $data) use ($project) { $this->assertSame('event.received', $data['event']); $this->assertSame('profiler', $data['data']['type']); $this->assertSame($project, $data['data']['project']); diff --git a/tests/Feature/Interfaces/Http/Sentry/SentryVueReplayActionTest.php b/tests/Feature/Interfaces/Http/Sentry/SentryVueReplayActionTest.php index 567c3c93..24bb12a0 100644 --- a/tests/Feature/Interfaces/Http/Sentry/SentryVueReplayActionTest.php +++ b/tests/Feature/Interfaces/Http/Sentry/SentryVueReplayActionTest.php @@ -18,56 +18,109 @@ final class SentryVueReplayActionTest extends ControllerTestCase {"type":"replay_event","replay_start_timestamp":1718352515.3176,"timestamp":1718352606.631,"error_ids":["800d6a6ef3174aa5ba3f619204f9d1a9"],"trace_ids":[],"urls":["http://localhost:5173/"],"replay_id":"53cbfd0ae9fd4ea783cec45310432a3c","segment_id":0,"replay_type":"buffer","request":{"url":"http://localhost:5173/","headers":{"Referer":"http://localhost:5173/","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"}},"event_id":"53cbfd0ae9fd4ea783cec45310432a3c","environment":"production","sdk":{"integrations":["InboundFilters","FunctionToString","BrowserApiErrors","Breadcrumbs","GlobalHandlers","LinkedErrors","Dedupe","HttpContext","Vue","BrowserTracing","Replay","ReplayCanvas"],"name":"sentry.javascript.vue","version":"8.9.2"},"transaction":"home","platform":"javascript"} {"type":"replay_recording","length":5315} {"segment_id":0} -xœÜ\ërÛ6~Ž:ͤ]Q)ëšÉì¶ét›mf’fû£ît ’S„–¤d{3~÷ý xE9VÛT¶eÎýà@úõc'½ßÓÎüªÛY‘”tæ;ۘ®;óÎ6M÷ó~?dKnY’ÎGÞdØït;·Á*Ývæž7t;[l¶)îÞô¡ÛIƒMR²Û£eâM‡#< ¼«‡®šhd’ -0`û4`Q‚)÷ä>ddÅ%[vWoé’Å«W$:’¤3Oãívš$èÿ€Cú–¤˜lÐóºÇ,6[ÑvHè+¶ÛÇِ7b"g’o¾ Ãé*6YãŽ$¼ígzb M¯£ý!ÕH«Öˆ¦·,¾ùަ$ Éû8Ĉ5 “üá+²O1ý–­ªÁÉgoé`Fþ@ɊÆÕçÉœ¢å9ãýœ½[¡A=€—Û \ý„VŒÓ*6Ed‡ ¨Â.äR8,Â`ùbèà.¹ORºSwþû…É ß -ÃIšÆÁâòI>vBqÓ¨µ©¿d•a´¸Ï+“/·$N($Ùyÿó÷ÂoA# AaÝT`ÆñïÒ2:ƒý£AY‡h±L2¶LR‹¸È1 ·{§x¾dQJ#>‡˜ý劃%uÅM× ¢ +xœÜ\ërÛ6~Ž:ͤ]Q)ëšÉì¶ét›mf’fû£ît ’S„–¤d{3~÷ý xE9VÛT¶eÎýà@úõc'½ßÓÎüªÛY‘”tæ;ۘ®;óÎ6M÷ó~?dKnY’ÎGÞdØït;·Á*Ývæž7t;[l¶)îÞô¡ÛIƒMR²Û£eâM‡#< +¼«‡®šhd’ +0`û4`Q‚)÷ä>ddÅ%[vWoé’Å«W$:’¤3Oãívš$èÿ€Cú–¤˜lÐóºÇ,6[ÑvHè+¶ÛÇِ7b"g’o¾ Ãé*6YãŽ$¼ígzb +M¯£ý!ÕH«Öˆ¦·,¾ùަ$ Éû8Ĉ5 “üá+²O1ý–­ªÁÉgoé`Fþ@ɊÆÕçÉœ¢å9ãýœ½[¡A=€—Û \ý„VŒÓ*6Ed‡ +¨Â.äR8,Â`ùbèà.¹ORºSwþû +É ÃŸ +ÃIšÆÁâòI>vBqÓ¨µ©¿d•a´¸Ï+“/·$N($Ùyÿó÷ÂoA# +AaÝT`ÆñïÒ2:ƒý£AY‡h±L2¶LR‹¸È1 ·{§x¾dQJ#>‡˜ý劃%uÅM× ¢ Hè&0búÒë -lXL,X¤AÒ¶Œb$ ä•Fék¼üI™=ÈÙà*jgKÒûêl²£Ý_&ÜpEvAJ]ërˆ~ÌXڏéž%}²ß'ýぺû˜} Ë´ŸÄË>I ñ¤¿#AÔãPZ1琝ŽëSwéÞn1ïÜùb-^/ +lXL,X¤AÒ¶Œb$ +ä•Fék¼üI™=ÈÙà*jgKÒûêl²£Ý_&ÜpEvAJ]ërˆ~ÌXڏéž%}²ß'ýぺû˜} Ë´ŸÄË>I ñ¤¿#AÔãPZ1琝ŽëSwéÞn1ïÜùb-^/ ÍnÂÖ)6å?¥g;ÐßùüG?[„dyƒfoÊŠÍ œ/^¥gœ?å?úY­‚ ãíË! -tû*8ð nÈÒõæN¼YçãA×Qƒž?ûÊÒ߯íïùÕþ+ßhðÓ«®£þ½ñÈÒ]A/v¿šæÝ¹@rԏ$~^ ·¶c=Îãq©³BØ*ÄU×Mô‡£®“¿êº?䊲d!‹ÝÄ´‰Ù!ZYÀ—»Iy—ûŠæú™Tðfsb% ýJò­tv·ìØ8Ä3†pçD›BoSdFWÞÜÜ/Á"tº²Ÿ;Þž¬ý׎cç9ëÑMK–[ºý\8_9¯#Çi)aB-e›bKäöÙN™n#‚LiÛH Ò³^…‡ëèáë®3Ÿ/èšÅ”_‘uJc°tÁîÜ$øŸ˜Fbˆ¦Ύě š;BRk¸M÷V„½¹±xGB]°Õ= 삘fϼÁà¸}á¤>9†À¤*¹2çÑ)I” Ä1Ì/ÃG‰CIBAÒ5€d£ _8ä4Ǩ7–ø¯É.ïçÎk„€¸ë¸ˆ'!¬P$<]ç[þ$Ëwâþ{ è:םwtèóþõu§ë¼e –²®óæî~C£®ó~qˆÒC×A¾š’˜†!ð}çÐç#®;ßÅ,X÷?ÐðHÓ`IœŸèò¶Ï`q€ #Є0 xވó]H1¦¤"Äs瞇t,‚0Hï¡·tq€<1xCي¾À -9B¦¬ «/@-—¹Ë’ÌàO…l -A;䐲Ξ¬2µócº³ ŸtÞ&¦4ÔLÙxòN2¡E,¢Z à\ŸÃK{ÓÞ|î©Mù’0äaÁŸž~XpÒnÄ?éÈ\Üq-.i„t鯴"JSP ¹æ*n ü«ý„+{$û@cÖ!&¸F +tû*8ð +nÈÒõæN¼YçãA×Qƒž?ûÊÒ߯íïùÕþ+ßhðÓ«®£þ½ñÈÒ]A/v¿šæÝ¹@rԏ$~^ ·¶c=Îãq©³BØ*ÄU×Mô‡£®“¿êº?䊲d!‹ÝÄ´‰Ù!ZYÀ—»Iy—ûŠæú™Tðfsb% +ýJò­tv·ìØ8Ä3†pçD›BoSdFWÞÜÜ/Á"tº²Ÿ;Þž¬ý׎cç9ëÑMK–[ºý\8_9¯#Çi)aB-e›bKäöÙN™n#‚LiÛH Ò³^ +‡ëèáë®3Ÿ/èšÅ”_‘uJc°tÁîÜ$øŸ˜Fbˆ¦Ύě š;BRk¸M÷V„½¹±xGB]°Õ= 삘fϼÁà¸}á¤>9†À¤*¹2çÑ)I” Ä1Ì/ÃG‰CIBAÒ5€d£ +_8ä4Ǩ7–ø¯É.ïçÎk„€¸ë¸ˆ'!¬P$<]ç[þ$Ëwâþ{ +è:םwtèóþõu§ë¼e +–²®óæî~C£®ó~qˆÒC×A¾š’˜†!ð}çÐç#®;ßÅ,X÷?ÐðHÓ`IœŸèò¶Ï`q€ #Є0 xވó]H1¦¤"Äs瞇t,‚0Hï¡·tq€<1xCي¾À +9B¦¬ «/@-—¹Ë’ÌàO +l +A;䐲Ξ¬2µócº³ ŸtÞ&¦4ÔLÙxòN2¡E,¢Z +à\ŸÃK{ÓÞ|î©Mù’0äaÁŸž~XpÒnÄ?éÈ\Üq-.i„t鯴"JSP ¹æ*n +ü«ý„+{$û@cÖ!&¸F G¼K –È"h,À9Žd®î¿‰ðœ¿Cí±BÁú„£uØEé­cþgÐÉ9Ÿ±š#dzªäH™är€gBž§²,oð'¤YK¬~ Ó©T8d¿°8\õÐëŸø{Æçx)¦†ÐMï^ž%K¶§«—Ô›P2›xÏøÂà¼mëýšaê* ¿Çe  -Fã÷Ƃ CjÌ*y„2¦ Çõ²À³ZaKóëIO!¦Ä©AHڀ|^ TØìpÃ}™RšS +Fã÷Ƃ CjÌ*y„2¦ +Çõ²À³ZaKóëIO!¦Ä©AHڀ|^ +TØìpÃ}™RšS ø¤³†t6ªÖP«–m­wIÕúf¿o¡H2!ÃÉâQŠ$ÖΊK çR)PŒ¸3¼+Dz‡^È6¬n¸¶w±·Põ¤Ê¨#r¬®?ø²èú}íúKz“Mà UÎa;¤χH¸¢—Þ‘%F"Ý:Òºií±ù\` -ÞWxø=¢bÄUOÀmä` YHF<£'•© ×epÍ Y )„è¢é¨¢»â$uÙÚåŠ"RŸ,y+&£6›´«Q›8!IrãL·°p_>ϐ72㯜¾#c|€MíL5ãZ .2r<Þm s;aÞäb/†àÄ5‡3wø»œÀ¢ÎU—#Õ5“™›ÉÑÔóÌW+Yóی SͳN +ÞWxø=¢bÄUOÀmä` YHF<£'•© ×epÍ +Y +)„è¢é¨¢»â$uÙÚåŠ"RŸ,y+&£6›´«Q›8!IrãL·°p_>ϐ72㯜¾#c|€MíL5ãZ .2r<Þm +s;aÞäb/†àÄ5‡3wø»œÀ¢ÎU—#Õ5“™›ÉÑÔóÌW+Yóی +SͳN Îk¤—±ùgÄÅ_hˆ;úlláÏ«Ùb¸ŽáÏz\RJ -N®¦{( º&.>ôVbÿ2©ƒÈ‡Aøá>ª O)˜ nX+ƒrŸ+o––7>Pyâì®ÁqåA°€}5žWÓIRû”í`T½+ѱf2¹ãóŒßmD$t=wubbgÙA ÿPËyX  -¾t\ÇÇ2¤ úc1¥Ö²€…A(÷zÒ³¶Z½IWƒ[|Êç”L š•X³»ÌÙÕ2L­WEԒŠrÝÁïyñ Žàl @@‰^³íŠm -Ñ*C‘VKë â\”ó'"\ˆ‹ϵg˹&!1!(TؼøD{ñ±º2ÜyÅrLäî3/PÕº}¾ˆ{ô7Ÿ@OáÛV\ظ©Ä7+t@áû ¼šÒzn5«µð"|eâRHnš².Ømª•Èž‚d5Uñ¸>•Ê Y›¥áÛÂrójhT‰ŒLhÚQÕ]­“2èSêeVËr–×nN¼½RVWwáe£ôâO•µø¶RÏvاÖ8ðÉ%ú¥n㊿I$‡6ó©Âj¹eT®”®y†ö!AÖräek$ªQòûû³¢~‡Ô·Cš5hKmUKaì+¶½/zՀáӒxà²xóä:šD7›mN¡ö˾®÷ù¶"'ˆMQ&ÿU+©^ +N®¦{( +º&.>ôVbÿ2©ƒÈ‡Aøá>ª O)˜ nX+ƒrŸ+o––7>Pyâì®ÁqåA°€}5žWÓIRû”í`T½+ѱf2¹ãóŒßmD$t=wubbgÙA +ÿPËyX + +¾t\ÇÇ2¤ úc1¥Ö²€ +A(÷zÒ³¶Z½IWƒ[|Êç”L š•X³»ÌÙÕ2L­WEԒŠrÝÁïyñ Žàl @@‰^³íŠm +Ñ*C‘VKë +â\”ó'"\ˆ‹ϵg˹&!1!(TؼøD{ñ±º2ÜyÅrLäî3/PÕº}¾ˆ{ô7Ÿ@OáÛV\ظ©Ä7+t@áû +¼šÒzn5«µð"|eâRHnš².Ømª•Èž‚d5Uñ¸>•Ê Y›¥áÛÂrójhT‰ŒLhÚQÕ]­“2èSêeVËr–×nN¼½RVWwáe£ôâO•µø¶RÏvاÖ8ðÉ%ú¥n㊿I$‡6ó©Âj¹eT®”®y†ö!AÖräek$ªQòûû³¢~‡Ô·Cš5hKmUKaì+¶½/zՀáӒxà²xóä:šD7›mN¡ö˾®÷ù¶"'ˆMQ&ÿU+©^ #­ûC›îc¥òdnÒ*É¢ç8uÌB™¾¹ÈΖ׎mÝÍ'Žâ.1_Ocðžlè£ûJï"^m”gÐI Å öL1+dõ¾ÔUn¹òiömÆÇ‹ÂOª„ 1Û ‚† £R¥bàÉTË(ÝðلX+öÇjN`$~õ(„7±¤É»ÿüÛLõ®f îFâ5hÉÛLûlVËUnË@n šŸÂ¨õš¯^j@“kV}´ŒŒ„ÑÚÛpٖخ<þÈÆúª‡øÃÂW5÷ЬҎad(µ-}É©ü¢æAá™âËØðœ66µŸå×-?7¯1¶n´Õx -3ÃÁúޑÿ]w·âșNÛ'%§"šÚ8•±á‘­hë fl Ÿ£S7ùÓ¢LZhîDëë¸ÉŽTï&—£Í˰¦’ÁÖ “öI´}I°9à(EM ?u™ôä!ŋ¬&ÚóNšSh›‡LQÀlÞá$ñK Ü;ÉçNÓÃ*`=lC_Âó*LuÞ=1"DÃâhbhèy¤m‚t{Xz>°m”°ˆ»½éYHøÈ“(žj˜6YL!B(ý.(½!lCùeƒšÌ¶Ís’;···½å½8”Ü ØEb­ÂñJ3¤)…)eˑO’Åp3§JX.A^.'u¥°×û‘Ó¦„)§Öº YÙ§³l“M›ÜxY©ÊY‹áGf¶u·œO:ñkòg¦d³¦ +3ÃÁúޑÿ]w·âșNÛ'%§"šÚ8•±á‘­hë +fl +Ÿ£S7ùÓ¢LZhîDëë¸ÉŽTï&—£Í˰¦’ÁÖ “öI´}I°9à(EM ?u™ôä!ŋ¬&ÚóNšSh›‡LQÀlÞá$ñK Ü;ÉçNÓÃ*`=lC_Âó*LuÞ=1"DÃâhbhèy¤m‚t{Xz>°m”°ˆ»½éYHøÈ“(žj˜6YL!B(ý.(½!lCùeƒšÌ¶Ís’;···½å½8”Ü +ØEb­ÂñJ3¤) +)eˑO’Åp3§JX.A^.'u¥°×û‘Ó¦„)§Öº YÙ§³l“M›ÜxY©ÊY‹áGf¶u·œO:ñkòg¦d³¦ ’«&µQ}´’5÷Ç6ãøÜr¦™-™>±ÒòP=)'E¶Sçå¤hÖ"€ÏtÚ<{²ôô/¡×f©¥{·POo õŸŠiÓ¿)'-®,êMºKtÁcðèTa^œ¾ØòL㨓oД%T=:KÈöÍ.H–ÂÔ8ŠgžÅk Íz®ê$mhMÝC -sº4}…½U‡)4»fÌ÷pãÓ3=Ag¹|ʼÈh-‘š×Q|lŸî•ӼšÆdŠ-78Ÿ)ä–&8bââîRéÂ5O͕æÔ¤L¯ßÆ9¢¸©ýƒžiöwÙWEAôÉ6V[‡{œqnÃø\ÛS™ýëF|³Êx:â7Öõt§\IQôjÑÿäâÈÛ +sº4} +½U‡)4»fÌ÷pãÓ3=Ag¹|ʼÈh-‘š×Q|lŸî•ӼšÆdŠ-78Ÿ)ä–&8bââîRéÂ5O͕æÔ¤L¯ßÆ9¢¸©ýƒžiöwÙWEAôÉ6V[‡{œqnÃø\ÛS™ýëF|³Êx:â7Öõt§\IQôjÑÿäâÈÛ û1ëqgîlIzé0¡±Ô»iJ‰mw?J>²H÷£÷›qZqyÏ¢®C†%1Ü¥ø\-ÿ”q`6tÅýgïÓ“ü¥‰Ðù´‡úd;¶”b‰É“3ªt%žDôöâ)CY'ò2‡Bãy¦P!¨ü=6_º -Rž ê˜y¹}0e@MnÓ`Ò—E_Þ%ÒS´q騑i7•§ÝÖªègLQ -üãƒ)ê6§9Š›ÖÛòå³ ¦(*ž ¦f¶ØXVԝr=mSØôNò¤Ý”cKɾª{|%c«ßª•ð5.^æ>X|™‹/U-mazy +Rž +ê˜y¹}0e@MnÓ`Ò—E_Þ%ÒS´q騑i7•§ÝÖªègLQ +üãƒ)ê6§9Š›ÖÛòå³ +¦(*ž +¦f¶ØXVԝr=mSØôNò¤Ý”cKɾª{|%c«ßª•ð5.^æ>X|™‹/U-mazy ·mµL5wÛ¬RAñLK1¿4O؇êEe£¿ÔÝóMÆX8ªé¡Ê×Üõ:ºæ‡B²Î¹F긝ÓÈ/Zx³^‹ï•À÷[à®øn -ÀÅ|‡í{O&ÿgï wہ8þ*Ó>oU q’~éSìÖH*mª”*Óöö;¸8#æÿ›:ŽÑ/i)8ÃÝ/¡-£ãýí°ßI’P8Z_Þ?îÐyóåó_oD´J©ô¶ú¯+=ݕ¼«‡­ÏkïE’:Átóªí y¥ß}ÞUs”åµ'¹b}jÞGùôyåó<¯˜8Ǽ›õ1¯ÈƒržjK^³’Yãòn[}»m3'¹f­m[mZºyô^ÆËÕ>[=¶m‹òªg¤Ëk5oc˜z ç³j74îS]̺êÊ[+ü&\ÞçεòœŒó¼ÏÞÝþðëYЎÃËk²e¼|?üôWjúãm/ª>¼Þ½¼í²‘¸ð(ºåÌÿïØ+®©¾ ì{ï×r<ÚÈ +ÀÅ|‡í{O&ÿgï +wہ8þ*Ó>oU +q’~éSìÖH*mª”*Óöö;¸8#æÿ›:ŽÑ/i)8ÃÝ/¡-£ãýí°ßI’P8Z_Þ?îÐyóåó_oD´J©ô¶ú¯+=ݕ¼«‡­ÏkïE’:Átóªí y¥ß}ÞUs”åµ'¹b}jÞGùôyåó<¯˜8Ǽ›õ1¯ÈƒržjK^³’Yãòn[}»m3'¹f­m[mZºyô^ÆËÕ>[=¶m‹òªg¤Ëk5oc˜z ç³j74îS]̺êÊ[+ü&\ÞçεòœŒó¼ÏÞÝþðëYЎÃËk²e¼|?üôWjúãm/ª>¼Þ½¼í²‘¸ð(ºåÌÿïØ+®©¾ +ì{ï×r<ÚÈ ¯Ø83k´–{³^I—hE\ÒùJD%ÉDüÊ›p£c#µiä9ÿÈHy™¡Ú ƒÇíRþÝy÷©ÿú§'ñ¡ÿí˟ÚÀ.ŸÔFwÉ_'gːä>®WZ&ÐéŽ}4S~–ç·Eþ§×¸È/çãÒ„[ö^]¤Ï7?>0Á:~IŠÕ6´.Ù ™*Åj¹+Äç~pK¦nü̜[$Ô*n_è—sƨ“ºáN†¼J±Ã´ÑX N4Òé¡påcU>VåcU>VåcU>VåcU>Våc -âcSË߃€tê†WÙY•5?^r,‰;öºY¹Z•«U¹Z•«5/®XØüE7HNü+s«2·*s«2·*skþÌ-°’{ß îý!€I›sɀGã4*pX¨p?Âo?EwèeÓb@^q£Â¸¿ŠõëºÚûAtv·u]äP´¿g÷ `@ïÒÓа3Œ„»,ôǯ&Éú‚l.‹ŠÓÙóŁc@w<+„‰VÙðIf4b¤^<&•Å -KL(n^EŒ„ó⇝¼sÓÿKàÎ@ón!$4k¼ˆ½^(4 AƇ”ðîË ýVû⦅(ÞlD(ž‰ê%Èv— ŠÅ’rсÄ|24Nsv 6 ;^ÜL ;6¤ -ԖBàd7GRœ~ ÑVq³Â0Ù ހ‚™Pۜ†RŠ—¿hJ¬nD(8xÞ(”¤g} Do Òq +âcSË߃€tê†WÙY•5?^r,‰;öºY¹Z•«U¹Z•«5/®XØüE7HNü+s«2·*s«2·*skþÌ-°’{ß +îý!€I›sɀGã4*pX¨p?Âo?EwèeÓb@^q£Â¸¿ŠõëºÚûAtv·u]äP´¿g÷ +`@ïÒÓа3Œ„»,ôǯ&Éú‚l.‹ŠÓÙóŁc@w<+„‰VÙðIf4b¤^<&•Å +KL(n^EŒ„ó⇝¼sÓÿKàÎ@ón!$4k¼ˆ½^(4 AƇ”ðîË ýVû⦠+(ÞlD(ž‰ê%Èv— +ŠÅ’rсÄ|24Nsv 6 ;^ÜL ;6¤ +ԖBàd7GRœ~ +ÑVq³Â0Ù ހ‚™Pۜ†RŠ—¿hJ¬nD(8xÞ(”¤g} Do Òq è,ü}ԔGÕNšړ)rËWÐ6Ì -,ŽÂIô&of™ ]Ò Å°+Àë€úd>†\Ý €í@…Å”Ó`îb•Ü -ÒS&(λ, h ¾,0iÀ1éz%Нäe ÇÑAfÄ^RÙ)ÄhX=P'90HÆEýsÐbP‚<þ‚'Áýh𳲀ÁŽG÷ +,ŽÂIô&of™ ]Ò Å°+Àë€úd>†\Ý +€í@ +Å”Ó`îb•Ü +ÒS&(λ, h +¾,0iÀ1éz%Нäe ÇÑAfÄ^RÙ)ÄhX=P'90HÆEýsÐbP‚<þ‚'Áýh𳲀ÁŽG÷ ›NJ:é·Â=MëAA&Ó,GE%ÈÔÈ“¤?¹CΉV+Àµr“ÂĂËp–±¬bnB® P›nIæ2ë‚bÛë øÁX}Å1ƒôœ™tG9qù:”Bв'éÌüBPÃx„Çi`† äÔ²ƒW~q -Á{Ñî&HÈq³ä’kR¶Ð Ãþ%Èì+q‰Yˆyzp 4¯rjP‚LPÝK’ +Á{Ñî&HÈq³ä’kR¶Ð Ãþ%Èì+q‰Yˆyzp +4¯rjP‚LPÝK’ }-Š€Œ@$ä¹ÐÊøÄÛ¦Ù}¸@fñ]Õ< Ž EøeÊ&ñވñ—íºbþåĬG :ž±V|:Ê(±¨´=K$$PÔ[ᇏ -‹• ÝÖ¬"¿Á²K^ƨÊ¢-‚Û®b‘ØÞSg2ÿB—’Û²HløíÈC_lÒ—À“Ãv(±9χãïî¼cS-bÅ%×@\ýñ‰‰M:°¾êÝcÅ}×NÒC³ž5cäv‚%±h¾ÔÂÃ,l’{07AIuÂJÿ¿•À¶–ó9·rUõ¯³3Êm†Áð]z€ +‹• +ÝÖ¬"¿Á²K^ƨÊ¢-‚Û®b‘ØÞSg2ÿB—’Û²HløíÈC_lÒ—À“Ãv(±9χãïî¼cS-bÅ%×@\ýñ‰‰M:°¾êÝcÅ}×NÒC³ž5cäv‚%±h¾ÔÂÃ,l’{07AIuÂJÿ¿•À¶–ó9·rUõ¯³3Êm†Áð]z€ -i€aÚ{Ý목SÕ¤íüûÿØI!!h”§ª±ìÄ¡!ö×­|NÓ(ò -®'|sJ…\aB6 òö?gÜ<ﯔB ÊaF¬,Éê¹e€î<3€;3'³q C![qóPœ“ö h@œ%6mKk@ -GPçŸ Ë>ŒÒJÃbnҌYUã[D·n)Ìútð²Ûõº[A†šZ™•¹nM+Ýã´'¹n¦°kìr¯œdÌ5_V妏™Öä—4ÝO¶Tb¤H¶|»ü`±ŸÀþ»\翽2Ò&*ƒP7ô_ÇSwí¹ŠL:Å{|¼ +ˆ{¦ßàIrü>+1q'5vg2Q^Œ¢8Hø -ì—ó€É¬5‡Þýö¼·|ˆ®ÕËáx¢Æ„eñëbá/$ø1ŽÞ83®f!Znï ”lõÎp‰Ô5ñë‚?¢½Ewd½QuøôIÈ㵃žAâÁ”·x_VWp gïˆ0=uA‚«¸³ØIã¶›ò}Öb+8{^¯Ù1® +®'|sJ +\aB6 òö?gÜ<ﯔB ÊaF¬,Éê¹e€î<3€;3'³q +C![qóPœ“ö h@œ%6mKk@ +GPçŸ +Ë>ŒÒJÃbnҌYUã[D·n)Ìútð²Ûõº[A†šZ™•¹nM+Ýã´'¹n¦°kìr¯œdÌ5_V妏™Öä—4ÝO¶Tb¤H¶|»ü`±ŸÀþ»\翽2Ò&*ƒP7ô_ÇSwí¹ŠL:Å{|¼ ++ˆ{¦ßàIrü>+1q'5vg2Q^Œ¢8Hø +ì—ó€É¬5‡Þýö¼·|ˆ®ÕËáx¢Æ„eñëbá/$ø1ŽÞ83®f!Znï +”lõÎp‰Ô5ñë‚?¢½Ewd½QuøôIÈ㵃žAâÁ”·x_VWp gïˆ0=uA‚«¸³ØIã¶›ò}Öb+8{^¯Ù1® BODY; private Project $project; diff --git a/tests/Feature/Interfaces/TCP/Monolog/JsonPayloadTest.php b/tests/Feature/Interfaces/TCP/Monolog/JsonPayloadTest.php index 9948ed7a..0e8f9b53 100644 --- a/tests/Feature/Interfaces/TCP/Monolog/JsonPayloadTest.php +++ b/tests/Feature/Interfaces/TCP/Monolog/JsonPayloadTest.php @@ -64,7 +64,7 @@ private function buildMessage(Key|string|null $project = null): array ]; if ($project !== null) { - $payload['context']['project'] = (string)$project; + $payload['context']['project'] = (string) $project; } return $payload; diff --git a/tests/Feature/Interfaces/TCP/Smtp/EmailTest.php b/tests/Feature/Interfaces/TCP/Smtp/EmailTest.php index 5eb6d256..856d4eeb 100644 --- a/tests/Feature/Interfaces/TCP/Smtp/EmailTest.php +++ b/tests/Feature/Interfaces/TCP/Smtp/EmailTest.php @@ -52,7 +52,7 @@ public function testSendEmail(): void ->with( \Mockery::on(function (Attachment $attachment) { $this->assertSame('logo-embeddable', $attachment->getFilename()); - $this->assertSame(1206, $attachment->getSize()); + $this->assertSame(1207, $attachment->getSize()); $this->assertSame('image/svg+xml', $attachment->getMime()); // Check attachments storage @@ -97,7 +97,7 @@ public function testSendEmail(): void ->with( \Mockery::on(function (Attachment $attachment) { $this->assertSame('logo.svg', $attachment->getFilename()); - $this->assertSame(1206, $attachment->getSize()); + $this->assertSame(1207, $attachment->getSize()); $this->assertSame('image/svg+xml', $attachment->getMime()); // Check attachments storage @@ -116,6 +116,45 @@ public function testSendEmail(): void $this->assertEventPushed($sentMessage, 'foo'); } + public function testSendMultipleEmails(): void + { + $project = $this->createProject('foo'); + $connectionUuid = Uuid::uuid7(); + + // We'll send two emails in the same SMTP session + $email1 = $this->buildEmail(); + $email1->getHeaders()->addIdHeader('Message-ID', $id1 = $email1->generateMessageId()); + + $email2 = $this->buildEmailWithCyrillic(); + $email2->getHeaders()->addIdHeader('Message-ID', $id2 = $email2->generateMessageId()); + + // Set up attachment expectations (fixed to 7 based on the actual number of attachments) + // The first email has 4 attachments, the second has 3 + $this->attachments->shouldReceive('store')->times(7)->andReturn(true); + + // Build SMTP client + $client = $this->buildSmtpClient( + username: (string) $project->getKey(), + uuid: $connectionUuid, + ); + + // Send first email + $sentMessage1 = $client->send($email1); + + // Validate the first message + $this->validateMessage($id1, (string) $connectionUuid); + $this->assertEventPushed($sentMessage1, 'foo'); + + // Check that state is reset properly by sending second email + $client->send($email2); + + // This would fail before our fix if the state wasn't properly reset + $messageData2 = $this->getEmailMessage((string) $connectionUuid); + $this->assertFalse($messageData2->waitBody, 'waitBody flag should be reset after sending email'); + + $response = $this->handleSmtpRequest(message: '', event: TCPEvent::Close); + $this->assertInstanceOf(CloseConnection::class, $response); + } private function getEmailMessage(string $uuid): Message { @@ -239,7 +278,7 @@ private function assertEventPushed(SentMessage $message, ?string $project = null public function buildEmail(): Email { - return (new Email) + return (new Email()) ->subject('Test message') ->date(new \DateTimeImmutable('2024-05-02 16:01:33')) ->addTo( @@ -251,9 +290,9 @@ public function buildEmail(): Email new Address('customer@example.com', 'Customer'), 'theboss@example.com', ) - ->addFrom(new Address('no-reply@site.com', 'Bob Example'),) - ->attachFromPath(path: __DIR__ . '/hello.txt',) - ->attachFromPath(path: __DIR__ . '/sample.pdf',) + ->addFrom(new Address('no-reply@site.com', 'Bob Example'), ) + ->attachFromPath(path: __DIR__ . '/hello.txt', ) + ->attachFromPath(path: __DIR__ . '/sample.pdf', ) ->attachFromPath(path: __DIR__ . '/logo.svg') ->addPart( (new DataPart(new File(__DIR__ . '/logo.svg'), 'logo-embeddable'))->asInline()->setContentId( @@ -264,6 +303,37 @@ public function buildEmail(): Email body: <<<'TEXT' Hello Alice.
This is a test message with 5 header fields and 4 lines in the message body. +TEXT + , + ); + } + + public function buildEmailWithCyrillic(): Email + { + // Similar to buildEmail but with Cyrillic content + return (new Email()) + ->subject('Test message with Cyrillic') + ->date(new \DateTimeImmutable('2024-05-02 16:01:33')) + ->addTo( + new Address('alice@example.com', 'Alice Doe'), + 'barney@example.com', + ) + ->addFrom(new Address('no-reply@site.com', 'Bob Example'), ) + ->attachFromPath(path: __DIR__ . '/hello.txt', ) + ->attachFromPath(path: __DIR__ . '/logo.svg') + ->addPart( + (new DataPart(new File(__DIR__ . '/logo.svg'), 'logo-embeddable'))->asInline()->setContentId( + 'test-cid@buggregator', + ), + ) + ->html( + body: <<<'TEXT' + +

съешь же ещё этих мягких французских булок, да выпей чаю

+

съешь же ещё этих мягких французских булок, да выпей чаю

+

съешь же ещё этих мягких французских булок, да выпей чаю

+

съешь же ещё этих мягких французских булок, да выпей чаю

+

съешь же ещё этих мягких французских булок, да выпей чаю

TEXT , ); diff --git a/tests/Feature/Interfaces/TCP/Smtp/MessageTest.php b/tests/Feature/Interfaces/TCP/Smtp/MessageTest.php new file mode 100644 index 00000000..7bb07b12 --- /dev/null +++ b/tests/Feature/Interfaces/TCP/Smtp/MessageTest.php @@ -0,0 +1,83 @@ +appendBody("Hello World\r\n"); + $this->assertFalse($message->bodyHasEos()); + + // Add Cyrillic content + $message->appendBody("съешь же ещё этих мягких французских булок, да выпей чаю\r\n"); + $this->assertFalse($message->bodyHasEos()); + + // Add more Cyrillic content with varying lengths + $cyrillicRepeated = str_repeat("съешь же ещё этих мягких французских булок, да выпей чаю\r\n", 5); + $message->appendBody($cyrillicRepeated); + $this->assertFalse($message->bodyHasEos()); + + // Add end of stream marker + $message->appendBody(".\r\n"); + $this->assertTrue($message->bodyHasEos()); + + // Check that getBody removes the EOS marker + $body = $message->getBody(); + $this->assertStringNotContainsString("\r\n.\r\n", $body); + + // Verify content is preserved + $this->assertStringContainsString("Hello World", $body); + $this->assertStringContainsString("съешь же ещё этих мягких французских булок, да выпей чаю", $body); + } + + public function testMultipleEOSMarkers(): void + { + $message = new Message('test-uuid'); + + // Add content with a period at the start of a line (which should be escaped) + $message->appendBody("Line 1\r\n"); + $message->appendBody(".Line starting with period\r\n"); + $message->appendBody("Line 3\r\n"); + + // Add an EOS marker in the middle (shouldn't be considered as EOS) + $message->appendBody(".\r\nMore content\r\n"); + $this->assertFalse($message->bodyHasEos()); + + // Now add actual EOS marker at the end + $message->appendBody(".\r\n"); + $this->assertTrue($message->bodyHasEos()); + + // Verify the body content is correct + $body = $message->getBody(); + $this->assertStringContainsString("Line 1", $body); + $this->assertStringContainsString(".Line starting with period", $body); + $this->assertStringContainsString("Line 3", $body); + $this->assertStringContainsString(".\r\nMore content", $body); + } + + public function testLargeBodyWithEOS(): void + { + $message = new Message('test-uuid'); + + // Add a large body + $largeBody = str_repeat("Lorem ipsum dolor sit amet. ", 1000); + $message->appendBody($largeBody); + $message->appendBody("\r\n.\r\n"); + + $this->assertTrue($message->bodyHasEos()); + + // Verify the body content is correct + $body = $message->getBody(); + $this->assertStringContainsString("Lorem ipsum dolor sit amet.", $body); + $this->assertStringNotContainsString("\r\n.\r\n", $body); + } +} diff --git a/tests/Feature/Interfaces/TCP/Smtp/ServiceTest.php b/tests/Feature/Interfaces/TCP/Smtp/ServiceTest.php new file mode 100644 index 00000000..986ae8a1 --- /dev/null +++ b/tests/Feature/Interfaces/TCP/Smtp/ServiceTest.php @@ -0,0 +1,210 @@ +cache = $this->createMock(CacheInterface::class); + + // Create a real EmailBodyStorage with a mock cache + $this->emailBodyStorage = new EmailBodyStorage($this->cache); + + $this->attachments = $this->createMock(AttachmentStorageInterface::class); + $this->bus = $this->createMock(CommandBusInterface::class); + + $this->service = new Service( + $this->bus, + $this->emailBodyStorage, + $this->attachments, + ); + } + + protected function tearDown(): void + { + ParserTestHelper::resetParserFactory(); + parent::tearDown(); + } + + public function testResetBodyOnDataCommand(): void + { + $connectionUuid = (string) Uuid::uuid4(); + $message = new Message($connectionUuid); + $message->body = 'Some existing content that should be cleared'; + + // Setup the cache mock to return our message with existing content + $this->cache + ->expects($this->once()) + ->method('get') + ->with($connectionUuid, $this->anything()) + ->willReturn($message); + + // Expect the cache to be updated with the reset message + $this->cache + ->expects($this->once()) + ->method('set') + ->with( + $connectionUuid, + $this->callback(fn($persistedMessage) => $persistedMessage->body === '' && $persistedMessage->waitBody === true), + $this->anything(), + ); + + // Create a request with DATA command + $request = new Request( + remoteAddr: '127.0.0.1', + event: TcpEvent::Data, + body: "DATA\r\n", + connectionUuid: $connectionUuid, + server: 'test-server', + ); + + // Handle the request + $response = $this->service->handle($request); + + // Verify response is correct + $this->assertInstanceOf(RespondMessage::class, $response); + $this->assertStringContainsString('354', $response->getBody()); + } + + public function testResetWaitBodyAfterMessageProcessing(): void + { + $connectionUuid = (string) Uuid::uuid4(); + $message = new Message($connectionUuid); + $message->waitBody = true; + $message->body = "Test message content\r\n.\r\n"; // With EOS marker + + // Create a real MailMessage object instead of a mock + $mailMessage = new MailMessage( + id: 'test-message-id', + raw: $message->getBody(), + sender: [['email' => 'test@example.com', 'name' => 'Test Sender']], + recipients: [], + ccs: [], + subject: 'Test Subject', + htmlBody: '

Test HTML

', + textBody: 'Test plain text', + replyTo: [], + allRecipients: [], + attachments: [], + ); + + ParserTestHelper::setupParserWithPredefinedResult($mailMessage); + + // Set up the cache mock + $this->cache + ->expects($this->once()) + ->method('get') + ->with($connectionUuid, $this->anything()) + ->willReturn($message); + + // Setup mocks for dispatchMessage + $this->attachments + ->expects($this->once()) + ->method('store') + ->willReturn([]); + + $this->bus + ->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(HandleReceivedEvent::class)); + + // Create a request with the last part of message body + $request = new Request( + remoteAddr: '127.0.0.1', + event: TcpEvent::Data, + body: "Final line of the message\r\n.\r\n", + connectionUuid: $connectionUuid, + server: 'test-server', + ); + + // Handle the request + $response = $this->service->handle($request); + + // Verify the waitBody flag was reset + $this->assertFalse($message->waitBody, 'waitBody flag should be reset after processing a message'); + + // Verify response code is 250 (message accepted) + $this->assertInstanceOf(RespondMessage::class, $response); + $this->assertStringContainsString('250', $response->getBody()); + } + + public function testCorrectResponseForDataWithCyrillic(): void + { + $connectionUuid = (string) Uuid::uuid4(); + $message = new Message($connectionUuid); + $message->waitBody = true; + + // Setup the cache mock + $this->cache + ->expects($this->once()) + ->method('get') + ->with($connectionUuid, $this->anything()) + ->willReturn($message); + + // Expect the cache to be updated + $this->cache + ->expects($this->once()) + ->method('set') + ->with( + $connectionUuid, + $this->callback(fn($persistedMessage) => str_contains( + $persistedMessage->body, + "съешь же ещё этих мягких французских булок, да выпей чаю", + )), + $this->anything(), + ); + + // Create a request with Cyrillic content + $request = new Request( + remoteAddr: '127.0.0.1', + event: TcpEvent::Data, + body: "съешь же ещё этих мягких французских булок, да выпей чаю\r\n", + connectionUuid: $connectionUuid, + server: 'test-server', + ); + + // Handle the request + $response = $this->service->handle($request); + + // Verify the body contains the Cyrillic content + $this->assertStringContainsString( + "съешь же ещё этих мягких французских булок, да выпей чаю", + $message->body, + ); + + // Verify correct response for partial data + $this->assertInstanceOf(RespondMessage::class, $response); + $this->assertStringContainsString('250', $response->getBody()); // OK response + } +} diff --git a/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php b/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php index f5f260e8..7b85a9ea 100644 --- a/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php +++ b/tests/Unit/Modules/Events/Domain/CacheEventRepositoryTest.php @@ -49,15 +49,15 @@ public function testDeleteByUuids(): void $this->repository->deleteAll([ 'uuid' => [ - (string)$event1->getUuid(), - (string)$event3->getUuid(), + (string) $event1->getUuid(), + (string) $event3->getUuid(), ], ]); $this->assertCount(1, \iterator_to_array($this->repository->findAll())); $result = \iterator_to_array($this->repository->findAll()); - $this->assertSame((string)$event2->getUuid(), (string)$result[0]->getUuid()); + $this->assertSame((string) $event2->getUuid(), (string) $result[0]->getUuid()); } public function testDeleteByTypeAndUuids(): void @@ -73,9 +73,9 @@ public function testDeleteByTypeAndUuids(): void $this->repository->deleteAll([ 'type' => 'foo', 'uuid' => [ - (string)$event3->getUuid(), - (string)$event5->getUuid(), - (string)$event4->getUuid(), + (string) $event3->getUuid(), + (string) $event5->getUuid(), + (string) $event4->getUuid(), ], ]); @@ -83,8 +83,8 @@ public function testDeleteByTypeAndUuids(): void $result = \iterator_to_array($this->repository->findAll()); - $this->assertSame((string)$event1->getUuid(), (string)$result[0]->getUuid()); - $this->assertSame((string)$event2->getUuid(), (string)$result[1]->getUuid()); - $this->assertSame((string)$event3->getUuid(), (string)$result[2]->getUuid()); + $this->assertSame((string) $event1->getUuid(), (string) $result[0]->getUuid()); + $this->assertSame((string) $event2->getUuid(), (string) $result[1]->getUuid()); + $this->assertSame((string) $event3->getUuid(), (string) $result[2]->getUuid()); } } diff --git a/tests/Unit/Modules/Webhooks/RetryPolicyTest.php b/tests/Unit/Modules/Webhooks/RetryPolicyTest.php index b2982680..28bd0612 100644 --- a/tests/Unit/Modules/Webhooks/RetryPolicyTest.php +++ b/tests/Unit/Modules/Webhooks/RetryPolicyTest.php @@ -25,7 +25,6 @@ public function testCanTry(): void $this->assertSame($expected, $seconds); $i++; }), - maxRetries: 3, delay: 5, retryMultiplier: 2, @@ -46,7 +45,6 @@ public function testCanTryWithZeroRetries(): void timer: new Timer(function (int $seconds): void { $this->fail('No retries should be made'); }), - maxRetries: 0, delay: 5, retryMultiplier: 2, @@ -58,10 +56,7 @@ public function testCanTryWithZeroRetries(): void public function testCanTryWithOneRetry(): void { $policy = new RetryPolicy( - timer: new Timer(function (int $seconds): void { - - }), - + timer: new Timer(function (int $seconds): void {}), maxRetries: 1, delay: 5, retryMultiplier: 2, diff --git a/tests/Utilities/ParserTestHelper.php b/tests/Utilities/ParserTestHelper.php new file mode 100644 index 00000000..dceedb07 --- /dev/null +++ b/tests/Utilities/ParserTestHelper.php @@ -0,0 +1,47 @@ +getProperty('testResult'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($parser, $message); + + // Configure the ParserFactory to use our configured Parser + $parserFactory = ParserFactory::getInstance(); + $parserFactory->setParser($parser); + + return $parser; + } + + /** + * Reset the ParserFactory to its default state + */ + public static function resetParserFactory(): void + { + ParserFactory::reset(); + } +}