# 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` | **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` (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
<?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
<?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:

```php
// 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**

```bash
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
<?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('is keyed to SECRETS_KEY — rotating it makes the password unreadable', function () {
    // The POSITIVE proof, and the one that discriminates. Without it the pair
    // below passes even against an APP_KEY implementation.
    $box = Mailbox::factory()->create(['password' => 'geheim']);

    config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));

    expect(fn () => $box->fresh()->password)
        ->toThrow(Illuminate\Contracts\Encryption\DecryptException::class);
});

it('is NOT keyed to APP_KEY — rotating that leaves mail working', function () {
    $box = Mailbox::factory()->create(['password' => 'bleibt-lesbar']);

    config()->set('app.key', 'base64:'.base64_encode(random_bytes(32)));

    // forgetInstance is what makes this test mean anything. Laravel's encrypter
    // is a container singleton resolved once from whatever app.key held at the
    // time; config()->set() afterwards mutates the array and never rebuilds it.
    // Without this line the test passes even when the model is rewired to
    // Laravel's own APP_KEY-based Crypt facade — proven by mutation during the
    // Task 2 review, where every test stayed green through exactly that change.
    app()->forgetInstance('encrypter');

    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
<?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
<?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
<?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**

```bash
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
<?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
<?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
<?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**

```bash
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
<?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:

```php
        /*
        | 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
<?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()`:

```php
        // 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**

```bash
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
<?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
<?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`:

```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:

```php
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:

```php
        $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**

```bash
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
<?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
<?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`:

```php
        '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**

```bash
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
<?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
<?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
<?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:

```blade
    @if (! $usable)
        <x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
    @endif
```

And into both language files:

```php
    '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
<?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.

```blade
<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:

```blade
<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):

```blade
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
```

In `routes/admin.php`, beside the other pages:

```php
Route::get('/mail', Admin\Mail::class)->name('mail');
```

In `app/Support/Navigation.php`, in the `system` group, **before** `admin.secrets`:

```php
                ['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
<?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**

```bash
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**

```bash
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
<?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
<?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`:

```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:

```blade
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
```

Add to both language files (German shown):

```php
    '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**

```bash
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**

```bash
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.
