From 04a92ec7dec56e62a40502f2326e64f8365f19af Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 10 May 2026 11:18:57 -0700 Subject: [PATCH] Add branded dark-themed HTML email template (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a shared email module that wraps all transactional emails in a cohesive dark-themed template matching the site's visual identity (Barlow font, #14171e background, #adf661 CTA buttons, logomark PNG header). - app/lib/email.server.ts: new module with sendEmail, wrapInEmailTemplate, emailButton, emailParagraph, and escapeHtml helpers; Resend instance is lazily initialized as a singleton - public/email-logo.png: 96×124px PNG of the logomark for email use (SVG gradients are not supported in Outlook) - auth.server.ts: password reset and email verification now use the branded template; errors are thrown so Better Auth can surface failures - support.tsx: internal notification uses sendEmail; adds a user-facing branded confirmation email (fire-and-forget so failures don't surface to the user); plain text fallback included - scripts/sync-prod-db.sh: remove first_name/last_name from sanitize query (columns were dropped in #399) - 13 unit tests covering all template helpers Co-authored-by: Claude Sonnet 4.6 --- app/lib/__tests__/email.server.test.ts | 101 ++++++++++++++++++ app/lib/auth.server.ts | 26 +++-- app/lib/email.server.ts | 138 +++++++++++++++++++++++++ app/routes/support.tsx | 34 ++++-- public/email-logo.png | Bin 0 -> 3164 bytes scripts/sync-prod-db.sh | 2 - 6 files changed, 281 insertions(+), 20 deletions(-) create mode 100644 app/lib/__tests__/email.server.test.ts create mode 100644 app/lib/email.server.ts create mode 100644 public/email-logo.png diff --git a/app/lib/__tests__/email.server.test.ts b/app/lib/__tests__/email.server.test.ts new file mode 100644 index 0000000..b926951 --- /dev/null +++ b/app/lib/__tests__/email.server.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + escapeHtml, + emailParagraph, + emailButton, + wrapInEmailTemplate, +} from "~/lib/email.server"; + +describe("escapeHtml", () => { + it("escapes all five special characters", () => { + expect(escapeHtml('&<>"\'')).toBe("&<>"'"); + }); + + it("leaves safe strings unchanged", () => { + expect(escapeHtml("Hello World")).toBe("Hello World"); + }); +}); + +describe("emailParagraph", () => { + it("wraps content in a

tag", () => { + const result = emailParagraph("Hello"); + expect(result).toContain("

"); + }); + + it("passes HTML through unescaped (caller is responsible for escaping)", () => { + const result = emailParagraph('link'); + expect(result).toContain(''); + }); +}); + +describe("emailButton", () => { + it("contains the href and button text", () => { + const result = emailButton("https://example.com/verify", "Verify My Email"); + expect(result).toContain("https://example.com/verify"); + expect(result).toContain("Verify My Email"); + }); + + it("renders a table-wrapped anchor for Outlook compatibility", () => { + const result = emailButton("https://example.com", "Click me"); + expect(result).toContain(" { + const originalAppUrl = process.env.APP_URL; + + beforeEach(() => { + process.env.APP_URL = "https://test.brackt.com"; + }); + + afterEach(() => { + if (originalAppUrl === undefined) { + delete process.env.APP_URL; + } else { + process.env.APP_URL = originalAppUrl; + } + }); + + it("returns a complete HTML document", () => { + const result = wrapInEmailTemplate("

Body

"); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + it("injects the content into the template", () => { + const result = wrapInEmailTemplate("

My content

"); + expect(result).toContain("

My content

"); + }); + + it("includes the preheader text when provided", () => { + const result = wrapInEmailTemplate("

Body

", "Preview text here"); + expect(result).toContain("Preview text here"); + }); + + it("omits the preheader span when not provided", () => { + const result = wrapInEmailTemplate("

Body

"); + expect(result).not.toContain("mso-hide:all"); + }); + + it("uses APP_URL for the logo src", () => { + const result = wrapInEmailTemplate("

Body

"); + expect(result).toContain("https://test.brackt.com/email-logo.png"); + }); + + it("falls back to brackt.com when APP_URL is not set", () => { + delete process.env.APP_URL; + const result = wrapInEmailTemplate("

Body

"); + expect(result).toContain("https://brackt.com/email-logo.png"); + }); + + it("escapes preheader text to prevent injection", () => { + const result = wrapInEmailTemplate("

Body

", ""); + expect(result).not.toContain("