CluPilotCloud/tests/Feature/Admin/InboundMailTest.php

441 lines
17 KiB
PHP

<?php
use App\Livewire\Admin\Inbox;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\SupportRequest;
use App\Services\Mail\FakeMailbox;
use App\Services\Mail\FetchedMail;
use App\Services\Mail\IngestInboundMail;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\MailParser;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* The mail a customer writes, read where it can be answered.
*
* The socket half cannot be exercised without a mail server, and a test that
* needs one is a test nobody runs. What IS exercised here is everything that
* decides anything: the parsing, against messages in the shape real mail
* arrives in, and every judgement the ingest makes afterwards.
*/
beforeEach(function () {
$this->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: <kunde@example.test>',
'Message-ID: <abc123@example.test>',
'From: Kanzlei Berger <kunde@example.test>',
'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('<abc123@example.test>');
});
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: <mixed@example.test>',
'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',
'',
'<p>Der <b>ausgezeichnete</b> Text.</p>',
'--B1--',
]);
expect(MailParser::parse('7', $raw)->body)->toBe('Der einfache Text.');
});
it('strips the markup when HTML is all there is', function () {
$raw = rawMail('<p>Bitte um <b>R&uuml;ckruf</b>.</p>', ['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: <att@example.test>',
'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: <x@y>', '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()]');
});