Skip to content

feat: redesign transactional email templates with 2026 best practices - #73

Merged
jhosepmyr merged 1 commit into
developfrom
feature/improve-email-html-templates
Jul 10, 2026
Merged

feat: redesign transactional email templates with 2026 best practices#73
jhosepmyr merged 1 commit into
developfrom
feature/improve-email-html-templates

Conversation

@jhosepmyr

Copy link
Copy Markdown
Contributor

Description

Researched current (2026) transactional-email best practices and rebuilt every email ReqsAI sends (verification, password reset, org invitation, project invitation, project assignment) — they were bare <p>/<a> string concatenation with no styling, no plain-text fallback, and no protection against HTML injection from user-controlled values.

What changed:

  • Table-based layout, all CSS inlined — Outlook renders email via Word's engine (no flexbox/grid support), and Gmail strips <style> blocks, so inline style= attributes on tables/cells is the only approach that renders consistently everywhere.
  • Real multipart/alternative — every email now sends an HTML body and a plain-text body generated from the same structured content (new EmailContent record + EmailTemplateRenderer), so clients that can't/won't render HTML still get something readable.
  • HTML-escaping fix — display names, organization names, role/project names were previously interpolated raw into the HTML string. A display name containing <script>...</script> would have been injected verbatim. Now escaped via HtmlUtils.htmlEscape before insertion.
  • Accessible CTA button — descriptive label (not "click here"), ~48px tall, full raw-link fallback text underneath (screen readers + in case the button doesn't render).
  • Hidden preheader — controls the inbox preview snippet instead of leaving it to chance.
  • Accurate expiry copy — verification/reset emails now state the real token lifetime (24h / 1h, matching IAM_EMAIL_VERIFICATION_EXPIRATION / IAM_PASSWORD_RESET_EXPIRATION defaults).
  • Branded header bar (ReqsAI navy/red) + footer with dynamic copyright year.

Bounded context / area: iam

Related issue / US: N/A — hygiene/quality pass, not tied to a ticket


Type of Change

  • feat — new feature
  • fix — bug fix
  • refactor — code change without behavior change (the public EmailNotificationPort contract is unchanged; only the rendered content and MIME structure improved)
  • test — tests only
  • docs — documentation only
  • build / ci — build, dependencies, or CI/CD
  • chore — maintenance

Checklist

  • The PR targets develop (not main)
  • Branch name follows feature/*, bugfix/*, or hotfix/*
  • Commits follow Conventional Commits
  • ./gradlew build passes locally (compile + tests + verifyModularity)
  • New cross-module access respects module boundaries — new code stays inside iam.infrastructure.email
  • Added/updated tests for the change — multipart structure, HTML-escaping, and existing content assertions all covered in SmtpEmailAdapterTest
  • No secrets, credentials, or .pem keys are committed
  • CHANGELOG.md updated under [Unreleased]
  • Database changes are expressed as Flyway migrations — N/A

How to Test

  1. ./gradlew test --tests "*SmtpEmailAdapterTest*" — covers multipart/alternative structure, HTML-escaping of hostile input, and existing content assertions
  2. Locally with Mailpit (reqsai.email.provider=mailpit): trigger any of the 5 email flows (sign-up, password reset, org invite, project invite, direct project assignment) and inspect the rendered message in Mailpit's UI — both the HTML and plain-text tabs
  3. Confirm the CTA button, fallback link, preheader (visible in the inbox list preview), and footer render correctly

Notes / Screenshots (optional)

EmailNotificationPort's public signatures are unchanged — this is entirely internal to the SMTP adapter's rendering, so no other module or caller needed changes.

…tices

Replaces bare <p>/<a> string concatenation with a proper table-based,
inline-CSS HTML layout (Outlook-safe), a hidden preheader, an
accessible CTA button with a raw-link fallback, and a real
multipart/alternative plain-text body generated from the same
structured EmailContent — so HTML and plain text can't drift apart.

Also fixes an HTML-injection gap: display/organization/project names
were interpolated raw into the email body; they're now escaped via
EmailTemplateRenderer before insertion.
@jhosepmyr
jhosepmyr requested a review from Kyrubi as a code owner July 10, 2026 16:37
@jhosepmyr
jhosepmyr merged commit 8ec84a1 into develop Jul 10, 2026
5 checks passed
@jhosepmyr

Copy link
Copy Markdown
Contributor Author

Real end-to-end verification against a running app + Mailpit (all 5 flows triggered via actual API calls, not just unit tests) confirmed 4/5 templates render correctly — Mailpit's built-in HTML Check also reported 95.54% email-client compatibility across 186 tests (Outlook, Outlook.com, macOS, iOS, Android, webmail).

Found and fixed one real bug: sendProjectInvitationEmail's subject/heading/preheader were identical to sendInvitationEmail's — a recipient scanning their inbox couldn't tell a project invite apart from a plain org invite (the only difference was buried in an extra body paragraph). Fixed in bd2cddb — both now name the project explicitly in the subject line.

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