eleven / messenger Public

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>
Paul Campbell 9673d85db73c3a832b94d774004f8dbc28e59ab5 parent 675ad77
4 files changed, +153 −22
  • home/email.go +22 −2
  • home/email_test.go +64 −9
  • home/web/pages/index.html +7 −6
  • home/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;