CluPilotCloud/docs/superpowers/plans/2026-07-27-mailboxes.md

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-4 in Buttons/Tabellen, size-5 in 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, niemals APP_KEY. Begründung steht in SecretVault: 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 test muss 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 <?php verstößt außerdem gegen Pints blank_line_after_opening_tag.
  • Blade-Anonymkomponenten liegen unter resources/views/components/.
  • Tailwind-Namenskollisionen vermeiden (.ring, .num sind 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 neuFrom + 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 (private encrypter() 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: Mailbox model 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|SYSTEM constants, 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 in mailers)
  • 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üssel item.mail_password entfernen)
  • 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 (Navigationslabel mail)
  • Test: tests/Feature/Mail/MailSettingsPageTest.php

Interfaces:

  • Consumes: Mailbox, MailPurpose, MailboxResolver

  • Produces: Route admin.mail, Berechtigung mail.manage, Modal-Komponente edit-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 guard operator, 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 (Aktion test)
  • 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 .env entfernen, 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.password ist ersetzt, die Support-Warteschlange in der Konsole ist jetzt baubar.