Put a real sender on every mail, and a reply address where one helps
parent
961bf62032
commit
64a33c675e
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use App\Services\Mail\MailboxResolver;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* From and Reply-To, taken from the mailbox behind a purpose.
|
||||
*
|
||||
* Reply-To is the whole reason sending alone is enough: a customer answering a
|
||||
* support reply lands in the support mailbox, read in an ordinary mail client,
|
||||
* so no IMAP is needed to close the loop.
|
||||
*
|
||||
* Except on a no-reply mailbox. A "no-reply" address you can reply to is a lie
|
||||
* in the sender, and the one place the promise is made is the address itself.
|
||||
*/
|
||||
trait SendsFromMailbox
|
||||
{
|
||||
protected function mailboxEnvelope(string $purpose, string $subject): Envelope
|
||||
{
|
||||
$box = app(MailboxResolver::class)->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
use App\Mail\MaintenanceCancelledMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Notifications\CloudReady;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->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');
|
||||
});
|
||||
Loading…
Reference in New Issue