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
parent
edaceb9597
commit
743ba96d13
|
|
@ -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 |
|
||||||
Loading…
Reference in New Issue