'#12b76a', 'w' => '#f79009', 'p' => '#e86b1c', 'b' => '#f04438']; public const MARKDOWN = [ 'html_input' => 'escape', 'allow_unsafe_links' => false, 'renderer' => ['soft_break' => "
\n"], ]; protected const INCIDENT_TAGS = ['brand', 'incident', 'status', 'message', 'components', 'link', 'unsubscribe', 'preferences', 'when', 'name']; protected const INCIDENT_BODY = <<<'MD' {status} # {incident} Affects **{components}** > {message} {when} [View status page]({link}) MD; /** @var array, subject: string, body: string}> */ protected const TEMPLATES = [ 'subscribe_confirm' => [ 'label' => 'Subscribe confirmation', 'tags' => ['brand', 'link', 'hours', 'name'], 'subject' => 'Confirm your subscription to {brand} status updates', 'body' => <<<'MD' # Confirm your subscription You asked to be told about incidents on the {brand} status page. Confirm the address and we will mail you when something happens, and when it is fixed. [Confirm subscription]({link}) The link is good for {hours} hours. If you did not ask for this, ignore this mail; the address is forgotten on its own. MD, ], 'incident_opened' => [ 'label' => 'Incident opened', 'tags' => self::INCIDENT_TAGS, 'subject' => '[{brand}] {incident}: {status}', 'body' => self::INCIDENT_BODY, ], 'incident_updated' => [ 'label' => 'Incident updated', 'tags' => self::INCIDENT_TAGS, 'subject' => '[{brand}] {incident}: {status}', 'body' => self::INCIDENT_BODY, ], 'incident_resolved' => [ 'label' => 'Incident resolved', 'tags' => self::INCIDENT_TAGS, 'subject' => '[{brand}] {incident}: {status}', 'body' => self::INCIDENT_BODY, ], 'maintenance_scheduled' => [ 'label' => 'Maintenance scheduled', 'tags' => ['brand', 'maintenance', 'message', 'components', 'starts', 'ends', 'link', 'unsubscribe', 'preferences', 'name'], 'subject' => '[{brand}] Scheduled maintenance: {maintenance}', 'body' => <<<'MD' Scheduled maintenance # {maintenance} From **{starts}** until **{ends}** Affects **{components}** > {message} [View status page]({link}) MD, ], ]; /** @return list */ public static function keys(): array { return array_keys(self::TEMPLATES); } /** @return array key => label */ public static function labels(): array { return array_map(fn ($t) => $t['label'], self::TEMPLATES); } /** @return list */ public static function tags(string $key): array { return array_map(fn ($tag) => '{'.$tag.'}', self::TEMPLATES[$key]['tags']); } public static function defaultSubject(string $key): string { return __(self::TEMPLATES[$key]['subject']); } public static function defaultBody(string $key): string { return __(self::TEMPLATES[$key]['body']); } /** Which incident template an update gets: first word, last word, or one in between. */ public static function forUpdate(IncidentUpdate $update): string { if ($update->status === IncidentStatus::Resolved) { return 'incident_resolved'; } $first = ! $update->incident->updates()->where('id', '<', $update->id)->exists(); return $first ? 'incident_opened' : 'incident_updated'; } /** The {name} tag: the part before the @, which is as close to a name as a subscriber gets. */ public static function nameFor(string $email): string { return Str::before($email, '@') ?: $email; } public function __construct(protected License $license) {} /** Custom wording is part of the Brand pack; without a key the defaults go out. */ protected function custom(string $setting): ?string { return $this->license->has(License::FEATURE_BRAND_PACK) ? Setting::get($setting) : null; } public function subject(string $key): string { return (string) ($this->custom("mail.template.$key.subject") ?? self::defaultSubject($key)); } public function body(string $key): string { return (string) ($this->custom("mail.template.$key.body") ?? self::defaultBody($key)); } public function isDefault(string $key): bool { return Setting::get("mail.template.$key.subject") === null && Setting::get("mail.template.$key.body") === null; } public function save(string $key, string $subject, string $body): void { Setting::put("mail.template.$key.subject", $subject); Setting::put("mail.template.$key.body", str_replace("\r\n", "\n", $body)); } public function reset(string $key): void { Setting::put("mail.template.$key.subject", null); Setting::put("mail.template.$key.body", null); } /** * Subject, HTML and plain text for one template. Tag values arrive as text * and stay text; only {message} is Markdown, because the operator wrote it * as Markdown. Unknown tags are left as typed. * * @param array $vars tag name (without braces) => value * @return array{subject: string, html: string, text: string} */ public function render(string $key, array $vars, ?string $subject = null, ?string $body = null): array { $vars = array_map(fn ($v) => str_replace("\r\n", "\n", (string) $v), $vars); $frame = self::frame($vars['unsubscribe'] ?? null, $vars['preferences'] ?? null); // Not a tag: the state colour is the frame's business, never something to type. $line = self::TONES[$vars['tone'] ?? ''] ?? null; unset($vars['tone']); $subject = strtr($subject ?? $this->subject($key), self::pairs($vars, escape: false)); $markdown = self::substitute(str_replace("\r\n", "\n", $body ?? $this->body($key)), $vars); $html = Str::markdown($markdown, self::MARKDOWN); $footer = "\n\n{$frame['brand']} ".__('status page').": {$frame['link']}" .($frame['unsubscribe'] ? "\n".__('Unsubscribe').": {$frame['unsubscribe']}" : '') .($frame['preferences'] ? "\n".__('Manage subscription').": {$frame['preferences']}" : ''); return [ // A newline in a subject is a header injection; the line is folded instead. 'subject' => trim(preg_replace('/\s*\n\s*/', ' ', $subject)), 'html' => view('mail.template', $frame + ['tone' => $line, 'body' => MailMarkup::style($html, $frame['accent'], $line)])->render(), 'text' => MailMarkup::text($html).$footer, ]; } /** * What the frame needs. url() rather than the stored path: a mail is read * away from the site, so a relative logo path shows a broken image. * * @return array{brand: string, accent: string, logo: ?string, link: string, unsubscribe: ?string, preferences: ?string} */ public static function frame(?string $unsubscribe = null, ?string $preferences = null): array { $branding = app(Branding::class); $logo = $branding->logoUrl(); if ($logo === null && $branding->name() === 'Pharos' && ! $branding->creditHidden()) { $logo = app(Branding::class)->builtInAssetUrl('pharos-email-logo.png'); } return [ 'brand' => $branding->name(), 'accent' => $branding->accent(), 'logo' => $logo ? url($logo) : null, 'link' => PageUrls::route('status'), 'unsubscribe' => $unsubscribe ?: null, 'preferences' => $preferences ?: null, ]; } /** * Sample values for the preview and the test mail, so an admin sees a real * incident rather than a row of braces. * * @return array */ public function sample(string $key, User $user): array { // A preview of a mail subscribers get, so in the zone they get it in. return Clock::withInstallationZone(fn () => $this->sampleVars($key, $user)); } /** @return array */ protected function sampleVars(string $key, User $user): array { $name = self::nameFor($user->email); if ($key === 'subscribe_confirm') { return [ 'brand' => self::frame()['brand'], 'link' => url('/subscribe/confirm/preview'), 'hours' => Subscriber::CONFIRM_HOURS, 'name' => $name, ]; } if ($key === 'maintenance_scheduled') { $start = Clock::now()->addDay()->setTime(22, 0); return [ 'brand' => self::frame()['brand'], 'maintenance' => 'Database upgrade', 'message' => "We upgrade the database cluster. **Expect short interruptions** of up to five minutes.\n\n- Websites stay online\n- Mail is queued, not lost", 'components' => 'Database, Control panel', 'starts' => $start->translatedFormat('j F Y, H:i'), 'ends' => $start->copy()->addHours(2)->translatedFormat('j F Y, H:i'), 'link' => PageUrls::route('status'), 'unsubscribe' => url('/unsubscribe/preview'), 'name' => $name, 'tone' => 'w', ]; } $status = match ($key) { 'incident_opened' => IncidentStatus::Investigating, 'incident_resolved' => IncidentStatus::Resolved, default => IncidentStatus::Identified, }; return [ 'brand' => self::frame()['brand'], 'incident' => 'Outbound email delayed', 'status' => $status->label(), 'message' => "We are seeing **delays of up to 20 minutes** on outbound mail while the queue drains.\n\n- Incoming mail is not affected\n- No messages are lost", 'components' => 'Mail, Outbound queue', 'link' => PageUrls::route('status'), 'unsubscribe' => url('/unsubscribe/preview'), 'when' => Clock::now()->translatedFormat('j F Y, H:i'), // the customer's zone, like a real notice 'name' => $name, 'tone' => $key === 'incident_resolved' ? 'ok' : 'p', ]; } /** @param array $vars */ protected static function substitute(string $body, array $vars): string { // "Affects {components}" with nothing affected would print "Affects": // a line whose only tag is empty is left out altogether. foreach ($vars as $tag => $value) { if ($value === '') { $body = preg_replace('/^[^\n{]*\{'.$tag.'\}[^\n{]*(?:\n|$)/m', '', $body); } } // "> {message}" must quote the whole message, not just its first line: // whatever precedes the tag on its line is repeated for every line. if (isset($vars['message'])) { $body = preg_replace_callback( '/^([ >]+)\{message\}/m', fn ($m) => $m[1].str_replace("\n", "\n".$m[1], $vars['message']), $body, ); } return strtr($body, self::pairs($vars, escape: true)); } /** * @param array $vars * @return array */ protected static function pairs(array $vars, bool $escape): array { $pairs = []; foreach ($vars as $tag => $value) { $pairs['{'.$tag.'}'] = $escape && $tag !== 'message' ? self::escape($value) : $value; } return $pairs; } /** Backslash-escapes Markdown punctuation, so a value is printed as typed. */ protected static function escape(string $value): string { return preg_replace('/([\\\\`*_{}\[\]()#+\-.!<>|~])/', '\\\\$1', $value); } }