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