From e9243715e2125b3f019f4945a3a2aee1199357b6 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Tue, 25 Aug 2026 14:44:53 -0400 Subject: [PATCH 1/2] feat(mail): one shared transactional email layout, used by every email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiger had no email templates. Six bodies were hand-concatenated PHP strings and they had drifted badly: password reset and the sign-in code were styled inline, backup was barely styled, and signup verification, site registration and the SMTP test were bare

tags. The signup verification is the FIRST email a new customer ever receives, and it looked like a debug dump. Adds `Tiger_Mail::template($name, $vars)` — renders a content template into a shared `layout.phtml` and uses it as the HTML body. Plain-text is still derived automatically by send(), so deliverability is unchanged. Path cascade, lowest to highest: core -> app -> registered modules -> ACTIVE THEME. The theme sitting on top is the point: an operator rebrands every transactional email by dropping emails/*.phtml into their theme, no code change. `addTemplatePath()` lets a third-party module ship its own. The layout is written to EMAIL rules, not web rules, and the comments say why: table layout with role="presentation" (flex/grid are unusable in Outlook's Word renderer), styles INLINE (Gmail strips + + + + + +

+ + +
+ + + + + + + + + diff --git a/core/views/emails/otp.phtml b/core/views/emails/otp.phtml new file mode 100644 index 00000000..77d0653b --- /dev/null +++ b/core/views/emails/otp.phtml @@ -0,0 +1,20 @@ + +

Your sign-in code

+ +

Use this code to finish signing in. It expires in 10 minutes.

+ + + + + + +
+ code) ?> +
+ +

If you didn’t request this, you can ignore this email — no one can sign in without the code.

diff --git a/core/views/emails/register-verify.phtml b/core/views/emails/register-verify.phtml new file mode 100644 index 00000000..7dedfbf9 --- /dev/null +++ b/core/views/emails/register-verify.phtml @@ -0,0 +1,24 @@ +url; +?> +

Verify your Tiger install

+ +

Confirm this email address to finish registering your site.

+ + + + + +
+ Domain
+ domain) ?> +
+ +btnUrl = $url; $this->btnLabel = 'Verify my site'; echo $this->render('_button.phtml'); ?> + +

Or paste this link into your browser:

+

diff --git a/core/views/emails/reset.phtml b/core/views/emails/reset.phtml new file mode 100644 index 00000000..0863e71b --- /dev/null +++ b/core/views/emails/reset.phtml @@ -0,0 +1,23 @@ +url; +?> +

Reset your password

+ +

We received a request to reset your password. Choose a new one with the button below.

+ +

This link expires in 1 hour.

+ +btnUrl = $url; $this->btnLabel = 'Reset password'; echo $this->render('_button.phtml'); ?> + +

Or paste this link into your browser:

+

+ + + +
 
+ +

If you didn’t request this, you can safely ignore this email — your password won’t change.

diff --git a/core/views/emails/test.phtml b/core/views/emails/test.phtml new file mode 100644 index 00000000..9fa7f481 --- /dev/null +++ b/core/views/emails/test.phtml @@ -0,0 +1,45 @@ + Email SMTP. + * + * It deliberately reports HOW it was delivered: the whole point of the test is to prove a specific + * provider/host works, and an admin comparing two configurations needs to know which one arrived. + * + * Variables: $via, $sentAt. + */ +$e = function ($s) { return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); }; +?> + + + + +
+ ✓  Delivery confirmed +
+ +

Outgoing mail is working

+ +

This is a test message from your siteName) ?> install. Because it reached your inbox, password resets, verification links and notifications will reach your users too.

+ + + via): ?> + + + + + + sentAt): ?> + + + + + +
Sent viavia) ?>
Sent atsentAt) ?>
+ + + +
 
+ +

You received this because someone used Send test on the System › Email SMTP screen. No action is needed.

diff --git a/core/views/emails/verify.phtml b/core/views/emails/verify.phtml new file mode 100644 index 00000000..55f97a51 --- /dev/null +++ b/core/views/emails/verify.phtml @@ -0,0 +1,21 @@ +url; +?> +

Confirm your email address

+ +

Welcome to siteName) ?>! Confirm your email address to activate your account.

+ +btnUrl = $url; $this->btnLabel = 'Verify my email'; echo $this->render('_button.phtml'); ?> + +

Or paste this link into your browser:

+

+ + + +
 
+ +

If you didn’t create this account, you can safely ignore this email.

diff --git a/library/Tiger/Backup.php b/library/Tiger/Backup.php index da3ffe13..edf6f51d 100644 --- a/library/Tiger/Backup.php +++ b/library/Tiger/Backup.php @@ -447,14 +447,17 @@ protected static function _notify($ok, $filename, array $data, array $opts) try { $host = $_SERVER['HTTP_HOST'] ?? gethostname(); $subject = ($ok ? '✅ Backup succeeded' : '⚠️ Backup FAILED') . ' — ' . $host; - $body = $ok - ? '

Backup ' . htmlspecialchars($filename) . ' completed.

Size: ' - . self::hsize((int) ($data['size'] ?? 0)) . '
Components: ' . htmlspecialchars(implode(', ', $data['components'] ?? [])) . '

' - : '

Backup ' . htmlspecialchars($filename) . ' failed.

Reason: ' - . htmlspecialchars((string) ($data['error'] ?? 'unknown')) . '

'; $mail = new Tiger_Mail(); foreach ($to as $addr) { $mail->to($addr); } - $mail->subject($subject)->html($body)->send(); + $mail->subject($subject) + ->template('backup', [ + 'ok' => $ok, + 'filename' => $filename, + 'size' => self::hsize((int) ($data['size'] ?? 0)), + 'components' => implode(', ', $data['components'] ?? []), + 'error' => (string) ($data['error'] ?? ''), + ]) + ->send(); } catch (Throwable $e) { Tiger_Log::warn('backup.notify.failed', ['error' => $e->getMessage()]); } diff --git a/library/Tiger/Mail.php b/library/Tiger/Mail.php index 6d02b124..6c162d7a 100644 --- a/library/Tiger/Mail.php +++ b/library/Tiger/Mail.php @@ -258,6 +258,102 @@ public static function transportFor(array $v) return new Zend_Mail_Transport_Smtp($host, $opts); } + /** + * Extra template directories, newest first (a module registers its own). + * + * @var array + */ + protected static $_templatePaths = []; + + /** + * Register a directory of email templates — how a module ships its own transactional emails + * while still rendering inside the shared layout. + * + * @param string $path an absolute directory containing `.phtml` + * @return void + */ + public static function addTemplatePath($path) + { + $path = rtrim((string) $path, '/'); + if ($path !== '' && !in_array($path, self::$_templatePaths, true)) { + array_unshift(self::$_templatePaths, $path); + } + } + + /** + * The template search path, in LOWEST-to-highest precedence order (Zend_View::addScriptPath + * prepends, so the last one added wins): core → app → registered modules → active theme. + * + * A theme sitting at the top is the point — an operator rebrands every transactional email by + * dropping `emails/*.phtml` into their theme, with no code change and nothing to keep in sync. + * + * @return array existing directories only + */ + protected static function _templatePaths() + { + $paths = [__DIR__ . '/../../core/views/emails']; + + if (defined('APPLICATION_PATH')) { $paths[] = APPLICATION_PATH . '/views/emails'; } + + foreach (array_reverse(self::$_templatePaths) as $p) { $paths[] = $p; } + + try { + if (class_exists('Tiger_Theme') && ($dir = Tiger_Theme::dir())) { $paths[] = $dir . '/emails'; } + } catch (Throwable $e) { + // no active theme resolved — the core template is still there + } + + return array_values(array_filter($paths, 'is_dir')); + } + + /** + * Render a template into the shared email layout and use it as this message's HTML body. + * + * The content script produces the message body; `layout.phtml` wraps it in the branded shell + * (header, card, footer) so every email Tiger sends looks like one product. A plain-text + * alternative is still auto-derived from the result by `send()`, so deliverability is unchanged. + * + * (new Tiger_Mail())->to($email)->subject('Reset your password') + * ->template('reset', ['url' => $url])->send(); + * + * @param string $name the template name, without `.phtml` + * @param array $vars variables exposed to the template + * @return self this instance, for chaining + * @throws Zend_View_Exception when neither the template nor the layout can be found + */ + public function template($name, array $vars = []) + { + $view = new Zend_View(); + $view->setEncoding('UTF-8'); + foreach (self::_templatePaths() as $path) { $view->addScriptPath($path); } + + $vars += ['siteName' => $this->_siteName(), 'siteUrl' => $this->_siteUrl()]; + $view->assign($vars); + + $content = $view->render($name . '.phtml'); + + $view->assign('content', $content); + $this->_html = $view->render('layout.phtml'); + + return $this; + } + + /** The install's site name, for the email header/footer. */ + protected function _siteName() + { + $cfg = $this->_config ?: (Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null); + $name = ($cfg && $cfg->get('tiger') && $cfg->tiger->get('site')) ? (string) $cfg->tiger->site->get('name') : ''; + if ($name !== '') { return $name; } + return $this->_configFrom()[1] ?: 'Tiger'; + } + + /** The install's public base URL, or '' when it isn't configured (the footer then omits the link). */ + protected function _siteUrl() + { + $cfg = $this->_config ?: (Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null); + return ($cfg && $cfg->get('tiger') && $cfg->tiger->get('site')) ? rtrim((string) $cfg->tiger->site->get('url'), '/') : ''; + } + /** * Instantiate a provider's API driver. * diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index f672069d..031d3317 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -214,7 +214,7 @@ public function requestPasswordReset($email, $baseUrl) (new Tiger_Mail()) ->to($user->email) ->subject('Reset your password') - ->html($this->_resetEmailHtml($url)) + ->template('reset', ['url' => $url]) ->send(); } catch (Throwable $e) { error_log('Tiger password-reset mail failed: ' . $e->getMessage()); @@ -329,7 +329,7 @@ public function requestLoginCode($email) (new Tiger_Mail()) ->to($user->email) ->subject('Your sign-in code: ' . $code) - ->html($this->_otpEmailHtml($code)) + ->template('otp', ['code' => $code]) ->send(); } catch (Throwable $e) { error_log('Tiger OTP mail failed: ' . $e->getMessage()); @@ -650,37 +650,7 @@ protected function _enrollNs() return new Zend_Session_Namespace('Tiger_TotpEnroll'); } - /** The one-time-code email body (the code shown large + monospace). */ - protected function _otpEmailHtml($code) - { - $c = htmlspecialchars((string) $code, ENT_QUOTES); - return '
' - . '

Your sign-in code

' - . '

Use this code to finish signing in. It expires in 10 minutes.

' - . '

' . $c . '

' - . '

If you didn\'t request this, you can ignore this email — ' - . 'no one can sign in without the code.

' - . '
'; - } - /** The reset email body (inline styles for mail-client compatibility). */ - protected function _resetEmailHtml($url) - { - $u = htmlspecialchars((string) $url, ENT_QUOTES); - return '
' - . '

Reset your password

' - . '

We received a request to reset your password. Choose a new one with the button below. ' - . 'This link expires in 1 hour.

' - . '

' - . 'Reset password

' - . '

Or paste this link into your browser:
' - . '' . $u . '

' - . '

If you didn\'t request this, you can safely ignore this ' - . 'email — your password won\'t change.

' - . '
'; - } /** * Switch the active org for the already-authenticated user, re-resolving the diff --git a/modules/register/services/Registration.php b/modules/register/services/Registration.php index c4836cc3..a33e682f 100644 --- a/modules/register/services/Registration.php +++ b/modules/register/services/Registration.php @@ -144,9 +144,7 @@ private function _issueEmailToken(string $email, string $domain): void (new Tiger_Mail()) ->to($email) ->subject('Verify your Tiger install') - ->html('

Click to verify your site:

' - . '

Verify my site →

' - . '

Domain: ' . htmlspecialchars($domain, ENT_QUOTES) . '

') + ->template('register-verify', ['url' => $url, 'domain' => $domain]) ->send(); } catch (Throwable $e) { // no MTA on a fresh install — the token is stored; the admin can resend. diff --git a/modules/signup/services/Signup.php b/modules/signup/services/Signup.php index 623307d9..e4e00eca 100644 --- a/modules/signup/services/Signup.php +++ b/modules/signup/services/Signup.php @@ -150,11 +150,7 @@ protected function _sendVerification($challengeId, $token, $email): void (new Tiger_Mail()) ->to($email) ->subject('Verify your email') - ->html( - '

Welcome to Tiger! Please confirm your email address to activate your account:

' - . '

Verify my email

' - . '

Or paste this link into your browser:
' . htmlspecialchars($url) . '

' - ) + ->template('verify', ['url' => $url]) ->send(); } catch (Throwable $e) { error_log('Tiger signup verification mail failed: ' . $e->getMessage()); diff --git a/modules/system/services/Settings.php b/modules/system/services/Settings.php index fa8568c2..42460280 100644 --- a/modules/system/services/Settings.php +++ b/modules/system/services/Settings.php @@ -203,20 +203,26 @@ public function mailTest(array $params): void $from = trim((string) ($params['mail_from_email'] ?? '')); if ($from !== '') { $mail->from($from, trim((string) ($params['mail_from_name'] ?? ''))); } + $via = $isApi + ? (string) $pDef['label'] + : (($values['transport'] === 'smtp' && $values['host'] !== '') + ? $values['host'] . ':' . ($values['port'] !== '' ? $values['port'] : '25') + : 'PHP mail() / sendmail'); + $mail->to($to) ->subject($this->_translate('system.settings.smtp.test_subject')) - ->html('

' . htmlspecialchars($this->_translate('system.settings.smtp.test_body'), ENT_QUOTES) . '

') + ->template('test', [ + 'via' => $via, + 'sentAt' => gmdate('Y-m-d H:i') . ' UTC', + 'preheader' => $this->_translate('system.settings.smtp.test_body'), + ]) ->send(Tiger_Mail::transportFor($values)); $this->_success([ 'ok' => true, 'to' => $to, 'ms' => (int) round((microtime(true) - $started) * 1000), - 'via' => $isApi - ? (string) $pDef['label'] - : (($values['transport'] === 'smtp' && $values['host'] !== '') - ? $values['host'] . ':' . ($values['port'] !== '' ? $values['port'] : '25') - : 'sendmail'), + 'via' => $via, ], 'system.settings.smtp.test_sent'); } catch (Throwable $e) { $this->_success([ diff --git a/tests/Integration/Mail/MailTemplateTest.php b/tests/Integration/Mail/MailTemplateTest.php new file mode 100644 index 00000000..72e4d855 --- /dev/null +++ b/tests/Integration/Mail/MailTemplateTest.php @@ -0,0 +1,139 @@ + ['site' => ['name' => 'Acme Corp', 'url' => 'https://acme.example.com']], + 'mail' => ['from' => ['email' => 'no-reply@acme.example.com', 'name' => 'Acme']], + ], true)); + } + + /** The HTML body Tiger_Mail would send, without sending it. */ + private function render(string $template, array $vars = []): string + { + $mail = new Tiger_Mail(); + $mail->to('someone@example.com')->subject('t')->template($template, $vars); + return (string) (new ReflectionProperty(Tiger_Mail::class, '_html'))->getValue($mail); + } + + #[Test] + public function the_body_is_rendered_into_the_layout(): void + { + $html = $this->render('reset', ['url' => 'https://acme.example.com/r/abc123']); + + $this->assertStringContainsString('Reset your password', $html, 'the template body is present'); + $this->assertStringContainsString('https://acme.example.com/r/abc123', $html, 'its variables are interpolated'); + $this->assertStringContainsString('Acme Corp', $html, 'and the layout wraps it'); + } + + #[Test] + public function templates_produce_genuinely_different_bodies(): void + { + // The empty-shell bug made every email identical in length. Comparing two templates catches + // a layout that renders but drops its content. + $reset = $this->render('reset', ['url' => 'https://acme.example.com/r/1']); + $otp = $this->render('otp', ['code' => '481902']); + + $this->assertNotSame($reset, $otp, 'different templates must not render the same document'); + $this->assertStringContainsString('481902', $otp, 'the code reaches the body'); + $this->assertStringNotContainsString('481902', $reset, 'and does not leak into another template'); + } + + #[Test] + public function every_shipped_template_renders(): void + { + $cases = [ + 'reset' => ['url' => 'https://acme.example.com/r/1'], + 'otp' => ['code' => '481902'], + 'verify' => ['url' => 'https://acme.example.com/v/1'], + 'register-verify' => ['url' => 'https://acme.example.com/rv/1', 'domain' => 'acme.example.com'], + 'backup' => ['ok' => true, 'filename' => 'b.zip', 'size' => '1 MB', 'components' => 'database'], + 'test' => ['via' => 'smtp.example.com:587', 'sentAt' => '2026-01-01 00:00 UTC'], + ]; + + foreach ($cases as $name => $vars) { + $html = $this->render($name, $vars); + $this->assertStringContainsString('assertStringContainsString('', $html, "$name closes cleanly"); + $this->assertGreaterThan(1500, strlen($html), "$name has real content, not an empty shell"); + } + } + + #[Test] + public function the_layout_is_built_for_email_clients_not_browsers(): void + { + $html = $this->render('reset', ['url' => 'https://acme.example.com/r/1']); + + // Inline styles are load-bearing: Gmail strips