Update emails

This commit is contained in:
DariusIII
2026-05-07 14:03:21 +02:00
parent fe08b777f1
commit ac1ac603cb
57 changed files with 1808 additions and 818 deletions
+12 -1
View File
@@ -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
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\IncidentImpactEnum;
use App\Enums\IncidentStatusEnum;
use App\Mail\AccountChange;
use App\Mail\AccountDeleted;
use App\Mail\AccountExpired;
use App\Mail\AccountWillExpire;
use App\Mail\ContactUs;
use App\Mail\ForgottenPassword;
use App\Mail\IncidentDetected;
use App\Mail\InvitationMail;
use App\Mail\NewAccountCreatedEmail;
use App\Mail\PasswordReset;
use App\Mail\WelcomeEmail;
use App\Models\Invitation;
use App\Models\ServiceIncident;
use App\Models\User;
use App\Notifications\VerifyEmailBranded;
use Illuminate\Console\Command;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Markdown;
use Illuminate\Support\Carbon;
/**
* Generates static HTML previews for every transactional and notification email
* the application can send. Useful for spot-checking the themed templates
* across mail clients without needing to wire up Mailpit or trigger real
* application events. Output lands in `storage/app/mail-previews/*.html`.
*/
class MailPreview extends Command
{
protected $signature = 'mail:preview
{--out= : Override the output directory (defaults to storage/app/mail-previews)}';
protected $description = 'Render every mailable to static HTML files for visual QA across email clients.';
public function handle(): int
{
$directory = (string) ($this->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<string, callable(): Mailable> $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];
}
}
-43
View File
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Mail\SendInvite;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendInviteEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private string $email;
private string $url;
private User $user;
/**
* SendInviteEmail constructor.
*/
public function __construct(string $email, User $user, string $url)
{
$this->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));
}
}
+22 -23
View File
@@ -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,
]);
}
}
+18 -21
View File
@@ -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,
]);
}
}
+22 -14
View File
@@ -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,
]);
}
}
+24 -15
View File
@@ -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,
]);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Mail\Concerns;
/**
* Adds a configurable, site-wide subject prefix (default "[App Name] ") to
* mailables, so transactional emails read consistently in users' inboxes.
*
* Concrete mailables call `$this->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);
}
}
+20 -11
View File
@@ -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,
]);
}
}
+16 -14
View File
@@ -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,
]);
}
}
+21 -9
View File
@@ -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'),
]);
}
+14 -15
View File
@@ -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<int, Attachment>
*/
public function attachments(): array
+18 -26
View File
@@ -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,
]);
}
}
+21 -28
View File
@@ -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,
]);
}
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class SendInvite extends Mailable
{
use Queueable, SerializesModels;
/**
* @var User
*/
public $user;
/**
* @var string
*/
public $invite;
/**
* @var mixed
*/
private $siteEmail;
/**
* @var mixed
*/
private $siteTitle;
/**
* SendInvite constructor.
*/
public function __construct(User $user, mixed $invite)
{
$this->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']]);
}
}
+18 -23
View File
@@ -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,
]);
}
}
+7 -3
View File
@@ -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);
}
/**
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
/**
* Branded replacement for Laravel's framework `VerifyEmail` notification.
*
* Renders through our themed Markdown mail components (see
* `resources/views/vendor/mail/*` and `resources/views/emails/markdown/verify.blade.php`)
* so verification emails match the rest of the transactional email branding
* instead of falling back to the default Laravel notification look.
*/
class VerifyEmailBranded extends VerifyEmail implements ShouldQueue
{
use Queueable;
public function __construct()
{
$this->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.",
]);
}
}
+23 -1
View File
@@ -1,7 +1,7 @@
<?php
return [
'default' => 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
| <x-mail.layout> component). The `subject_prefix` is automatically applied
| by the `App\Mail\Concerns\HasBrandedSubject` trait so every email subject
| reads "[<App Name>] <subject>" 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'),
],
];
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
use App\User;
use App\Models\User;
return [
@@ -0,0 +1,7 @@
@props([
'type' => 'info',
])
@php
$typeClass = in_array($type, ['info', 'success', 'warning', 'danger'], true) ? $type : 'info';
@endphp
<div class="alert-box alert-{{ $typeClass }}">{{ $slot }}</div>
@@ -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
<div style="text-align: {{ $align }}; margin: 24px 0;">
<a href="{{ $url }}" class="{{ $colorClass }}" target="_blank" rel="noopener">{{ $slot }}</a>
</div>
@@ -0,0 +1,7 @@
@props([
'variant' => 'info',
])
@php
$variantClass = $variant === 'warning' ? 'warning-box' : 'info-box';
@endphp
<div class="{{ $variantClass }}">{{ $slot }}</div>
@@ -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
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{ $renderedTitle }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #334155;
background-color: #f8fafc;
padding: 20px;
-webkit-text-size-adjust: 100%;
}
.preheader {
display: none !important;
visibility: hidden;
opacity: 0;
color: transparent;
height: 0;
width: 0;
overflow: hidden;
mso-hide: all;
}
.email-wrapper {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.06), 0 1px 2px -1px rgba(15, 23, 42, 0.06);
}
.email-header {
background-color: #ffffff;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
padding: 28px 40px;
text-align: center;
}
.email-header h1 {
font-size: 22px;
font-weight: 700;
margin: 0;
color: #0f172a;
letter-spacing: -0.01em;
}
.email-header .brand {
display: inline-block;
font-size: 14px;
font-weight: 600;
color: #2563eb;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
text-decoration: none;
}
.email-header .logo { height: 56px; width: auto; }
.email-body { padding: 36px 40px; }
.email-content { font-size: 16px; color: #334155; }
.email-content p { margin-bottom: 16px; }
.email-content h2 { color: #0f172a; font-size: 18px; font-weight: 600; margin: 24px 0 12px; }
.email-content h3 { color: #0f172a; font-size: 15px; font-weight: 600; margin: 20px 0 10px; }
.greeting { font-size: 17px; color: #0f172a; font-weight: 500; margin-bottom: 18px; }
.signature {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 14px;
}
.button {
display: inline-block;
background-color: #2563eb;
color: #ffffff !important;
text-decoration: none;
padding: 12px 26px;
border-radius: 6px;
font-weight: 600;
font-size: 15px;
margin: 16px 0;
mso-padding-alt: 0;
line-height: 1.4;
}
.button-success { background-color: #16a34a; }
.button-danger { background-color: #dc2626; }
.button-secondary { background-color: #475569; }
.info-box {
background-color: #eff6ff;
border-left: 4px solid #2563eb;
padding: 16px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
color: #1e3a8a;
}
.warning-box {
background-color: #fff7ed;
border-left: 4px solid #f59e0b;
padding: 16px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
color: #92400e;
}
.alert-box {
padding: 14px 18px;
margin: 20px 0;
border-radius: 6px;
font-size: 15px;
line-height: 1.5;
}
.alert-info { background-color: #eff6ff; border-left: 4px solid #2563eb; color: #1e3a8a; }
.alert-success { background-color: #f0fdf4; border-left: 4px solid #16a34a; color: #166534; }
.alert-warning { background-color: #fff7ed; border-left: 4px solid #f59e0b; color: #92400e; }
.alert-danger { background-color: #fef2f2; border-left: 4px solid #dc2626; color: #991b1b; }
.link-text {
word-break: break-all;
background-color: #f1f5f9;
padding: 12px 16px;
border-radius: 6px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
display: block;
margin: 10px 0;
color: #1e293b;
}
.status-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.status-table td {
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #334155;
vertical-align: top;
}
.status-table .status-label {
font-weight: 600;
color: #0f172a;
width: 150px;
white-space: nowrap;
}
.email-footer {
background-color: #f8fafc;
padding: 22px 40px;
text-align: center;
font-size: 12px;
color: #94a3b8;
border-top: 1px solid #e2e8f0;
}
.email-footer p { margin: 4px 0; line-height: 1.6; }
.email-footer a { color: #64748b; text-decoration: underline; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
@media only screen and (max-width: 600px) {
body { padding: 0; }
.email-wrapper { border-radius: 0; border-left: 0; border-right: 0; }
.email-header, .email-body, .email-footer { padding-left: 24px; padding-right: 24px; }
.button { display: block; text-align: center; }
}
</style>
</head>
<body>
@if ($preheader)
<span class="preheader">{{ $preheader }}</span>
@endif
<div class="email-wrapper">
@isset($header)
{{ $header }}
@else
<div class="email-header">
<a href="{{ $brandUrl }}" class="brand">{{ $brandName }}</a>
@if ($logoUrl)
<div><img src="{{ $logoUrl }}" alt="{{ $brandName }}" class="logo"></div>
@endif
@if ($title)
<h1>{{ $title }}</h1>
@endif
</div>
@endisset
<div class="email-body">
<div class="email-content">
{{ $slot }}
</div>
</div>
@isset($footer)
{{ $footer }}
@else
<div class="email-footer">
<p>&copy; {{ date('Y') }} {{ $brandName }}. {{ __('All rights reserved.') }}</p>
<p>{{ __('This is an automated message. Please do not reply directly to this email.') }}</p>
@if (\Illuminate\Support\Facades\Route::has('contact-us'))
<p><a href="{{ url('/contact-us') }}">{{ __('Contact us') }}</a></p>
@endif
</div>
@endisset
</div>
</body>
</html>
@@ -0,0 +1,3 @@
<table class="status-table" role="presentation" cellspacing="0" cellpadding="0">
{{ $slot }}
</table>
@@ -1,24 +0,0 @@
@extends('emails.email_layout')
@section('title')
Account Expiration Notice
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Dear {{ $username }},</p>
<div class="warning-box">
<strong>⚠️ Attention Required:</strong> Your account role <strong>{{ $account }}</strong> is about to expire in less than <strong>{{ $days }} day(s)</strong>.
</div>
<p>To continue enjoying uninterrupted access to all your current features and benefits, please take action before your subscription expires.</p>
<p>If you have any questions about renewing your account or need assistance, please don't hesitate to contact us.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -1,26 +0,0 @@
@extends('emails.email_layout')
@section('title')
Account Level Changed
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Dear {{ $username }},</p>
<p>We wanted to let you know that your account level has been updated.</p>
<div class="info-box">
<strong>🔄 New Account Level:</strong> {{ $account }}
</div>
<p>Your new account level is now active, and you can start enjoying all the associated features and benefits immediately.</p>
<p>If you have any questions about your new account level or need assistance, please don't hesitate to contact us.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -1,22 +0,0 @@
@extends('emails.email_layout')
@section('title')
User Account Deleted
@endsection
@section('site_name', $site)
@section('content')
<p>This is a notification that a user has deleted their account from <strong>{{ $site }}</strong>.</p>
<div class="info-box">
<strong>👤 Deleted Account:</strong> {{ $username }}
</div>
<p>No further action is required. This email is for informational purposes only.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} System</strong></p>
</div>
@endsection
@@ -1,26 +0,0 @@
@extends('emails.email_layout')
@section('title')
Account Expired
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Dear {{ $username }},</p>
<p>We regret to inform you that your account subscription has expired.</p>
<div class="warning-box">
<strong>📉 Account Downgraded:</strong> Your account has been automatically downgraded to <strong>{{ $account }}</strong>.
</div>
<p>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.</p>
<p>If you have any questions or need assistance with the renewal process, please don't hesitate to contact us.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -1,18 +0,0 @@
@extends('emails.email_layout')
@section('title')
Contact Form Submission
@endsection
@section('content')
<p>A new contact form has been submitted.</p>
<div class="info-box">
<strong>📝 Message:</strong>
<div style="margin-top: 10px;">
{!! nl2br(e($mailBody)) !!}
</div>
</div>
<p>Please respond to this inquiry at your earliest convenience.</p>
@endsection
@@ -1,204 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title')</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f7;
padding: 20px;
}
.email-wrapper {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.email-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
padding: 30px 40px;
text-align: center;
}
.email-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.email-body {
padding: 40px;
}
.email-content {
font-size: 16px;
color: #51545e;
}
.email-content p {
margin-bottom: 16px;
}
.greeting {
font-size: 18px;
color: #333333;
margin-bottom: 20px;
}
.signature {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eaeaea;
color: #6b6e76;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 14px 28px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
transition: transform 0.2s ease;
}
.button:hover {
transform: translateY(-2px);
}
.info-box {
background-color: #f8f9fa;
border-left: 4px solid #667eea;
padding: 16px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 16px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
color: #856404;
}
.email-footer {
background-color: #f8f9fa;
padding: 24px 40px;
text-align: center;
font-size: 13px;
color: #9a9ea6;
}
.email-footer p {
margin: 4px 0;
}
a {
color: #667eea;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.link-text {
word-break: break-all;
background-color: #f8f9fa;
padding: 12px 16px;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
display: block;
margin: 10px 0;
}
.alert-box {
padding: 16px 20px;
margin: 20px 0;
border-radius: 6px;
font-size: 15px;
}
.alert-danger {
background-color: #fef2f2;
border-left: 4px solid #dc2626;
color: #991b1b;
}
.alert-warning {
background-color: #fff3cd;
border-left: 4px solid #f59e0b;
color: #92400e;
}
.alert-info {
background-color: #eff6ff;
border-left: 4px solid #3b82f6;
color: #1e40af;
}
.alert-success {
background-color: #f0fdf4;
border-left: 4px solid #16a34a;
color: #166534;
}
.status-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.status-table td {
padding: 10px 12px;
border-bottom: 1px solid #eaeaea;
font-size: 14px;
color: #51545e;
vertical-align: top;
}
.status-table .status-label {
font-weight: 600;
color: #333333;
width: 140px;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-header">
<h1>@yield('title')</h1>
</div>
<div class="email-body">
<div class="email-content">
@yield('content')
</div>
</div>
<div class="email-footer">
<p>© {{ date('Y') }} @yield('site_name', config('app.name')). All rights reserved.</p>
<p>This is an automated message. Please do not reply directly to this email.</p>
</div>
</div>
</body>
</html>
@@ -1,29 +0,0 @@
@extends('emails.email_layout')
@section('title')
Password Reset Request
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Hello,</p>
<p>We received a request to reset the password associated with this email address.</p>
<div style="text-align: center;">
<a href="{{ $resetLink }}" class="button">Reset Password</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<span class="link-text">{{ $resetLink }}</span>
<div class="warning-box">
<strong>🔒 Security Notice:</strong> If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.
</div>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -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)
<div class="alert-box alert-success">
<strong> Resolved</strong> The following incident has been automatically resolved.
</div>
@php
$title = $resolved
? "Incident Resolved — {$services}"
: "Incident Detected{$services}";
$impactValue = $incident->impact->value;
$alertType = $resolved
? 'success'
: ($impactValue === 'critical' ? 'danger' : ($impactValue === 'major' ? 'warning' : 'info'));
@endphp
<x-mail.layout :title="$title" :preheader="$preheader" :site-name="$site">
@if ($resolved)
<x-mail.alert type="success">
<strong>Resolved</strong> The following incident has been automatically resolved.
</x-mail.alert>
@else
<div class="alert-box alert-{{ $incident->impact->value === 'critical' ? 'danger' : ($incident->impact->value === 'major' ? 'warning' : 'info') }}">
<strong>⚠️ {{ ucfirst($incident->impact->value) }} Impact</strong> An automated health check has detected a service issue.
</div>
<x-mail.alert :type="$alertType">
<strong>{{ ucfirst($impactValue) }} impact</strong> An automated health check has detected a service issue.
</x-mail.alert>
@endif
<table class="status-table">
<x-mail.status-table>
<tr>
<td class="status-label">Incident</td>
<td>{{ $incident->title }}</td>
</tr>
<tr>
<td class="status-label">Affected Services</td>
<td class="status-label">Affected services</td>
<td>{{ $services }}</td>
</tr>
<tr>
<td class="status-label">Impact</td>
<td>{{ ucfirst($incident->impact->value) }}</td>
<td>{{ ucfirst($impactValue) }}</td>
</tr>
<tr>
<td class="status-label">Status</td>
@@ -38,30 +39,30 @@
<td class="status-label">Started</td>
<td>{{ $incident->started_at->format('M j, Y g:i A T') }}</td>
</tr>
@if($resolved && $incident->resolved_at)
<tr>
<td class="status-label">Resolved</td>
<td>{{ $incident->resolved_at->format('M j, Y g:i A T') }}</td>
</tr>
<tr>
<td class="status-label">Duration</td>
<td>{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}</td>
</tr>
@if ($resolved && $incident->resolved_at)
<tr>
<td class="status-label">Resolved</td>
<td>{{ $incident->resolved_at->format('M j, Y g:i A T') }}</td>
</tr>
<tr>
<td class="status-label">Duration</td>
<td>{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}</td>
</tr>
@endif
</table>
</x-mail.status-table>
@if($incident->description)
<div class="info-box">
@if ($incident->description)
<x-mail.info-box>
<strong>Details:</strong><br>
{!! nl2br(e($incident->description)) !!}
</div>
</x-mail.info-box>
@endif
<p style="text-align: center; margin-top: 24px;">
<a href="{{ $statusUrl }}" class="button">View Status Dashboard</a>
</p>
<x-mail.button :url="$statusUrl" :color="$resolved ? 'success' : 'primary'">
View status dashboard
</x-mail.button>
<div class="signature">
<p> {{ $site }} Monitoring</p>
</div>
@endsection
</x-mail.layout>
+18 -22
View File
@@ -1,35 +1,31 @@
@extends('emails.email_layout')
<x-mail.layout
:title="'You\'re invited to ' . $siteName"
:preheader="$preheader"
:site-name="$siteName">
@section('title')
You're Invited to {{ $siteName }}!
@endsection
@section('site_name', $siteName)
@section('content')
<p class="greeting">Hello!</p>
<p>Great news! <strong>{{ $invitedBy->username }}</strong> has invited you to join <strong>{{ $siteName }}</strong>, our exclusive community.</p>
<p>Great news! <strong>{{ $invitedByName }}</strong> has invited you to join <strong>{{ $siteName }}</strong>, our exclusive community.</p>
<div style="text-align: center;">
<a href="{{ $registerUrl }}" class="button">Accept Invitation</a>
</div>
<x-mail.button :url="$registerUrl">Accept invitation</x-mail.button>
<p>Or copy and paste this link into your browser:</p>
<p>If the button above doesn't work, copy and paste this link into your browser:</p>
<span class="link-text">{{ $registerUrl }}</span>
<div class="warning-box">
<strong>⚠️ Important:</strong> This invitation will expire on <strong>{{ $expiresAt->format('F j, Y \a\t g:i A') }}</strong>. Please complete your registration before this date.
</div>
<x-mail.info-box variant="warning">
<strong>Heads up:</strong> This invitation expires on
<strong>{{ $expiresAt->format('F j, Y \a\t g:i A') }}</strong>.
Please complete your registration before then.
</x-mail.info-box>
<div class="info-box">
<strong>💡 What's Next?</strong> Click the button above to create your account and start exploring everything {{ $siteName }} has to offer.
</div>
<x-mail.info-box>
<strong>What's next?</strong> Click the button above to create your account and start exploring everything {{ $siteName }} has to offer.
</x-mail.info-box>
<p>If you have any questions, please don't hesitate to contact us after registering.</p>
<p>If you have any questions, feel free to contact us once you've registered.</p>
<div class="signature">
<p>Welcome aboard!</p>
<p>Welcome aboard,</p>
<p><strong>The {{ $siteName }} Team</strong></p>
</div>
@endsection
</x-mail.layout>
@@ -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,<br>
The {{ $site }} Team
@endcomponent
@@ -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
@@ -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,<br>
The {{ $site }} Team
@endcomponent
@@ -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,<br>
The {{ $site }} Team
@endcomponent
@@ -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
@@ -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:
<small>{{ $resetLink }}</small>
@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,<br>
The {{ $site }} Team
@endcomponent
@@ -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
@@ -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,<br>
The {{ $site }} Team
@endcomponent
@@ -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:
<small>{{ $url }}</small>
Best regards,<br>
The {{ $site }} Team
@endcomponent
@@ -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,<br>
The {{ $site }} Team
@endcomponent
@@ -1,22 +0,0 @@
@extends('emails.email_layout')
@section('title')
New User Registration
@endsection
@section('site_name', $site)
@section('content')
<p>A new user has registered on <strong>{{ $site }}</strong>.</p>
<div class="info-box">
<strong>👤 New User:</strong> {{ $username }}
</div>
<p>This is an automated notification. No action is required unless you need to review the new registration.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} System</strong></p>
</div>
@endsection
@@ -1,29 +0,0 @@
@extends('emails.email_layout')
@section('title')
Password Reset Successful
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Dear {{ $userName }},</p>
<p>Your password has been successfully reset.</p>
<div class="info-box">
<strong>🔑 Your New Password:</strong>
<div style="margin-top: 10px; font-family: monospace; font-size: 18px; letter-spacing: 2px;">{{ $newPass }}</div>
</div>
<div class="warning-box">
<strong>🔒 Security Recommendation:</strong> For your security, we strongly recommend changing this password to something memorable after logging in.
</div>
<p>If you did not request this password reset, please contact us immediately.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -1,31 +0,0 @@
@extends('emails.email_layout')
@section('title')
You've Been Invited!
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Hello {{ $email }},</p>
<p>Great news! <strong>{{ $username }}</strong> has invited you to join <strong>{{ $site }}</strong>.</p>
<div style="text-align: center;">
<a href="{{ $invite }}" class="button">Accept Invitation</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<span class="link-text">{{ $invite }}</span>
<div class="info-box">
<strong>💡 What's Next?</strong> Click the button above to create your account and start exploring everything {{ $site }} has to offer.
</div>
<p>If you have any questions, feel free to reach out to us after registering.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
@@ -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
@@ -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
-24
View File
@@ -1,24 +0,0 @@
@extends('emails.email_layout')
@section('title')
Welcome to {{ $site }}
@endsection
@section('site_name', $site)
@section('content')
<p class="greeting">Dear {{ $username }},</p>
<p>Welcome to <strong>{{ $site }}</strong>! We're thrilled to have you join our community.</p>
<div class="info-box">
<strong>📧 Next Step:</strong> Your account verification email will be sent separately. Please check your inbox and verify your email address to complete the registration process.
</div>
<p>If you have any questions or need assistance, don't hesitate to reach out to us.</p>
<div class="signature">
<p>Best regards,</p>
<p><strong>The {{ $site }} Team</strong></p>
</div>
@endsection
+24
View File
@@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" style="color: #ffffff !important; -webkit-text-fill-color: #ffffff; text-decoration: none !important;" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
+19
View File
@@ -0,0 +1,19 @@
<tr>
<td>
<table class="footer" align="center" width="600" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
<p style="font-size: 12px; color: #94a3b8; margin: 6px 0 0;">
{{ __('This is an automated message. Please do not reply directly to this email.') }}
</p>
@if (\Illuminate\Support\Facades\Route::has('contact-us'))
<p style="font-size: 12px; color: #94a3b8; margin: 4px 0 0;">
<a href="{{ url('/contact-us') }}" style="color: #64748b; text-decoration: underline;">{{ __('Contact us') }}</a>
</p>
@endif
</td>
</tr>
</table>
</td>
</tr>
+13
View File
@@ -0,0 +1,13 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@php($logoUrl = config('mail.brand.logo_url'))
@if ($logoUrl)
<img src="{{ $logoUrl }}" class="logo" alt="{{ trim(strip_tags($slot)) }}">
@else
{!! $slot !!}
@endif
</a>
</td>
</tr>
+341
View File
@@ -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;
}
+257
View File
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Enums\IncidentImpactEnum;
use App\Enums\IncidentStatusEnum;
use App\Mail\AccountChange;
use App\Mail\AccountDeleted;
use App\Mail\AccountExpired;
use App\Mail\AccountWillExpire;
use App\Mail\ContactUs;
use App\Mail\ForgottenPassword;
use App\Mail\IncidentDetected;
use App\Mail\InvitationMail;
use App\Mail\NewAccountCreatedEmail;
use App\Mail\PasswordReset;
use App\Mail\WelcomeEmail;
use App\Models\Invitation;
use App\Models\ServiceIncident;
use App\Models\User;
use App\Notifications\VerifyEmailBranded;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Markdown;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* Renders every mailable in isolation to guarantee that the themed brand
* palette is applied site-wide and that no template still references the
* legacy purple gradient (#667eea / #764ba2) from the previous email layout.
*/
class MailRenderingTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'app.name' => '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<string, array{0: \Closure}>
*/
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);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Models\User;
use App\Notifications\VerifyEmailBranded;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification;
use stdClass;
use Tests\TestCase;
class VerifyEmailBrandedTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'app.name' => '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);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Mail\WelcomeEmail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Tests\TestCase;
class WelcomeEmailTest extends TestCase
{
public function test_welcome_email_renders_with_branded_subject_and_content(): void
{
config([
'app.name' => '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.'
);
}
}