diff --git a/app/Models/Mailbox.php b/app/Models/Mailbox.php new file mode 100644 index 0000000..1950bfd --- /dev/null +++ b/app/Models/Mailbox.php @@ -0,0 +1,76 @@ + '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; + } +} diff --git a/database/factories/MailboxFactory.php b/database/factories/MailboxFactory.php new file mode 100644 index 0000000..b5dd450 --- /dev/null +++ b/database/factories/MailboxFactory.php @@ -0,0 +1,21 @@ + $this->faker->unique()->word(), + 'address' => $this->faker->unique()->safeEmail(), + 'display_name' => 'CluPilot', + 'username' => null, + 'password' => 'test-passwort', + 'no_reply' => false, + 'active' => true, + ]; + } +} diff --git a/database/migrations/2026_07_28_090000_create_mailboxes_table.php b/database/migrations/2026_07_28_090000_create_mailboxes_table.php new file mode 100644 index 0000000..190ef4a --- /dev/null +++ b/database/migrations/2026_07_28_090000_create_mailboxes_table.php @@ -0,0 +1,59 @@ +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'); + } +}; diff --git a/tests/Feature/Mail/MailboxModelTest.php b/tests/Feature/Mail/MailboxModelTest.php new file mode 100644 index 0000000..bbf9aa5 --- /dev/null +++ b/tests/Feature/Mail/MailboxModelTest.php @@ -0,0 +1,41 @@ +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(); +});