diff --git a/app/Mail/Concerns/SendsFromMailbox.php b/app/Mail/Concerns/SendsFromMailbox.php new file mode 100644 index 0000000..c06bf9e --- /dev/null +++ b/app/Mail/Concerns/SendsFromMailbox.php @@ -0,0 +1,37 @@ +for($purpose); + + if ($box === null) { + // No mailbox configured yet — fall back to the framework default so + // an installation mid-setup still sends rather than crashing. + return new Envelope(subject: $subject); + } + + return new Envelope( + from: new Address($box->address, $box->display_name ?: null), + replyTo: $box->no_reply ? [] : [new Address($box->address, $box->display_name ?: null)], + subject: $subject, + ); + } +} diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index ef9771c..5a69eda 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -2,8 +2,10 @@ namespace App\Mail; +use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; +use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -14,7 +16,7 @@ use Illuminate\Queue\SerializesModels; class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SendsFromMailbox, SerializesModels; /** The ledger row this mail confirms once actually delivered. */ public ?int $notificationId = null; @@ -22,7 +24,11 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue public function __construct( public MaintenanceWindow $window, public Customer $customer, - ) {} + ) { + // Names the mailer, which is all that is serialised into the queue — + // the credentials behind it are resolved when the worker sends. + $this->mailer('cp_'.MailPurpose::MAINTENANCE); + } public function headers(): Headers { @@ -31,7 +37,10 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue public function envelope(): Envelope { - return new Envelope(subject: __('maintenance.mail_subject', ['title' => $this->window->title])); + return $this->mailboxEnvelope( + MailPurpose::MAINTENANCE, + __('maintenance.mail_subject', ['title' => $this->window->title]), + ); } public function content(): Content diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index 52293c4..e5a43a6 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -2,8 +2,10 @@ namespace App\Mail; +use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; +use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -14,7 +16,7 @@ use Illuminate\Queue\SerializesModels; class MaintenanceCancelledMail extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SendsFromMailbox, SerializesModels; /** The ledger row this mail confirms once actually delivered. */ public ?int $notificationId = null; @@ -22,7 +24,9 @@ class MaintenanceCancelledMail extends Mailable implements ShouldQueue public function __construct( public MaintenanceWindow $window, public Customer $customer, - ) {} + ) { + $this->mailer('cp_'.MailPurpose::MAINTENANCE); + } public function headers(): Headers { @@ -31,7 +35,10 @@ class MaintenanceCancelledMail extends Mailable implements ShouldQueue public function envelope(): Envelope { - return new Envelope(subject: __('maintenance.mail_cancel_subject', ['title' => $this->window->title])); + return $this->mailboxEnvelope( + MailPurpose::MAINTENANCE, + __('maintenance.mail_cancel_subject', ['title' => $this->window->title]), + ); } public function content(): Content diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index bd1273d..8d02112 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -3,6 +3,8 @@ namespace App\Notifications; use App\Models\Instance; +use App\Services\Mail\MailboxResolver; +use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -38,7 +40,20 @@ class CloudReady extends Notification { $url = 'https://'.$this->instance->subdomain.'.'.config('provisioning.dns.zone'); - return (new MailMessage) + $box = app(MailboxResolver::class) + ->for(MailPurpose::PROVISIONING); + + $message = (new MailMessage)->mailer('cp_'.MailPurpose::PROVISIONING); + + if ($box !== null) { + $message->from($box->address, $box->display_name ?: null); + + if (! $box->no_reply) { + $message->replyTo($box->address, $box->display_name ?: null); + } + } + + return $message ->subject(__('provisioning.mail.ready_subject')) ->greeting(__('provisioning.mail.ready_greeting')) ->line(__('provisioning.mail.ready_line')) diff --git a/tests/Feature/Mail/SenderAddressTest.php b/tests/Feature/Mail/SenderAddressTest.php new file mode 100644 index 0000000..3bc1153 --- /dev/null +++ b/tests/Feature/Mail/SenderAddressTest.php @@ -0,0 +1,139 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); +}); + +it('sends from the mailbox its purpose points at', function () { + Mailbox::factory()->create([ + 'key' => 'no-reply', + 'address' => 'no-reply@clupilot.com', + 'display_name' => 'CluPilot', + 'no_reply' => true, + ]); + Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply'); + + $mail = new MaintenanceAnnouncementMail( + MaintenanceWindow::factory()->create(), + Customer::factory()->create(), + ); + + $envelope = $mail->envelope(); + + expect($envelope->from->address)->toBe('no-reply@clupilot.com') + ->and($envelope->from->name)->toBe('CluPilot'); +}); + +it('omits Reply-To on a no-reply mailbox, because that is what the name promises', function () { + Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'no_reply' => true]); + Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply'); + + $envelope = (new MaintenanceAnnouncementMail( + MaintenanceWindow::factory()->create(), + Customer::factory()->create(), + ))->envelope(); + + expect($envelope->replyTo)->toBeEmpty(); +}); + +it('sets Reply-To on an answerable mailbox, so a customer reply reaches a human', function () { + Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'no_reply' => false]); + Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'support'); + + $envelope = (new MaintenanceAnnouncementMail( + MaintenanceWindow::factory()->create(), + Customer::factory()->create(), + ))->envelope(); + + expect($envelope->replyTo[0]->address)->toBe('support@clupilot.com'); +}); + +it('names the purpose mailer so the queue worker picks the right transport', function () { + $mail = new MaintenanceAnnouncementMail( + MaintenanceWindow::factory()->create(), + Customer::factory()->create(), + ); + + expect($mail->mailer)->toBe('cp_maintenance'); +}); + +it('gives the cancellation mail the same sender as the announcement', function () { + Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'no_reply' => true]); + Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply'); + + $mail = new MaintenanceCancelledMail( + MaintenanceWindow::factory()->create(), + Customer::factory()->create(), + ); + + expect($mail->envelope()->from->address)->toBe('no-reply@clupilot.com') + ->and($mail->mailer)->toBe('cp_maintenance'); +}); + +it('falls back to the framework default envelope when no mailbox is configured for the purpose', function () { + // Neither a purpose mapping nor a system fallback exists anywhere in + // Settings — MailboxResolver::for() returns null. An installation + // mid-setup must still hand back a usable Envelope (subject intact) + // rather than dereferencing a null mailbox. + $window = MaintenanceWindow::factory()->create(); + + $envelope = (new MaintenanceAnnouncementMail($window, Customer::factory()->create()))->envelope(); + + expect($envelope->from)->toBeNull() + ->and($envelope->replyTo)->toBeEmpty() + ->and($envelope->subject)->toBe(__('maintenance.mail_subject', ['title' => $window->title])); +}); + +it('sends the provisioning notification from the provisioning mailbox', function () { + Mailbox::factory()->create([ + 'key' => 'no-reply', + 'address' => 'no-reply@clupilot.com', + 'display_name' => 'CluPilot', + 'no_reply' => true, + ]); + Settings::set(MailPurpose::settingKey(MailPurpose::PROVISIONING), 'no-reply'); + + $notification = new CloudReady( + Instance::factory()->create(), + 'admin', + Crypt::encryptString('geheim'), + ); + + $message = $notification->toMail(new stdClass); + + expect($message->from[0])->toBe('no-reply@clupilot.com') + ->and($message->mailer)->toBe('cp_provisioning') + // no_reply is set, so no Reply-To — the same promise as everywhere else. + ->and($message->replyTo)->toBeEmpty(); +}); + +it('sets Reply-To on the provisioning notification when its mailbox allows replies', function () { + // The mirror image of the no-reply case above: a support-style mailbox + // behind PROVISIONING must still get a Reply-To, the same as maintenance + // does. Without this, CloudReady's `if (! $box->no_reply)` branch is only + // ever exercised by the no_reply=true side and a deleted replyTo() call + // would go unnoticed. + Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'no_reply' => false]); + Settings::set(MailPurpose::settingKey(MailPurpose::PROVISIONING), 'support'); + + $notification = new CloudReady( + Instance::factory()->create(), + 'admin', + Crypt::encryptString('geheim'), + ); + + $message = $notification->toMail(new stdClass); + + expect($message->replyTo[0][0])->toBe('support@clupilot.com'); +});