2175 lines
77 KiB
Markdown
2175 lines
77 KiB
Markdown
# 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.
|