Prove a mailbox works by actually sending from it
parent
a9c777c79a
commit
befb67327f
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailboxTester;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\Settings;
|
||||
|
|
@ -14,11 +15,9 @@ use Livewire\Component;
|
|||
*
|
||||
* The server sits at the top because there is one of it; the mailboxes are a
|
||||
* list because there are several; the mapping is last because it only makes
|
||||
* sense once both exist.
|
||||
*
|
||||
* The test-send button is a later task's job. This page only shows and edits
|
||||
* what already exists — a button that cannot actually test is a promise the
|
||||
* page does not keep (the same reasoning the secrets page already applies).
|
||||
* sense once both exist. The test-send button lives with the mailboxes: it
|
||||
* proves one specific mailbox can actually send, which is the only thing
|
||||
* that makes the rest of this page more than a form.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Mail extends Component
|
||||
|
|
@ -32,6 +31,13 @@ class Mail extends Component
|
|||
/** @var array<string, string> purpose => mailbox key */
|
||||
public array $purposes = [];
|
||||
|
||||
public string $testRecipient = '';
|
||||
|
||||
/** @var array{ok: bool, error: ?string}|null */
|
||||
public ?array $testResult = null;
|
||||
|
||||
public ?string $testedKey = null;
|
||||
|
||||
/**
|
||||
* Whether credentials can be stored at all on this installation.
|
||||
*
|
||||
|
|
@ -92,6 +98,21 @@ class Mail extends Component
|
|||
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
|
||||
}
|
||||
|
||||
public function test(string $uuid): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$this->validate(
|
||||
['testRecipient' => ['required', 'email']],
|
||||
['testRecipient.required' => __('mail_settings.test_recipient_required')],
|
||||
);
|
||||
|
||||
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$this->testedKey = $box->key;
|
||||
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->usable = app(SecretCipher::class)->isUsable();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Secrets\SecretCipher;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Sends one real message with one mailbox's credentials.
|
||||
*
|
||||
* Deliberately built with Mail::build() rather than going through the
|
||||
* configured mailers: on a development machine the default is the log, and a
|
||||
* button that honoured that would report success while writing to a file. A
|
||||
* check that quietly examines something else is worse than no check — the
|
||||
* same mistake the timezone tests made by computing the expected value with
|
||||
* the implementation they were testing.
|
||||
*
|
||||
* @return array{ok: bool, error: ?string}
|
||||
*/
|
||||
final class MailboxTester
|
||||
{
|
||||
public function __construct(private readonly SecretCipher $cipher) {}
|
||||
|
||||
public function run(Mailbox $box, string $recipient): array
|
||||
{
|
||||
// Guarded BEFORE anything reads $box->password: that accessor
|
||||
// decrypts under SECRETS_KEY, and it is not set on this installation
|
||||
// at all (the mail settings page's own banner says so). Reading it
|
||||
// first would let a RuntimeException out of a Livewire action —
|
||||
// Task 7's EditMailbox::save() shipped a Critical fix for exactly
|
||||
// this shape of bug. The tester touches the same cipher and gets the
|
||||
// same guard, checked here rather than left for the property access
|
||||
// below to throw through.
|
||||
if (! $this->cipher->isUsable()) {
|
||||
return ['ok' => false, 'error' => __('mail_settings.no_key')];
|
||||
}
|
||||
|
||||
if ($box->password === null) {
|
||||
return ['ok' => false, 'error' => __('mail_settings.test_no_password')];
|
||||
}
|
||||
|
||||
$port = (int) Settings::get('mail.port', 587);
|
||||
|
||||
try {
|
||||
Mail::build([
|
||||
'transport' => 'smtp',
|
||||
'host' => (string) Settings::get('mail.host', ''),
|
||||
'port' => $port,
|
||||
// 'scheme', NOT 'encryption'. Laravel 13's MailManager reads
|
||||
// scheme only (MailManager.php:196) — an 'encryption' key is
|
||||
// silently ignored, and the connection would go out in the
|
||||
// clear while the console reported TLS.
|
||||
'scheme' => (Settings::get('mail.encryption') === 'ssl' || $port === 465) ? 'smtps' : 'smtp',
|
||||
'username' => $box->smtpUsername(),
|
||||
'password' => $box->password,
|
||||
])->raw(
|
||||
__('mail_settings.test_body', ['key' => $box->key]),
|
||||
function ($message) use ($box, $recipient) {
|
||||
$message->to($recipient)
|
||||
->from($box->address, $box->display_name ?: null)
|
||||
->subject(__('mail_settings.test_subject'));
|
||||
},
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
// Verbatim. With wrong credentials, or a server that rejects the
|
||||
// message outright, the server's own sentence IS the entire
|
||||
// diagnosis — "sending failed" would throw that away.
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$box->forceFill(['last_verified_at' => now()])->save();
|
||||
|
||||
return ['ok' => true, 'error' => null];
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,14 @@ return [
|
|||
],
|
||||
'purposes_saved' => 'Zuordnung gespeichert.',
|
||||
'system_required' => '„System" muss ein Postfach haben — es ist der Rückfall für alle anderen.',
|
||||
|
||||
'test' => 'Testmail senden',
|
||||
'test_recipient' => 'Testempfänger',
|
||||
'test_hint' => 'Der Testversand geht wirklich raus — auch auf dieser Installation, auf der gewöhnliche Mails nur protokolliert werden.',
|
||||
'test_recipient_required' => 'Bitte eine Empfängeradresse eingeben.',
|
||||
'test_subject' => 'CluPilot — Testnachricht',
|
||||
'test_body' => 'Diese Nachricht bestätigt, dass das Postfach :key verschicken kann.',
|
||||
'test_no_password' => 'Für dieses Postfach ist kein Passwort hinterlegt.',
|
||||
'test_ok' => 'Zugestellt. Das Postfach kann verschicken.',
|
||||
'test_failed' => 'Der Mailserver hat abgelehnt:',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -41,4 +41,14 @@ return [
|
|||
],
|
||||
'purposes_saved' => 'Mapping saved.',
|
||||
'system_required' => '"System" must have a mailbox — it is the fallback for all the others.',
|
||||
|
||||
'test' => 'Send test mail',
|
||||
'test_recipient' => 'Test recipient',
|
||||
'test_hint' => 'The test send really goes out — even on this installation, where ordinary mail is only logged.',
|
||||
'test_recipient_required' => 'Please enter a recipient address.',
|
||||
'test_subject' => 'CluPilot — test message',
|
||||
'test_body' => 'This message confirms that the mailbox :key can send.',
|
||||
'test_no_password' => 'No password is stored for this mailbox.',
|
||||
'test_ok' => 'Delivered. The mailbox can send.',
|
||||
'test_failed' => 'The mail server rejected it:',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,24 @@
|
|||
<x-ui.card>
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.boxes_title') }}</h2>
|
||||
|
||||
{{-- One shared recipient field for every row's test button below — a
|
||||
field ABOVE the table, not one growing inside a <td> (R20). --}}
|
||||
<div class="mt-4 max-w-sm">
|
||||
<x-ui.input name="testRecipient" type="email" wire:model="testRecipient" :label="__('mail_settings.test_recipient')" />
|
||||
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($testResult)
|
||||
<x-ui.alert :variant="$testResult['ok'] ? 'success' : 'danger'" class="mt-4">
|
||||
<span class="font-medium">{{ $testedKey }}:</span>
|
||||
@if ($testResult['ok'])
|
||||
{{ __('mail_settings.test_ok') }}
|
||||
@else
|
||||
{{ __('mail_settings.test_failed') }} {{ $testResult['error'] }}
|
||||
@endif
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<table class="mt-5 w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left">
|
||||
|
|
@ -55,11 +73,12 @@
|
|||
<td class="py-3 text-muted">
|
||||
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
{{-- Edit only (R18: icon beside its text, single line).
|
||||
The test-send button belongs to a later task —
|
||||
shipping one that cannot yet test anything is a
|
||||
promise this page does not keep. --}}
|
||||
<td class="py-3 text-right space-x-2">
|
||||
{{-- R18: icon beside its text, single line, size-4. --}}
|
||||
<x-ui.button size="sm" variant="ghost" wire:click="test('{{ $box->uuid }}')"
|
||||
wire:loading.attr="disabled" wire:target="test('{{ $box->uuid }}')">
|
||||
<x-ui.icon name="send" class="size-4" />{{ __('mail_settings.test') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button size="sm" variant="ghost"
|
||||
x-on:click="$dispatch('openModal', { component: 'edit-mailbox', arguments: { uuid: '{{ $box->uuid }}' } })">
|
||||
<x-ui.icon name="pencil" class="size-4" />{{ __('mail_settings.edit') }}
|
||||
|
|
|
|||
|
|
@ -297,3 +297,111 @@ it('pre-fills the form from the existing record', function () {
|
|||
->assertSet('noReply', true)
|
||||
->assertSet('active', false);
|
||||
});
|
||||
|
||||
// --- Task 8: the test-send button. MailboxTesterTest.php covers
|
||||
// MailboxTester itself in depth (bypassing the log/array mailer, verbatim
|
||||
// error passthrough, the SECRETS_KEY guard, a real success and a real SMTP
|
||||
// rejection). What is left to prove here is the WIRING: does the button on
|
||||
// this page reach that service with the right arguments, under the right
|
||||
// authorization and validation, and does the page show what came back.
|
||||
|
||||
it('refuses to run a mailbox test without mail.manage, re-checked independently of mount', function () {
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
$owner = User::factory()->operator('Owner')->create();
|
||||
$billing = User::factory()->operator('Billing')->create();
|
||||
|
||||
$page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk();
|
||||
|
||||
Livewire::actingAs($billing);
|
||||
|
||||
$page->set('testRecipient', 'ziel@example.com')
|
||||
->call('test', $box->uuid)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('requires a recipient before sending a test', function () {
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('testRecipient', '')
|
||||
->call('test', $box->uuid)
|
||||
->assertHasErrors('testRecipient');
|
||||
});
|
||||
|
||||
it('requires the recipient to actually be an email address', function () {
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('testRecipient', 'not-an-email')
|
||||
->call('test', $box->uuid)
|
||||
->assertHasErrors('testRecipient');
|
||||
});
|
||||
|
||||
it('runs the real tester against the chosen mailbox and records the result', function () {
|
||||
// 127.0.0.1:1 is refused in well under a millisecond (see
|
||||
// MailboxTesterTest) — fast and deterministic enough to run through the
|
||||
// REAL MailboxTester here rather than a mock, proving the button is
|
||||
// wired to the thing that actually sends, not a stand-in for it.
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', 1);
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('testRecipient', 'ziel@example.com')
|
||||
->call('test', $box->uuid)
|
||||
->assertSet('testedKey', 'support')
|
||||
->assertSet('testResult.ok', false)
|
||||
->assertSet('testResult.error', fn ($error) => str_contains((string) $error, 'Connection refused'));
|
||||
});
|
||||
|
||||
it('shows the result against the mailbox it actually tested', function () {
|
||||
// Not assertSee('support') alone: the mailboxes table already prints
|
||||
// every box's own key in its row regardless of which one was tested, so
|
||||
// that assertion would stay green even if $testedKey were wired to the
|
||||
// wrong box (caught by mutation — see the task report). 'support:' with
|
||||
// the colon is unique to the alert's "<key>: <message>" markup; the table
|
||||
// row never follows a key with one.
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', 1);
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
Mailbox::factory()->create(['key' => 'billing']); // a second row: proves it's not just "any key"
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('testRecipient', 'ziel@example.com')
|
||||
->call('test', $box->uuid)
|
||||
->assertSee('support:')
|
||||
->assertDontSee('billing:')
|
||||
->assertSee(__('mail_settings.test_failed'));
|
||||
});
|
||||
|
||||
it('does not crash the test button when SECRETS_KEY is missing, and says so instead', function () {
|
||||
// The gap the brief could not know about: $box->password decrypts on
|
||||
// read, and this installation has no SECRETS_KEY at all. Proven at the
|
||||
// MailboxTester unit level already — this proves the BUTTON reaches that
|
||||
// guard rather than a mock standing in for it, the same distinction
|
||||
// "re-checks mail.manage on every page action" draws for authorization.
|
||||
$box = Mailbox::factory()->create(['key' => 'support']);
|
||||
config()->set('admin_access.secrets_key', '');
|
||||
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->set('testRecipient', 'ziel@example.com')
|
||||
->call('test', $box->uuid)
|
||||
->assertSet('testResult.error', __('mail_settings.no_key'));
|
||||
});
|
||||
|
||||
it('offers a per-row test-send button, not only edit', function () {
|
||||
$blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php'));
|
||||
|
||||
expect($blade)->toContain('wire:click="test(');
|
||||
});
|
||||
|
||||
it('tells the operator the test send really goes out, even though ordinary mail only logs here', function () {
|
||||
Livewire::actingAs(User::factory()->operator('Owner')->create())
|
||||
->test(MailPage::class)
|
||||
->assertSee(__('mail_settings.test_hint'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailboxTester;
|
||||
use App\Support\Settings;
|
||||
use Tests\Support\FakeSmtpServer;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
config()->set('mail.default', 'log'); // as on a development machine
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', 1);
|
||||
Settings::set('mail.encryption', 'tls');
|
||||
});
|
||||
|
||||
/*
|
||||
| The brief points this beforeEach at 'mail.example.invalid' and asserts the
|
||||
| error mentions it. Measured by hand in this container (php artisan tinker
|
||||
| equivalent, three runs each): a lookup for 'mail.example.invalid' fails in
|
||||
| 0.7-7ms via NXDOMAIN, but that number depends on a resolver being reachable
|
||||
| at all — nothing here guarantees that in every environment this suite might
|
||||
| run in. 127.0.0.1:1 (nothing listens on port 1) refused every one of those
|
||||
| three runs in ~0.2ms with zero DNS involved, and it is the convention this
|
||||
| codebase already settled on for the identical reason — see
|
||||
| MailboxTransportTest's "LOAD-BEARING for the MAIL_MAILER=log guard" comment.
|
||||
| Using it here keeps both tests fast AND deterministic instead of merely fast
|
||||
| today.
|
||||
*/
|
||||
|
||||
it('does NOT quietly succeed through the log mailer', function () {
|
||||
// The whole point: on dev the default mailer is the log, and a tester
|
||||
// that honoured it would report success while writing to a file — a
|
||||
// test that proves nothing. The log "transport" never fails, so only a
|
||||
// real attempt (which a closed port refuses) can make this assertion
|
||||
// meaningful.
|
||||
$result = app(MailboxTester::class)->run(
|
||||
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
|
||||
'ziel@example.com',
|
||||
);
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['error'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('passes the SMTP error through verbatim, because that is the whole diagnosis', function () {
|
||||
$result = app(MailboxTester::class)->run(
|
||||
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
|
||||
'ziel@example.com',
|
||||
);
|
||||
|
||||
// Not "Fehler beim Senden" — the operating system's own sentence, naming
|
||||
// the actual host:port and the actual reason.
|
||||
expect($result['error'])->toContain('127.0.0.1:1')
|
||||
->and($result['error'])->toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('refuses a mailbox without a password rather than pretending to try', function () {
|
||||
$box = Mailbox::factory()->create(['password' => null]);
|
||||
|
||||
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['error'])->toContain('Passwort');
|
||||
});
|
||||
|
||||
it('stamps last_verified_at only on success', function () {
|
||||
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
||||
|
||||
app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($box->fresh()->last_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('gives a readable message instead of throwing when SECRETS_KEY is unusable', function () {
|
||||
// Facts the brief could not know: SECRETS_KEY is not set on this
|
||||
// installation at all (the mail settings page's own banner says so).
|
||||
// $box->password decrypts on read, so touching it throws a
|
||||
// RuntimeException out of SecretCipher — Task 7's EditMailbox::save()
|
||||
// shipped a Critical fix for exactly this shape of bug. The tester
|
||||
// touches the very same cipher and needs the very same guard.
|
||||
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
||||
|
||||
config()->set('admin_access.secrets_key', '');
|
||||
|
||||
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['error'])->toBe(__('mail_settings.no_key'));
|
||||
});
|
||||
|
||||
/*
|
||||
| LOAD-BEARING for the success path. Every test above only proves a FAILURE
|
||||
| is reported correctly — none of them ever reaches
|
||||
| `$box->forceFill(['last_verified_at' => now()])->save()`, so all of them
|
||||
| would stay green even if that line were deleted outright. A real success
|
||||
| needs something real to answer on the other end of the socket; see
|
||||
| FakeSmtpServer for why that has to be a subprocess rather than an in-process
|
||||
| fake.
|
||||
*/
|
||||
it('actually delivers and stamps last_verified_at when the server accepts the message', function () {
|
||||
$server = FakeSmtpServer::succeeding();
|
||||
|
||||
try {
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', $server->port);
|
||||
|
||||
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
||||
|
||||
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['error'])->toBeNull()
|
||||
->and($box->fresh()->last_verified_at)->not->toBeNull();
|
||||
} finally {
|
||||
$server->stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes a real SMTP rejection through verbatim too, not only a refused connection', function () {
|
||||
// The strongest version of "the server's own sentence": an actual
|
||||
// protocol-level 5xx from an actual SMTP dialogue, not merely the
|
||||
// operating system's "connection refused". Proves MailboxTester's catch
|
||||
// block does not paraphrase or replace what Symfony Mailer reports.
|
||||
$server = FakeSmtpServer::rejectingAt('rcpt', '550 5.1.1 No such user ziel@example.com');
|
||||
|
||||
try {
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', $server->port);
|
||||
|
||||
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
||||
|
||||
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['error'])->toContain('550 5.1.1 No such user ziel@example.com')
|
||||
->and($box->fresh()->last_verified_at)->toBeNull();
|
||||
} finally {
|
||||
$server->stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('actually attempts TLS when SSL is configured on a non-standard port, instead of silently going out in the clear', function () {
|
||||
// The exact trap this task names: Laravel 13's MailManager reads
|
||||
// 'scheme', not 'encryption' (MailManager.php:196). An 'encryption' key
|
||||
// is silently ignored, and MailManager falls back to choosing the scheme
|
||||
// from the port number alone — smtps only for port 465, smtp otherwise.
|
||||
// Configured for SSL on a non-465 port (a real EU hoster's submission
|
||||
// port with implicit TLS, say), that bug would connect in PLAIN TEXT
|
||||
// while the console still reported TLS.
|
||||
//
|
||||
// FakeSmtpServer only ever speaks plain text, so it can tell the two
|
||||
// apart: a correct 'scheme' => 'smtps' opens an implicit-TLS connection
|
||||
// this server cannot answer and the send FAILS; the buggy 'encryption'
|
||||
// key would default to plain 'smtp' on this non-465 port and let it
|
||||
// succeed instead. Verified by hand this fails in ~0.2s, not a hang —
|
||||
// OpenSSL rejects the server's plain-text greeting as an invalid TLS
|
||||
// record immediately.
|
||||
$server = FakeSmtpServer::succeeding();
|
||||
|
||||
try {
|
||||
Settings::set('mail.host', '127.0.0.1');
|
||||
Settings::set('mail.port', $server->port); // deliberately not 465
|
||||
Settings::set('mail.encryption', 'ssl');
|
||||
|
||||
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
||||
|
||||
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
} finally {
|
||||
$server->stop();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A throwaway SMTP server for exactly one connection, run as a real
|
||||
* subprocess.
|
||||
*
|
||||
* MailboxTester opens a real socket through Symfony's EsmtpTransport — the
|
||||
* same class production traffic uses — so proving its SUCCESS path (not
|
||||
* only the refused-connection failure every other test in this file uses)
|
||||
* means something has to answer on the other end of a real TCP connection.
|
||||
* PHP is single-threaded: a "server" living in the same process as the test
|
||||
* could never accept() a connection while that same process is blocked
|
||||
* inside the socket call it is supposed to be answering. Hence a genuine
|
||||
* child process (proc_open) rather than a Fake* stub swapped through the
|
||||
* container, which is how every other fake in this suite (SSH, Proxmox,
|
||||
* WireGuard — see tests/Pest.php) works.
|
||||
*/
|
||||
final class FakeSmtpServer
|
||||
{
|
||||
/** @var resource */
|
||||
private $process;
|
||||
|
||||
/** @var array<int, resource> */
|
||||
private array $pipes;
|
||||
|
||||
public readonly int $port;
|
||||
|
||||
private function __construct($process, array $pipes, int $port)
|
||||
{
|
||||
$this->process = $process;
|
||||
$this->pipes = $pipes;
|
||||
$this->port = $port;
|
||||
}
|
||||
|
||||
/** Accepts one connection and completes a full, successful send. */
|
||||
public static function succeeding(): self
|
||||
{
|
||||
return self::spawn(['failAt' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts one connection and rejects it with a real SMTP response at the
|
||||
* given stage. 'rcpt' is the simplest choice for a caller that just wants
|
||||
* *a* rejection: MAIL FROM has already succeeded by then, so the failure
|
||||
* is unambiguously the server's own — 'ehlo' is deliberately unsupported
|
||||
* because EsmtpTransport retries a failed EHLO as plain HELO rather than
|
||||
* giving up, which this one-shot server does not simulate.
|
||||
*/
|
||||
public static function rejectingAt(string $stage, string $response): self
|
||||
{
|
||||
return self::spawn(['failAt' => $stage, 'failResponse' => $response]);
|
||||
}
|
||||
|
||||
private static function spawn(array $config): self
|
||||
{
|
||||
$process = proc_open(
|
||||
[PHP_BINARY, __DIR__.'/fake_smtp_server_process.php'],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new RuntimeException('Could not start the fake SMTP server subprocess.');
|
||||
}
|
||||
|
||||
fwrite($pipes[0], json_encode($config));
|
||||
fclose($pipes[0]);
|
||||
|
||||
// A bound port comes back almost instantly; this timeout only
|
||||
// matters if the subprocess never starts at all, so the test suite
|
||||
// fails fast instead of hanging.
|
||||
stream_set_timeout($pipes[1], 5);
|
||||
$port = (int) trim((string) fgets($pipes[1]));
|
||||
|
||||
if ($port <= 0) {
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
proc_terminate($process);
|
||||
proc_close($process);
|
||||
|
||||
throw new RuntimeException("Fake SMTP server failed to bind a port. stderr: {$stderr}");
|
||||
}
|
||||
|
||||
return new self($process, $pipes, $port);
|
||||
}
|
||||
|
||||
public function stop(): void
|
||||
{
|
||||
foreach ($this->pipes as $pipe) {
|
||||
if (is_resource($pipe)) {
|
||||
fclose($pipe);
|
||||
}
|
||||
}
|
||||
|
||||
proc_terminate($this->process);
|
||||
proc_close($this->process);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* A one-shot ESMTP server, run as its own OS process (see FakeSmtpServer for
|
||||
* why a subprocess and not an in-process fake is necessary here).
|
||||
*
|
||||
* Configuration arrives as one JSON object on STDIN: {"failAt": string|null,
|
||||
* "failResponse": string}. failAt names the command after which the server
|
||||
* answers with failResponse instead of success — 'mail', 'rcpt', or 'data'.
|
||||
* null completes a normal, successful send. ('ehlo' is deliberately not a
|
||||
* supported stage: EsmtpTransport retries a failed EHLO as plain HELO before
|
||||
* giving up, so a clean single-rejection test needs a later stage.)
|
||||
*
|
||||
* Binds an ephemeral port on 127.0.0.1, prints it as the first line of
|
||||
* STDOUT so the parent process can connect, accepts exactly one connection,
|
||||
* and plays the minimal EHLO/MAIL FROM/RCPT TO/DATA dialogue. Neither
|
||||
* STARTTLS nor AUTH is advertised in the EHLO response, so
|
||||
* EsmtpTransport — auto_tls defaults true, but only upgrades when the
|
||||
* server actually offers STARTTLS — never attempts either, and the whole
|
||||
* exchange stays in plain text.
|
||||
*/
|
||||
$config = json_decode((string) stream_get_contents(STDIN), true) ?: [];
|
||||
$failAt = $config['failAt'] ?? null;
|
||||
$failResponse = $config['failResponse'] ?? '550 Rejected';
|
||||
|
||||
$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||||
|
||||
if ($server === false) {
|
||||
fwrite(STDERR, "bind failed: {$errstr}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$name = stream_socket_get_name($server, false);
|
||||
$port = (int) substr($name, strrpos($name, ':') + 1);
|
||||
|
||||
echo $port, "\n";
|
||||
flush();
|
||||
|
||||
$conn = @stream_socket_accept($server, 10);
|
||||
|
||||
if ($conn === false) {
|
||||
fwrite(STDERR, "accept timed out\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Not readLine()/respond(): PHP function names are case-insensitive, and
|
||||
// readLine collides with the readline extension's built-in readline(),
|
||||
// fataling with "Cannot redeclare readline()" the moment this file loads.
|
||||
function smtpReadLine($conn): string
|
||||
{
|
||||
return (string) fgets($conn, 8192);
|
||||
}
|
||||
|
||||
function smtpRespond($conn, string $line): void
|
||||
{
|
||||
fwrite($conn, $line."\r\n");
|
||||
}
|
||||
|
||||
smtpRespond($conn, '220 fake.smtp ESMTP');
|
||||
|
||||
smtpReadLine($conn); // EHLO
|
||||
smtpRespond($conn, '250 fake.smtp');
|
||||
|
||||
smtpReadLine($conn); // MAIL FROM
|
||||
if ($failAt === 'mail') {
|
||||
smtpRespond($conn, $failResponse);
|
||||
exit(0);
|
||||
}
|
||||
smtpRespond($conn, '250 OK');
|
||||
|
||||
smtpReadLine($conn); // RCPT TO
|
||||
if ($failAt === 'rcpt') {
|
||||
smtpRespond($conn, $failResponse);
|
||||
exit(0);
|
||||
}
|
||||
smtpRespond($conn, '250 OK');
|
||||
|
||||
smtpReadLine($conn); // DATA
|
||||
if ($failAt === 'data') {
|
||||
smtpRespond($conn, $failResponse);
|
||||
exit(0);
|
||||
}
|
||||
smtpRespond($conn, '354 Go ahead');
|
||||
|
||||
// Message body: read until the bare "." terminator line.
|
||||
while (($line = smtpReadLine($conn)) !== '' && rtrim($line, "\r\n") !== '.') {
|
||||
// discard
|
||||
}
|
||||
smtpRespond($conn, '250 OK: queued as FAKE123');
|
||||
|
||||
// SmtpTransport::__destruct() calls stop(), which sends QUIT — but only
|
||||
// after the send this helper exists for has already returned, so answering
|
||||
// it is a courtesy, not a requirement.
|
||||
stream_set_timeout($conn, 2);
|
||||
smtpReadLine($conn);
|
||||
smtpRespond($conn, '221 Bye');
|
||||
|
||||
fclose($conn);
|
||||
fclose($server);
|
||||
Loading…
Reference in New Issue