mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 22:01:33 +00:00
Update emails
This commit is contained in:
+12
-1
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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>© {{ 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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user