76 KiB
Postfächer und Absender — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: CluPilot verschickt aus mehreren gepflegten Postfächern (no-reply, office, support, info, billing) statt aus einem einzigen SMTP-Zugang, und in der Konsole ist einstellbar, welche Mail aus welchem Postfach geht.
Architecture: Der SMTP-Server steht einmal in app_settings; jedes Postfach ist eine Zeile in mailboxes mit einem Passwort, das über denselben SECRETS_KEY verschlüsselt wird wie die Zugangsdaten. Eine feste Liste von Zwecken zeigt auf Postfächer; aufgelöst wird beim Versenden durch einen eigenen Symfony-Transport, nie beim Booten — sonst hielte ein langlaufender Queue-Worker für immer die Zugangsdaten, die bei seinem Start galten.
Tech Stack: Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, MariaDB 11.4, Redis, wire-elements/modal, Tailwind v4. Docker-first.
Global Constraints
- Alle Befehle laufen im Container:
docker compose exec -T app <cmd>. Nie auf dem Host. - R1/R2: Seiten sind klassenbasierte Livewire-Vollseiten-Komponenten. Kein Volt.
- R13: Pfade und Route-Namen englisch. Oberflächentexte deutsch, ausschließlich über
lang/de/*. - R18: Icons neben dem Text,
size-4in Buttons/Tabellen,size-5in der Navigation. Icons in<x-ui.nav-item>gehören in<x-slot:icon>. - R19: Jede angezeigte Zeit geht durch
->local(). Gespeichert wird UTC. Nie->timezone(config('app.timezone')). - R20: Bearbeiten mit Eingabefeldern passiert im Modal (
$dispatch('openModal', …)), nie in der Tabellenzeile. Ausnahme: ein einzelnes<select>in der Zeile. - Verschlüsselung: Postfach-Passwörter mit
SECRETS_KEY, niemalsAPP_KEY. Begründung steht inSecretVault: APP_KEY wird routinemäßig gewechselt. - Nie beim Booten in
config('mail')schreiben. Auflösung geschieht im Transport, also zum Sendezeitpunkt. - Verifikation nach jeder Aufgabe:
docker compose exec -T app php artisan testmuss vollständig grün sein (Ausgangsstand: 637 Tests grün). - Die
// pfad/zur/datei.php-Zeile in den Codeblöcken dieses Plans ist eine Annotation, kein Code. Sie sagt, wohin die Datei gehört. Die Repo-Konvention ist<?php, Leerzeile,namespace— genau wie in jeder bestehenden Datei. Ein Kommentar direkt hinter<?phpverstößt außerdem gegen Pintsblank_line_after_opening_tag. - Blade-Anonymkomponenten liegen unter
resources/views/components/. - Tailwind-Namenskollisionen vermeiden (
.ring,.numsind bereits einmal kollidiert — siehe Handoff §2.1).
File Structure
| Datei | Verantwortung |
|---|---|
app/Services/Secrets/SecretCipher.php |
neu — der SECRETS_KEY-Encrypter, herausgezogen aus SecretVault, damit ihn Vault und Postfächer benutzen ohne die base64/32-Byte-Logik zu duplizieren |
app/Services/Secrets/SecretVault.php |
benutzt SecretCipher; verliert den Registry-Eintrag mail.password |
database/migrations/..._create_mailboxes_table.php |
neu — Tabelle |
app/Models/Mailbox.php |
neu — Datensatz, Ver-/Entschlüsselung des Passworts |
database/factories/MailboxFactory.php |
neu — für Tests |
app/Services/Mail/MailPurpose.php |
neu — die kuratierte Zweckliste, Code statt Daten |
app/Services/Mail/MailboxResolver.php |
neu — Zweck → Postfach, mit Rückfall auf system |
app/Mail/Transport/MailboxTransport.php |
neu — löst beim Senden auf, respektiert MAIL_MAILER=log |
app/Mail/Concerns/SendsFromMailbox.php |
neu — From + Reply-To aus dem Postfach |
app/Services/Mail/MailboxTester.php |
neu — Testversand am Log-Mailer vorbei |
app/Livewire/Admin/Mail.php |
neu — Konsolenseite |
app/Livewire/EditMailbox.php |
neu — Bearbeiten-Modal (R20) |
resources/views/livewire/admin/mail.blade.php |
neu — Ansicht |
resources/views/livewire/edit-mailbox.blade.php |
neu — Modalinhalt |
lang/de/mail_settings.php, lang/en/mail_settings.php |
neu — Texte |
config/mail.php |
fünf statische Mailer-Einträge mit transport => mailbox |
app/Providers/AppServiceProvider.php |
registriert den mailbox-Transport |
app/Support/Navigation.php |
Eintrag admin.mail in der Gruppe System |
routes/admin.php |
Route mail |
app/Mail/MaintenanceAnnouncementMail.php, MaintenanceCancelledMail.php, app/Notifications/CloudReady.php |
bekommen ihren Zweck |
Task 1: Den Encrypter herausziehen
Der SECRETS_KEY-Encrypter steckt privat in SecretVault und enthält Logik mit
dokumentierter Fehlerhistorie (base64 mit und ohne base64:-Präfix, 44 Bytes
gegen 32). Diese Logik ein zweites Mal zu schreiben wäre der teuerste denkbare
Fehler in diesem Plan.
Files:
- Create:
app/Services/Secrets/SecretCipher.php - Modify:
app/Services/Secrets/SecretVault.php(privateencrypter()entfällt) - Test:
tests/Feature/Admin/SecretCipherTest.php
Interfaces:
-
Produces:
SecretCipher::encrypt(string $plain): string,SecretCipher::decrypt(string $cipher): string,SecretCipher::isUsable(): bool -
Step 1: Write the failing test
<?php
// tests/Feature/Admin/SecretCipherTest.php
use App\Services\Secrets\SecretCipher;
it('round-trips a value with a raw base64 key', function () {
config()->set('admin_access.secrets_key', base64_encode(random_bytes(32)));
$cipher = app(SecretCipher::class);
expect($cipher->decrypt($cipher->encrypt('hunter2')))->toBe('hunter2');
});
it('accepts the base64: prefix as well, because the documented generator omits it', function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
$cipher = app(SecretCipher::class);
expect($cipher->decrypt($cipher->encrypt('hunter2')))->toBe('hunter2');
});
it('refuses rather than silently falling back to APP_KEY', function () {
config()->set('admin_access.secrets_key', '');
expect(fn () => app(SecretCipher::class)->encrypt('x'))
->toThrow(RuntimeException::class, 'SECRETS_KEY');
expect(app(SecretCipher::class)->isUsable())->toBeFalse();
});
it('refuses a key that is not 32 bytes', function () {
config()->set('admin_access.secrets_key', 'zu-kurz');
expect(fn () => app(SecretCipher::class)->encrypt('x'))
->toThrow(RuntimeException::class, '32 bytes');
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=SecretCipherTest
Expected: FAIL — Class "App\Services\Secrets\SecretCipher" not found
- Step 3: Write the implementation
<?php
// app/Services/Secrets/SecretCipher.php
namespace App\Services\Secrets;
use Illuminate\Encryption\Encrypter;
use RuntimeException;
/**
* The SECRETS_KEY encrypter, in one place.
*
* Its own key rather than APP_KEY, because rotating APP_KEY is ordinary
* maintenance and would otherwise render every stored credential unreadable —
* discovered when Stripe stops answering, not when it happens.
*
* Extracted from SecretVault so mailbox passwords use the SAME key handling
* rather than a second copy of it. The copy is what would rot: the two
* spellings below exist because requiring the `base64:` prefix once made every
* read and write fail on exactly the setup the config comment documents.
*/
final class SecretCipher
{
public function encrypt(string $plain): string
{
return $this->encrypter()->encryptString($plain);
}
public function decrypt(string $cipher): string
{
return $this->encrypter()->decryptString($cipher);
}
/** Is storing credentials possible at all on this installation? */
public function isUsable(): bool
{
return (string) config('admin_access.secrets_key') !== '';
}
private function encrypter(): Encrypter
{
$key = (string) config('admin_access.secrets_key');
if ($key === '') {
throw new RuntimeException('SECRETS_KEY is not set — refusing to store or read credentials.');
}
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
if (strlen($key) !== 32) {
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === 32) {
$key = $decoded;
}
}
if (strlen($key) !== 32) {
throw new RuntimeException('SECRETS_KEY must be 32 bytes (or base64 of 32 bytes).');
}
return new Encrypter($key, 'aes-256-cbc');
}
}
- Step 4: Point SecretVault at it
In app/Services/Secrets/SecretVault.php: delete the private encrypter()
method entirely, delete the now-unused use Illuminate\Encryption\Encrypter;,
and replace its three call sites:
// in get()
return app(SecretCipher::class)->decrypt($row->value);
// in put()
'value' => app(SecretCipher::class)->encrypt($value),
// isUsable() becomes a delegation
public function isUsable(): bool
{
return app(SecretCipher::class)->isUsable();
}
- Step 5: Run the full suite
Run: docker compose exec -T app php artisan test
Expected: PASS — all previous tests plus the four new ones. SecretVaultTest
must still pass unchanged; it is the proof the extraction changed no behaviour.
- Step 6: Commit
git add app/Services/Secrets/SecretCipher.php app/Services/Secrets/SecretVault.php tests/Feature/Admin/SecretCipherTest.php
git commit -m "Pull the secrets encrypter out where two callers can share it"
Task 2: Tabelle und Modell
Files:
- Create:
database/migrations/2026_07_28_090000_create_mailboxes_table.php - Create:
app/Models/Mailbox.php - Create:
database/factories/MailboxFactory.php - Test:
tests/Feature/Mail/MailboxModelTest.php
Interfaces:
-
Consumes:
SecretCipher(Task 1) -
Produces:
Mailboxmodel with$mailbox->password(plaintext accessor, ciphertext column),Mailbox::findByKey(string $key): ?Mailbox -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailboxModelTest.php
use App\Models\Mailbox;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('stores the password encrypted and never in the clear', function () {
$box = Mailbox::factory()->create(['key' => 'support', 'password' => 'sehr-geheim']);
$stored = DB::table('mailboxes')->where('id', $box->id)->value('password');
expect($stored)->not->toContain('sehr-geheim')
->and($box->fresh()->password)->toBe('sehr-geheim');
});
it('uses SECRETS_KEY, not APP_KEY — rotating APP_KEY leaves mail working', function () {
$box = Mailbox::factory()->create(['password' => 'bleibt-lesbar']);
config()->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
expect($box->fresh()->password)->toBe('bleibt-lesbar');
});
it('falls back to the address when no separate username is given', function () {
$box = Mailbox::factory()->create([
'address' => 'support@clupilot.com',
'username' => null,
]);
expect($box->smtpUsername())->toBe('support@clupilot.com');
});
it('finds a mailbox by its key', function () {
Mailbox::factory()->create(['key' => 'billing']);
expect(Mailbox::findByKey('billing'))->not->toBeNull()
->and(Mailbox::findByKey('gibtsnicht'))->toBeNull();
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailboxModelTest
Expected: FAIL — Class "App\Models\Mailbox" not found
- Step 3: Write the migration
<?php
// database/migrations/2026_07_28_090000_create_mailboxes_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The addresses CluPilot sends from.
*
* Previously there was one: the secret `mail.password`, pointing at
* mail.mailers.smtp.password. One password, one sender, and no way to say that
* an invoice comes from billing@ while a maintenance notice comes from
* no-reply@.
*
* The SERVER is not in here. All mailboxes live on one host, so it sits once in
* app_settings — a provider change is then one card, not five rows.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('mailboxes', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// The name a purpose points at. Free-form: the five seeded ones are
// a starting point, not a ceiling.
$table->string('key', 48)->unique();
$table->string('address');
$table->string('display_name')->nullable();
// Null means "same as address", which is the usual case and saves
// typing the address twice.
$table->string('username')->nullable();
// Ciphertext under SECRETS_KEY. Nullable so a mailbox can exist on
// the page before anyone has typed its password.
$table->text('password')->nullable();
// Suppresses Reply-To. A "no-reply" you can reply to is a lie in
// the sender.
$table->boolean('no_reply')->default(false);
$table->boolean('active')->default(true);
// When the test send last succeeded. Null is honest: it means
// nobody has proven these credentials work, not that they are bad.
$table->timestamp('last_verified_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('mailboxes');
}
};
- Step 4: Write the model and factory
<?php
// app/Models/Mailbox.php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Secrets\SecretCipher;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* One sending address.
*
* The password is stored under SECRETS_KEY rather than APP_KEY, and therefore
* not through Laravel's `encrypted` cast — that cast uses APP_KEY, which is
* rotated as ordinary maintenance and would take every mailbox with it.
*/
class Mailbox extends Model
{
// HasUuid assigns the uuid on create AND makes it the route key — R11:
// URLs address records by UUID, not by integer primary key. Seventeen
// models already use it; do not hand-roll a booted() replacement.
use HasFactory, HasUuid;
protected $fillable = [
'key', 'address', 'display_name', 'username',
'password', 'no_reply', 'active', 'last_verified_at',
];
protected function casts(): array
{
return [
'no_reply' => 'boolean',
'active' => 'boolean',
'last_verified_at' => 'datetime',
];
}
public static function findByKey(string $key): ?self
{
return static::query()->where('key', $key)->first();
}
/** Plaintext in, ciphertext to the column. */
public function setPasswordAttribute(?string $value): void
{
$this->attributes['password'] = ($value === null || $value === '')
? null
: app(SecretCipher::class)->encrypt($value);
}
/** Ciphertext from the column, plaintext out. */
public function getPasswordAttribute(?string $value): ?string
{
return ($value === null || $value === '')
? null
: app(SecretCipher::class)->decrypt($value);
}
/** The SMTP user: an explicit one, or the address itself. */
public function smtpUsername(): string
{
return $this->username !== null && $this->username !== ''
? $this->username
: $this->address;
}
/** Enough to send with: an address and a password. */
public function isConfigured(): bool
{
return $this->active && $this->address !== '' && $this->getRawOriginal('password') !== null;
}
}
<?php
// database/factories/MailboxFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class MailboxFactory extends Factory
{
public function definition(): array
{
return [
'key' => $this->faker->unique()->word(),
'address' => $this->faker->unique()->safeEmail(),
'display_name' => 'CluPilot',
'username' => null,
'password' => 'test-passwort',
'no_reply' => false,
'active' => true,
];
}
}
- Step 5: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailboxModelTest
Expected: PASS — four tests.
- Step 6: Commit
git add database/migrations/2026_07_28_090000_create_mailboxes_table.php app/Models/Mailbox.php database/factories/MailboxFactory.php tests/Feature/Mail/MailboxModelTest.php
git commit -m "Give every sending address a record of its own"
Task 3: Zwecke und Auflösung
Files:
- Create:
app/Services/Mail/MailPurpose.php - Create:
app/Services/Mail/MailboxResolver.php - Test:
tests/Feature/Mail/MailboxResolverTest.php
Interfaces:
-
Consumes:
Mailbox(Task 2),App\Support\Settings -
Produces:
MailPurpose::ALL(array of purpose strings),MailPurpose::MAINTENANCE|PROVISIONING|SUPPORT|BILLING|SYSTEMconstants,MailboxResolver::for(string $purpose): ?Mailbox -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailboxResolverTest.php
use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('resolves a purpose to the mailbox it points at', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set('mail.purpose.support', 'support');
expect(app(MailboxResolver::class)->for(MailPurpose::SUPPORT)?->address)
->toBe('support@clupilot.com');
});
it('falls back to system when a purpose has no mailbox', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com']);
Settings::set('mail.purpose.system', 'no-reply');
// billing deliberately unset
expect(app(MailboxResolver::class)->for(MailPurpose::BILLING)?->address)
->toBe('no-reply@clupilot.com');
});
it('falls back to system when the named mailbox no longer exists', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com']);
Settings::set('mail.purpose.system', 'no-reply');
Settings::set('mail.purpose.billing', 'geloescht');
expect(app(MailboxResolver::class)->for(MailPurpose::BILLING)?->address)
->toBe('no-reply@clupilot.com');
});
it('returns null rather than guessing when even system is unset', function () {
expect(app(MailboxResolver::class)->for(MailPurpose::SYSTEM))->toBeNull();
});
it('rejects a purpose that is not in the curated list', function () {
expect(fn () => app(MailboxResolver::class)->for('erfunden'))
->toThrow(InvalidArgumentException::class, 'erfunden');
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailboxResolverTest
Expected: FAIL — Class "App\Services\Mail\MailPurpose" not found
- Step 3: Write the implementation
<?php
// app/Services/Mail/MailPurpose.php
namespace App\Services\Mail;
/**
* What CluPilot sends, as a fixed list.
*
* Code and not data, for the same reason SecretVault::REGISTRY is curated: a
* mapping that can name anything cannot be checked, and a purpose without a
* sender would only be noticed at runtime — as a mail that did not arrive.
*
* SYSTEM is the fallback. Not a separate "which is the default" switch, which
* could contradict the mapping; it is a row of the same table and is edited
* like any other.
*/
final class MailPurpose
{
public const MAINTENANCE = 'maintenance';
public const PROVISIONING = 'provisioning';
public const SUPPORT = 'support';
public const BILLING = 'billing';
public const SYSTEM = 'system';
/** @var array<int, string> */
public const ALL = [
self::MAINTENANCE,
self::PROVISIONING,
self::SUPPORT,
self::BILLING,
self::SYSTEM,
];
/** The settings key holding the mailbox key for a purpose. */
public static function settingKey(string $purpose): string
{
return 'mail.purpose.'.$purpose;
}
}
<?php
// app/Services/Mail/MailboxResolver.php
namespace App\Services\Mail;
use App\Models\Mailbox;
use App\Support\Settings;
use InvalidArgumentException;
/**
* Which mailbox a given kind of mail goes out from.
*
* Read on every resolve rather than cached in a property: a queue worker lives
* for hours, and a mapping cached at construction would keep sending from the
* address that was configured when the worker started.
*/
final class MailboxResolver
{
public function for(string $purpose): ?Mailbox
{
if (! in_array($purpose, MailPurpose::ALL, true)) {
throw new InvalidArgumentException("Unknown mail purpose [{$purpose}].");
}
$box = $this->named(Settings::get(MailPurpose::settingKey($purpose)));
if ($box !== null) {
return $box;
}
// Fall back rather than fail: a mail that goes out from the wrong
// address is recoverable, one that is never sent may not be noticed.
return $purpose === MailPurpose::SYSTEM
? null
: $this->named(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)));
}
private function named(mixed $key): ?Mailbox
{
return is_string($key) && $key !== '' ? Mailbox::findByKey($key) : null;
}
}
- Step 4: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailboxResolverTest
Expected: PASS — five tests.
- Step 5: Commit
git add app/Services/Mail/ tests/Feature/Mail/MailboxResolverTest.php
git commit -m "Say which kind of mail leaves from which mailbox"
Task 4: Der Transport, der beim Senden auflöst
Das ist die Aufgabe, an der Regel 3 hängt. Die fünf Mailer-Einträge in
config/mail.php sind statisch — sie nennen nur einen Zweck. Erst wenn der
Mailer benutzt wird, schaut der Transport in der Datenbank nach.
Files:
- Create:
app/Mail/Transport/MailboxTransport.php - Modify:
config/mail.php(fünf Einträge inmailers) - Modify:
app/Providers/AppServiceProvider.php(Transport registrieren) - Test:
tests/Feature/Mail/MailboxTransportTest.php
Interfaces:
-
Consumes:
MailboxResolver(Task 3) -
Produces: Mailer-Namen
cp_maintenance,cp_provisioning,cp_support,cp_billing,cp_system -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailboxTransportTest.php
use App\Mail\Transport\MailboxTransport;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
Settings::set('mail.host', 'mail.example.test');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
});
it('registers a mailer per purpose without touching the database at boot', function () {
// The five entries exist as plain config — no query has run to build them.
foreach (MailPurpose::ALL as $purpose) {
expect(config('mail.mailers.cp_'.$purpose))
->toMatchArray(['transport' => 'mailbox', 'purpose' => $purpose]);
}
});
it('builds the transport from the mailbox that is current at send time', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp'); // delivery on, as on live
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect($transport)->toBeInstanceOf(MailboxTransport::class)
->and((string) $transport)->toContain('support@clupilot.com');
});
it('logs instead of delivering while MAIL_MAILER is log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'log');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
// Still our transport — but it hands the message to the log, so a seeder
// run cannot mail a real customer from a development machine.
expect((string) $transport)->toContain('log');
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailboxTransportTest
Expected: FAIL — config('mail.mailers.cp_maintenance') is null
- Step 3: Add the five static mailer entries
In config/mail.php, inside the 'mailers' => [ array, after the 'smtp'
entry:
/*
| One mailer per purpose. Deliberately STATIC: they name a purpose and
| nothing else, so building this config reads no database. Which
| mailbox a purpose resolves to is decided by MailboxTransport at send
| time — see App\Services\Secrets\SecretVault rule 3, which this
| follows: an overlay at boot costs a query on every request and leaves
| long-running queue workers holding whatever was true when they started.
*/
'cp_maintenance' => ['transport' => 'mailbox', 'purpose' => 'maintenance'],
'cp_provisioning' => ['transport' => 'mailbox', 'purpose' => 'provisioning'],
'cp_support' => ['transport' => 'mailbox', 'purpose' => 'support'],
'cp_billing' => ['transport' => 'mailbox', 'purpose' => 'billing'],
'cp_system' => ['transport' => 'mailbox', 'purpose' => 'system'],
- Step 4: Write the transport
<?php
// app/Mail/Transport/MailboxTransport.php
namespace App\Mail\Transport;
use App\Services\Mail\MailboxResolver;
use App\Support\Settings;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\NullTransport;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Mime\RawMessage;
/**
* Resolves its credentials when it sends, not when it is built.
*
* The mails are queued. A worker builds its mailer once and lives for hours, so
* anything decided in a constructor is decided for the rest of that worker's
* life — including a password the operator has since corrected in the console.
*
* The delegate is cached against a FINGERPRINT of the credentials rather than
* rebuilt per message: correct when they change, and no new SMTP connection per
* mail when they do not.
*/
class MailboxTransport implements TransportInterface
{
private ?TransportInterface $delegate = null;
private ?string $fingerprint = null;
public function __construct(private readonly string $purpose) {}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
return $this->delegate()->send($message, $envelope);
}
public function __toString(): string
{
return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured');
}
/** What this transport currently points at — used by __toString and tests. */
private function describe(): ?string
{
if ($this->isLogging()) {
return 'log';
}
return app(MailboxResolver::class)->for($this->purpose)?->address;
}
/**
* Delivery is off wherever the default mailer is the log.
*
* This is what keeps a development machine from mailing real customers: the
* mailables name a purpose, so without this check they would bypass
* MAIL_MAILER=log entirely and send for real.
*/
private function isLogging(): bool
{
return config('mail.default') === 'log';
}
private function delegate(): TransportInterface
{
if ($this->isLogging()) {
return new LogTransport(Log::channel(config('mail.mailers.log.channel')));
}
$box = app(MailboxResolver::class)->for($this->purpose);
if ($box === null || ! $box->isConfigured()) {
// Loud, not silent: a mail with no mailbox behind it must not look
// like it was sent.
throw new \RuntimeException(
"No configured mailbox for mail purpose [{$this->purpose}]."
);
}
$host = (string) Settings::get('mail.host', '');
$port = (int) Settings::get('mail.port', 587);
$encryption = (string) Settings::get('mail.encryption', 'tls');
$fingerprint = md5(implode('|', [
$host, $port, $encryption, $box->smtpUsername(), (string) $box->password,
]));
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
// The third argument is IMPLICIT TLS (smtps, port 465) — not
// STARTTLS. EsmtpTransport negotiates STARTTLS by itself on a
// plain connection, which is what port 587 wants. Passing true for
// 'tls' here would open an SSL socket against a server expecting
// STARTTLS and hang.
$transport = new EsmtpTransport($host, $port, $encryption === 'ssl' || $port === 465);
$transport->setUsername($box->smtpUsername());
$transport->setPassword((string) $box->password);
$this->delegate = $transport;
$this->fingerprint = $fingerprint;
}
return $this->delegate;
}
}
- Step 5: Register the transport
In app/Providers/AppServiceProvider.php, inside boot():
// Registered as a DRIVER, not as configuration: the closure runs when a
// mailer is first resolved — inside the queue worker, at send time —
// so nothing here reads the database while the application boots.
Mail::extend('mailbox', fn (array $config) => new \App\Mail\Transport\MailboxTransport($config['purpose']));
Add use Illuminate\Support\Facades\Mail; to the imports if it is not there.
- Step 6: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailboxTransportTest
Expected: PASS — three tests.
- Step 7: Commit
git add app/Mail/Transport/MailboxTransport.php config/mail.php app/Providers/AppServiceProvider.php tests/Feature/Mail/MailboxTransportTest.php
git commit -m "Resolve the sending mailbox when the mail goes out, not at boot"
Task 5: Absender, Reply-To und die drei vorhandenen Mails
Files:
- Create:
app/Mail/Concerns/SendsFromMailbox.php - Modify:
app/Mail/MaintenanceAnnouncementMail.php,app/Mail/MaintenanceCancelledMail.php - Modify:
app/Notifications/CloudReady.php - Test:
tests/Feature/Mail/SenderAddressTest.php
Interfaces:
-
Consumes:
MailboxResolver(Task 3), Mailer-Namen (Task 4) -
Produces:
SendsFromMailbox::mailboxEnvelope(string $purpose, string $subject): Envelope -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/SenderAddressTest.php
use App\Mail\MaintenanceAnnouncementMail;
use App\Models\Customer;
use App\Models\Mailbox;
use App\Models\MaintenanceWindow;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('sends from the mailbox its purpose points at', function () {
Mailbox::factory()->create([
'key' => 'no-reply',
'address' => 'no-reply@clupilot.com',
'display_name' => 'CluPilot',
'no_reply' => true,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply');
$mail = new MaintenanceAnnouncementMail(
MaintenanceWindow::factory()->create(),
Customer::factory()->create(),
);
$envelope = $mail->envelope();
expect($envelope->from->address)->toBe('no-reply@clupilot.com')
->and($envelope->from->name)->toBe('CluPilot');
});
it('omits Reply-To on a no-reply mailbox, because that is what the name promises', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'no_reply' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply');
$envelope = (new MaintenanceAnnouncementMail(
MaintenanceWindow::factory()->create(),
Customer::factory()->create(),
))->envelope();
expect($envelope->replyTo)->toBeEmpty();
});
it('sets Reply-To on an answerable mailbox, so a customer reply reaches a human', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'no_reply' => false]);
Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'support');
$envelope = (new MaintenanceAnnouncementMail(
MaintenanceWindow::factory()->create(),
Customer::factory()->create(),
))->envelope();
expect($envelope->replyTo[0]->address)->toBe('support@clupilot.com');
});
it('names the purpose mailer so the queue worker picks the right transport', function () {
$mail = new MaintenanceAnnouncementMail(
MaintenanceWindow::factory()->create(),
Customer::factory()->create(),
);
expect($mail->mailer)->toBe('cp_maintenance');
});
it('gives the cancellation mail the same sender as the announcement', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'no_reply' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply');
$mail = new App\Mail\MaintenanceCancelledMail(
MaintenanceWindow::factory()->create(),
Customer::factory()->create(),
);
expect($mail->envelope()->from->address)->toBe('no-reply@clupilot.com')
->and($mail->mailer)->toBe('cp_maintenance');
});
it('sends the provisioning notification from the provisioning mailbox', function () {
Mailbox::factory()->create([
'key' => 'no-reply',
'address' => 'no-reply@clupilot.com',
'display_name' => 'CluPilot',
'no_reply' => true,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::PROVISIONING), 'no-reply');
$notification = new App\Notifications\CloudReady(
App\Models\Instance::factory()->create(),
'admin',
Illuminate\Support\Facades\Crypt::encryptString('geheim'),
);
$message = $notification->toMail(new stdClass);
expect($message->from[0])->toBe('no-reply@clupilot.com')
// no_reply is set, so no Reply-To — the same promise as everywhere else.
->and($message->replyTo)->toBeEmpty();
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=SenderAddressTest
Expected: FAIL — $envelope->from is null
- Step 3: Write the trait
<?php
// app/Mail/Concerns/SendsFromMailbox.php
namespace App\Mail\Concerns;
use App\Services\Mail\MailboxResolver;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
/**
* From and Reply-To, taken from the mailbox behind a purpose.
*
* Reply-To is the whole reason sending alone is enough: a customer answering a
* support reply lands in the support mailbox, read in an ordinary mail client,
* so no IMAP is needed to close the loop.
*
* Except on a no-reply mailbox. A "no-reply" address you can reply to is a lie
* in the sender, and the one place the promise is made is the address itself.
*/
trait SendsFromMailbox
{
protected function mailboxEnvelope(string $purpose, string $subject): Envelope
{
$box = app(MailboxResolver::class)->for($purpose);
if ($box === null) {
// No mailbox configured yet — fall back to the framework default so
// an installation mid-setup still sends rather than crashing.
return new Envelope(subject: $subject);
}
return new Envelope(
from: new Address($box->address, $box->display_name ?: null),
replyTo: $box->no_reply ? [] : [new Address($box->address, $box->display_name ?: null)],
subject: $subject,
);
}
}
- Step 4: Wire the two mailables
In app/Mail/MaintenanceAnnouncementMail.php:
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels, SendsFromMailbox;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {
// Names the mailer, which is all that is serialised into the queue —
// the credentials behind it are resolved when the worker sends.
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_subject', ['title' => $this->window->title]),
);
}
And in app/Mail/MaintenanceCancelledMail.php — the same shape, its own
subject key:
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels, SendsFromMailbox;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_cancel_subject', ['title' => $this->window->title]),
);
}
Leave the existing headers() and content() methods untouched in both files.
- Step 5: Wire the notification
CloudReady builds a MailMessage, not an Envelope. In
app/Notifications/CloudReady.php, inside toMail(), before the ->subject(…)
chain:
$box = app(\App\Services\Mail\MailboxResolver::class)
->for(\App\Services\Mail\MailPurpose::PROVISIONING);
$message = (new MailMessage)->mailer('cp_'.\App\Services\Mail\MailPurpose::PROVISIONING);
if ($box !== null) {
$message->from($box->address, $box->display_name ?: null);
if (! $box->no_reply) {
$message->replyTo($box->address, $box->display_name ?: null);
}
}
return $message
->subject(__('provisioning.mail.ready_subject'))
// … the existing chain continues unchanged
- Step 6: Run the tests
Run: docker compose exec -T app php artisan test --filter=SenderAddressTest
Expected: PASS — four tests.
Then the full suite: docker compose exec -T app php artisan test
Expected: PASS — existing maintenance-mail tests must still pass.
- Step 7: Commit
git add app/Mail/ app/Notifications/CloudReady.php tests/Feature/Mail/SenderAddressTest.php
git commit -m "Put a real sender on every mail, and a reply address where one helps"
Task 6: Übernahme aus .env, und mail.password verlässt die Registry
Files:
- Create:
database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php - Modify:
app/Services/Secrets/SecretVault.php(Registry-Eintrag entfernen) - Modify:
lang/de/secrets.php,lang/en/secrets.php(Schlüsselitem.mail_passwordentfernen) - Test:
tests/Feature/Mail/MailboxTakeoverTest.php
Interfaces:
-
Consumes:
Mailbox(Task 2),MailPurpose(Task 3) -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailboxTakeoverTest.php
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretVault;
use App\Support\Settings;
it('seeds the five mailboxes so the page shows what is expected', function () {
expect(Mailbox::query()->pluck('key')->sort()->values()->all())
->toBe(['billing', 'info', 'no-reply', 'office', 'support']);
});
it('marks no-reply as unanswerable and the rest as answerable', function () {
expect(Mailbox::findByKey('no-reply')->no_reply)->toBeTrue()
->and(Mailbox::findByKey('support')->no_reply)->toBeFalse();
});
it('points every purpose at a mailbox, so nothing sends from nowhere', function () {
foreach (MailPurpose::ALL as $purpose) {
expect(Settings::get(MailPurpose::settingKey($purpose)))->not->toBeNull();
}
});
it('no longer offers mail.password as a secret, because it is superseded', function () {
expect(SecretVault::REGISTRY)->not->toHaveKey('mail.password');
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailboxTakeoverTest
Expected: FAIL — no mailboxes exist
- Step 3: Write the migration
<?php
// database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Takes the single configured SMTP account over into the new mailbox table,
* and puts the other four addresses on the page as empty rows.
*
* Empty rather than absent: a page that lists what is expected tells the owner
* what is still missing. An empty list would only say "nothing here".
*/
return new class extends Migration
{
/** @var array<string, array{no_reply: bool, purposes: array<int, string>}> */
private const SEED = [
'no-reply' => ['no_reply' => true, 'purposes' => [MailPurpose::MAINTENANCE, MailPurpose::PROVISIONING, MailPurpose::SYSTEM]],
'support' => ['no_reply' => false, 'purposes' => [MailPurpose::SUPPORT]],
'billing' => ['no_reply' => false, 'purposes' => [MailPurpose::BILLING]],
'office' => ['no_reply' => false, 'purposes' => []],
'info' => ['no_reply' => false, 'purposes' => []],
];
public function up(): void
{
// The server, once. Whatever the .env already proved workable.
Settings::set('mail.host', (string) config('mail.mailers.smtp.host', ''));
$port = (int) config('mail.mailers.smtp.port', 587);
Settings::set('mail.port', $port);
// NOT copied from MAIL_SCHEME. That variable currently reads "tls",
// which is not a Symfony mailer scheme — Symfony knows smtp and smtps,
// and Laravel 13's MailManager passes scheme straight through with no
// encryption fallback. It has never failed only because MAIL_MAILER is
// log, so nothing has opened a real connection.
//
// Stored as a human-facing choice instead, translated to a scheme where
// it is used: 465 means implicit TLS, everything else means STARTTLS.
Settings::set('mail.encryption', $port === 465 ? 'ssl' : 'tls');
$configuredUser = (string) config('mail.mailers.smtp.username', '');
$domain = str_contains($configuredUser, '@')
? substr($configuredUser, strpos($configuredUser, '@') + 1)
: 'clupilot.com';
// A password may already be stored in the vault; it moves rather than
// being left behind in a registry entry that is about to disappear.
$storedPassword = DB::table('app_secrets')->where('key', 'mail.password')->value('value');
$envPassword = (string) config('mail.mailers.smtp.password', '');
foreach (self::SEED as $key => $meta) {
$isConfigured = $key === 'no-reply' && $configuredUser !== '';
$box = new Mailbox([
'key' => $key,
'address' => $isConfigured ? $configuredUser : $key.'@'.$domain,
'display_name' => 'CluPilot',
'no_reply' => $meta['no_reply'],
'active' => true,
]);
// Only the account that actually exists gets credentials.
if ($isConfigured && $envPassword !== '') {
$box->password = $envPassword;
}
$box->save();
// The ciphertext from the vault is already under SECRETS_KEY, so it
// is carried across as-is rather than decrypted and re-encrypted.
if ($isConfigured && $storedPassword !== null) {
DB::table('mailboxes')->where('id', $box->id)->update(['password' => $storedPassword]);
}
foreach ($meta['purposes'] as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $key);
}
}
DB::table('app_secrets')->where('key', 'mail.password')->delete();
}
public function down(): void
{
DB::table('mailboxes')->whereIn('key', array_keys(self::SEED))->delete();
foreach (MailPurpose::ALL as $purpose) {
DB::table('app_settings')->where('key', MailPurpose::settingKey($purpose))->delete();
}
DB::table('app_settings')->whereIn('key', ['mail.host', 'mail.port', 'mail.encryption'])->delete();
}
};
- Step 4: Remove the superseded registry entry
In app/Services/Secrets/SecretVault.php, delete these four lines from
REGISTRY:
'mail.password' => [
'config' => 'mail.mailers.smtp.password',
'label' => 'secrets.item.mail_password',
],
In lang/de/secrets.php and lang/en/secrets.php, delete the
'mail_password' => … line from the item array.
- Step 5: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailboxTakeoverTest
Expected: PASS — four tests.
Then: docker compose exec -T app php artisan test
Expected: PASS — SecretsPageTest and SecretVaultTest must still pass; if
either names mail.password, update it to one of the remaining three entries.
- Step 6: Commit
git add database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php app/Services/Secrets/SecretVault.php lang/de/secrets.php lang/en/secrets.php tests/Feature/Mail/MailboxTakeoverTest.php
git commit -m "Move the one SMTP account into the mailbox table it outgrew"
Task 7: Die Konsolenseite
Files:
- Create:
database/migrations/2026_07_28_110000_add_mail_manage_permission.php - Create:
app/Livewire/Admin/Mail.php,resources/views/livewire/admin/mail.blade.php - Create:
app/Livewire/EditMailbox.php,resources/views/livewire/edit-mailbox.blade.php - Create:
lang/de/mail_settings.php,lang/en/mail_settings.php - Modify:
routes/admin.php,app/Support/Navigation.php,lang/de/admin.php(Navigationslabelmail) - Test:
tests/Feature/Mail/MailSettingsPageTest.php
Interfaces:
-
Consumes:
Mailbox,MailPurpose,MailboxResolver -
Produces: Route
admin.mail, Berechtigungmail.manage, Modal-Komponenteedit-mailbox -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailSettingsPageTest.php
use App\Livewire\Admin\Mail as MailPage;
use App\Livewire\EditMailbox;
use App\Models\Mailbox;
use App\Models\User;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Livewire\Livewire;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('is closed to an operator without mail.manage', function () {
$user = User::factory()->operator('Read-only')->create();
Livewire::actingAs($user)->test(MailPage::class)->assertForbidden();
});
it('opens for an operator who has it', function () {
$user = User::factory()->operator('Owner')->create();
Livewire::actingAs($user)->test(MailPage::class)->assertOk();
});
it('is not opened by secrets.manage alone — that is the point of splitting them', function () {
// Billing holds secrets-adjacent capabilities but has no business changing
// the support sender, and vice versa. If this ever passes, the two
// capabilities have quietly been merged again.
$user = User::factory()->operator('Billing')->create();
expect($user->can('mail.manage'))->toBeFalse();
Livewire::actingAs($user)->test(MailPage::class)->assertForbidden();
});
it('edits a mailbox in a modal, never in the row (R20)', function () {
$box = Mailbox::factory()->create(['key' => 'support']);
$blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php'));
expect($blade)->not->toMatch('/<td[^>]*>(?:(?!<\/td>).)*<input/s')
->and($blade)->toContain('openModal');
Livewire::actingAs(User::factory()->operator('Owner')->create())
->test(EditMailbox::class, ['uuid' => $box->uuid])
->set('address', 'neu@clupilot.com')
->call('save');
expect($box->fresh()->address)->toBe('neu@clupilot.com');
});
it('never hands a stored password back to the browser', function () {
$box = Mailbox::factory()->create(['password' => 'streng-geheim']);
Livewire::actingAs(User::factory()->operator('Owner')->create())
->test(EditMailbox::class, ['uuid' => $box->uuid])
->assertSet('password', '')
->assertDontSee('streng-geheim');
});
it('keeps the old password when the field is left empty', function () {
$box = Mailbox::factory()->create(['password' => 'bleibt']);
Livewire::actingAs(User::factory()->operator('Owner')->create())
->test(EditMailbox::class, ['uuid' => $box->uuid])
->set('address', 'neu@clupilot.com')
->call('save');
expect($box->fresh()->password)->toBe('bleibt');
});
it('says so when SECRETS_KEY is missing, instead of crashing on the first password', function () {
config()->set('admin_access.secrets_key', '');
Livewire::actingAs(User::factory()->operator('Owner')->create())
->test(MailPage::class)
->assertOk()
->assertSet('usable', false)
->assertSee(__('mail_settings.no_key'));
});
it('refuses to leave the system purpose empty, because it is the fallback', function () {
Mailbox::factory()->create(['key' => 'no-reply']);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
Livewire::actingAs(User::factory()->operator('Owner')->create())
->test(MailPage::class)
->set('purposes.system', '')
->call('savePurposes')
->assertHasErrors('purposes.system');
expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply');
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailSettingsPageTest
Expected: FAIL — Class "App\Livewire\Admin\Mail" not found
- Step 3: Add the permission
<?php
// database/migrations/2026_07_28_110000_add_mail_manage_permission.php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Its own capability, not secrets.manage.
*
* Whoever keeps the support sender working does not thereby need the Stripe key
* and the DNS token. Splitting it is the difference between "can change an
* address" and "can move money".
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permission = Permission::findOrCreate('mail.manage', 'web');
foreach (['Owner', 'Admin', 'Support'] as $role) {
Role::findByName($role, 'web')->givePermissionTo($permission);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::where('name', 'mail.manage')->where('guard_name', 'web')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
};
Note for the operator-identity plan: this permission is created under guard
web. The identity migration moves every permission to guardoperator, and will carry this one along because it moves them all.
- Step 4: Write the page component
<?php
// app/Livewire/Admin/Mail.php
namespace App\Livewire\Admin;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The sending addresses, and which kind of mail leaves from which.
*
* The server sits at the top because there is one of it; the mailboxes are a
* list because there are several; the mapping is last because it only makes
* sense once both exist.
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> purpose => mailbox key */
public array $purposes = [];
public function mount(): void
{
$this->authorize('mail.manage');
$this->host = (string) Settings::get('mail.host', '');
$this->port = (int) Settings::get('mail.port', 587);
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
foreach (MailPurpose::ALL as $purpose) {
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
}
}
public function saveServer(): void
{
$this->authorize('mail.manage');
// Rules on the ACTION, not on the property: a #[Validate] attribute on
// a Livewire property applies class-wide, and savePurposes() below
// would drag these along.
$this->validate([
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'encryption' => ['required', 'in:tls,ssl,none'],
]);
Settings::set('mail.host', $this->host);
Settings::set('mail.port', (int) $this->port);
Settings::set('mail.encryption', $this->encryption);
$this->dispatch('notify', message: __('mail_settings.server_saved'));
}
public function savePurposes(): void
{
$this->authorize('mail.manage');
// system is the fallback, so it is the one that may not be empty —
// there is nothing left to fall back to.
$this->validate(
['purposes.system' => ['required', 'string']],
['purposes.system.required' => __('mail_settings.system_required')],
);
foreach (MailPurpose::ALL as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
/**
* Whether credentials can be stored at all on this installation.
*
* Public so the page can say it rather than throwing on the first password
* a mailbox tries to decrypt — the same courtesy the secrets page already
* extends. A missing SECRETS_KEY is a setup state, not an error.
*/
public bool $usable = true;
public function render()
{
$this->usable = app(\App\Services\Secrets\SecretCipher::class)->isUsable();
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
]);
}
}
In the blade, directly under the page header — the same shape the secrets page uses:
@if (! $usable)
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
@endif
And into both language files:
'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Postfach-Passwörter gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.',
- Step 5: Write the modal component
<?php
// app/Livewire/EditMailbox.php
namespace App\Livewire;
use App\Models\Mailbox;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a mailbox, in a modal (R20).
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* itself and re-reads the record instead of trusting a property the browser
* hydrated.
*/
class EditMailbox extends ModalComponent
{
public string $uuid = '';
public string $address = '';
public string $displayName = '';
public string $username = '';
/** Always starts empty: a stored password never travels to the browser. */
public string $password = '';
public bool $noReply = false;
public bool $active = true;
public function mount(string $uuid): void
{
$this->authorize('mail.manage');
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $box->uuid;
$this->address = $box->address;
$this->displayName = (string) $box->display_name;
$this->username = (string) $box->username;
$this->noReply = $box->no_reply;
$this->active = $box->active;
}
public function save(): void
{
$this->authorize('mail.manage');
$this->validate([
'address' => ['required', 'email', 'max:255'],
'displayName' => ['nullable', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
]);
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
$box->address = $this->address;
$box->display_name = $this->displayName ?: null;
$box->username = $this->username ?: null;
$box->no_reply = $this->noReply;
$box->active = $this->active;
// Empty means "leave it alone", not "delete it" — otherwise every edit
// of an address would silently drop the password.
if ($this->password !== '') {
$box->password = $this->password;
$box->last_verified_at = null;
}
$box->save();
$this->password = '';
$this->dispatch('notify', message: __('mail_settings.saved'));
$this->dispatch('mailbox-saved');
$this->closeModal();
}
}
- Step 6: Write the views, route, navigation and language files
resources/views/livewire/admin/mail.blade.php — three cards in this order.
Open resources/views/livewire/admin/secrets.blade.php alongside and copy its
card and heading markup verbatim so the two pages cannot drift apart:
There is no x-ui.select component and no page-header component — the
console pages write both out by hand. The header below is copied from
secrets.blade.php verbatim, text-2xl included: a mass edit once pushed 23
page titles to sm:text-3xl (40px) where the approved template has 30px, so do
not "improve" this line.
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
</div>
{{-- 1. The server. One of it, so one card. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.server_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.server_hint') }}</p>
<div class="mt-5 grid gap-4 sm:grid-cols-3">
<x-ui.input name="host" wire:model="host" :label="__('mail_settings.host')" />
<x-ui.input name="port" type="number" wire:model="port" :label="__('mail_settings.port')" />
<div>
<label for="encryption" class="text-sm font-medium text-ink">{{ __('mail_settings.encryption') }}</label>
<select id="encryption" wire:model="encryption"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="none">{{ __('mail_settings.encryption_none') }}</option>
</select>
@error('encryption')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<x-ui.button wire:click="saveServer" variant="primary" size="md" class="mt-5">
{{ __('mail_settings.save') }}
</x-ui.button>
</x-ui.card>
{{-- 2. The mailboxes. NO input field inside a <td> — R20. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.boxes_title') }}</h2>
<div class="mt-4 max-w-md">
<x-ui.input wire:model="testRecipient" type="email" :label="__('mail_settings.test_recipient')" />
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
</div>
@if ($testResult !== null)
<x-ui.alert :variant="$testResult['ok'] ? 'success' : 'danger'" class="mt-4">
{{ $testResult['ok'] ? __('mail_settings.test_ok') : __('mail_settings.test_failed').' '.$testResult['error'] }}
</x-ui.alert>
@endif
<table class="mt-5 w-full text-sm">
<thead>
<tr class="border-b border-line text-left">
<th class="lbl pb-2">{{ __('mail_settings.address') }}</th>
<th class="lbl pb-2">{{ __('mail_settings.last_verified') }}</th>
<th class="lbl pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($mailboxes as $box)
<tr class="border-b border-line">
<td class="py-3">
<span class="font-medium text-ink">{{ $box->address }}</span>
<span class="ml-2 text-muted">{{ $box->key }}</span>
</td>
<td class="py-3 text-muted">
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
</td>
<td class="py-3 text-right">
{{-- Action column always visible, both buttons single-line (R18). --}}
<x-ui.button size="sm" variant="ghost" wire:click="test('{{ $box->uuid }}')">
<x-slot:icon><x-ui.icon name="send" class="size-4" /></x-slot:icon>
{{ __('mail_settings.test') }}
</x-ui.button>
{{-- the edit button below --}}
</td>
</tr>
@endforeach
</tbody>
</table>
</x-ui.card>
{{-- 3. The mapping. One <select> per row — R20's stated exception. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.purposes_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.purposes_hint') }}</p>
<div class="mt-5 space-y-3">
@foreach ($purposeList as $purpose)
<div class="flex items-center justify-between gap-6 border-b border-line py-2">
<span class="text-sm text-ink">{{ __('mail_settings.purpose.'.$purpose) }}</span>
<select wire:model="purposes.{{ $purpose }}"
class="w-64 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">—</option>
@foreach ($mailboxes as $box)
<option value="{{ $box->key }}">{{ $box->address }}</option>
@endforeach
</select>
</div>
@endforeach
</div>
@error('purposes.system')<p class="mt-3 text-xs text-danger">{{ $message }}</p>@enderror
<x-ui.button wire:click="savePurposes" variant="primary" size="md" class="mt-5">
{{ __('mail_settings.save') }}
</x-ui.button>
</x-ui.card>
</div>
Add 'save' => 'Speichern', and 'encryption_none' => 'Keine', to both language
files.
Do not invent components. The set is fixed: alert, badge, button, card, chart, checkbox, icon, input, logo, metric, nav-item, otp-input, progress-stepper, ring, spark, stat-tile. Anything else is written out by hand,
copying the classes from a console page that already does it.
Into the action column of the mailbox table, beside the test button:
<x-ui.button size="sm" variant="ghost"
x-on:click="$dispatch('openModal', { component: 'edit-mailbox', arguments: { uuid: '{{ $box->uuid }}' } })">
<x-slot:icon><x-ui.icon name="pencil" class="size-4" /></x-slot:icon>
{{ __('mail_settings.edit') }}
</x-ui.button>
The purpose table uses one <select> per row bound to wire:model="purposes.{{ $purpose }}" —
R20's stated exception, so no modal.
Times use ->local() (R19):
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
In routes/admin.php, beside the other pages:
Route::get('/mail', Admin\Mail::class)->name('mail');
In app/Support/Navigation.php, in the system group, before admin.secrets:
['admin.mail', 'mail', 'mail', 'mail.manage'],
Add 'mail' => 'E-Mail', to the nav array in lang/de/admin.php and
'mail' => 'Email', in lang/en/admin.php.
Create lang/de/mail_settings.php:
<?php
return [
'title' => 'E-Mail',
'subtitle' => 'Absenderadressen und der Server, über den sie verschickt werden.',
'server_title' => 'Mailserver',
'server_hint' => 'Gilt für alle Postfächer — sie liegen auf demselben Server.',
'host' => 'Server',
'port' => 'Port',
'encryption' => 'Verschlüsselung',
'server_saved' => 'Serverdaten gespeichert.',
'boxes_title' => 'Postfächer',
'address' => 'Adresse',
'display_name' => 'Anzeigename',
'username' => 'SMTP-Benutzer',
'username_hint' => 'Leer lassen, wenn er der Adresse entspricht.',
'password' => 'Passwort',
'password_hint' => 'Wird verschlüsselt gespeichert und nie wieder vollständig angezeigt. Leer lassen, um das bisherige zu behalten.',
'no_reply' => 'Keine Antworten (kein Reply-To)',
'no_reply_hint' => 'Ein „no-reply", auf das man antworten kann, ist eine Lüge im Absender.',
'active' => 'Aktiv',
'edit' => 'Bearbeiten',
'saved' => 'Postfach gespeichert.',
'never_verified' => 'Noch nicht bestätigt',
'last_verified' => 'Zuletzt bestätigt',
'purposes_title' => 'Wer verschickt was',
'purposes_hint' => 'Ein Zweck ohne Postfach verschickt über „System".',
'purpose' => [
'maintenance' => 'Wartungsankündigungen',
'provisioning' => 'Bereitstellung und Bestellbestätigung',
'support' => 'Antworten auf Support-Anfragen',
'billing' => 'Rechnungen und Zahlungserinnerungen',
'system' => 'System (Rückfall für alles Übrige)',
],
'purposes_saved' => 'Zuordnung gespeichert.',
'system_required' => '„System" muss ein Postfach haben — es ist der Rückfall für alle anderen.',
];
Create lang/en/mail_settings.php with the same keys, translated.
- Step 7: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailSettingsPageTest
Expected: PASS — six tests.
Then: docker compose exec -T app php artisan test
Expected: PASS — IconLayoutTest, DisplayTimezoneTest and EditInModalTest
cover the new blade automatically and must stay green.
- Step 8: Build assets and look at it
docker compose exec -T app npm run build
Open the page over the VPN (/admin/mail) and check: zero console errors, the
icon sits beside its text, no input field inside a table row.
- Step 9: Commit
git add app/Livewire/Admin/Mail.php app/Livewire/EditMailbox.php resources/views/livewire/admin/mail.blade.php resources/views/livewire/edit-mailbox.blade.php lang/ routes/admin.php app/Support/Navigation.php database/migrations/2026_07_28_110000_add_mail_manage_permission.php tests/Feature/Mail/MailSettingsPageTest.php
git commit -m "Give the mailboxes a page, and the support sender its own capability"
Task 8: Testversand, der wirklich sendet
Files:
- Create:
app/Services/Mail/MailboxTester.php - Modify:
app/Livewire/Admin/Mail.php(Aktiontest) - Modify:
resources/views/livewire/admin/mail.blade.php(Knopf + Ergebnis) - Test:
tests/Feature/Mail/MailboxTesterTest.php
Interfaces:
-
Consumes:
Mailbox(Task 2),Settings -
Produces:
MailboxTester::run(Mailbox $box, string $recipient): array{ok: bool, error: ?string} -
Step 1: Write the failing test
<?php
// tests/Feature/Mail/MailboxTesterTest.php
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Support\Settings;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
config()->set('mail.default', 'log'); // as on a development machine
Settings::set('mail.host', 'mail.example.invalid');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
});
it('does NOT quietly succeed through the log mailer', function () {
// The whole point: on dev the default mailer is the log, and a tester that
// honoured it would report success while writing to a file — a test that
// proves nothing. mail.example.invalid cannot resolve, so a real attempt
// must fail.
$result = app(MailboxTester::class)->run(
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
'ziel@example.com',
);
expect($result['ok'])->toBeFalse()
->and($result['error'])->not->toBeNull();
});
it('passes the SMTP error through verbatim, because that is the whole diagnosis', function () {
$result = app(MailboxTester::class)->run(
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
'ziel@example.com',
);
// Not "Fehler beim Senden" — the server's own sentence.
expect($result['error'])->toContain('mail.example.invalid');
});
it('refuses a mailbox without a password rather than pretending to try', function () {
$box = Mailbox::factory()->create(['password' => null]);
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
expect($result['ok'])->toBeFalse()
->and($result['error'])->toContain('Passwort');
});
it('stamps last_verified_at only on success', function () {
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
app(MailboxTester::class)->run($box, 'ziel@example.com');
expect($box->fresh()->last_verified_at)->toBeNull();
});
- Step 2: Run test to verify it fails
Run: docker compose exec -T app php artisan test --filter=MailboxTesterTest
Expected: FAIL — Class "App\Services\Mail\MailboxTester" not found
- Step 3: Write the tester
<?php
// app/Services/Mail/MailboxTester.php
namespace App\Services\Mail;
use App\Models\Mailbox;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Sends one real message with one mailbox's credentials.
*
* Deliberately built with Mail::build() rather than going through the
* configured mailers: on a development machine the default is the log, and a
* button that honoured that would report success while writing to a file. A
* check that quietly examines something else is worse than no check — the same
* mistake the timezone tests made by computing the expected value with the
* implementation they were testing.
*
* @return array{ok: bool, error: ?string}
*/
final class MailboxTester
{
public function run(Mailbox $box, string $recipient): array
{
if ($box->password === null) {
return ['ok' => false, 'error' => __('mail_settings.test_no_password')];
}
$port = (int) Settings::get('mail.port', 587);
try {
Mail::build([
'transport' => 'smtp',
'host' => (string) Settings::get('mail.host', ''),
'port' => $port,
// 'scheme', NOT 'encryption'. Laravel 13's MailManager reads
// scheme only (MailManager.php:196) — an 'encryption' key is
// silently ignored, and the connection would go out in the
// clear while the console reported TLS.
'scheme' => (Settings::get('mail.encryption') === 'ssl' || $port === 465) ? 'smtps' : 'smtp',
'username' => $box->smtpUsername(),
'password' => $box->password,
])->raw(
__('mail_settings.test_body', ['key' => $box->key]),
function ($message) use ($box, $recipient) {
$message->to($recipient)
->from($box->address, $box->display_name ?: null)
->subject(__('mail_settings.test_subject'));
},
);
} catch (Throwable $e) {
// Verbatim. With wrong credentials the server's own sentence is the
// entire diagnosis, and "sending failed" throws it away.
return ['ok' => false, 'error' => $e->getMessage()];
}
$box->forceFill(['last_verified_at' => now()])->save();
return ['ok' => true, 'error' => null];
}
}
- Step 4: Wire the button
Add to app/Livewire/Admin/Mail.php:
public string $testRecipient = '';
/** @var array{ok: bool, error: ?string}|null */
public ?array $testResult = null;
public ?string $testedKey = null;
public function test(string $uuid): void
{
$this->authorize('mail.manage');
$this->validate(
['testRecipient' => ['required', 'email']],
['testRecipient.required' => __('mail_settings.test_recipient_required')],
);
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->testedKey = $box->key;
$this->testResult = app(\App\Services\Mail\MailboxTester::class)
->run($box, $this->testRecipient);
}
In the blade, above the mailbox table, one recipient field plus a per-row
button, and the result rendered as <x-ui.alert>. The field text must say that
this really sends:
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
Add to both language files (German shown):
'test' => 'Testmail senden',
'test_recipient' => 'Testempfänger',
'test_hint' => 'Der Testversand geht wirklich raus — auch auf dieser Installation, auf der gewöhnliche Mails nur protokolliert werden.',
'test_recipient_required' => 'Bitte eine Empfängeradresse eingeben.',
'test_subject' => 'CluPilot — Testnachricht',
'test_body' => 'Diese Nachricht bestätigt, dass das Postfach :key verschicken kann.',
'test_no_password' => 'Für dieses Postfach ist kein Passwort hinterlegt.',
'test_ok' => 'Zugestellt. Das Postfach kann verschicken.',
'test_failed' => 'Der Mailserver hat abgelehnt:',
- Step 5: Run the tests
Run: docker compose exec -T app php artisan test --filter=MailboxTesterTest
Expected: PASS — four tests.
Then the full suite: docker compose exec -T app php artisan test
Expected: PASS.
- Step 6: Verify in the browser and by really sending
docker compose exec -T app npm run build
Over the VPN: open /admin/mail, put the real credentials into no-reply, and
send a test to your own address. It must actually arrive. If it does not, the
error shown must be the server's own sentence — not "Fehler beim Senden".
- Step 7: Commit
git add app/Services/Mail/MailboxTester.php app/Livewire/Admin/Mail.php resources/views/livewire/admin/mail.blade.php lang/ tests/Feature/Mail/MailboxTesterTest.php
git commit -m "Prove a mailbox works by actually sending from it"
Nach dem letzten Task
- Codex-Review (R15) — Ablauf steht in der Nutzer-Memory
clupilot-r15-codex-review. Im Hintergrund laufen lassen; der Aufruf überschreitet zwei Minuten. - Das SMTP-Passwort aus
.enventfernen, sobald es im Postfach hinterlegt und per Testversand bestätigt ist. Ein Wechsel bei der Gelegenheit ist empfohlen — es lag im Klartext in der Datei. - Push auf
main, danach Live-Update über Konsole → Einstellungen → Update anfordern (VPN-only, nicht per SSH). - Handoff fortschreiben:
mail.passwordist ersetzt, die Support-Warteschlange in der Konsole ist jetzt baubar.