Bereitschaft: das Absender-Postfach wird gefragt, ob es senden KANN

main
nexxo 2026-07-31 20:35:58 +02:00
parent 82c6d62057
commit 19ed6461cf
6 changed files with 152 additions and 8 deletions

View File

@ -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;

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -0,0 +1,97 @@
<?php
use App\Mail\Transport\MailboxTransport;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\MailDelivery;
use App\Support\Readiness\DeliveryChecks;
use App\Support\Settings;
/**
* Warum die „Passwort vergessen"-Mail nicht ankam, obwohl die Zustellung
* eingeschaltet war.
*
* Die Kette, im Code nachgelesen:
*
* User::sendPasswordResetNotification ResetPasswordMail (ShouldQueue)
* Warteschlange MailboxTransport('system')
* MailboxResolver::for('system') liefert null
* delegate() WIRFT „No configured mailbox for mail purpose [system]"
* der Auftrag scheitert und landet in `failed_jobs`
*
* Und niemand erfährt davon: `RecordSentMail` hört auf `MessageSent`, also auf
* das, was den Server VERLASSEN hat ein Wurf davor hinterlässt keine Zeile im
* Postausgang. Das Portal hat dem Kunden längst „sehen Sie in Ihr Postfach"
* gesagt.
*
* Die Bereitschaftsseite hätte das melden müssen und tat es nicht: sie fragte
* `Mailbox::query()->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');
});

View File

@ -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();
});