From 19ed6461cf1eb851a0d4f4a1c810b114ec622eb8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 20:35:58 +0200 Subject: [PATCH] Bereitschaft: das Absender-Postfach wird gefragt, ob es senden KANN --- app/Services/Mail/MailboxResolver.php | 26 +++++ app/Support/Readiness/DeliveryChecks.php | 14 ++- lang/de/readiness.php | 4 +- lang/en/readiness.php | 4 +- .../Mail/SystemMailboxReadinessTest.php | 97 +++++++++++++++++++ .../Feature/Readiness/DeliveryChecksTest.php | 15 ++- 6 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Mail/SystemMailboxReadinessTest.php diff --git a/app/Services/Mail/MailboxResolver.php b/app/Services/Mail/MailboxResolver.php index 86ec076..09636a4 100644 --- a/app/Services/Mail/MailboxResolver.php +++ b/app/Services/Mail/MailboxResolver.php @@ -34,6 +34,32 @@ final class MailboxResolver : $this->active($this->named(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))); } + /** + * Das Postfach, das diese Art Mail TATSAECHLICH tragen wuerde — oder null. + * + * `for()` beantwortet „welches Postfach ist zugeordnet", das hier + * beantwortet „welches kann senden": zugeordnet, aktiv, und mit + * Zugangsdaten, falls es sich anmeldet. Das sind nicht dieselben Fragen. + * + * Entstanden aus einem gemeldeten Fehler (31.7.2026): die + * „Passwort vergessen"-Mail kam nicht an, obwohl die Zustellung + * eingeschaltet war. Die Bereitschaftsseite fragte eine DRITTE Frage — ob + * ueberhaupt irgendein Postfach angelegt ist — und stand deshalb auf gruen, + * waehrend jede Systemmail in der Warteschlange an + * MailboxTransport::delegate() starb. Der Kunde las „sehen Sie in Ihr + * Postfach", und im Postausgang stand nichts, weil RecordSentMail auf + * MessageSent hoert: was nie gesendet wurde, hinterlaesst keine Zeile. + * + * `Mailbox::isConfigured()` bleibt die eine Stelle, die weiss, was + * „kann senden" heisst; hier und im Transport wird sie nur gefragt. + */ + public function usableFor(string $purpose): ?Mailbox + { + $box = $this->for($purpose); + + return $box !== null && $box->isConfigured() ? $box : null; + } + private function named(mixed $key): ?Mailbox { return is_string($key) && $key !== '' ? Mailbox::findByKey($key) : null; diff --git a/app/Support/Readiness/DeliveryChecks.php b/app/Support/Readiness/DeliveryChecks.php index 93eaa97..5ec2c9a 100644 --- a/app/Support/Readiness/DeliveryChecks.php +++ b/app/Support/Readiness/DeliveryChecks.php @@ -3,8 +3,9 @@ namespace App\Support\Readiness; use App\Mail\Transport\MailboxTransport; -use App\Models\Mailbox; use App\Models\MailTemplate; +use App\Services\Mail\MailboxResolver; +use App\Services\Mail\MailPurpose; use App\Services\Secrets\SecretVault; use App\Support\MailDelivery; @@ -42,7 +43,16 @@ final class DeliveryChecks label: __('readiness.delivery.mailbox'), breaks: __('readiness.delivery.mailbox_breaks'), tab: 'mail', - satisfied: Mailbox::query()->exists(), + // Nicht mehr `Mailbox::query()->exists()`. Dass IRGENDEIN + // Postfach angelegt ist, entscheidet nichts: gesendet wird + // ueber das Postfach, das dem Zweck `system` zugeordnet ist, + // und das muss aktiv sein und Zugangsdaten haben. Die alte + // Fassung stand gruen, waehrend jede Systemmail — Passwort + // vergessen, Adresse bestaetigen, neues Geraet — in der + // Warteschlange starb. `system` genuegt als Pruefgegenstand, + // weil MailboxResolver jeden anderen Zweck darauf zurueckfallen + // laesst. + satisfied: app(MailboxResolver::class)->usableFor(MailPurpose::SYSTEM) !== null, ), new Check( key: 'delivery.mail_templates', diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 8683f07..2f64c61 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -100,8 +100,8 @@ return [ 'delivery' => [ 'mailer_not_log' => 'Mailtransport stellt tatsächlich zu', 'mailer_not_log_breaks' => 'Bei mail.default=log, =array oder ganz ohne gesetzten Standard läuft die Maschine, der Beleg wird ausgestellt, und die Mail, die dem Kunden seine fertige Cloud ankündigt, verlässt den Server nie — sie landet in einer Datei oder verschwindet spurlos. Der Kunde erfährt nie, dass er bezahlt hat und etwas läuft.', - 'mailbox' => 'Mindestens eine Mailbox', - 'mailbox_breaks' => 'Ohne Mailbox wirft der Versand aus CompleteProvisioning eine Ausnahme — der letzte Schritt der Bereitstellung schlägt fehl und wiederholt sich, obwohl die Maschine längst läuft und der Kunde schon bezahlt hat.', + 'mailbox' => 'Absender-Postfach kann senden', + 'mailbox_breaks' => 'Dem Zweck „System" ist kein sendefähiges Postfach zugeordnet — es fehlt, ist deaktiviert, oder es meldet sich an, ohne dass ein Passwort hinterlegt ist. Jede Systemmail stirbt dann in der Warteschlange: „Passwort vergessen", Adressbestätigung, Warnung bei neuem Gerät. Der Kunde liest „sehen Sie in Ihr Postfach", und im Postausgang steht nichts, weil nie etwas gesendet wurde.', 'mail_templates' => 'Mindestens eine Antwortvorlage', 'mail_templates_breaks' => 'Auf der Kundenseite bleibt die Vorlagen-Auswahl leer, wo sie sonst anklickbare Antworten anbietet — der Support-Mitarbeiter merkt es sofort beim Öffnen, findet aber nichts zum Einfügen und tippt jede Antwort neu. Zwei Kunden mit derselben Frage bekommen dadurch leicht zwei unterschiedliche Auskünfte, ohne dass irgendwo ein Fehler erscheint.', 'inbound_password' => 'Passwort für eingehende Mail', diff --git a/lang/en/readiness.php b/lang/en/readiness.php index a8c40d6..58704b9 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -95,8 +95,8 @@ return [ 'delivery' => [ 'mailer_not_log' => 'Mail transport actually delivers', 'mailer_not_log_breaks' => 'With mail.default=log, =array, or no default configured at all, the machine runs, the invoice is issued, and the mail announcing the finished cloud never leaves the server — it lands in a file or vanishes without a trace. The customer never learns they paid for something that is running.', - 'mailbox' => 'At least one mailbox', - 'mailbox_breaks' => 'With no mailbox, sending from CompleteProvisioning throws — the last step of provisioning fails and keeps retrying, even though the machine is already running and the customer has already paid.', + 'mailbox' => 'Sending mailbox can actually send', + 'mailbox_breaks' => 'No usable mailbox is mapped to the [system] purpose — it is missing, switched off, or it authenticates with no password stored. Every system mail then dies in the queue: password reset, address confirmation, new-device warning. The customer reads "check your inbox", and the outbox shows nothing, because nothing was ever sent.', 'mail_templates' => 'At least one reply template', 'mail_templates_breaks' => 'On the customer page, the template picker sits empty where it would otherwise offer ready answers to click — the support agent notices immediately on opening it, finds nothing to insert, and types every reply from scratch. Two customers asking the same question can then easily get two different answers, with no error appearing anywhere.', 'inbound_password' => 'Inbound mail password', diff --git a/tests/Feature/Mail/SystemMailboxReadinessTest.php b/tests/Feature/Mail/SystemMailboxReadinessTest.php new file mode 100644 index 0000000..49173ba --- /dev/null +++ b/tests/Feature/Mail/SystemMailboxReadinessTest.php @@ -0,0 +1,97 @@ +exists()` — ob IRGENDEIN Postfach angelegt ist. Das ist + * nicht die Frage, an der das Senden hängt. + */ +beforeEach(function () { + Settings::set(MailDelivery::SETTING, MailDelivery::DELIVERING); + Mailbox::query()->delete(); + Settings::forget(MailPurpose::settingKey(MailPurpose::SYSTEM)); +}); + +function systemCheckSatisfied(): bool +{ + foreach (DeliveryChecks::all() as $check) { + if ($check->key === 'delivery.mailbox') { + return $check->satisfied; + } + } + + throw new RuntimeException('delivery.mailbox check is gone'); +} + +it('is not satisfied by a mailbox that no purpose points at', function () { + // Genau der gemeldete Zustand: ein Postfach ist angelegt, die Seite ist + // gruen, und jede Systemmail stirbt trotzdem in der Warteschlange. + Mailbox::factory()->create(['key' => 'irgendwas', 'active' => true]); + + expect(systemCheckSatisfied())->toBeFalse(); +}); + +it('is not satisfied when the system purpose points at an inactive mailbox', function () { + $box = Mailbox::factory()->create(['key' => 'system', 'active' => false]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), $box->key); + + expect(systemCheckSatisfied())->toBeFalse(); +}); + +it('is not satisfied when the mailbox authenticates but carries no password', function () { + $box = Mailbox::factory()->create([ + 'key' => 'system', 'active' => true, 'authenticates' => true, 'password' => null, + ]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), $box->key); + + expect(systemCheckSatisfied())->toBeFalse(); +}); + +it('is satisfied by a mailbox the transport would actually use', function () { + $box = Mailbox::factory()->create([ + 'key' => 'system', 'active' => true, 'authenticates' => false, + ]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), $box->key); + + expect(systemCheckSatisfied())->toBeTrue(); +}); + +it('agrees with the transport, which is the whole point', function () { + // Zwei Stellen, die dieselbe Frage verschieden beantworten, sind der Weg, + // auf dem sie auseinanderlaufen — dieses Projekt hat das mehrfach bezahlt. + // Der Test haelt die zwei aneinander, nicht jede an eine eigene Meinung. + Mailbox::factory()->create(['key' => 'irgendwas', 'active' => true]); + + expect(systemCheckSatisfied())->toBeFalse() + ->and((string) new MailboxTransport(MailPurpose::SYSTEM))->toContain('unconfigured'); + + $box = Mailbox::factory()->create([ + 'key' => 'system', 'active' => true, 'authenticates' => false, + ]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), $box->key); + + expect(systemCheckSatisfied())->toBeTrue() + ->and((string) new MailboxTransport(MailPurpose::SYSTEM))->not->toContain('unconfigured'); +}); diff --git a/tests/Feature/Readiness/DeliveryChecksTest.php b/tests/Feature/Readiness/DeliveryChecksTest.php index b3c49c0..e8309e9 100644 --- a/tests/Feature/Readiness/DeliveryChecksTest.php +++ b/tests/Feature/Readiness/DeliveryChecksTest.php @@ -3,10 +3,12 @@ use App\Models\Mailbox; use App\Models\MailTemplate; use App\Models\Operator; +use App\Services\Mail\MailPurpose; use App\Services\Secrets\SecretVault; use App\Support\OperatingMode; use App\Support\Readiness; use App\Support\Readiness\Check; +use App\Support\Settings; function deliveryCheck(string $key): ?Check { @@ -51,7 +53,7 @@ it('accepts a real mailer', function () { expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue(); }); -it('needs at least one mailbox to send from', function () { +it('needs a mailbox the transport would actually send from', function () { // Eine Migration seedet fünf Mailboxen (no-reply, support, billing, // office, info) für jede Installation — auch für diese Suite. Auf den // leeren Zustand zurückgesetzt, den dieser Test eigentlich prüft (siehe @@ -64,7 +66,16 @@ it('needs at least one mailbox to send from', function () { // Kunde schon bezahlt hat. expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse(); - Mailbox::factory()->create(); + // Bis zum 31.7.2026 stand hier `Mailbox::factory()->create()` und danach + // `toBeTrue()`: IRGENDEIN Postfach genügte. Das war der Fehler. Gesendet + // wird über das Postfach, das dem Zweck `system` zugeordnet ist — ein + // angelegtes, aber unverknüpftes ändert daran nichts, und die Seite stand + // grün, während jede „Passwort vergessen"-Mail in der Warteschlange starb. + $box = Mailbox::factory()->create(['key' => 'nicht-verknuepft', 'active' => true]); + + expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse(); + + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), $box->key); expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue(); });