Give every sending address a record of its own
parent
5cbd949bd5
commit
270ec942d6
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue