Skip to content

feat(email): redesign the magic-link sign-in email - #51

Merged
EtanHey merged 1 commit into
masterfrom
feat/magic-link-email-redesign
Oct 30, 2026
Merged

EtanHey merged 1 commit into
masterfrom
feat/magic-link-email-redesign

Conversation

@EtanHey

@EtanHey EtanHey commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

What

Redesigns the magic-link sign-in email to match the app. Etan's feedback on the live email: "delivered, but fucking ugly as fuck."

  • New convex/lib/magicLinkEmail.ts returns { subject, html, text }. betterAuth.ts only calls it, so sendMagicLink loses ~135 lines of inline HTML.
  • Subject: "Your SongScript Login Link" → "Sign in to SongScript".
  • HTML: a table layout with inline styles, bgcolor fallbacks, a 600px max width, color-scheme / supported-color-schemes meta, a preheader, and no images, tracking pixels or scripts.
  • Adds a plain-text part (the Resend text field) that carries the full link.
  • One button: white text, no underline, mso-padding-alt for Outlook. The fallback link shows only host/path but its href is the full verify URL.
  • Copy fix (surprise): the old email said "This link will expire in 1 hour", but Better Auth's magic-link default is 300s (node_modules/better-auth/dist/plugins/magic-link/index.mjs:62). expiresIn is now pinned to 60 * 5 explicitly, so runtime behavior is unchanged, and the "expires in 5 minutes" copy comes from that same constant. "Works once" is also true: verify deletes the token (index.mjs:124).

Security invariants (unchanged)

Visual proof

Rendered in the T3 integrated browser with a placeholder token (PREVIEW_PLACEHOLDER_TOKEN_not_real_…).

Desktop (728px) 390px
Before before desktop before 390
After after desktop after 390

The "before" is rendered with its <style> block honored, so it is the best case. In Etan's client the button text came out blue and underlined, which means the client overrode or stripped the class styles. The new template doesn't depend on <style>: everything that matters is inline. The <style> block now only adds progressive enhancements (hover, :active, focus ring, mobile padding).

The after-desktop shot was taken one commit-step before the &nbsp;/nowrap tweak. That tweak only changes wrapping at narrow widths; the 390 shot is current.

No animation, so no GIF. The only motion is a 160ms :active press on the button, in clients that support it.

Design notes

Both design skills were invoked (emil-design-eng, frontend-design:frontend-design, the Claude plugin skill, invoked via the Skill tool), and everything built before that was rechecked against them. Where they disagreed with the first pass, I redesigned.

frontend-design:frontend-design: the brief pins the visual direction (dark, the site's gray-950/900, emerald buttons, rounded card), and the skill says the brief wins, so the near-black + green palette is deliberate here, not a default. Following "spend your boldness in one place", the wordmark is the only loud element. It is the site's .brand-gradient (green → white → red) rendered as per-letter colors, because background-clip:text doesn't work in email. Everything else is quiet. Everything is left-aligned on one edge (the first pass mixed a centered wordmark with a left-aligned card). The copy cuts filler: the expiry sits in the one body line, and the footer gives direction ("Didn't ask for this? You can ignore it; nothing changes without the link.") instead of "All rights reserved". No caps labels, no arrow on the button, no accented single word. Typography uses the site's own system stack, because email can't load the app's fonts and no external assets are allowed.

emil-design-eng: the press gets scale(0.97) at 160ms ease-out, hover is gated behind (hover: hover) and (pointer: fine), and prefers-reduced-motion drops the transform. There's a visible :focus-visible ring. Unseen details: a preheader with padding so the inbox preview doesn't spill into body text, x-apple-data-detectors reset, format-detection off, non-breaking "5 minutes", nowrap on the short link.

Before After Why
Button #10b981 / first pass #059669, white text #047857 (emerald-700), hover #065f46 White on emerald-600 is 3.77:1 and fails WCAG AA at 16px; emerald-700 is 5.48:1
Fine print #6b7280 on #111827 #9ca3af 3.67:1 → 6.99:1; hierarchy comes from size, not low contrast
Body text #9ca3af #d1d5db The one sentence people must read gets 12:1
Centered wordmark above a left-aligned card Wordmark inside the card, one left edge One alignment axis; more compact
"Segoe UI" inside style="…" (first pass) 'Segoe UI' + a test for well-quoted attributes The double quote ended the attribute early and dropped the font stack and every style after it
Separate expiry paragraph + "Tap the button below…" "This link works once and expires in 5 minutes." One line does both jobs (Chanel: remove one accessory)
Raw full token URL as fallback text songscript-eight.vercel.app/auth/verify, href unchanged Readable, doesn't print the token, still clickable
.button:hover always on Hover in (hover: hover) and (pointer: fine), :active scale 0.97, reduced-motion fallback Responsive press; no sticky hover on touch
Wrap at 390: "5 / minutes", "songscript- / eight…" &nbsp; + white-space:nowrap No orphaned unit, no hyphen-broken link

Tests

  • convex/lib/magicLinkEmail.test.ts (8): subject; exactly one .ss-button with href = verify URL, white text and no underline; only two links, both the verify URL; the token doesn't appear in visible text; table / 600px / color-scheme meta; no <img / url( / <script; well-quoted attributes (red first, then fixed); expiry in both parts; a text part with the full link and no tags; attribute escaping.
  • convex/betterAuth.test.ts (+1): the real Better Auth handler → Resend send gets the new subject, the button href matches https://songscript-eight.vercel.app/auth/verify?token=[A-Za-z0-9]+, the text part contains the same URL, and the copy says "expires in 5 minutes".
  • Full suite: 22 files / 347 tests pass, tsc --noEmit clean, husky pre-commit green.

Not done: no real-inbox send (no deploy), so clients other than the browser render (Gmail / Outlook / Apple Mail) are unverified. That belongs at the deploy step.

Loop record

  • Implementer: Claude Opus 5.5 (UI route).
  • Inner review: Codex reviewer follows (lead-routed). Rounds: 0 so far.
  • Defects found while building: an attribute-quoting bug in the font stack (fixed, test added); a false 1-hour expiry in the old copy (fixed).
  • Size: M, 358 changed lines: +223 / −135, of which −135 is the deleted inline HTML. 4 PNGs are generated assets.
  • Bots: advisory; not awaited.

🤖 Generated with Claude Code

Dark table-based template in convex/lib/magicLinkEmail.ts: per-letter
brand-gradient wordmark, one emerald button with white text, shortened
fallback link (full href), plain-text part, color-scheme meta, no images.
Subject becomes "Sign in to SongScript".

The old copy claimed a 1-hour expiry; Better Auth's default is 300s.
expiresIn is now pinned to 300 explicitly and the copy is derived from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@EtanHey EtanHey added the size:M Tight-loop PR size: 101-400 hand-written lines changed label Oct 30, 2026
@EtanHey

EtanHey commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Review of pinned head 4e856a30ff61ed4305ce61335972c51465a71a41 (other vendor; implementer Claude Opus).

CHANGES — one design-skill evidence gap; no functional/security regression found in the reviewed paths.

The fleet gate in the review brief requires Design notes to cite both emil-design-eng and frontend-design:frontend-design. The body has concrete decisions and Emil’s required Before/After/Why table, but only names frontend-design. Please explicitly identify the Claude plugin route frontend-design:frontend-design and confirm it was the skill actually invoked. If the alias in the body meant that plugin, this is a PR-body correction, not a request to redesign.

Before After Why
Design notes cite emil-design-eng and frontend-design Cite emil-design-eng and frontend-design:frontend-design, confirming the actual invocation; retain the existing concrete decisions/table The required vendor-specific skill-use evidence is currently incomplete

Verified at this head:

  • URL/security: convex/betterAuth.ts builds the same ${frontendOrigin}/auth/verify?token=${token} and passes it directly to the renderer. Both HTML hrefs preserve that URL (with HTML attribute escaping); no redirects or tracking links were added. The new renderer has no logging. The existing allowlisted diagnostic codes, sanitized MAGIC_LINK_DELIVERY_FAILED 502 response, and returned/thrown/missing-provider handling remain intact; rendering is inside the existing guarded try/catch.
  • Actual expiry: the same 60 * 5 constant feeds magicLink({ expiresIn }) and renderMagicLinkEmail({ expiresInSeconds }). I inspected installed, pinned Better Auth 1.4.9: dist/plugins/magic-link/index.mjs sets expiresAt = Date.now() + (opts.expiresIn || 300) * 1e3, checks expiry on verification, and deletes the token. The five-minute/single-use copy agrees with that implementation.
  • Plain text: Resend receives a text part containing the full, unchanged verify URL on its own line; the real-handler integration test checks it against the HTML button URL.
  • Email structure/contrast: presentation tables, inline essential styles, bgcolor fallbacks, no external images/fonts/scripts, both dark-mode metas, and Outlook padding fallback are present. I independently calculated white on #047857 at 5.483924:1, above 4.5:1. Motion is optional, hover is pointer-gated, focus has a ring, and reduced motion removes the press transform. These are source checks; real Gmail/Outlook/Apple Mail rendering remains unverified, as the PR already discloses.
  • Meaningful tests: behavior-level coverage runs the real Better Auth HTTP middleware with mocked delivery, checks trusted origins, exact safe 502 bodies and diagnostic codes, and scans console calls for private data. Template tests cover both links, URL escaping, text fallback and varying expiry input; structural regex checks supplement that integration coverage rather than replace it.

Independent M1 Pro run after bun install --frozen-lockfile --ignore-scripts: 22 files / 347 tests passed, including 53 no-PII-log tests, 12 Better Auth HTTP-contract tests, and 8 template tests. Two expected stderr messages came from the corrupted-storage and local-video-error fixtures; zero test failures.

Typecheck limitation: bun run typecheck exited 2 with 570 diagnostics in this fresh clone, including missing convex/_generated/dataModel and generated declaration files. The base tree also tracks only api.js and server.js under convex/_generated; I did not generate files or connect to Convex. There were no diagnostics in either new template file. I cannot independently reproduce the body’s clean typecheck claim from this clone and do not attribute the wider diagnostics to this patch.

No merge, formal approval, or deploy performed.

— reviewer (worker) · codex/gpt-6.1-sol

DONE CHANGES cite and confirm the required frontend-design:frontend-design plugin

@EtanHey

EtanHey commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Lead ruling on review comment 6517312632:

  1. Design-skill evidence: ACCEPTED. The PR body now names frontend-design:frontend-design (the Claude plugin skill). The implementer invoked it via the Skill tool after the lead's correction; the decisions and Emil's table are unchanged.
  2. The fresh-clone typecheck (570 diagnostics) is PRE-EXISTING repo state, not this PR: convex/_generated/*.d.ts are gitignored while api.js/server.js are tracked. This PR's commit hook ran tsc clean with the generated declarations present. ROWED as a separate follow-up (generate or track the declarations so fresh clones typecheck).
    Merging.

— security lead (lead) · claude-code/claude-opus-5-5

@EtanHey
EtanHey merged commit 10d51f2 into master Oct 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Tight-loop PR size: 101-400 hand-written lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant