home: email magic link — tap the mail's code instead of typing it (#1091) (#1126)
The sign-in mail gains a link under the code: the SAME address and code,
carried in the URL #fragment (https://<origin>/#email=<addr>&code=<6 digits>)
— which browsers never send to any server. Clicking runs the ordinary
/api/email/verify from the page, so the link shares the code's single-use/
expiry/attempt machinery and adds zero new server auth surface; the code
stays first in the mail as the spine (recovery-grade, works typed anywhere).
- email.go: emailSignInLink + emailCodeBody (origin from s.origin); encoding
is QueryEscape with space-"+" as %20, exactly what decodeURIComponent
inverts (plus-addressed local parts round-trip).
- index.html: the pre-paint gate also trips on #email=, raising the waiting
face (booting + siwa-wait) before first paint; the CSP inline-script hash
self-heals from the served bytes.
- home.js: #email= boot handler — clear the hash via replaceState, verify
under the cover, land through the existing markSignedIn("email") +
persistSession + enterAnchorSession path, then the shared anchorArrival
(factored out of finishSiwa). Failure lifts to the doors with "That
sign-in link has expired — request a fresh code."
- email_test.go: the fake SES now captures bodies; the new
TestEmailMailCarriesMagicLink pins the link format server-side and
redeems the parsed fragment values through the real verify.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+153
−22
home/email.go+22 −2home/email_test.go+64 −9home/web/pages/index.html+7 −6home/web/static/home.js+60 −5
diff --git a/home/email.go b/home/email.go| index 9315bc1..c1dfa50 100644 |
| --- a/home/email.go |
| +++ b/home/email.go |
| @@ -25,6 +25,7 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| + "net/url" |
| "strings" |
| "sync" |
| "time" |
| @@ -97,6 +98,26 @@ func (c *emailCodes) redeem(index, code string, now time.Time) bool { |
| func (s *Server) emailEnabled() bool { return s.mail != nil && len(s.emailPepper) > 0 } |
| +// emailSignInLink is the magic link under the code: the SAME address and code |
| +// the mail asks you to type, carried in the URL #fragment — which browsers |
| +// never send to any server. Clicking just runs the ordinary |
| +// /api/email/verify from the page, so the link adds zero server auth surface. |
| +// Encoding: QueryEscape with its space-"+" swapped to %20, which is exactly |
| +// what the page's decodeURIComponent inverts (a plus-addressed local part |
| +// round-trips: + → %2B → +). |
| +func emailSignInLink(origin, addr, code string) string { |
| + return origin + "/#email=" + strings.ReplaceAll(url.QueryEscape(addr), "+", "%20") + "&code=" + code |
| +} |
| + |
| +// emailCodeBody is the sign-in mail. The code stays first and prominent — it |
| +// is the spine (works typed into any browser); the link is the same values as |
| +// a convenience tap. |
| +func emailCodeBody(origin, addr, code string) string { |
| + return "Your sign-in code is " + code + "\n\n" + |
| + "Or tap: " + emailSignInLink(origin, addr, code) + "\n\n" + |
| + "It works for 15 minutes. If you didn't ask for it, ignore this email." |
| +} |
| + |
| // emailIndex is the blind index: HMAC(pepper, lowercase(address)). Lowercase |
| // is the WHOLE normalization — plus-addressing and provider dot-games are the |
| // user's business, not ours to strip (per the design doc). |
| @@ -132,8 +153,7 @@ func (s *Server) emailRequest(w http.ResponseWriter, r *http.Request) { |
| // arrives. The error is logged for the operator. |
| go func() { |
| if err := s.mail.send(addr, "Your Eleven sign-in code", |
| - "Your sign-in code is "+code+"\n\n"+ |
| - "It works for 15 minutes. If you didn't ask for it, ignore this email."); err != nil { |
| + emailCodeBody(s.origin, addr, code)); err != nil { |
| log.Printf("email code send failed: %v", err) |
| } |
| }() |
diff --git a/home/email_test.go b/home/email_test.go| index 57720f5..7e8bd4d 100644 |
| --- a/home/email_test.go |
| +++ b/home/email_test.go |
| @@ -9,19 +9,26 @@ import ( |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "net/url" |
| "regexp" |
| + "strings" |
| "testing" |
| "time" |
| ) |
| var codeRe = regexp.MustCompile(`\b(\d{6})\b`) |
| +// sentMail is one captured fake-SES send: recipient, the first 6-digit code |
| +// in the body (the typed code — it precedes the magic link's copy), and the |
| +// whole body for contract assertions. |
| +type sentMail struct{ to, code, body string } |
| + |
| // emailServer is newTestServer with the email anchor on, wired to a fake SES |
| -// that pushes each sent (to, code) onto a channel. |
| -func emailServer(t *testing.T) (*httptest.Server, *Server, chan [2]string) { |
| +// that pushes each captured send onto a channel. |
| +func emailServer(t *testing.T) (*httptest.Server, *Server, chan sentMail) { |
| t.Helper() |
| ts, s := newTestServer(t) |
| - sent := make(chan [2]string, 8) |
| + sent := make(chan sentMail, 8) |
| fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| body, _ := io.ReadAll(r.Body) |
| var req struct { |
| @@ -46,7 +53,7 @@ func emailServer(t *testing.T) (*httptest.Server, *Server, chan [2]string) { |
| t.Errorf("fake ses: no code in %q", req.Content.Simple.Body.Text.Data) |
| m = []string{"", ""} |
| } |
| - sent <- [2]string{req.Destination.ToAddresses[0], m[1]} |
| + sent <- sentMail{to: req.Destination.ToAddresses[0], code: m[1], body: req.Content.Simple.Body.Text.Data} |
| writeJSON(w, http.StatusOK, map[string]string{"MessageId": "fake"}) |
| })) |
| t.Cleanup(fake.Close) |
| @@ -56,7 +63,7 @@ func emailServer(t *testing.T) (*httptest.Server, *Server, chan [2]string) { |
| return ts, s, sent |
| } |
| -func requestCode(t *testing.T, ts *httptest.Server, sent chan [2]string, addr string) string { |
| +func requestMail(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) sentMail { |
| t.Helper() |
| b, _ := json.Marshal(map[string]string{"email": addr}) |
| resp := do(t, "POST", ts.URL+"/api/email/request", "", nil, b) |
| @@ -65,16 +72,21 @@ func requestCode(t *testing.T, ts *httptest.Server, sent chan [2]string, addr st |
| } |
| select { |
| case m := <-sent: |
| - if m[0] != addr { |
| - t.Fatalf("mail went to %q, want %q", m[0], addr) |
| + if m.to != addr { |
| + t.Fatalf("mail went to %q, want %q", m.to, addr) |
| } |
| - return m[1] |
| + return m |
| case <-time.After(2 * time.Second): |
| t.Fatalf("no mail arrived for %s", addr) |
| - return "" |
| + return sentMail{} |
| } |
| } |
| +func requestCode(t *testing.T, ts *httptest.Server, sent chan sentMail, addr string) string { |
| + t.Helper() |
| + return requestMail(t, ts, sent, addr).code |
| +} |
| + |
| func verifyCode(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response { |
| t.Helper() |
| b, _ := json.Marshal(body) |
| @@ -234,3 +246,46 @@ func TestEmailLinkToExistingAccount(t *testing.T) { |
| t.Fatalf("linked sign-in reads a different vault: %q", b) |
| } |
| } |
| + |
| +// TestEmailMailCarriesMagicLink pins the mail side of the magic-link contract |
| +// (#1091): under the code, the same address+code as a tappable link whose |
| +// values ride the URL #fragment (never sent to any server — the page reads |
| +// the hash and runs the ordinary /api/email/verify). The JS half can't run |
| +// here, so the format assertion is the spec: origin + "/#email=" + the |
| +// address percent-escaped exactly as decodeURIComponent inverts (plus- |
| +// addressing round-trips) + "&code=" + the 6 digits. |
| +func TestEmailMailCarriesMagicLink(t *testing.T) { |
| + ts, _, sent := emailServer(t) |
| + const addr = "paul+tag@example.com" |
| + m := requestMail(t, ts, sent, addr) |
| + |
| + link := regexp.MustCompile(`(?m)^Or tap: (\S+)$`).FindStringSubmatch(m.body) |
| + if link == nil { |
| + t.Fatalf("no link line in %q", m.body) |
| + } |
| + // The test server's origin is http://localhost:1748 (newTestServer). |
| + want := "http://localhost:1748/#email=paul%2Btag%40example.com&code=" + m.code |
| + if link[1] != want { |
| + t.Fatalf("link = %q, want %q", link[1], want) |
| + } |
| + // The code stays first — the link rides under it, never replaces it. |
| + if !strings.HasPrefix(m.body, "Your sign-in code is "+m.code) { |
| + t.Fatalf("code is not the mail's opening line: %q", m.body) |
| + } |
| + |
| + // And the link's values redeem through the ordinary verify: decode the |
| + // fragment the way the page does (percent-unescape, "+" left alone — |
| + // PathUnescape matches decodeURIComponent here) and post it. |
| + frag := regexp.MustCompile(`^http://localhost:1748/#email=([^&]*)&code=(\d{6})$`).FindStringSubmatch(link[1]) |
| + if frag == nil { |
| + t.Fatalf("link doesn't match the boot fragment shape: %q", link[1]) |
| + } |
| + decoded, err := url.PathUnescape(frag[1]) |
| + if err != nil || decoded != addr { |
| + t.Fatalf("fragment address decodes to %q (%v), want %q", decoded, err, addr) |
| + } |
| + resp := verifyCode(t, ts, nil, map[string]any{"email": decoded, "code": frag[2]}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("verify via link values: got %d", resp.StatusCode) |
| + } |
| +} |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index ab2f558..6253f92 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -666,17 +666,18 @@ |
| // Pre-paint gate: if this browser holds a live session — the tab's own, or |
| // a stay-signed-in one in localStorage (home_session_expires is a plain |
| // epoch-ms key exactly so this check needs no JSON) — or is landing back |
| - // from Apple's sign-in (#siwa= fragment), show the loading cover from the |
| - // very first frame, so no path ever flashes the Sign-in screen. The Apple |
| - // return gets the WAITING face (smoke + ring, no lockup — siwa-wait): the |
| - // brand only lands once the sign-in has confirmed. Signed-out visitors |
| - // skip it all; home.js lifts the cover on every path it was raised for. |
| + // from Apple's sign-in (#siwa= fragment) or the mail's magic link (#email= |
| + // fragment), show the loading cover from the very first frame, so no path |
| + // ever flashes the Sign-in screen. Both fragment landings get the WAITING |
| + // face (smoke + ring, no lockup — siwa-wait): the brand only lands once the |
| + // sign-in has confirmed. Signed-out visitors skip it all; home.js lifts the |
| + // cover on every path it was raised for. |
| try { |
| // The card never paints uncomposed: composeCard() decides its doors |
| // (last-used/platform), so the default markup must not get a frame. |
| // home.js lifts this at the end of module init on every path. |
| document.documentElement.classList.add("composing"); |
| - if (location.hash.indexOf("#siwa=") === 0) |
| + if (location.hash.indexOf("#siwa=") === 0 || location.hash.indexOf("#email=") === 0) |
| document.documentElement.classList.add("booting", "siwa-wait"); |
| else if ((sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek")) || |
| +localStorage.getItem("home_session_expires") > Date.now()) |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 103ffde..b800521 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -483,10 +483,15 @@ async function finishSiwa(idToken) { |
| finishHomeLoader({ immediate: true }); |
| return; |
| } |
| - // Phase two, arrival: the lockup fades in over the ring's spot (~400ms), |
| - // dwells (~450ms), then the cover lifts while the spaces screen slides up |
| - // into place beneath it. Reduced motion: no dwell, no slide — plain fades |
| - // (brand-in and arrive are inert under the reduce query). |
| + anchorArrival(); |
| +} |
| + |
| +// anchorArrival is phase two for a covered anchor sign-in (Apple's return leg, |
| +// the email magic link): the lockup fades in over the ring's spot (~400ms), |
| +// dwells (~450ms), then the cover lifts while the spaces screen slides up |
| +// into place beneath it. Reduced motion: no dwell, no slide — plain fades |
| +// (brand-in and arrive are inert under the reduce query). |
| +function anchorArrival() { |
| const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| document.documentElement.classList.remove("siwa-wait"); // ring → lockup |
| $("app-loading").classList.add("brand-in"); |
| @@ -647,6 +652,33 @@ async function verifyEmailCode(e) { |
| } |
| } |
| +// finishEmailLink completes the mail's magic link (the #email= boot fragment): |
| +// the address+code from the fragment — the very values the mail asks you to |
| +// type — go to the ordinary /api/email/verify, so the link shares the code's |
| +// single-use/expiry/attempt machinery and adds no auth surface of its own. |
| +// The pre-paint waiting face is up the whole time; success lands exactly like |
| +// the typed-code path, then earns the same arrival as Apple's return. |
| +async function finishEmailLink(email, code) { |
| + setStatus("Confirming it’s you…", "busy"); |
| + try { |
| + const res = await apiJSON("/api/email/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email, code }), |
| + }); |
| + markSignedIn("email"); |
| + persistSession(res.token, "email"); |
| + await enterAnchorSession(res); // renders the landing beneath the cover |
| + } catch { |
| + // An expired or already-redeemed code: no brand moment — lift straight to |
| + // the doors and say what to do next. |
| + setStatus("That sign-in link has expired — request a fresh code.", "error"); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| + anchorArrival(); |
| +} |
| + |
| // enterAnchorSession lands an email/Apple sign-in. The token is held exactly |
| // like the passkey path holds its own (sessionStorage, this tab only); the |
| // ceremony proof is not kept, matching the passkey path, which only ever uses |
| @@ -2112,6 +2144,29 @@ let siwaBooting = false; // claims the boot — the cold-open path below stands |
| } |
| } |
| +// The mail's magic link (email.go) lands here as #email=<address>&code=<6 |
| +// digits> — the same values the mail asks you to type, riding the fragment, |
| +// which never reaches any server. It runs the ordinary /api/email/verify, so |
| +// the code's single-use/expiry/attempt machinery is the whole story — and it |
| +// works in any browser, recovery-grade, exactly like typing the code. The |
| +// hash clears before the verify, so a reload can't rerun the (single-use |
| +// anyway) code. |
| +let emailLinkBooting = false; // claims the boot — the cold-open path below stands down |
| +if (!siwaBooting && location.hash.indexOf("#email=") === 0) { |
| + emailLinkBooting = true; |
| + // The pre-paint gate already put the waiting face up (html.siwa-wait) — |
| + // the clean card never shows. |
| + const m = location.hash.match(/^#email=([^&]*)&code=(\d{6})$/); |
| + history.replaceState(null, "", location.pathname + location.search); |
| + if (m) { |
| + finishEmailLink(decodeURIComponent(m[1]), m[2]); |
| + } else { |
| + // A mangled link (a mail client truncated it): lift to the doors. |
| + finishHomeLoader({ immediate: true }); |
| + setStatus("That sign-in link didn’t work — request a fresh code.", "error"); |
| + } |
| +} |
| + |
| $("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = true; }); |
| $("account-menu-btn").addEventListener("click", () => { |
| if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu(); |
| @@ -2186,7 +2241,7 @@ if (state.token && state.dek) { |
| } catch { /* token or DEK went stale; the button still works */ } |
| finally { finishHomeLoader(); } |
| })(); |
| -} else if (!siwaBooting) { |
| +} else if (!siwaBooting && !emailLinkBooting) { |
| // The cold open (stay signed in, Option A): no tab session, but a live |
| // persisted token in localStorage. Adopt it and land exactly like an anchor |
| // sign-in — the sealed prompt when a blob exists, the empty list when not; |