diff --git a/core/views/emails/_button.phtml b/core/views/emails/_button.phtml new file mode 100644 index 00000000..e7f761a7 --- /dev/null +++ b/core/views/emails/_button.phtml @@ -0,0 +1,27 @@ +, not a styled div: Outlook renders through Word, + * which ignores padding on block elements and would collapse a CSS button into bare underlined + * text. The `mso-padding-alt` + conditional wrapper give Outlook a real box to draw. + * + * Variables: $btnUrl, $btnLabel. + */ +$e = function ($s) { return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); }; +$url = (string) $this->btnUrl; +$label = (string) $this->btnLabel; +if ($url === '' || $label === '') { return; } +?> + + + + +
+ + + +
diff --git a/core/views/emails/backup.phtml b/core/views/emails/backup.phtml new file mode 100644 index 00000000..70c4bf43 --- /dev/null +++ b/core/views/emails/backup.phtml @@ -0,0 +1,33 @@ +ok; +?> +

+ Backup +

+ + + + + + +
+ filename) ?>
+ error ?: 'unknown error') ?> +
+ + + + + + + + + + + +
Sizesize) ?>
Componentscomponents) ?>
+ diff --git a/core/views/emails/layout.phtml b/core/views/emails/layout.phtml new file mode 100644 index 00000000..df2261f0 --- /dev/null +++ b/core/views/emails/layout.phtml @@ -0,0 +1,132 @@ + + + + + + +
+ + +
+ + + + + + + + + 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/modules/system/views/scripts/settings/index.phtml b/modules/system/views/scripts/settings/index.phtml index 83269c7b..804edf26 100644 --- a/modules/system/views/scripts/settings/index.phtml +++ b/modules/system/views/scripts/settings/index.phtml @@ -502,6 +502,14 @@ document.addEventListener('DOMContentLoaded', function () { var form = document.getElementById('sys-settings-form'); var fb = document.getElementById('sys-settings-feedback'); + // TigerDOM.notify takes HTML, so any value interpolated into a message is escaped first — + // a provider's error text and a submitted address are both attacker-influenceable. + var esc = function (s) { + var d = document.createElement('div'); + d.textContent = (s == null ? '' : String(s)); + return d.innerHTML; + }; + document.getElementById('sys-settings-save').addEventListener('click', function () { var btn = this; fb.innerHTML = ''; @@ -675,12 +683,13 @@ document.addEventListener('DOMContentLoaded', function () { var out = document.getElementById('mail-test-result'); var to = document.getElementById('set-mail-test-to').value.trim(); if (to === '') { - out.className = 'small text-danger'; out.textContent = '✗ ' + Tiger.t('mailTestNeedsAddress'); + TigerDOM.notify(out, Tiger.t('mailTestNeedsAddress'), { type: 'error', dismissOnClick: true }); return; } var fd = new URLSearchParams(); fd.set('module', 'system'); fd.set('service', 'settings'); fd.set('method', 'mailTest'); fd.set('to', to); + out.innerHTML = ''; ['mail_provider', 'mail_smtp_host', 'mail_smtp_port', 'mail_smtp_ssl', 'mail_smtp_auth', 'mail_smtp_username', 'mail_smtp_password', 'mail_from_email', 'mail_from_name'].forEach(function (k) { var input = form.querySelector('[name="' + k + '"]'); @@ -693,20 +702,28 @@ document.addEventListener('DOMContentLoaded', function () { if (inp.value !== '') { fd.set(inp.name, inp.value); } }); } - out.className = 'small text-body-secondary'; out.textContent = Tiger.t('mailTestSending'); + // The button carries the in-flight state (TigerButton spinner) and TigerDOM.notify owns + // the message envelope — themed alert, icon, reveal animation, dismiss. Never hand-rolled. TigerButton.run(this, function () { return fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }).then(function (r) { return r.json().catch(function () { return {}; }); }); }).then(function (res) { var d = (res && res.data) || {}; if (d.ok) { - out.className = 'small text-success'; - out.textContent = '✓ ' + Tiger.t('mailTestSent') + ' ' + d.to + ' — ' + (d.via || '') + ' (' + (d.ms || 0) + 'ms)'; + var detail = [d.via, (d.ms || 0) + 'ms'].filter(Boolean).join(' · '); + TigerDOM.notify(out, + '' + Tiger.t('mailTestSent') + ' ' + esc(d.to) + '' + + (detail ? '
' + esc(detail) + '
' : ''), + { type: 'success', dismissOnClick: true }); } else { - // The transport's own message is the diagnostic — show it verbatim, wrapped. - out.className = 'small text-danger text-break'; - out.textContent = '✗ ' + (d.error || ((res && res.messages && res.messages[0]) ? res.messages[0].message : Tiger.t('mailTestFailed'))); + // The transport's own message is the diagnostic — verbatim, and it STICKS + // (notify keeps errors until dismissed) so it can actually be read and acted on. + var msg = d.error || ((res && res.messages && res.messages[0]) ? res.messages[0].message : Tiger.t('mailTestFailed')); + TigerDOM.notify(out, '' + esc(msg) + '', + { type: 'error', dismissOnClick: true }); } - }).catch(function () { out.className = 'small text-danger'; out.textContent = Tiger.t('networkErrorShort'); }); + }).catch(function () { + TigerDOM.notify(out, Tiger.t('networkErrorShort'), { type: 'error', dismissOnClick: true }); + }); }); } }); 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