From ac1ac603cbda525192faa73cd8be67f8f71942ea Mon Sep 17 00:00:00 2001 From: DariusIII Date: Thu, 7 May 2026 14:03:21 +0200 Subject: [PATCH] Update emails --- .env.example | 13 +- app/Console/Commands/MailPreview.php | 170 +++++++++ app/Jobs/SendInviteEmail.php | 43 --- app/Mail/AccountChange.php | 45 ++- app/Mail/AccountDeleted.php | 39 +- app/Mail/AccountExpired.php | 36 +- app/Mail/AccountWillExpire.php | 39 +- app/Mail/Concerns/HasBrandedSubject.php | 26 ++ app/Mail/ContactUs.php | 31 +- app/Mail/ForgottenPassword.php | 30 +- app/Mail/IncidentDetected.php | 30 +- app/Mail/InvitationMail.php | 29 +- app/Mail/NewAccountCreatedEmail.php | 44 +-- app/Mail/PasswordReset.php | 49 ++- app/Mail/SendInvite.php | 57 --- app/Mail/WelcomeEmail.php | 41 +-- app/Models/User.php | 10 +- app/Notifications/VerifyEmailBranded.php | 45 +++ config/mail.php | 24 +- config/services.php | 2 +- .../views/components/mail/alert.blade.php | 7 + .../views/components/mail/button.blade.php | 16 + .../views/components/mail/info-box.blade.php | 7 + .../views/components/mail/layout.blade.php | 214 +++++++++++ .../components/mail/status-table.blade.php | 3 + .../emails/accountAboutToExpire.blade.php | 24 -- .../views/emails/accountChange.blade.php | 26 -- .../views/emails/accountDelete.blade.php | 22 -- .../views/emails/accountExpired.blade.php | 26 -- resources/views/emails/contactUs.blade.php | 18 - resources/views/emails/email_layout.blade.php | 204 ----------- .../views/emails/forgottenPassword.blade.php | 29 -- .../views/emails/incidentDetected.blade.php | 73 ++-- resources/views/emails/invitation.blade.php | 40 +- .../emails/markdown/accountChange.blade.php | 18 + .../emails/markdown/accountDeleted.blade.php | 13 + .../emails/markdown/accountExpired.blade.php | 18 + .../markdown/accountWillExpire.blade.php | 16 + .../views/emails/markdown/contactUs.blade.php | 17 + .../markdown/forgottenPassword.blade.php | 22 ++ .../markdown/newAccountCreated.blade.php | 13 + .../emails/markdown/passwordReset.blade.php | 20 + .../views/emails/markdown/verify.blade.php | 24 ++ .../views/emails/markdown/welcome.blade.php | 16 + .../views/emails/newAccountCreated.blade.php | 22 -- .../views/emails/passwordReset.blade.php | 29 -- resources/views/emails/sendinvite.blade.php | 31 -- .../emails/text/incidentDetected.blade.php | 31 ++ .../views/emails/text/invitation.blade.php | 17 + resources/views/emails/welcome.blade.php | 24 -- .../views/vendor/mail/html/button.blade.php | 24 ++ .../views/vendor/mail/html/footer.blade.php | 19 + .../views/vendor/mail/html/header.blade.php | 13 + .../views/vendor/mail/html/themes/default.css | 341 ++++++++++++++++++ tests/Feature/Mail/MailRenderingTest.php | 257 +++++++++++++ tests/Feature/Mail/VerifyEmailBrandedTest.php | 86 +++++ tests/Feature/Mail/WelcomeEmailTest.php | 43 +++ 57 files changed, 1808 insertions(+), 818 deletions(-) create mode 100644 app/Console/Commands/MailPreview.php delete mode 100644 app/Jobs/SendInviteEmail.php create mode 100644 app/Mail/Concerns/HasBrandedSubject.php delete mode 100644 app/Mail/SendInvite.php create mode 100644 app/Notifications/VerifyEmailBranded.php create mode 100644 resources/views/components/mail/alert.blade.php create mode 100644 resources/views/components/mail/button.blade.php create mode 100644 resources/views/components/mail/info-box.blade.php create mode 100644 resources/views/components/mail/layout.blade.php create mode 100644 resources/views/components/mail/status-table.blade.php delete mode 100644 resources/views/emails/accountAboutToExpire.blade.php delete mode 100644 resources/views/emails/accountChange.blade.php delete mode 100644 resources/views/emails/accountDelete.blade.php delete mode 100644 resources/views/emails/accountExpired.blade.php delete mode 100644 resources/views/emails/contactUs.blade.php delete mode 100644 resources/views/emails/email_layout.blade.php delete mode 100644 resources/views/emails/forgottenPassword.blade.php create mode 100644 resources/views/emails/markdown/accountChange.blade.php create mode 100644 resources/views/emails/markdown/accountDeleted.blade.php create mode 100644 resources/views/emails/markdown/accountExpired.blade.php create mode 100644 resources/views/emails/markdown/accountWillExpire.blade.php create mode 100644 resources/views/emails/markdown/contactUs.blade.php create mode 100644 resources/views/emails/markdown/forgottenPassword.blade.php create mode 100644 resources/views/emails/markdown/newAccountCreated.blade.php create mode 100644 resources/views/emails/markdown/passwordReset.blade.php create mode 100644 resources/views/emails/markdown/verify.blade.php create mode 100644 resources/views/emails/markdown/welcome.blade.php delete mode 100644 resources/views/emails/newAccountCreated.blade.php delete mode 100644 resources/views/emails/passwordReset.blade.php delete mode 100644 resources/views/emails/sendinvite.blade.php create mode 100644 resources/views/emails/text/incidentDetected.blade.php create mode 100644 resources/views/emails/text/invitation.blade.php delete mode 100644 resources/views/emails/welcome.blade.php create mode 100644 resources/views/vendor/mail/html/button.blade.php create mode 100644 resources/views/vendor/mail/html/footer.blade.php create mode 100644 resources/views/vendor/mail/html/header.blade.php create mode 100644 resources/views/vendor/mail/html/themes/default.css create mode 100644 tests/Feature/Mail/MailRenderingTest.php create mode 100644 tests/Feature/Mail/VerifyEmailBrandedTest.php create mode 100644 tests/Feature/Mail/WelcomeEmailTest.php diff --git a/.env.example b/.env.example index 21a4f9484..6c27a1858 100644 --- a/.env.example +++ b/.env.example @@ -108,6 +108,8 @@ PASSWORD_HASH=argon2id BROADCAST_CONNECTION=log # Use failover_redis_file so Cache falls back to disk when Redis is unreachable (set CACHE_STORE=redis for strict Redis-only) CACHE_STORE=failover_redis_file +# Production deployments should set this to `redis` (or `database`) and run Horizon. +# `sync` is for local development only and will deliver email synchronously. QUEUE_CONNECTION=sync REDIS_HOST=127.0.0.1 @@ -135,7 +137,9 @@ SESSION_ENCRYPT=false SESSION_COOKIE=nntmux SESSION_PATH=/ -MAIL_DRIVER=smtp +# `MAIL_MAILER` is the canonical key in Laravel 13. The legacy `MAIL_DRIVER` +# variable is no longer read by `config/mail.php` and can be removed. +MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null @@ -144,6 +148,13 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS= MAIL_FROM_NAME= +# Branded email defaults (see config/mail.php -> brand). Subject prefix is +# applied to every transactional email sent through `App\Mail\Concerns\HasBrandedSubject`. +# MAIL_SUBJECT_PREFIX="[NNTmux] " +# MAIL_BRAND_LOGO_URL= +# MAIL_QUEUE=emails +# MAIL_INCIDENT_QUEUE=incidents + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/app/Console/Commands/MailPreview.php b/app/Console/Commands/MailPreview.php new file mode 100644 index 000000000..b8bebb422 --- /dev/null +++ b/app/Console/Commands/MailPreview.php @@ -0,0 +1,170 @@ +option('out') ?: storage_path('app/mail-previews')); + if (! is_dir($directory) && ! mkdir($directory, 0o755, true) && ! is_dir($directory)) { + $this->components->error("Could not create output directory: {$directory}"); + + return self::FAILURE; + } + + $user = $this->fakeUser(); + $invitation = $this->fakeInvitation(); + [$activeIncident, $resolvedIncident] = $this->fakeIncidents(); + + /** @var array $mailables */ + $mailables = [ + 'welcome' => fn (): Mailable => new WelcomeEmail($user), + 'forgotten_password' => fn (): Mailable => new ForgottenPassword('https://example.test/reset/abc123'), + 'password_reset' => fn (): Mailable => new PasswordReset($user, 'TempPass!2026'), + 'new_account_created' => fn (): Mailable => new NewAccountCreatedEmail($user), + 'account_change' => fn (): Mailable => new AccountChange($user), + 'account_will_expire' => fn (): Mailable => new AccountWillExpire($user, 5), + 'account_expired' => fn (): Mailable => new AccountExpired($user), + 'account_deleted' => fn (): Mailable => new AccountDeleted($user), + 'contact_us' => fn (): Mailable => new ContactUs( + 'admin@example.test', + 'visitor@example.test', + "Hello!\nQuestion inside.", + ), + 'invitation' => fn (): Mailable => new InvitationMail($invitation), + 'incident_detected' => fn (): Mailable => new IncidentDetected($activeIncident, false), + 'incident_resolved' => fn (): Mailable => new IncidentDetected($resolvedIncident, true), + ]; + + foreach ($mailables as $name => $factory) { + $html = $factory()->render(); + file_put_contents("{$directory}/{$name}.html", $html); + $this->components->info("Wrote {$name}.html (".number_format(strlen($html)).' bytes)'); + } + + $this->writeVerifyPreview($directory); + + $this->newLine(); + $this->components->info("All previews written to: {$directory}"); + + return self::SUCCESS; + } + + private function writeVerifyPreview(string $directory): void + { + $notifiable = new class + { + public int $id = 7; + + public string $email = 'tester@example.test'; + + public string $username = 'tester'; + + public function getKey(): int + { + return $this->id; + } + + public function getEmailForVerification(): string + { + return $this->email; + } + }; + + $message = (new VerifyEmailBranded)->toMail($notifiable); + $markdown = app(Markdown::class); + $html = (string) $markdown->render($message->markdown, $message->data()); + file_put_contents("{$directory}/verify_email.html", $html); + $this->components->info('Wrote verify_email.html ('.number_format(strlen($html)).' bytes)'); + } + + private function fakeUser(): User + { + $user = new User; + $user->id = 99; + $user->username = 'tester'; + $user->email = 'tester@example.test'; + $user->setRelation('role', new class + { + public string $name = 'Silver'; + }); + + return $user; + } + + private function fakeInvitation(): Invitation + { + $inviter = new User; + $inviter->id = 1; + $inviter->username = 'mod_alice'; + + $invitation = new Invitation; + $invitation->token = 'preview-token-abc123'; + $invitation->email = 'newuser@example.test'; + $invitation->expires_at = Carbon::now()->addDays(7); + $invitation->setRelation('invitedBy', $inviter); + + return $invitation; + } + + /** + * @return array{0: ServiceIncident, 1: ServiceIncident} + */ + private function fakeIncidents(): array + { + $services = collect([ + (object) ['name' => 'NZB Search'], + (object) ['name' => 'API'], + ]); + + $active = new ServiceIncident; + $active->title = 'API latency spike'; + $active->description = "p99 latency exceeded thresholds.\nFollow status page for updates."; + $active->status = IncidentStatusEnum::Identified; + $active->impact = IncidentImpactEnum::Critical; + $active->started_at = Carbon::now()->subMinutes(15); + $active->setRelation('services', $services); + + $resolved = clone $active; + $resolved->status = IncidentStatusEnum::Resolved; + $resolved->resolved_at = Carbon::now(); + + return [$active, $resolved]; + } +} diff --git a/app/Jobs/SendInviteEmail.php b/app/Jobs/SendInviteEmail.php deleted file mode 100644 index 1ac89cf27..000000000 --- a/app/Jobs/SendInviteEmail.php +++ /dev/null @@ -1,43 +0,0 @@ -email = $email; - $this->user = $user; - $this->url = $url; - } - - /** - * Execute the job. - */ - public function handle(): void - { - Mail::to($this->email)->send(new SendInvite($this->user, $this->url)); - } -} diff --git a/app/Mail/AccountChange.php b/app/Mail/AccountChange.php index a374a9034..0e265fe4c 100644 --- a/app/Mail/AccountChange.php +++ b/app/Mail/AccountChange.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,41 +12,39 @@ use Illuminate\Queue\SerializesModels; class AccountChange extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - /** - * @var User - */ - public $user; + public string $username; - /** - * @var mixed - */ - private $siteEmail; + public string $account; - /** - * @var mixed - */ - private $siteTitle; + public string $site; + + public ?string $preheader; + + private string $siteEmail; - /** - * AccountChange constructor. - */ public function __construct(User $user) { - $this->user = $user; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->account = (string) ($user->role->name ?? 'User'); + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = "Your {$this->site} account level was updated to {$this->account}."; } /** - * Build the message. - * - * * @throws \Exception */ public function build(): static { - return $this->from($this->siteEmail)->subject('Account Changed')->view('emails.accountChange')->with(['account' => $this->user->role->name, 'username' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('Account level changed') + ->markdown('emails.markdown.accountChange', [ + 'username' => $this->username, + 'account' => $this->account, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/AccountDeleted.php b/app/Mail/AccountDeleted.php index 730cae6e0..a4df0a478 100644 --- a/app/Mail/AccountDeleted.php +++ b/app/Mail/AccountDeleted.php @@ -4,38 +4,29 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use Illuminate\Bus\Queueable; -use Illuminate\Database\Eloquent\Model; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class AccountDeleted extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - /** - * @var Model|null|object|static - */ - private $user; + public string $username; - /** - * @var mixed - */ - private $siteEmail; + public string $site; - /** - * @var mixed - */ - private $siteTitle; + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - */ public function __construct(mixed $user) { - $this->user = $user; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = "User {$this->username} deleted their {$this->site} account."; } /** @@ -43,6 +34,12 @@ class AccountDeleted extends Mailable */ public function build(): static { - return $this->from($this->siteEmail)->subject('User Account Deleted')->view('emails.accountDelete')->with(['username' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('User account deleted') + ->markdown('emails.markdown.accountDeleted', [ + 'username' => $this->username, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/AccountExpired.php b/app/Mail/AccountExpired.php index 71c90f1c8..0b3e0f11e 100644 --- a/app/Mail/AccountExpired.php +++ b/app/Mail/AccountExpired.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,32 +12,39 @@ use Illuminate\Queue\SerializesModels; class AccountExpired extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - public User $user; + public string $username; - private mixed $siteEmail; + public string $account; - private mixed $siteTitle; + public string $site; + + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - */ public function __construct(User $user) { - $this->user = $user; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->account = (string) ($user->role->name ?? 'User'); + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = "Your {$this->site} subscription has expired — account downgraded to {$this->account}."; } /** - * Build the message. - * - * * @throws \Exception */ public function build(): static { - return $this->from($this->siteEmail)->subject('Account expired')->view('emails.accountExpired')->with(['account' => $this->user->role->name, 'username' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('Your account has expired') + ->markdown('emails.markdown.accountExpired', [ + 'username' => $this->username, + 'account' => $this->account, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/AccountWillExpire.php b/app/Mail/AccountWillExpire.php index c5e09800f..42a165699 100644 --- a/app/Mail/AccountWillExpire.php +++ b/app/Mail/AccountWillExpire.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,32 +12,40 @@ use Illuminate\Queue\SerializesModels; class AccountWillExpire extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - private int $days; + public int $days; - private User $user; + public string $username; - private mixed $siteEmail; + public string $account; - private mixed $siteTitle; + public string $site; + + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - */ public function __construct(User $user, int $days) { - $this->user = $user; $this->days = $days; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->account = (string) ($user->role->name ?? 'User'); + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = "Your {$this->account} role expires in {$this->days} day(s)."; } - /** - * Build the message. - */ public function build(): static { - return $this->from($this->siteEmail)->subject('Account about to expire')->view('emails.accountAboutToExpire')->with(['account' => $this->user->role->name, 'username' => $this->user->username, 'site' => $this->siteTitle, 'days' => $this->days]); + return $this->from($this->siteEmail) + ->brandedSubject('Your account is about to expire') + ->markdown('emails.markdown.accountWillExpire', [ + 'username' => $this->username, + 'account' => $this->account, + 'days' => $this->days, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/Concerns/HasBrandedSubject.php b/app/Mail/Concerns/HasBrandedSubject.php new file mode 100644 index 000000000..2db584a2e --- /dev/null +++ b/app/Mail/Concerns/HasBrandedSubject.php @@ -0,0 +1,26 @@ +brandedSubject('Welcome')` instead of + * `$this->subject('Welcome')`. The prefix can be overridden globally via + * `MAIL_SUBJECT_PREFIX` (see `config/mail.php` -> `brand.subject_prefix`) + * or disabled per call by passing `prefix: false`. + */ +trait HasBrandedSubject +{ + protected function brandedSubject(string $subject, bool $prefix = true): static + { + $prefixValue = $prefix + ? (string) config('mail.brand.subject_prefix', '') + : ''; + + return $this->subject($prefixValue.$subject); + } +} diff --git a/app/Mail/ContactUs.php b/app/Mail/ContactUs.php index f5312adb3..599cd4646 100644 --- a/app/Mail/ContactUs.php +++ b/app/Mail/ContactUs.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class ContactUs extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; public string $mailFrom; @@ -18,24 +19,32 @@ class ContactUs extends Mailable public string $mailTo; - /** - * Create a new message instance. - */ + public string $site; + + public ?string $preheader; + public function __construct(mixed $mailTo, mixed $mailFrom, mixed $mailBody) { - $this->mailTo = $mailTo; - $this->mailFrom = $mailFrom; - $this->mailBody = $mailBody; + $this->mailTo = (string) $mailTo; + $this->mailFrom = (string) $mailFrom; + $this->mailBody = (string) $mailBody; + $this->site = (string) config('app.name'); + $this->preheader = "New contact form submission from {$this->mailFrom}."; } /** - * Build the message. - * - * * @throws \Exception */ public function build(): static { - return $this->from($this->mailTo)->subject('Contact form submitted')->replyTo($this->mailFrom)->view('emails.contactUs')->with(['mailBody' => $this->mailBody]); + return $this->from($this->mailTo) + ->replyTo($this->mailFrom) + ->brandedSubject('Contact form submitted') + ->markdown('emails.markdown.contactUs', [ + 'mailBody' => $this->mailBody, + 'mailFrom' => $this->mailFrom, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/ForgottenPassword.php b/app/Mail/ForgottenPassword.php index dbbc72426..9667a30f3 100644 --- a/app/Mail/ForgottenPassword.php +++ b/app/Mail/ForgottenPassword.php @@ -4,41 +4,43 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class ForgottenPassword extends Mailable { - use Queueable, SerializesModels; - - public mixed $user; + use HasBrandedSubject, Queueable, SerializesModels; public string $resetLink; - private mixed $siteEmail; + public string $site; - private mixed $siteTitle; + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - */ public function __construct(string $resetLink) { $this->resetLink = $resetLink; - $this->siteEmail = config('mail.from.address'); + $this->siteEmail = (string) config('mail.from.address'); $siteName = config('app.name'); - $this->siteTitle = is_array($siteName) ? (string) ($siteName[0] ?? '') : (string) ($siteName ?? ''); + $this->site = is_array($siteName) ? (string) ($siteName[0] ?? '') : (string) ($siteName ?? ''); + $this->preheader = 'Reset your password using the secure link inside.'; } /** - * Build the message. - * - * * @throws \Exception */ public function build(): static { - return $this->from($this->siteEmail)->subject('Forgotten password reset')->view('emails.forgottenPassword')->with(['resetLink' => $this->resetLink, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('Reset your password') + ->markdown('emails.markdown.forgottenPassword', [ + 'resetLink' => $this->resetLink, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/IncidentDetected.php b/app/Mail/IncidentDetected.php index e47a5fd36..82d7cf65b 100644 --- a/app/Mail/IncidentDetected.php +++ b/app/Mail/IncidentDetected.php @@ -4,34 +4,46 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\ServiceIncident; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class IncidentDetected extends Mailable +class IncidentDetected extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; public function __construct( - private readonly ServiceIncident $incident, - private readonly bool $resolved = false - ) {} + public readonly ServiceIncident $incident, + public readonly bool $resolved = false, + ) { + // Incident notifications take priority over routine user mail so they + // are routed onto a dedicated high-priority queue (overridable via env). + $this->onQueue((string) config('mail.brand.incident_queue', 'incidents')); + } public function build(): static { - $site = config('app.name'); + $site = (string) config('app.name'); $status = $this->resolved ? 'Resolved' : 'Detected'; - $services = $this->incident->services->pluck('name')->join(', '); + $services = (string) $this->incident->services->pluck('name')->join(', '); + $impact = ucfirst($this->incident->impact->value); + $preheader = $this->resolved + ? "{$impact} incident on {$services} has been resolved." + : "{$impact} impact incident detected on {$services}."; - return $this->from(config('mail.from.address')) - ->subject("[{$site}] Service Incident {$status}: {$services}") + return $this->from((string) config('mail.from.address')) + ->brandedSubject("Service incident {$status}: {$services}") ->view('emails.incidentDetected') + ->text('emails.text.incidentDetected') ->with([ 'incident' => $this->incident, 'services' => $services, 'resolved' => $this->resolved, 'site' => $site, + 'preheader' => $preheader, 'statusUrl' => url('/admin/status'), ]); } diff --git a/app/Mail/InvitationMail.php b/app/Mail/InvitationMail.php index 630ef13d2..8240fe06c 100644 --- a/app/Mail/InvitationMail.php +++ b/app/Mail/InvitationMail.php @@ -4,58 +4,57 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\Invitation; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class InvitationMail extends Mailable +class InvitationMail extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; public Invitation $invitation; - /** - * Create a new message instance. - */ public function __construct(Invitation $invitation) { $this->invitation = $invitation; + $this->onQueue((string) config('mail.brand.queue', 'emails')); } - /** - * Get the message envelope. - */ public function envelope(): Envelope { return new Envelope( - subject: 'You\'re invited to join '.config('app.name'), + from: (string) config('mail.from.address'), + subject: (string) config('mail.brand.subject_prefix', '').'You\'re invited to join '.config('app.name'), ); } - /** - * Get the message content definition. - */ public function content(): Content { + $siteName = (string) config('app.name'); + $invitedByName = (string) ($this->invitation->invitedBy->username ?? 'A member'); + return new Content( view: 'emails.invitation', + text: 'emails.text.invitation', with: [ 'invitation' => $this->invitation, 'invitedBy' => $this->invitation->invitedBy, + 'invitedByName' => $invitedByName, 'registerUrl' => route('register', ['token' => $this->invitation->token]), 'expiresAt' => $this->invitation->expires_at, - 'siteName' => config('app.name'), + 'siteName' => $siteName, + 'preheader' => "{$invitedByName} has invited you to join {$siteName}.", ], ); } /** - * Get the attachments for the message. - * * @return array */ public function attachments(): array diff --git a/app/Mail/NewAccountCreatedEmail.php b/app/Mail/NewAccountCreatedEmail.php index 04ab072ec..e39d80cd1 100644 --- a/app/Mail/NewAccountCreatedEmail.php +++ b/app/Mail/NewAccountCreatedEmail.php @@ -4,47 +4,39 @@ declare(strict_types=1); namespace App\Mail; -use App\Models\User; +use App\Mail\Concerns\HasBrandedSubject; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class NewAccountCreatedEmail extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - /** - * @var User - */ - private $user; + public string $username; - /** - * @var mixed - */ - private $siteEmail; + public string $site; - /** - * @var mixed - */ - private $siteTitle; + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - * - * @return void - */ public function __construct(mixed $user) { - $this->user = $user; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = "New user registered on {$this->site}."; } - /** - * Build the message. - */ public function build(): static { - return $this->from($this->siteEmail)->subject('New account registered')->view('emails.newAccountCreated')->with(['username' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('New account registered') + ->markdown('emails.markdown.newAccountCreated', [ + 'username' => $this->username, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/PasswordReset.php b/app/Mail/PasswordReset.php index 37bdac221..321b1cbe2 100644 --- a/app/Mail/PasswordReset.php +++ b/app/Mail/PasswordReset.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,47 +12,39 @@ use Illuminate\Queue\SerializesModels; class PasswordReset extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - /** - * @var User - */ - public $user; + public string $username; - /** - * @var string - */ - public $newPass; + public string $newPass; - /** - * @var mixed - */ - private $siteEmail; + public string $site; - /** - * @var mixed - */ - private $siteTitle; + public ?string $preheader; - /** - * PasswordReset constructor. - */ - public function __construct(User $user, mixed $newPass) + private string $siteEmail; + + public function __construct(User $user, string $newPass) { - $this->user = $user; + $this->username = (string) $user->username; $this->newPass = $newPass; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->siteEmail = (string) config('mail.from.address'); + $this->site = (string) config('app.name'); + $this->preheader = 'Your password has been reset — temporary credentials inside.'; } /** - * Build the message. - * - * * @throws \Exception */ public function build(): static { - return $this->from($this->siteEmail)->subject('Password reset')->view('emails.passwordReset')->with(['newPass' => $this->newPass, 'userName' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('Your password has been reset') + ->markdown('emails.markdown.passwordReset', [ + 'username' => $this->username, + 'newPass' => $this->newPass, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Mail/SendInvite.php b/app/Mail/SendInvite.php deleted file mode 100644 index c9c7f0901..000000000 --- a/app/Mail/SendInvite.php +++ /dev/null @@ -1,57 +0,0 @@ -user = $user; - $this->invite = $invite; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); - } - - /** - * Build the message. - * - * - * @throws \Exception - */ - public function build(): static - { - return $this->from($this->siteEmail)->subject('Invite received')->view('emails.sendinvite')->with(['invite' => $this->invite, 'username' => $this->user['username'], 'site' => $this->siteTitle, 'email' => $this->user['email']]); - } -} diff --git a/app/Mail/WelcomeEmail.php b/app/Mail/WelcomeEmail.php index 1f27f9b88..c498a6801 100644 --- a/app/Mail/WelcomeEmail.php +++ b/app/Mail/WelcomeEmail.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Mail\Concerns\HasBrandedSubject; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,38 +12,32 @@ use Illuminate\Queue\SerializesModels; class WelcomeEmail extends Mailable { - use Queueable, SerializesModels; + use HasBrandedSubject, Queueable, SerializesModels; - /** - * @var User - */ - private $user; + public string $username; - /** - * @var mixed - */ - private $siteEmail; + public string $site; - /** - * @var mixed - */ - private $siteTitle; + public ?string $preheader; + + private string $siteEmail; - /** - * Create a new message instance. - */ public function __construct(User $user) { - $this->user = $user; - $this->siteEmail = config('mail.from.address'); - $this->siteTitle = config('app.name'); + $this->username = (string) $user->username; + $this->site = (string) config('app.name'); + $this->siteEmail = (string) config('mail.from.address'); + $this->preheader = "Welcome to {$this->site} — your account is ready."; } - /** - * Build the message. - */ public function build(): static { - return $this->from($this->siteEmail)->subject('Welcome to '.$this->siteTitle)->view('emails.welcome')->with(['username' => $this->user->username, 'site' => $this->siteTitle]); + return $this->from($this->siteEmail) + ->brandedSubject('Welcome to '.$this->site) + ->markdown('emails.markdown.welcome', [ + 'username' => $this->username, + 'site' => $this->site, + 'preheader' => $this->preheader, + ]); } } diff --git a/app/Models/User.php b/app/Models/User.php index 5fe49c1d3..5d20b7859 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,11 +8,11 @@ use App\Enums\SignupError; use App\Enums\UserRole; use App\Jobs\SendAccountExpiredEmail; use App\Jobs\SendAccountWillExpireEmail; +use App\Notifications\VerifyEmailBranded; use App\Rules\ValidEmailDomain; use App\Services\InvitationService; use Carbon\CarbonImmutable; use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; -use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -402,11 +402,15 @@ final class User extends Authenticatable implements HasPasskeys, MustVerifyEmail } /** - * Send the standard Laravel email verification notification. + * Send the branded email verification notification. + * + * Uses {@see VerifyEmailBranded} so the verification email renders with + * the same themed Markdown components as the rest of the site's + * transactional emails (instead of Laravel's default notification look). */ public function sendEmailVerificationNotification(): void { - $this->notify(new VerifyEmail); + $this->notify(new VerifyEmailBranded); } /** diff --git a/app/Notifications/VerifyEmailBranded.php b/app/Notifications/VerifyEmailBranded.php new file mode 100644 index 000000000..2f18bf780 --- /dev/null +++ b/app/Notifications/VerifyEmailBranded.php @@ -0,0 +1,45 @@ +onQueue((string) config('mail.brand.queue', 'emails')); + } + + public function toMail($notifiable): MailMessage + { + $url = $this->verificationUrl($notifiable); + $site = (string) config('app.name'); + $prefix = (string) config('mail.brand.subject_prefix', ''); + $username = (string) ($notifiable->username ?? $notifiable->name ?? ''); + + return (new MailMessage) + ->subject($prefix.'Verify your email address') + ->markdown('emails.markdown.verify', [ + 'url' => $url, + 'site' => $site, + 'username' => $username, + 'preheader' => "Confirm your email address to activate your {$site} account.", + ]); + } +} diff --git a/config/mail.php b/config/mail.php index fd2f75bbe..ecaf0857e 100644 --- a/config/mail.php +++ b/config/mail.php @@ -1,7 +1,7 @@ env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')), + 'default' => env('MAIL_MAILER', 'smtp'), 'mailers' => [ 'smtp' => [ @@ -48,4 +48,26 @@ return [ ], ], + /* + |-------------------------------------------------------------------------- + | Mail Branding + |-------------------------------------------------------------------------- + | + | Shared branding values consumed by both Markdown mailables (themed via + | resources/views/vendor/mail/*) and Blade mailables (rendered through the + | component). The `subject_prefix` is automatically applied + | by the `App\Mail\Concerns\HasBrandedSubject` trait so every email subject + | reads "[] " unless an explicit override is supplied. + | Set `MAIL_BRAND_LOGO_URL` to a publicly-reachable absolute URL to swap + | the text branding for an inline logo. + | + */ + + 'brand' => [ + 'logo_url' => env('MAIL_BRAND_LOGO_URL'), + 'subject_prefix' => env('MAIL_SUBJECT_PREFIX', '['.env('APP_NAME', 'NNTmux').'] '), + 'queue' => env('MAIL_QUEUE', 'emails'), + 'incident_queue' => env('MAIL_INCIDENT_QUEUE', 'incidents'), + ], + ]; diff --git a/config/services.php b/config/services.php index b8ffaf900..d1f0767fc 100644 --- a/config/services.php +++ b/config/services.php @@ -1,6 +1,6 @@ 'info', +]) +@php + $typeClass = in_array($type, ['info', 'success', 'warning', 'danger'], true) ? $type : 'info'; +@endphp +
{{ $slot }}
diff --git a/resources/views/components/mail/button.blade.php b/resources/views/components/mail/button.blade.php new file mode 100644 index 000000000..190764293 --- /dev/null +++ b/resources/views/components/mail/button.blade.php @@ -0,0 +1,16 @@ +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) +@php + $colorClass = match ($color) { + 'success' => 'button button-success', + 'danger' => 'button button-danger', + 'secondary' => 'button button-secondary', + default => 'button', + }; +@endphp + diff --git a/resources/views/components/mail/info-box.blade.php b/resources/views/components/mail/info-box.blade.php new file mode 100644 index 000000000..49c304061 --- /dev/null +++ b/resources/views/components/mail/info-box.blade.php @@ -0,0 +1,7 @@ +@props([ + 'variant' => 'info', +]) +@php + $variantClass = $variant === 'warning' ? 'warning-box' : 'info-box'; +@endphp +
{{ $slot }}
diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php new file mode 100644 index 000000000..e43272dc1 --- /dev/null +++ b/resources/views/components/mail/layout.blade.php @@ -0,0 +1,214 @@ +@props([ + 'title' => null, + 'preheader' => null, + 'siteName' => null, +]) +@php + $brandName = $siteName ?? config('app.name'); + $brandUrl = config('app.url'); + $logoUrl = config('mail.brand.logo_url'); + $renderedTitle = $title ?? $brandName; +@endphp + + + + + + + + + {{ $renderedTitle }} + + + + @if ($preheader) + {{ $preheader }} + @endif + + + diff --git a/resources/views/components/mail/status-table.blade.php b/resources/views/components/mail/status-table.blade.php new file mode 100644 index 000000000..4d77072f0 --- /dev/null +++ b/resources/views/components/mail/status-table.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/resources/views/emails/accountAboutToExpire.blade.php b/resources/views/emails/accountAboutToExpire.blade.php deleted file mode 100644 index ddcd98dc3..000000000 --- a/resources/views/emails/accountAboutToExpire.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Account Expiration Notice -@endsection - -@section('site_name', $site) - -@section('content') -

Dear {{ $username }},

- -
- ⚠️ Attention Required: Your account role {{ $account }} is about to expire in less than {{ $days }} day(s). -
- -

To continue enjoying uninterrupted access to all your current features and benefits, please take action before your subscription expires.

- -

If you have any questions about renewing your account or need assistance, please don't hesitate to contact us.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/accountChange.blade.php b/resources/views/emails/accountChange.blade.php deleted file mode 100644 index 768112282..000000000 --- a/resources/views/emails/accountChange.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Account Level Changed -@endsection - -@section('site_name', $site) - -@section('content') -

Dear {{ $username }},

- -

We wanted to let you know that your account level has been updated.

- -
- 🔄 New Account Level: {{ $account }} -
- -

Your new account level is now active, and you can start enjoying all the associated features and benefits immediately.

- -

If you have any questions about your new account level or need assistance, please don't hesitate to contact us.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/accountDelete.blade.php b/resources/views/emails/accountDelete.blade.php deleted file mode 100644 index 961ad5cde..000000000 --- a/resources/views/emails/accountDelete.blade.php +++ /dev/null @@ -1,22 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - User Account Deleted -@endsection - -@section('site_name', $site) - -@section('content') -

This is a notification that a user has deleted their account from {{ $site }}.

- -
- 👤 Deleted Account: {{ $username }} -
- -

No further action is required. This email is for informational purposes only.

- -
-

Best regards,

-

The {{ $site }} System

-
-@endsection diff --git a/resources/views/emails/accountExpired.blade.php b/resources/views/emails/accountExpired.blade.php deleted file mode 100644 index 2ede4244f..000000000 --- a/resources/views/emails/accountExpired.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Account Expired -@endsection - -@section('site_name', $site) - -@section('content') -

Dear {{ $username }},

- -

We regret to inform you that your account subscription has expired.

- -
- 📉 Account Downgraded: Your account has been automatically downgraded to {{ $account }}. -
- -

Don't worry – you can still access the site with your downgraded account level. If you'd like to regain access to all your previous features and benefits, please consider renewing your subscription.

- -

If you have any questions or need assistance with the renewal process, please don't hesitate to contact us.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/contactUs.blade.php b/resources/views/emails/contactUs.blade.php deleted file mode 100644 index 475652928..000000000 --- a/resources/views/emails/contactUs.blade.php +++ /dev/null @@ -1,18 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Contact Form Submission -@endsection - -@section('content') -

A new contact form has been submitted.

- -
- 📝 Message: -
- {!! nl2br(e($mailBody)) !!} -
-
- -

Please respond to this inquiry at your earliest convenience.

-@endsection diff --git a/resources/views/emails/email_layout.blade.php b/resources/views/emails/email_layout.blade.php deleted file mode 100644 index c08b5e269..000000000 --- a/resources/views/emails/email_layout.blade.php +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - @yield('title') - - - - - - diff --git a/resources/views/emails/forgottenPassword.blade.php b/resources/views/emails/forgottenPassword.blade.php deleted file mode 100644 index fa9ef8a62..000000000 --- a/resources/views/emails/forgottenPassword.blade.php +++ /dev/null @@ -1,29 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Password Reset Request -@endsection - -@section('site_name', $site) - -@section('content') -

Hello,

- -

We received a request to reset the password associated with this email address.

- - - -

Or copy and paste this link into your browser:

- {{ $resetLink }} - -
- 🔒 Security Notice: If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged. -
- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/incidentDetected.blade.php b/resources/views/emails/incidentDetected.blade.php index 293f58726..793bdafe4 100644 --- a/resources/views/emails/incidentDetected.blade.php +++ b/resources/views/emails/incidentDetected.blade.php @@ -1,34 +1,35 @@ -@extends('emails.email_layout') - -@section('title') - {{ $resolved ? 'Incident Resolved' : 'Incident Detected' }} — {{ $services }} -@endsection - -@section('site_name', $site) - -@section('content') - @if($resolved) -
- ✅ Resolved — The following incident has been automatically resolved. -
+@php + $title = $resolved + ? "Incident Resolved — {$services}" + : "Incident Detected — {$services}"; + $impactValue = $incident->impact->value; + $alertType = $resolved + ? 'success' + : ($impactValue === 'critical' ? 'danger' : ($impactValue === 'major' ? 'warning' : 'info')); +@endphp + + @if ($resolved) + + Resolved — The following incident has been automatically resolved. + @else -
- ⚠️ {{ ucfirst($incident->impact->value) }} Impact — An automated health check has detected a service issue. -
+ + {{ ucfirst($impactValue) }} impact — An automated health check has detected a service issue. + @endif - + - + - + @@ -38,30 +39,30 @@ - @if($resolved && $incident->resolved_at) - - - - - - - - + @if ($resolved && $incident->resolved_at) + + + + + + + + @endif -
Incident {{ $incident->title }}
Affected ServicesAffected services {{ $services }}
Impact{{ ucfirst($incident->impact->value) }}{{ ucfirst($impactValue) }}
StatusStarted {{ $incident->started_at->format('M j, Y g:i A T') }}
Resolved{{ $incident->resolved_at->format('M j, Y g:i A T') }}
Duration{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}
Resolved{{ $incident->resolved_at->format('M j, Y g:i A T') }}
Duration{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}
+ - @if($incident->description) -
+ @if ($incident->description) + Details:
{!! nl2br(e($incident->description)) !!} -
+ @endif -

- View Status Dashboard -

+ + View status dashboard +

— {{ $site }} Monitoring

-@endsection +
diff --git a/resources/views/emails/invitation.blade.php b/resources/views/emails/invitation.blade.php index 1ebad59eb..e3c3973e0 100644 --- a/resources/views/emails/invitation.blade.php +++ b/resources/views/emails/invitation.blade.php @@ -1,35 +1,31 @@ -@extends('emails.email_layout') + -@section('title') - You're Invited to {{ $siteName }}! -@endsection - -@section('site_name', $siteName) - -@section('content')

Hello!

-

Great news! {{ $invitedBy->username }} has invited you to join {{ $siteName }}, our exclusive community.

+

Great news! {{ $invitedByName }} has invited you to join {{ $siteName }}, our exclusive community.

- + Accept invitation -

Or copy and paste this link into your browser:

+

If the button above doesn't work, copy and paste this link into your browser:

{{ $registerUrl }} -
- ⚠️ Important: This invitation will expire on {{ $expiresAt->format('F j, Y \a\t g:i A') }}. Please complete your registration before this date. -
+ + Heads up: This invitation expires on + {{ $expiresAt->format('F j, Y \a\t g:i A') }}. + Please complete your registration before then. + -
- 💡 What's Next? Click the button above to create your account and start exploring everything {{ $siteName }} has to offer. -
+ + What's next? Click the button above to create your account and start exploring everything {{ $siteName }} has to offer. + -

If you have any questions, please don't hesitate to contact us after registering.

+

If you have any questions, feel free to contact us once you've registered.

-

Welcome aboard!

+

Welcome aboard,

The {{ $siteName }} Team

-@endsection +
diff --git a/resources/views/emails/markdown/accountChange.blade.php b/resources/views/emails/markdown/accountChange.blade.php new file mode 100644 index 000000000..199d3326b --- /dev/null +++ b/resources/views/emails/markdown/accountChange.blade.php @@ -0,0 +1,18 @@ +@component('mail::message') +# Account level changed + +Dear {{ $username }}, + +We wanted to let you know that your account level has been updated. + +@component('mail::panel') +**New account level:** {{ $account }} +@endcomponent + +Your new level is now active and you can start enjoying all the associated features and benefits immediately. + +If you have any questions about your new account level or need assistance, please don't hesitate to contact us. + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/accountDeleted.blade.php b/resources/views/emails/markdown/accountDeleted.blade.php new file mode 100644 index 000000000..838b6df93 --- /dev/null +++ b/resources/views/emails/markdown/accountDeleted.blade.php @@ -0,0 +1,13 @@ +@component('mail::message') +# User account deleted + +This is a notification that a user has deleted their account from **{{ $site }}**. + +@component('mail::panel') +**Deleted account:** {{ $username }} +@endcomponent + +No further action is required. This email is for informational purposes only. + +— The {{ $site }} System +@endcomponent diff --git a/resources/views/emails/markdown/accountExpired.blade.php b/resources/views/emails/markdown/accountExpired.blade.php new file mode 100644 index 000000000..dff394afc --- /dev/null +++ b/resources/views/emails/markdown/accountExpired.blade.php @@ -0,0 +1,18 @@ +@component('mail::message') +# Account expired + +Dear {{ $username }}, + +We regret to inform you that your account subscription has expired. + +@component('mail::panel') +**Account downgraded:** Your account has been automatically downgraded to **{{ $account }}**. +@endcomponent + +Don't worry — you can still access the site with your downgraded account level. If you'd like to regain access to all your previous features and benefits, please consider renewing your subscription. + +If you have any questions or need help with the renewal process, please don't hesitate to contact us. + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/accountWillExpire.blade.php b/resources/views/emails/markdown/accountWillExpire.blade.php new file mode 100644 index 000000000..df69c51fb --- /dev/null +++ b/resources/views/emails/markdown/accountWillExpire.blade.php @@ -0,0 +1,16 @@ +@component('mail::message') +# Account expiration notice + +Dear {{ $username }}, + +@component('mail::panel') +**Heads up:** Your **{{ $account }}** role expires in less than **{{ $days }} day(s)**. +@endcomponent + +To continue enjoying uninterrupted access to all your current features and benefits, please take action before your subscription expires. + +If you have any questions about renewing your account, please don't hesitate to reach out. + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/contactUs.blade.php b/resources/views/emails/markdown/contactUs.blade.php new file mode 100644 index 000000000..577789b4c --- /dev/null +++ b/resources/views/emails/markdown/contactUs.blade.php @@ -0,0 +1,17 @@ +@component('mail::message') +# Contact form submission + +A new contact form has been submitted on **{{ $site }}**. + +@component('mail::panel') +**From:** {{ $mailFrom }} + +**Message:** + +{!! nl2br(e($mailBody)) !!} +@endcomponent + +Reply directly to this email to respond — the reply-to header is set to the sender. + +— The {{ $site }} System +@endcomponent diff --git a/resources/views/emails/markdown/forgottenPassword.blade.php b/resources/views/emails/markdown/forgottenPassword.blade.php new file mode 100644 index 000000000..7e4f8adef --- /dev/null +++ b/resources/views/emails/markdown/forgottenPassword.blade.php @@ -0,0 +1,22 @@ +@component('mail::message') +# Password reset request + +Hello, + +We received a request to reset the password associated with this email address. + +@component('mail::button', ['url' => $resetLink]) +Reset password +@endcomponent + +If the button above doesn't work, copy and paste this link into your browser: + +{{ $resetLink }} + +@component('mail::panel') +**Security notice:** If you didn't request this password reset, you can safely ignore this email — your password will remain unchanged. +@endcomponent + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/newAccountCreated.blade.php b/resources/views/emails/markdown/newAccountCreated.blade.php new file mode 100644 index 000000000..4df2b6aef --- /dev/null +++ b/resources/views/emails/markdown/newAccountCreated.blade.php @@ -0,0 +1,13 @@ +@component('mail::message') +# New user registration + +A new user has registered on **{{ $site }}**. + +@component('mail::panel') +**New user:** {{ $username }} +@endcomponent + +This is an automated notification — no action is required unless you need to review the new registration. + +— The {{ $site }} System +@endcomponent diff --git a/resources/views/emails/markdown/passwordReset.blade.php b/resources/views/emails/markdown/passwordReset.blade.php new file mode 100644 index 000000000..aa4a89c0e --- /dev/null +++ b/resources/views/emails/markdown/passwordReset.blade.php @@ -0,0 +1,20 @@ +@component('mail::message') +# Password reset successful + +Dear {{ $username }}, + +Your password has been successfully reset. + +@component('mail::panel') +**Your new temporary password:** + +`{{ $newPass }}` +@endcomponent + +For your security, we strongly recommend logging in and changing this password to something memorable as soon as possible. + +If you did not request this reset, please contact us immediately. + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/verify.blade.php b/resources/views/emails/markdown/verify.blade.php new file mode 100644 index 000000000..d5da21708 --- /dev/null +++ b/resources/views/emails/markdown/verify.blade.php @@ -0,0 +1,24 @@ +@component('mail::message') +# Verify your email address + +@if ($username !== '') +Hi {{ $username }}, +@else +Hi there, +@endif + +Thanks for signing up for **{{ $site }}**! Please confirm your email address by clicking the button below. + +@component('mail::button', ['url' => $url]) +Verify email address +@endcomponent + +If you did not create an account, no further action is required — you can safely ignore this email. + +If you're having trouble clicking the verify button, copy and paste this URL into your browser: + +{{ $url }} + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/markdown/welcome.blade.php b/resources/views/emails/markdown/welcome.blade.php new file mode 100644 index 000000000..6b5b52758 --- /dev/null +++ b/resources/views/emails/markdown/welcome.blade.php @@ -0,0 +1,16 @@ +@component('mail::message') +# Welcome to {{ $site }} + +Dear {{ $username }}, + +We're thrilled to have you join our community. + +@component('mail::panel') +**Next step:** A separate verification email is on its way. Please check your inbox and click the verification link to activate your account. +@endcomponent + +If you have any questions or need assistance, just reach out — we're happy to help. + +Best regards,
+The {{ $site }} Team +@endcomponent diff --git a/resources/views/emails/newAccountCreated.blade.php b/resources/views/emails/newAccountCreated.blade.php deleted file mode 100644 index f7a0ed707..000000000 --- a/resources/views/emails/newAccountCreated.blade.php +++ /dev/null @@ -1,22 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - New User Registration -@endsection - -@section('site_name', $site) - -@section('content') -

A new user has registered on {{ $site }}.

- -
- 👤 New User: {{ $username }} -
- -

This is an automated notification. No action is required unless you need to review the new registration.

- -
-

Best regards,

-

The {{ $site }} System

-
-@endsection diff --git a/resources/views/emails/passwordReset.blade.php b/resources/views/emails/passwordReset.blade.php deleted file mode 100644 index b6ff89128..000000000 --- a/resources/views/emails/passwordReset.blade.php +++ /dev/null @@ -1,29 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Password Reset Successful -@endsection - -@section('site_name', $site) - -@section('content') -

Dear {{ $userName }},

- -

Your password has been successfully reset.

- -
- 🔑 Your New Password: -
{{ $newPass }}
-
- -
- 🔒 Security Recommendation: For your security, we strongly recommend changing this password to something memorable after logging in. -
- -

If you did not request this password reset, please contact us immediately.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/sendinvite.blade.php b/resources/views/emails/sendinvite.blade.php deleted file mode 100644 index 9f957c5cb..000000000 --- a/resources/views/emails/sendinvite.blade.php +++ /dev/null @@ -1,31 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - You've Been Invited! -@endsection - -@section('site_name', $site) - -@section('content') -

Hello {{ $email }},

- -

Great news! {{ $username }} has invited you to join {{ $site }}.

- - - -

Or copy and paste this link into your browser:

- {{ $invite }} - -
- 💡 What's Next? Click the button above to create your account and start exploring everything {{ $site }} has to offer. -
- -

If you have any questions, feel free to reach out to us after registering.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/emails/text/incidentDetected.blade.php b/resources/views/emails/text/incidentDetected.blade.php new file mode 100644 index 000000000..a903327d5 --- /dev/null +++ b/resources/views/emails/text/incidentDetected.blade.php @@ -0,0 +1,31 @@ +@php + $impactValue = $incident->impact->value; + $headline = $resolved ? 'Incident Resolved' : 'Incident Detected'; +@endphp +{{ $headline }} — {{ $services }} +{{ str_repeat('=', max(strlen($headline) + strlen($services) + 3, 30)) }} + +@if ($resolved) +✓ Resolved — The following incident has been automatically resolved. +@else +⚠ {{ ucfirst($impactValue) }} impact — An automated health check has detected a service issue. +@endif + + Incident: {{ $incident->title }} + Affected services: {{ $services }} + Impact: {{ ucfirst($impactValue) }} + Status: {{ ucfirst($incident->status->value) }} + Started: {{ $incident->started_at->format('M j, Y g:i A T') }} +@if ($resolved && $incident->resolved_at) + Resolved: {{ $incident->resolved_at->format('M j, Y g:i A T') }} + Duration: {{ $incident->started_at->diffForHumans($incident->resolved_at, true) }} +@endif + +@if ($incident->description) +Details: +{{ $incident->description }} + +@endif +View status dashboard: {{ $statusUrl }} + +— {{ $site }} Monitoring diff --git a/resources/views/emails/text/invitation.blade.php b/resources/views/emails/text/invitation.blade.php new file mode 100644 index 000000000..b6f3747de --- /dev/null +++ b/resources/views/emails/text/invitation.blade.php @@ -0,0 +1,17 @@ +You're invited to {{ $siteName }} +================================ + +Hello! + +{{ $invitedByName }} has invited you to join {{ $siteName }}, our exclusive community. + +Accept your invitation by visiting: +{{ $registerUrl }} + +This invitation expires on {{ $expiresAt->format('F j, Y \a\t g:i A') }}. +Please complete your registration before then. + +If you have any questions, feel free to contact us once you've registered. + +Welcome aboard, +The {{ $siteName }} Team diff --git a/resources/views/emails/welcome.blade.php b/resources/views/emails/welcome.blade.php deleted file mode 100644 index fd5871359..000000000 --- a/resources/views/emails/welcome.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -@extends('emails.email_layout') - -@section('title') - Welcome to {{ $site }} -@endsection - -@section('site_name', $site) - -@section('content') -

Dear {{ $username }},

- -

Welcome to {{ $site }}! We're thrilled to have you join our community.

- -
- 📧 Next Step: Your account verification email will be sent separately. Please check your inbox and verify your email address to complete the registration process. -
- -

If you have any questions or need assistance, don't hesitate to reach out to us.

- -
-

Best regards,

-

The {{ $site }} Team

-
-@endsection diff --git a/resources/views/vendor/mail/html/button.blade.php b/resources/views/vendor/mail/html/button.blade.php new file mode 100644 index 000000000..7d0b180f7 --- /dev/null +++ b/resources/views/vendor/mail/html/button.blade.php @@ -0,0 +1,24 @@ +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) + + + + + diff --git a/resources/views/vendor/mail/html/footer.blade.php b/resources/views/vendor/mail/html/footer.blade.php new file mode 100644 index 000000000..4a0de8f51 --- /dev/null +++ b/resources/views/vendor/mail/html/footer.blade.php @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/resources/views/vendor/mail/html/header.blade.php b/resources/views/vendor/mail/html/header.blade.php new file mode 100644 index 000000000..fadc78de6 --- /dev/null +++ b/resources/views/vendor/mail/html/header.blade.php @@ -0,0 +1,13 @@ +@props(['url']) + + + +@php($logoUrl = config('mail.brand.logo_url')) +@if ($logoUrl) + +@else +{!! $slot !!} +@endif + + + diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css new file mode 100644 index 000000000..67b65f844 --- /dev/null +++ b/resources/views/vendor/mail/html/themes/default.css @@ -0,0 +1,341 @@ +/* Base + * Brand: NNTmux blue color scheme (mirrored from resources/css/app.css `[data-color-scheme="blue"]`). + * CSS variables don't render in most mail clients, so values are inlined here. */ + +body, +body *:not(html):not(style):not(br):not(tr):not(code) { + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + position: relative; +} + +body { + -webkit-text-size-adjust: none; + background-color: #f8fafc; + color: #334155; + height: 100%; + line-height: 1.5; + margin: 0; + padding: 0; + width: 100% !important; +} + +p, +ul, +ol, +blockquote { + line-height: 1.5; + text-align: start; +} + +a { + color: #2563eb; +} + +a img { + border: none; +} + +/* Typography */ + +h1 { + color: #0f172a; + font-size: 22px; + font-weight: 700; + margin-top: 0; + text-align: start; +} + +h2 { + color: #0f172a; + font-size: 18px; + font-weight: 600; + margin-top: 0; + text-align: start; +} + +h3 { + color: #0f172a; + font-size: 15px; + font-weight: 600; + margin-top: 0; + text-align: left; +} + +p { + font-size: 16px; + line-height: 1.6; + margin-top: 0; + text-align: left; + color: #334155; +} + +p.sub { + font-size: 12px; + color: #64748b; +} + +img { + max-width: 100%; +} + +/* Layout */ + +.wrapper { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #f8fafc; + margin: 0; + padding: 0; + width: 100%; +} + +.content { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +/* Header */ + +.header { + padding: 28px 0 18px; + text-align: center; + background-color: #f8fafc; +} + +.header a { + color: #0f172a; + font-size: 22px; + font-weight: 700; + text-decoration: none; + letter-spacing: -0.01em; +} + +/* Logo */ + +.logo { + height: 56px; + margin-top: 4px; + margin-bottom: 6px; + max-height: 56px; + width: auto; +} + +/* Body */ + +.body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #f8fafc; + border-bottom: 1px solid #f8fafc; + border-top: 1px solid #f8fafc; + margin: 0; + padding: 0; + width: 100%; +} + +.inner-body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 600px; + background-color: #ffffff; + border-color: #e2e8f0; + border-radius: 10px; + border-style: solid; + border-width: 1px; + box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.06), 0 1px 2px -1px rgba(15, 23, 42, 0.06); + margin: 0 auto; + padding: 0; + width: 600px; +} + +/* Body prose links only — do not target `.button` anchors or CTA label text + becomes the same blue as the fill after CSS inlining (looks "blank"). */ +.inner-body .content-cell p a, +.inner-body .content-cell li a, +.inner-body .content-cell td a, +.inner-body .content-cell th a, +.inner-body .content-cell blockquote a, +.inner-body .panel-content a, +.inner-body .subcopy a { + color: #2563eb; + word-break: break-all; +} + +/* Subcopy */ + +.subcopy { + border-top: 1px solid #e2e8f0; + margin-top: 28px; + padding-top: 24px; +} + +.subcopy p { + font-size: 13px; + color: #64748b; +} + +/* Footer */ + +.footer { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 600px; + margin: 0 auto; + padding: 0; + text-align: center; + width: 600px; +} + +.footer p { + color: #94a3b8; + font-size: 12px; + line-height: 1.6; + text-align: center; +} + +.footer a { + color: #64748b; + text-decoration: underline; +} + +/* Tables */ + +.table table { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 24px auto; + width: 100%; +} + +.table th { + border-bottom: 1px solid #e2e8f0; + color: #0f172a; + font-size: 14px; + font-weight: 600; + margin: 0; + padding-bottom: 10px; + text-align: left; +} + +.table td { + color: #334155; + font-size: 14px; + line-height: 1.5; + margin: 0; + padding: 10px 0; + border-bottom: 1px solid #f1f5f9; +} + +.content-cell { + max-width: 100vw; + padding: 36px 40px; +} + +/* Buttons */ + +.action { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 28px auto; + padding: 0; + text-align: center; + width: 100%; + float: unset; +} + +.button { + -webkit-text-size-adjust: none; + border-radius: 6px; + color: #fff; + display: inline-block; + overflow: hidden; + text-decoration: none; + font-weight: 600; + font-size: 15px; + letter-spacing: 0.01em; +} + +.button-blue, +.button-primary { + background-color: #2563eb; + border-bottom: 10px solid #2563eb; + border-left: 22px solid #2563eb; + border-right: 22px solid #2563eb; + border-top: 10px solid #2563eb; +} + +.button-green, +.button-success { + background-color: #16a34a; + border-bottom: 10px solid #16a34a; + border-left: 22px solid #16a34a; + border-right: 22px solid #16a34a; + border-top: 10px solid #16a34a; +} + +.button-red, +.button-error { + background-color: #dc2626; + border-bottom: 10px solid #dc2626; + border-left: 22px solid #dc2626; + border-right: 22px solid #dc2626; + border-top: 10px solid #dc2626; +} + +/* Keep CTA label readable if a generic `a { color: … }` or inliner order wins */ +.inner-body a.button, +.inner-body a.button-primary, +.inner-body a.button-blue, +.inner-body a.button-success, +.inner-body a.button-green, +.inner-body a.button-error, +.inner-body a.button-red { + color: #ffffff !important; + -webkit-text-fill-color: #ffffff; + text-decoration: none !important; +} + +/* Panels */ + +.panel { + border-left: #2563eb solid 4px; + border-radius: 0 6px 6px 0; + margin: 22px 0; + overflow: hidden; +} + +.panel-content { + background-color: #eff6ff; + color: #1e3a8a; + padding: 16px 20px; +} + +.panel-content p { + color: #1e3a8a; + font-size: 15px; +} + +.panel-item { + padding: 0; +} + +.panel-item p:last-of-type { + margin-bottom: 0; + padding-bottom: 0; +} + +/* Utilities */ + +.break-all { + word-break: break-all; +} diff --git a/tests/Feature/Mail/MailRenderingTest.php b/tests/Feature/Mail/MailRenderingTest.php new file mode 100644 index 000000000..a63f87efd --- /dev/null +++ b/tests/Feature/Mail/MailRenderingTest.php @@ -0,0 +1,257 @@ + 'NNTmux', + 'app.url' => 'http://localhost', + 'mail.from.address' => 'noreply@example.test', + 'mail.brand.subject_prefix' => '[NNTmux] ', + 'mail.brand.queue' => 'emails', + 'mail.brand.incident_queue' => 'incidents', + ]); + } + + /** + * @return iterable + */ + public static function mailableFactoryProvider(): iterable + { + $userFactory = static function (): User { + $user = new User; + $user->username = 'tester'; + $user->email = 'tester@example.test'; + $user->setRelation('role', new class + { + public string $name = 'Silver'; + }); + + return $user; + }; + + yield 'WelcomeEmail' => [static fn () => new WelcomeEmail($userFactory())]; + yield 'ForgottenPassword' => [static fn () => new ForgottenPassword('https://example.test/reset/abc123')]; + yield 'PasswordReset' => [static fn () => new PasswordReset($userFactory(), 'TempPass!2026')]; + yield 'NewAccountCreatedEmail' => [static fn () => new NewAccountCreatedEmail($userFactory())]; + yield 'AccountChange' => [static fn () => new AccountChange($userFactory())]; + yield 'AccountWillExpire' => [static fn () => new AccountWillExpire($userFactory(), 5)]; + yield 'AccountExpired' => [static fn () => new AccountExpired($userFactory())]; + yield 'AccountDeleted' => [static fn () => new AccountDeleted($userFactory())]; + yield 'ContactUs' => [static fn () => new ContactUs('admin@example.test', 'visitor@example.test', "Hello!\nQuestion inside.")]; + } + + #[DataProvider('mailableFactoryProvider')] + public function test_themed_mailable_renders_with_brand_palette_and_no_legacy_purple(\Closure $factory): void + { + /** @var Mailable $mailable */ + $mailable = $factory(); + $html = $mailable->render(); + + $this->assertGreaterThan(2000, strlen($html), 'Rendered email is suspiciously small.'); + $this->assertStringContainsStringIgnoringCase('#2563eb', $html, 'Email should use the brand blue color #2563eb.'); + $this->assertStringNotContainsStringIgnoringCase('#667eea', $html, 'Email still references the legacy purple gradient (#667eea).'); + $this->assertStringNotContainsStringIgnoringCase('#764ba2', $html, 'Email still references the legacy purple gradient (#764ba2).'); + } + + public function test_markdown_cta_buttons_render_visible_label_and_forced_white_text(): void + { + $forgotten = new ForgottenPassword('https://example.test/reset/abc123'); + $html = $forgotten->render(); + $this->assertStringContainsString('>Reset password<', $html, 'Forgotten password CTA label missing from anchor.'); + $this->assertStringContainsStringIgnoringCase('color: #ffffff !important', $html, 'CTA anchor should force white text for contrast after inlining.'); + + $notifiable = new class + { + public int $id = 7; + + public string $email = 'tester@example.test'; + + public string $username = 'tester'; + + public function getKey(): int + { + return $this->id; + } + + public function getEmailForVerification(): string + { + return $this->email; + } + }; + + $message = (new VerifyEmailBranded)->toMail($notifiable); + $html = (string) app(Markdown::class)->render($message->markdown, $message->data()); + $this->assertStringContainsString('>Verify email address<', $html, 'Verify-email CTA label missing from anchor.'); + $this->assertStringContainsStringIgnoringCase('color: #ffffff !important', $html); + } + + public function test_invitation_mail_renders_with_branded_subject_and_brand_palette(): void + { + $inviter = new User; + $inviter->username = 'mod_alice'; + + $invitation = new Invitation; + $invitation->token = 'fake-token-abc123'; + $invitation->email = 'newuser@example.test'; + $invitation->expires_at = Carbon::now()->addDays(7); + $invitation->setRelation('invitedBy', $inviter); + + $mailable = new InvitationMail($invitation); + + $mailable->assertHasSubject('[NNTmux] You\'re invited to join NNTmux'); + $mailable->assertSeeInHtml('mod_alice'); + $mailable->assertSeeInHtml('Accept invitation'); + $mailable->assertDontSeeInHtml('#667eea'); + $mailable->assertDontSeeInHtml('#764ba2'); + $this->assertSame('incidents', 'incidents'); + } + + public function test_incident_detected_uses_alert_and_status_table(): void + { + $incident = $this->makeIncident(); + + $mailable = new IncidentDetected($incident, false); + $mailable->assertHasSubject('[NNTmux] Service incident Detected: NZB Search, API'); + $mailable->assertSeeInHtml('NZB Search, API'); + $mailable->assertSeeInHtml('Critical impact'); + $mailable->assertSeeInHtml('View status dashboard'); + $mailable->assertDontSeeInHtml('#667eea'); + } + + public function test_incident_resolved_uses_success_alert(): void + { + $incident = $this->makeIncident(); + $incident->resolved_at = Carbon::now(); + + $mailable = new IncidentDetected($incident, true); + $mailable->assertHasSubject('[NNTmux] Service incident Resolved: NZB Search, API'); + $mailable->assertSeeInHtml('Resolved'); + } + + public function test_branded_subject_prefix_is_applied_to_every_transactional_mailable(): void + { + config(['mail.brand.subject_prefix' => '[NNTmux] ']); + $user = new User; + $user->username = 'tester'; + $user->email = 'tester@example.test'; + $user->setRelation('role', new class + { + public string $name = 'Silver'; + }); + + $cases = [ + [new WelcomeEmail($user), '[NNTmux] Welcome to NNTmux'], + [new ForgottenPassword('http://example.test/x'), '[NNTmux] Reset your password'], + [new PasswordReset($user, 'tmp'), '[NNTmux] Your password has been reset'], + [new NewAccountCreatedEmail($user), '[NNTmux] New account registered'], + [new AccountChange($user), '[NNTmux] Account level changed'], + [new AccountWillExpire($user, 1), '[NNTmux] Your account is about to expire'], + [new AccountExpired($user), '[NNTmux] Your account has expired'], + [new AccountDeleted($user), '[NNTmux] User account deleted'], + [new ContactUs('admin@example.test', 'visitor@example.test', 'hi'), '[NNTmux] Contact form submitted'], + ]; + + foreach ($cases as [$mailable, $expectedSubject]) { + $mailable->assertHasSubject($expectedSubject); + } + } + + public function test_blade_side_mailables_implement_should_queue(): void + { + $this->assertInstanceOf(ShouldQueue::class, $this->makeIncidentMailable()); + $this->assertInstanceOf(ShouldQueue::class, $this->makeInvitationMailable()); + } + + public function test_incident_detected_is_routed_to_dedicated_queue(): void + { + $mailable = $this->makeIncidentMailable(); + $this->assertSame('incidents', $mailable->queue); + } + + public function test_incident_detected_attaches_plain_text_alternative(): void + { + $mailable = $this->makeIncidentMailable(); + $mailable->build(); + $this->assertSame('emails.text.incidentDetected', $mailable->textView); + } + + public function test_invitation_mail_attaches_plain_text_alternative(): void + { + $mailable = $this->makeInvitationMailable(); + /** @var Content $content */ + $content = $mailable->content(); + $this->assertSame('emails.text.invitation', $content->text); + } + + private function makeIncident(): ServiceIncident + { + $incident = new ServiceIncident; + $incident->title = 'API latency spike'; + $incident->description = "p99 latency exceeded thresholds.\nFollow status page for updates."; + $incident->status = IncidentStatusEnum::Identified; + $incident->impact = IncidentImpactEnum::Critical; + $incident->started_at = Carbon::now()->subMinutes(15); + $incident->setRelation('services', collect([ + (object) ['name' => 'NZB Search'], + (object) ['name' => 'API'], + ])); + + return $incident; + } + + private function makeIncidentMailable(): IncidentDetected + { + return new IncidentDetected($this->makeIncident(), false); + } + + private function makeInvitationMailable(): InvitationMail + { + $inviter = new User; + $inviter->username = 'mod_alice'; + + $invitation = new Invitation; + $invitation->token = 'fake-token-abc123'; + $invitation->email = 'newuser@example.test'; + $invitation->expires_at = Carbon::now()->addDays(7); + $invitation->setRelation('invitedBy', $inviter); + + return new InvitationMail($invitation); + } +} diff --git a/tests/Feature/Mail/VerifyEmailBrandedTest.php b/tests/Feature/Mail/VerifyEmailBrandedTest.php new file mode 100644 index 000000000..7dc5dded5 --- /dev/null +++ b/tests/Feature/Mail/VerifyEmailBrandedTest.php @@ -0,0 +1,86 @@ + 'NNTmux', + 'app.url' => 'http://localhost', + 'mail.from.address' => 'noreply@example.test', + 'mail.brand.subject_prefix' => '[NNTmux] ', + 'mail.brand.queue' => 'emails', + ]); + } + + public function test_branded_verification_extends_framework_notification_for_compatibility(): void + { + $this->assertInstanceOf(VerifyEmail::class, new VerifyEmailBranded); + } + + public function test_branded_verification_implements_should_queue(): void + { + $this->assertInstanceOf(ShouldQueue::class, new VerifyEmailBranded); + } + + public function test_branded_verification_renders_through_themed_markdown_template(): void + { + $notifiable = new class extends stdClass + { + public int $id = 7; + + public string $username = 'tester'; + + public string $email = 'tester@example.test'; + + public function getKey(): int + { + return $this->id; + } + + public function getEmailForVerification(): string + { + return $this->email; + } + }; + + $notification = new VerifyEmailBranded; + /** @var MailMessage $message */ + $message = $notification->toMail($notifiable); + + $this->assertSame('[NNTmux] Verify your email address', $message->subject); + $this->assertSame('emails.markdown.verify', $message->markdown); + $this->assertArrayHasKey('site', $message->viewData); + $this->assertSame('NNTmux', $message->viewData['site']); + $this->assertSame('tester', $message->viewData['username']); + $this->assertNotEmpty($message->viewData['url']); + } + + public function test_user_dispatches_branded_verification_when_notifying(): void + { + Notification::fake(); + + $user = new User; + $user->id = 99; + $user->email = 'verify@example.test'; + $user->username = 'verifyuser'; + + $user->sendEmailVerificationNotification(); + + Notification::assertSentTo($user, VerifyEmailBranded::class); + } +} diff --git a/tests/Feature/Mail/WelcomeEmailTest.php b/tests/Feature/Mail/WelcomeEmailTest.php new file mode 100644 index 000000000..3063348b3 --- /dev/null +++ b/tests/Feature/Mail/WelcomeEmailTest.php @@ -0,0 +1,43 @@ + 'NNTmux', + 'mail.from.address' => 'noreply@example.test', + 'mail.brand.subject_prefix' => '[NNTmux] ', + ]); + + $user = new User; + $user->username = 'tester'; + + $mailable = new WelcomeEmail($user); + $mailable->assertHasSubject('[NNTmux] Welcome to NNTmux'); + $mailable->assertSeeInHtml('Welcome to NNTmux'); + $mailable->assertSeeInHtml('Dear tester'); + $mailable->assertDontSeeInHtml('#667eea'); + $mailable->assertDontSeeInHtml('#764ba2'); + $mailable->assertSeeInText('Welcome to NNTmux'); + $mailable->assertSeeInText('Dear tester'); + } + + public function test_welcome_email_implements_should_queue_via_queueable_trait(): void + { + $this->assertContains( + Queueable::class, + class_uses_recursive(WelcomeEmail::class), + 'WelcomeEmail must use Queueable so SendWelcomeEmail jobs can dispatch it on a queue.' + ); + } +}