Say which kind of mail leaves from which mailbox
parent
c5021c4aa7
commit
06b84c5cd5
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue