Give every sending address a record of its own

feat/mailboxes
nexxo 2026-07-27 21:25:35 +02:00
parent 5cbd949bd5
commit 270ec942d6
4 changed files with 197 additions and 0 deletions

76
app/Models/Mailbox.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use App\Services\Secrets\SecretCipher;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/**
* 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
{
use HasFactory;
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',
];
}
protected static function booted(): void
{
static::creating(function (self $box) {
$box->uuid ??= (string) Str::uuid();
});
}
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;
}
}

View File

@ -0,0 +1,21 @@
<?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,
];
}
}

View File

@ -0,0 +1,59 @@
<?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');
}
};

View File

@ -0,0 +1,41 @@
<?php
use App\Models\Mailbox;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('stores the password encrypted and never in the clear', function () {
$box = Mailbox::factory()->create(['key' => 'support', 'password' => 'sehr-geheim']);
$stored = DB::table('mailboxes')->where('id', $box->id)->value('password');
expect($stored)->not->toContain('sehr-geheim')
->and($box->fresh()->password)->toBe('sehr-geheim');
});
it('uses SECRETS_KEY, not APP_KEY — rotating APP_KEY leaves mail working', function () {
$box = Mailbox::factory()->create(['password' => 'bleibt-lesbar']);
config()->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
expect($box->fresh()->password)->toBe('bleibt-lesbar');
});
it('falls back to the address when no separate username is given', function () {
$box = Mailbox::factory()->create([
'address' => 'support@clupilot.com',
'username' => null,
]);
expect($box->smtpUsername())->toBe('support@clupilot.com');
});
it('finds a mailbox by its key', function () {
Mailbox::factory()->create(['key' => 'billing']);
expect(Mailbox::findByKey('billing'))->not->toBeNull()
->and(Mailbox::findByKey('gibtsnicht'))->toBeNull();
});