Design mailboxes as records, not as one more secret

The console offers a single mail.password pointing at one SMTP mailbox,
which is nowhere near enough for five sending addresses. A mailbox is
address, display name, username and password; five of them as registry
entries would be twenty secrets named mail.support.username, and the
question of which mail sends from which address would still have no home.

Send-only stays usable because every message carries Reply-To on its own
mailbox, so a customer's reply lands in a mailbox someone reads. That is
what makes IMAP unnecessary rather than merely deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/mailboxes
nexxo 2026-07-27 20:51:03 +02:00
parent edaceb9597
commit 743ba96d13
1 changed files with 246 additions and 0 deletions

View File

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