Skip to content

feat(mail): one shared transactional email layout, used by every email - #189

Merged
WebTigers merged 2 commits into
mainfrom
feat/smtp-test-email
Aug 25, 2026
Merged

feat(mail): one shared transactional email layout, used by every email#189
WebTigers merged 2 commits into
mainfrom
feat/smtp-test-email

Conversation

@WebTigers

Copy link
Copy Markdown
Owner

Why

Tiger had no email templates. Six bodies were hand-concatenated PHP strings, and they had drifted:

Email Before
Password reset, sign-in code styled inline
Backup report barely styled
Signup verification bare <p> tags
Site registration bare <p> tags
SMTP test bare <p> tags

The 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 shared layout.phtml. Plain-text is still auto-derived by send(), 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/*.phtml into 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:

  • 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 — blocked by default in most clients, so a logo would render as a broken box on first open. 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; 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 $content produced 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.md current.

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.

WebTigers and others added 2 commits August 25, 2026 14:44
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
@WebTigers
WebTigers merged commit 09d4821 into main Aug 25, 2026
13 checks passed
@WebTigers
WebTigers deleted the feat/smtp-test-email branch August 25, 2026 19:02
@WebTigers WebTigers mentioned this pull request Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant