From 06b84c5cd5473b6526e06de73d1728077fa26704 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 22:00:16 +0200 Subject: [PATCH] Say which kind of mail leaves from which mailbox --- app/Services/Mail/MailPurpose.php | 38 ++++++++++++++++++ app/Services/Mail/MailboxResolver.php | 41 ++++++++++++++++++++ tests/Feature/Mail/MailboxResolverTest.php | 45 ++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 app/Services/Mail/MailPurpose.php create mode 100644 app/Services/Mail/MailboxResolver.php create mode 100644 tests/Feature/Mail/MailboxResolverTest.php diff --git a/app/Services/Mail/MailPurpose.php b/app/Services/Mail/MailPurpose.php new file mode 100644 index 0000000..b2346d9 --- /dev/null +++ b/app/Services/Mail/MailPurpose.php @@ -0,0 +1,38 @@ + */ + 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; + } +} diff --git a/app/Services/Mail/MailboxResolver.php b/app/Services/Mail/MailboxResolver.php new file mode 100644 index 0000000..6fc8e3c --- /dev/null +++ b/app/Services/Mail/MailboxResolver.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/tests/Feature/Mail/MailboxResolverTest.php b/tests/Feature/Mail/MailboxResolverTest.php new file mode 100644 index 0000000..bff7a43 --- /dev/null +++ b/tests/Feature/Mail/MailboxResolverTest.php @@ -0,0 +1,45 @@ +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'); +});