'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); } }