feat(mail): one shared transactional email layout, used by every email - #189
Merged
Conversation
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 <p> 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 <head><style> in several contexts, so the <style> block carries only progressive extras — dark mode, small screens), 600px, and NO remote images (they're blocked by default and render as a broken box on first open, so the wordmark is live text). The CTA is a table-based bulletproof button with mso-padding-alt, since Word ignores padding on block elements. Migrated all six senders and deleted the now-dead _resetEmailHtml/_otpEmailHtml. Copy is preserved as-is — this restyles, it does not reword. A bug worth recording: Zend_View does NOT extract assigned variables into local scope, so the layout's bare `$content` produced a perfectly valid, perfectly EMPTY email — every template the identical byte size, no error raised. Nobody would notice until a customer received a blank password reset. Caught only by rendering the templates and looking at them; there is now a test that compares two rendered templates specifically to catch a layout that drops its content. Tests (+12): the body reaching the layout, templates producing different output, all six rendering, the email-client constraints (inlined styles, presentational tables, no flex/grid, no <img>), backup success vs failure, variable escaping, and the preheader being present-but-hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXgMENcwa4Q8yCJaHpjHpf
I hand-rolled the result as `out.className = 'small text-success'` + textContent, which is exactly what AGENTS.md says never to do — and it looked it: bare green text with no envelope, no dismiss, and no reveal. Now TigerDOM.notify owns it: themed alert with the success outline and fa-circle-check, revealed with the standard animation, dismissible by click (dismissOnClick) as well as the ✕. Errors STICK by notify's own default so a transport failure can actually be read; success auto-dismisses. Dropped the manual "Testing…" text — TigerButton.run already carries the in-flight state as a spinner, which is the house pattern. Values interpolated into the message are escaped (notify takes HTML): a provider's error text and the submitted address are both attacker-influenceable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXgMENcwa4Q8yCJaHpjHpf
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Tiger had no email templates. Six bodies were hand-concatenated PHP strings, and they had drifted:
<p>tags<p>tags<p>tagsThe signup verification is the first email a new customer ever receives, and it looked like a debug dump.
What
Tiger_Mail::template($name, $vars)renders a content template into a sharedlayout.phtml. Plain-text is still auto-derived bysend(), so deliverability is unchanged.Path cascade (lowest → highest): core → app → registered modules → active theme. The theme on top is deliberate: an operator rebrands every transactional email by dropping
emails/*.phtmlinto their theme — no code change, nothing to keep in sync.addTemplatePath()lets a third-party module ship its own.Written to email rules, not web rules — and the comments say why, so nobody "modernizes" it later:
role="presentation"— flex/grid are unusable in Outlook's Word renderer<head><style>in several contexts, so the<style>block carries only progressive extras (dark mode, small screens)mso-padding-alt, since Word ignores padding on block elementsMigrated all six senders; deleted the now-dead
_resetEmailHtml/_otpEmailHtml. Copy is preserved — this restyles, it doesn't reword.A bug worth recording
Zend_View does not extract assigned variables into local scope, so the layout's bare
$contentproduced a perfectly valid, perfectly empty email — every template the identical byte size, no error raised. Nobody would notice until a customer got a blank password reset. Caught only by rendering the templates and looking at them.There's now a test that compares two rendered templates specifically to catch a layout that drops its content.
Tests
+12: body reaching the layout, templates producing genuinely different output, all six rendering with real content, the email-client constraints (inlined styles, presentational tables, no flex/grid, no
<img>), backup success vs failure, variable escaping, and the preheader being present-but-hidden.Full suite 2091, 0 failures, 15 deprecations (baseline). Coverage 72.7% (floor 72).
CAPABILITIES.mdcurrent.Not in scope
Email copy is still hardcoded English across all six — it was before this change too. Now that the bodies are templates rather than concatenated strings, keying them for the six locales is a clean follow-up.