diff --git a/docs/superpowers/specs/2026-07-27-mailboxes-design.md b/docs/superpowers/specs/2026-07-27-mailboxes-design.md new file mode 100644 index 0000000..32a5e71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-mailboxes-design.md @@ -0,0 +1,246 @@ +# Spec — Postfächer und Absender + +**Datum:** 2026-07-27 +**Status:** entworfen, noch nicht umgesetzt +**Umfang:** **nur senden.** Empfangen (IMAP, Zuordnung eingehender Antworten) ist +ausdrücklich nicht Teil davon — siehe §10. +**Verwandt:** `docs/superpowers/specs/2026-07-27-operator-identity-design.md` +(eigenes Vorhaben, keine Abhängigkeit in beide Richtungen) + +--- + +## 1. Ziel + +CluPilot verschickt aus mehreren Adressen — `no-reply`, `office`, `support`, +`info`, `billing`. Heute gibt es dafür **eine** Stelle: das Geheimnis +`mail.password` in der Zugangsdaten-Registry, das auf +`mail.mailers.smtp.password` zeigt. Ein einziges Passwort für einen einzigen +Absender. + +Das Vorhaben gibt jedem Postfach einen eigenen Datensatz, macht in der Konsole +einstellbar, welche Mail aus welchem Postfach geht, und stellt sicher, dass eine +Antwort des Kunden dort ankommt, wo sie jemand liest. + +### Warum das nicht in die Registry passt + +`SecretVault::REGISTRY` bildet **einen Schlüssel auf einen Config-Pfad** ab. Ein +Postfach ist Adresse, Anzeigename, Benutzer und Passwort. Fünf Postfächer als +Registry-Einträge wären zwanzig Einzelgeheimnisse mit Namen wie +`mail.support.username` — die Seite wäre voll und trotzdem unbedienbar, und die +Zuordnung „welche Mail aus welchem Postfach" hätte immer noch keinen Ort. + +### Geerbte Regeln + +`SecretVault` hat drei ausformulierte Regeln. Sie gelten hier unverändert: + +1. **Kuratiert, nie beliebig.** Die Liste der *Zwecke* ist fest; die Postfächer + sind Daten, aber ihre Form ist es nicht. +2. **Eigener Schlüssel.** Postfach-Passwörter werden mit `SECRETS_KEY` + verschlüsselt, **nicht** mit `APP_KEY`. Ein APP_KEY-Wechsel ist gewöhnliche + Wartung und darf den Mailversand nicht unbrauchbar machen. +3. **Beim Benutzen lesen, nie beim Booten überlagern.** Ein Overlay kostet auf + jeder Anfrage eine Abfrage und lässt langlaufende Queue-Worker für immer das + halten, was beim Start galt. + +--- + +## 2. Ausgangsbefund + +| Befund | Beleg | +|---|---| +| Genau **ein** Mail-Geheimnis | `SecretVault::REGISTRY` → `mail.password` → `mail.mailers.smtp.password` | +| Ein Server, ein Postfach | `.env`: `MAIL_HOST=mail.thinkidoo.com`, `MAIL_PORT=587`, `MAIL_SCHEME=tls`, `MAIL_USERNAME=no-reply@clupilot.com` | +| Es wird derzeit **nicht** verschickt | `MAIL_MAILER=log` | +| Absender-Vorgabe zeigt ins Leere | `MAIL_FROM_ADDRESS=hello@clupilot.local` — diese Adresse existiert nicht | +| Drei Mails vorhanden, keine setzt `from` | `MaintenanceAnnouncementMail`, `MaintenanceCancelledMail`, `CloudReady` — alle nehmen den globalen Absender | +| Alle Postfächer auf einem Server | entschieden 2026-07-27 | + +> **Nebenbefund:** Das SMTP-Passwort steht im Klartext in `.env`. Das ist bei +> Laravel normal und `.env` ist gitignored — aber sobald es im Vault liegt, +> gehört es dort weg, und ein Wechsel bei der Gelegenheit ist billig. + +--- + +## 3. Datenform + +### Server — einmal, in den Einstellungen + +`mail.host`, `mail.port`, `mail.encryption` über `App\Support\Settings`. Ein +Server, eine Stelle. Wenn sich der Anbieter ändert, ändert sich **eine** Karte, +nicht fünf Datensätze. + +### Tabelle `mailboxes` + +| Spalte | Zweck | +|---|---| +| `id`, `uuid` | Schlüssel; `uuid` für URLs | +| `key` (unique) | `no-reply`, `office`, `support`, `info`, `billing` — der Name, unter dem ein Zweck darauf zeigt | +| `address` | die Absenderadresse | +| `display_name` | „CluPilot Support" | +| `username` | SMTP-Benutzer; leer = `address` | +| `password` | verschlüsselt mit `SECRETS_KEY` | +| `no_reply` (bool) | unterdrückt `Reply-To`, siehe §5 | +| `active` (bool) | ein Postfach abschalten, ohne es zu löschen | +| `last_verified_at` | wann der Testversand zuletzt geklappt hat | +| `timestamps` | | + +`key` ist frei wählbar — die fünf sind Startwerte, keine Schranke. Was **nicht** +frei ist, ist die Liste der Zwecke. + +--- + +## 4. Zuordnung: welche Mail aus welchem Postfach + +Eine kuratierte Liste von Zwecken, je Zweck ein Postfach: + +| Zweck | Was daraus geht | Vorgabe | +|---|---|---| +| `maintenance` | Wartung angekündigt / abgesagt | `no-reply` | +| `provisioning` | Cloud bereit, Bestellbestätigung | `no-reply` | +| `support` | Antworten auf Support-Anfragen | `support` | +| `billing` | Rechnungen, Zahlungserinnerungen | `billing` | +| `system` | alles ohne eigenen Zweck | `no-reply` | + +- Einstellbar in der Konsole, ein Auswahlfeld je Zeile. +- **`system` ist der Rückfall**, und zwar per Definition statt per Schalter: ein + Zweck ohne belegtes Postfach benutzt das Postfach von `system`. Damit gibt es + kein zweites Feld „welches ist das Standardpostfach", das der Zuordnung + widersprechen könnte — der Rückfall *ist* eine Zeile der Zuordnungstabelle und + wird dort gepflegt wie jede andere. +- `system` selbst darf nicht leer bleiben: die Konsole verweigert das Speichern + mit leerem `system`-Postfach, denn das ist der eine Fall, für den es keinen + Rückfall mehr gibt. +- **Die Zweckliste ist Code, nicht Daten.** Ein Zweck ohne Absender wäre sonst + nur zur Laufzeit zu bemerken — an einer Mail, die nicht ankam. + +--- + +## 5. `Reply-To` — das, was Nur-Senden brauchbar macht + +Jede Mail bekommt `From` **und** `Reply-To` auf die Adresse ihres Postfachs. + +Ohne das wäre „nur senden" eine Sackgasse: der Kunde antwortet auf eine +Support-Antwort und die Antwort verschwindet. Mit `Reply-To` landet sie im +echten `support@`-Postfach, das im gewöhnlichen Mailprogramm gelesen wird — das +ist der Grund, warum Empfangen (IMAP) hier **nicht** gebraucht wird. + +**Ausnahme:** Postfächer mit `no_reply = true` bekommen **kein** `Reply-To`. +Ein „no-reply", auf das man antworten kann, ist eine Lüge im Absender. + +--- + +## 6. Versand zur Laufzeit + +- `App\Services\Mail\MailboxResolver` löst *Zweck → Postfach* auf und baut den + Symfony-Transport **beim Versenden**. Nichts wird beim Booten in die Config + geschrieben (Regel 3). +- Die drei vorhandenen Mailables bekommen ihren Zweck: `MaintenanceAnnouncementMail` + und `MaintenanceCancelledMail` → `maintenance`, `CloudReady` → `provisioning`. +- **Auf dev wird weiterhin nur protokolliert.** `MAIL_MAILER=log` bleibt für + gewöhnliche Mails wirksam, damit ein Seeder-Lauf oder ein Test mit echten + Adressen niemandem eine Wartungsankündigung schickt. Entschieden 2026-07-27. + +--- + +## 7. Testversand, der wirklich sendet + +Pro Postfach ein Knopf: echte SMTP-Verbindung mit **diesen** Zugangsdaten, an +eine eingetippte Adresse. + +- **Er geht am `MAIL_MAILER=log` vorbei.** Täte er das nicht, meldete er auf dev + Erfolg, während er in eine Logdatei schreibt — ein Test, der die + Implementierung nachrechnet statt sie zu prüfen. Genau der Fehler aus + Handoff §2.2, nur an anderer Stelle. +- Die Oberfläche sagt, dass wirklich verschickt wird. +- Schlägt es fehl, steht der SMTP-Fehler **wörtlich** da — nicht „Fehler beim + Senden". Bei falschen Zugangsdaten ist der Serversatz die ganze Diagnose. +- Erfolg setzt `last_verified_at`; die Liste zeigt „zuletzt bestätigt". + +> Damit ist die Testknopf-Bedingung der Registry erfüllt: es gibt einen echten +> Test, also ist der Knopf gerechtfertigt. Ein Knopf, der still etwas anderes +> prüft, wäre schlechter als keiner. + +--- + +## 8. Konsole + +- Eigene Seite `admin.mail`, Navigationsgruppe **System** neben Zugangsdaten, + eingetragen in `app/Support/Navigation.php` — der **einen** Quelle. +- Neue Berechtigung **`mail.manage`**, nicht `secrets.manage`: wer den + Support-Absender pflegt, braucht keinen Zugriff auf Stripe- und DNS-Schlüssel. + Rollen: `Owner`, `Admin` bekommen sie; `Support` ebenfalls. +- Aufbau: Server-Karte oben, Postfachliste darunter, Zuordnungstabelle zuletzt. +- **Bearbeiten im Modal (R20).** Die Zuordnungstabelle ist ein einzelnes + Auswahlfeld je Zeile — R20s ausdrückliche Ausnahme, kein Modal. +- Passwörter werden nie wieder angezeigt, gleiche Formulierung wie bei den + Zugangsdaten („Wird verschlüsselt gespeichert und nie wieder vollständig + angezeigt."). +- Alle Zeiten über `->local()` (R19), Icons neben dem Text (R18). + +--- + +## 9. Übernahme + +1. Die fünf Postfächer werden als Zeilen angelegt — leer, aber sichtbar, damit + die Seite zeigt, was erwartet wird. +2. Die vorhandenen `MAIL_*` aus `.env` füllen die Server-Karte und das + `no-reply`-Postfach. Es bricht nichts. +3. **`mail.password` verlässt die Registry.** Ein gespeicherter Wert wandert + vorher ins `no-reply`-Postfach. Die Karte verschwindet, weil sie **ersetzt** + ist — nicht, weil sie versteckt wird. +4. `MAIL_FROM_ADDRESS`/`MAIL_FROM_NAME` verlieren ihre Rolle; das Standardpostfach + übernimmt sie. Die Werte bleiben als Rückfall stehen, solange kein Postfach + eingerichtet ist. + +--- + +## 10. Abgrenzung + +- **Kein Empfangen.** Kein IMAP, keine Zuordnung eingehender Antworten, keine + Anhänge. `Reply-To` (§5) macht das für den Support-Fall entbehrlich. +- **Keine E-Mail-Vorlagen.** Bestellbestätigung und Störungsmeldung sind im + Handoff §3.2 vereinbart, brauchen aber erst diesen Unterbau. Eigenes Vorhaben. +- **Keine Support-Warteschlange.** Sie wird durch dieses Vorhaben *möglich* + (Handoff §3.1, Punkt 2), ist aber nicht darin enthalten. + +--- + +## 11. Tests + +- Zweck → Postfach, inklusive Rückfall auf das Standardpostfach. +- Passwort ist mit `SECRETS_KEY` verschlüsselt, **nicht** mit `APP_KEY`; ein + APP_KEY-Wechsel lässt den Versand unberührt. +- Ein gespeichertes Passwort wird nie an die Oberfläche zurückgegeben. +- `system` lässt sich nicht leer speichern; jeder andere Zweck darf leer bleiben + und fällt dann auf `system` zurück. +- `Reply-To` gesetzt für `support`, **fehlt** bei `no_reply = true`. +- Die drei vorhandenen Mailables tragen ihren Zweck und den richtigen Absender. +- Testversand benutzt den Transport des Postfachs, **nicht** den Log-Mailer — + geprüft am Transport, nicht am Rückgabewert. +- Gewöhnlicher Versand respektiert `MAIL_MAILER=log` auf dev. +- Nichts wird beim Booten in `config('mail')` geschrieben (Regel 3). +- `mail.manage` schützt die Seite; `secrets.manage` allein genügt nicht. +- R18/R19/R20 auf der neuen Seite. + +--- + +## 12. Reihenfolge + +| Phase | Inhalt | +|---|---| +| 1 | Tabelle, Modell, Verschlüsselung, Übernahme aus `.env`, `mail.password` aus der Registry | +| 2 | `MailboxResolver`, Zwecke, `Reply-To`, die drei Mailables umstellen | +| 3 | Konsolen-Seite, `mail.manage`, Navigation, Bearbeiten im Modal | +| 4 | Testversand am Log-Mailer vorbei, `last_verified_at`, wörtliche Fehlermeldung | + +--- + +## 13. Risiken + +| Risiko | Gegenmaßnahme | +|---|---| +| Ein falsch gesetzter Absender fällt erst auf, wenn eine Mail nicht ankommt | Zweckliste ist Code; ein unbelegter Zweck fällt auf das Standardpostfach zurück statt zu scheitern; Testversand pro Postfach | +| Queue-Worker halten alte Zugangsdaten | Auflösung beim Versenden, nicht beim Booten (Regel 3) — als Test festgeschrieben | +| `SECRETS_KEY` fehlt | Der Vault behandelt das bereits: Speichern ist dann abgeschaltet und die Konsole sagt es, statt Zugangsdaten im Klartext abzulegen. Gleiches Verhalten für Postfächer. | +| Versehentlicher Echtversand auf dev | `MAIL_MAILER=log` bleibt wirksam; nur der Testknopf sendet, und er sagt es | +| Vorhandenes SMTP-Passwort liegt im Klartext in `.env` | Nach der Übernahme aus `.env` entfernen; Passwortwechsel empfohlen |