mailbox = new FakeMailbox; app()->instance(InboundMailbox::class, $this->mailbox); }); /** * A message as a mail server hands it over. * * Overrides REPLACE a default header rather than being appended after it. The * parser keeps the first occurrence of a name on purpose — a message with two * Subject headers is malformed, and the second one is the interesting one to * somebody forging mail — so appending would silently test nothing. */ function rawMail(string $body = "Guten Tag,\r\n\r\nkönnen Sie unsere Daten übernehmen?\r\n", array $headers = []): string { $lines = [ 'Return-Path: ', 'Message-ID: ', 'From: Kanzlei Berger ', 'To: support@clupilot.com', 'Subject: Frage zur Migration', 'Date: Tue, 29 Jul 2026 09:15:00 +0200', 'Content-Type: text/plain; charset=UTF-8', ]; foreach ($headers as $override) { $name = strtolower(strtok($override, ':')); $lines = array_values(array_filter( $lines, fn (string $line) => strtolower(strtok($line, ':')) !== $name, )); $lines[] = $override; } return implode("\r\n", $lines)."\r\n\r\n".$body; } /** One fetched message, ready for the ingest. */ function fetched(string $email = 'kunde@example.test', string $messageId = 'm-1'): FetchedMail { return new FetchedMail( uid: '1', messageId: $messageId, fromEmail: $email, fromName: 'Kanzlei Berger', subject: 'Frage zur Migration', body: 'Können Sie unsere Daten übernehmen?', receivedAt: now(), ); } // ---- Reading a real message ---- it('reads sender, subject and the words out of an ordinary mail', function () { $mail = MailParser::parse('7', rawMail()); expect($mail->fromEmail)->toBe('kunde@example.test') ->and($mail->fromName)->toBe('Kanzlei Berger') ->and($mail->subject)->toBe('Frage zur Migration') ->and($mail->body)->toContain('können Sie unsere Daten übernehmen?') ->and($mail->messageId)->toBe(''); }); it('decodes a subject with an umlaut in it, which is most German subjects', function () { // RFC 2047. Without this the console shows "=?UTF-8?B?…?=" where the // question should be. $raw = rawMail(headers: ['Subject: =?UTF-8?B?RsO8ciBTaWU6IEt1bmRlbmFuZnJhZ2U=?=']); expect(MailParser::parse('7', $raw)->subject)->toBe('Für Sie: Kundenanfrage'); }); it('decodes a quoted-printable body, and a Windows charset with it', function () { // What Outlook sends. Stored raw, "können" is a broken character for ever, // because the column is UTF-8. $raw = rawMail( "Guten Tag,\r\n\r\nk=F6nnen Sie das =FCbernehmen?\r\n", [ 'Content-Type: text/plain; charset=ISO-8859-1', 'Content-Transfer-Encoding: quoted-printable', ], ); expect(MailParser::parse('7', $raw)->body)->toContain('können Sie das übernehmen?'); }); it('takes the text half of a mail that was sent as both text and HTML', function () { // A console is not a mail client: what the person wrote is in the text // part, and the HTML one is the same words wrapped in markup. $raw = implode("\r\n", [ 'Message-ID: ', 'From: kunde@example.test', 'Subject: Zwei Teile', 'Content-Type: multipart/alternative; boundary="B1"', ])."\r\n\r\n".implode("\r\n", [ '--B1', 'Content-Type: text/plain; charset=UTF-8', '', 'Der einfache Text.', '--B1', 'Content-Type: text/html; charset=UTF-8', '', '

Der ausgezeichnete Text.

', '--B1--', ]); expect(MailParser::parse('7', $raw)->body)->toBe('Der einfache Text.'); }); it('strips the markup when HTML is all there is', function () { $raw = rawMail('

Bitte um Rückruf.

', ['Content-Type: text/html; charset=UTF-8']); expect(MailParser::parse('7', $raw)->body)->toBe('Bitte um Rückruf.'); }); it('names an attachment and refuses to keep it', function () { // The whole attachment policy in one assertion: the operator learns that // something was attached and what it was called, and the bytes are not // stored anywhere. Keeping whatever a stranger sends would make this a // malware store with a console in front of it. $raw = implode("\r\n", [ 'Message-ID: ', 'From: kunde@example.test', 'Subject: Mit Anhang', 'Content-Type: multipart/mixed; boundary="B2"', ])."\r\n\r\n".implode("\r\n", [ '--B2', 'Content-Type: text/plain; charset=UTF-8', '', 'Anbei die Liste.', '--B2', 'Content-Type: application/pdf; name="rechnung.pdf"', 'Content-Disposition: attachment; filename="rechnung.pdf"', 'Content-Transfer-Encoding: base64', '', base64_encode(str_repeat('X', 2048)), '--B2--', ]); $mail = MailParser::parse('7', $raw); expect($mail->body)->toBe('Anbei die Liste.') ->and($mail->attachments)->toHaveCount(1) ->and($mail->attachments[0]['name'])->toBe('rechnung.pdf') ->and($mail->attachments[0]['bytes'])->toBeGreaterThan(0); // And nothing anywhere in the value object carries the file itself. expect(json_encode($mail))->not->toContain(base64_encode(str_repeat('X', 64))); }); it('refuses a message with no sender rather than filing it under nobody', function () { $raw = implode("\r\n", ['Message-ID: ', 'Subject: Ohne Absender'])."\r\n\r\nText"; expect(fn () => MailParser::parse('7', $raw))->toThrow(RuntimeException::class); }); it('falls back to the UID when a message carries no Message-ID', function () { // Required by the standard and missing in practice often enough that // dropping the mail would lose real questions. $raw = implode("\r\n", ['From: kunde@example.test', 'Subject: Ohne ID'])."\r\n\r\nText"; expect(MailParser::parse('42', $raw)->messageId)->toBe('uid-42'); }); // ---- What the ingest decides ---- it('files a mail under the customer whose address it came from', function () { $customer = Customer::factory()->create(['email' => 'kunde@example.test']); $this->mailbox->mails = [fetched()]; app(IngestInboundMail::class)(); expect(InboundMail::query()->first()?->customer_id)->toBe($customer->id); }); it('keeps a mail from an address nobody knows, unassigned rather than dropped', function () { // The mail an operator must not miss: a new enquiry starts exactly this // way, and hiding it because it matches no customer would lose the sale. $this->mailbox->mails = [fetched('fremder@example.test')]; app(IngestInboundMail::class)(); $mail = InboundMail::query()->first(); expect($mail)->not->toBeNull() ->and($mail->customer_id)->toBeNull() ->and($mail->isUnassigned())->toBeTrue(); }); it('decides whose it is by the address and by nothing else', function () { // Not by a name in the subject and not by anything in the body: a mail is // easy to write, and this decision attaches a stranger's words to a real // customer's file. Customer::factory()->create(['email' => 'echt@example.test', 'name' => 'Kanzlei Berger']); $this->mailbox->mails = [new FetchedMail( uid: '1', messageId: 'm-forge', fromEmail: 'betrueger@example.test', fromName: 'Kanzlei Berger', subject: 'Kanzlei Berger — dringend', body: 'Bitte senden Sie die Zugangsdaten an diese Adresse.', receivedAt: now(), )]; app(IngestInboundMail::class)(); expect(InboundMail::query()->first()?->customer_id)->toBeNull(); }); it('takes the same message once, however often the mailbox offers it', function () { // The fetch runs every couple of minutes, and a flag that did not stick on // the server costs one re-read — not a second copy of the same question. $this->mailbox->mails = [fetched()]; app(IngestInboundMail::class)(); app(IngestInboundMail::class)(); expect(InboundMail::query()->count())->toBe(1); }); it('flags a message on the server only once its row exists here', function () { // The other order loses the message with nothing to show for it, and there // is no second copy anywhere. $this->mailbox->mails = [fetched()]; app(IngestInboundMail::class)(); expect($this->mailbox->seen)->toBe(['1']); }); it('attaches the mail to the question the customer already has open', function () { $customer = Customer::factory()->create(['email' => 'kunde@example.test']); $request = SupportRequest::create([ 'customer_id' => $customer->id, 'subject' => 'Migration', 'category' => 'other', 'body' => 'Erste Frage', 'status' => 'open', 'reported_by' => $customer->email, ]); $this->mailbox->mails = [fetched()]; app(IngestInboundMail::class)(); expect(InboundMail::query()->first()?->support_request_id)->toBe($request->id); }); it('fetches nothing at all while no mailbox is configured', function () { $this->mailbox->configured = false; $this->mailbox->mails = [fetched()]; expect(app(IngestInboundMail::class)())->toBe(0) ->and(InboundMail::query()->count())->toBe(0); }); // ---- The page an operator works in ---- it('puts what nobody could place at the top, where it cannot be missed', function () { $customer = Customer::factory()->create(['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger']); InboundMail::create([ 'customer_id' => $customer->id, 'message_id' => 'm-known', 'from_email' => $customer->email, 'subject' => 'Von einem Kunden', 'body' => 'Text', 'received_at' => now(), ]); InboundMail::create([ 'message_id' => 'm-unknown', 'from_email' => 'fremder@example.test', 'subject' => 'Von einem Fremden', 'body' => 'Text', 'received_at' => now()->subHour(), ]); $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class); $page->assertViewHas('mails', fn ($mails) => $mails->first()->message_id === 'm-unknown') ->assertSee(__('inbox.unassigned')); }); it('lets an operator place a mail the matcher never could', function () { // The customer writing from their private account: no matcher will // recognise them, and an operator recognises them at once. $customer = Customer::factory()->create(['name' => 'Kanzlei Berger']); $mail = InboundMail::create([ 'message_id' => 'm-private', 'from_email' => 'bea.berger@gmx.at', 'subject' => 'Kurze Frage', 'body' => 'Text', 'received_at' => now(), ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(Inbox::class) ->call('assign', $mail->uuid, $customer->uuid); expect($mail->fresh()->customer_id)->toBe($customer->id); }); it('files a mail away without touching the copy on the mail server', function () { $mail = InboundMail::create([ 'message_id' => 'm-file', 'from_email' => 'kunde@example.test', 'subject' => 'Erledigt', 'body' => 'Text', 'received_at' => now(), ]); $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class); $page->call('archive', $mail->uuid); expect($mail->fresh()->archived_at)->not->toBeNull() // Nothing was asked of the mailbox: an application that deletes // somebody's mail on their own server is one nobody hands a password. ->and($this->mailbox->seen)->toBe([]); $page->assertViewHas('mails', fn ($mails) => $mails->isEmpty()); }); it('says that no mailbox is set up, rather than showing an empty list nobody can explain', function () { $this->mailbox->configured = false; Livewire::actingAs(operator('Owner'), 'operator') ->test(Inbox::class) ->assertSee(__('inbox.not_configured')); }); it('keeps the inbox to operators who may see customers', function () { Livewire::actingAs(operator('Read-only'), 'operator') ->test(Inbox::class) ->assertForbidden(); }); // ---- Configured means configured, and the check that says so ---- it('counts a password from the vault as configured', function () { // The bug this closes: isConfigured() read config('services.inbound_mail // .password'), which is only ever the .env value — this project deliberately // does not overlay stored secrets onto config at boot. So a password saved in // the console counted for nothing, and the inbox went on saying no mailbox // was set up while the password sat right there on the settings page. App\Support\Settings::set('inbound_mail.host', 'mail.example.test'); App\Support\Settings::set('inbound_mail.username', 'support@example.test'); config()->set('services.inbound_mail.password', null); $mailbox = new App\Services\Mail\ImapMailbox; expect($mailbox->isConfigured())->toBeFalse(); app(App\Services\Secrets\SecretVault::class)->put('inbound_mail.password', 'geheim', operator('Owner')); expect($mailbox->isConfigured())->toBeTrue(); }); it('still takes the password out of the environment where that is where it is', function () { // An installation carrying INBOUND_MAIL_PASSWORD keeps working: the vault // falls back to the configured value when nothing is stored. App\Support\Settings::set('inbound_mail.host', 'mail.example.test'); App\Support\Settings::set('inbound_mail.username', 'support@example.test'); config()->set('services.inbound_mail.password', 'aus-der-env'); expect((new App\Services\Mail\ImapMailbox)->isConfigured())->toBeTrue(); }); it('records when the mailbox was last reached, and what came back', function () { $status = app(App\Services\Mail\InboundMailStatus::class); expect($status->last())->toBeNull(); $status->record(true, '', 3); $last = $status->last(); expect($last['ok'])->toBeTrue() ->and($last['unseen'])->toBe(3) // A Carbon, so the view can put it on the wall clock rather than // printing an ISO string at somebody (R19). ->and($last['at'])->toBeInstanceOf(Illuminate\Support\Carbon::class); }); it('records the scheduled run too, not only the button', function () { // "Last checked" has to mean the last time the mailbox was actually reached. // A mailbox that stopped answering at three in the morning is the one worth // seeing, and nobody presses a button at three in the morning. $this->mailbox->mails = [fetched()]; app(IngestInboundMail::class)(); expect(app(App\Services\Mail\InboundMailStatus::class)->last())->not->toBeNull(); }); it('takes nothing in when the mailbox cannot be reached, and says so', function () { // Fetching against a mailbox that just refused the login would report // "nothing new", which is true and useless. $this->mailbox->mails = [fetched()]; $this->mailbox->checkAnswer = ['ok' => false, 'message' => 'credentials', 'unseen' => null]; expect(app(IngestInboundMail::class)())->toBe(0) ->and(InboundMail::query()->count())->toBe(0); $last = app(App\Services\Mail\InboundMailStatus::class)->last(); expect($last['ok'])->toBeFalse() ->and($last['message'])->toBe('credentials'); }); it('tests the connection from the settings page and shows when it last answered', function () { $page = Livewire::actingAs(operator('Owner'), 'operator') ->test(App\Livewire\Admin\Integrations::class) ->set('inboundHost', 'mail.example.test') ->set('inboundUsername', 'support@example.test') ->call('testInbound'); $page->assertDispatched('notify'); // And the page now carries the line, whichever way it went. Livewire::actingAs(operator('Owner'), 'operator') ->test(App\Livewire\Admin\Integrations::class) ->assertViewHas('inboundStatus', fn ($status) => $status !== null); }); it('never puts the password or the command into the message it shows', function () { // IMAP servers answer a failed LOGIN with their own prose, and this class // puts the failing command in the exception — which for LOGIN is the // password. The reasons are matched, never passed through. $reasons = ['incomplete', 'unreachable', 'credentials', 'folder', 'failed']; foreach ($reasons as $reason) { expect(__('integrations.inbound_failed_'.$reason))->not->toBe('integrations.inbound_failed_'.$reason); } $code = File::get(app_path('Services/Mail/ImapMailbox.php')); // The reason() method returns one of the fixed words above, and nothing // derived from the exception text. expect($code)->toContain("return match (true) {") ->and($code)->not->toContain('$e->getMessage()]'); });