Skip to content

feat(email-recovery): UX overhaul matching design-doc §8.6#3884

Open
sea-snake wants to merge 21 commits into
feat/email-recovery-deploy-scriptsfrom
feat/email-recovery-ux-overhaul
Open

feat(email-recovery): UX overhaul matching design-doc §8.6#3884
sea-snake wants to merge 21 commits into
feat/email-recovery-deploy-scriptsfrom
feat/email-recovery-ux-overhaul

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

@sea-snake sea-snake commented May 12, 2026

Summary

Substantial polish round on the email-recovery FE matching feedback from staging-A testing. No canister changes; all four commits are FE-only.

  • Send-confirmation-email view (was "Send the magic email") now matches the §8.6 mockup: 3-step progress indicator, pre-filled email card with To/From/Subject/Body rows, whole-row copy buttons (ContinueOnNewDevice pattern), mailto: "Open in mail app" button, subtle "Waiting for your email…" spinner. Cancel buttons removed across all wizard views — Dialog's × close is the only user-driven exit.
  • New UnsupportedDomain view shown when prepare returns DomainNotAllowlisted / DomainNotSupported. Heads "Can't use this email", with a <details> collapsible explaining the technical reason and a how-to-fix pointer (enable DNSSEC at registrar, or pick a known provider).
  • Access-and-recovery layout: tab renamed "Recovery phrase" → "Recovery methods" with a count badge (1 if either phrase or email present, 2 if both, hidden at 0). Manage-page heading follows.
  • Recovery phrase + email cards switched from a single-column flex stack to grid-cols-1 sm:grid-cols-2 — 2-up tiles on desktop, stack on mobile. Cards stretch to row height.
  • New RemoveEmailRecovery confirmation dialog mirroring RemovePasskey.svelte — "Remove recovery email?" + two short paragraphs + stacked danger / tertiary buttons. Removing an email previously had no warning at all.

PR Stack

# PR Description Status
0 #3836 Design doc Open
1 #3838 DNSSEC verifier scaffold Open
2 #3877 DKIM verifier (RFC 6376) Open
3 #3878 DMARC alignment (RFC 7489) Open
4 #3879 DoH fallback Open
5+6 #3880 Setup flow (storage + smtp_request) Open
7 #3881 Recovery flow (delegation) Open
8 #3882 Frontend + feature flag Open
9 #3883 Deploy/upgrade scripts Open
10 this PR Email-recovery UX overhaul Open

Test plan

  • CI green on the rebased deploy-scripts branch with these four UX commits applied (run was https://github.com/dfinity/internet-identity/actions/runs/25508963953 — head 46401af — all green except the e2e fix landed in the final commit).
  • Visual smoke test on staging A after deploy: setup happy path (gmail), unsupported-domain view (custom domain w/o DNSSEC), recovery flow, tab/counter, remove confirmation dialog.

🤖 Generated with Claude Code

@sea-snake sea-snake requested a review from a team as a code owner May 12, 2026 11:52
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from 5352b7a to f46b53a Compare May 12, 2026 12:00
@sea-snake sea-snake force-pushed the feat/email-recovery-ux-overhaul branch from 5b80360 to 1d0ce96 Compare May 12, 2026 12:01
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from f46b53a to a639a12 Compare May 12, 2026 12:24
@sea-snake sea-snake force-pushed the feat/email-recovery-ux-overhaul branch from 1d0ce96 to 8e5e90c Compare May 12, 2026 12:24
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from a639a12 to c1bad58 Compare May 12, 2026 13:02
@sea-snake sea-snake force-pushed the feat/email-recovery-ux-overhaul branch from 8e5e90c to 88cf6f5 Compare May 12, 2026 13:02
sea-snake-translation-bot pushed a commit to sea-snake-translation-bot/internet-identity that referenced this pull request May 12, 2026
…ck) (dfinity#3877)

## Summary

PR 2 of the email-recovery stack (`docs/ongoing/email-recovery.md` §10
Phase 0). Stacks on top of PR 3838 (DNSSEC verifier). Lands a
hand-rolled RFC 6376 DKIM verifier that consumes a parsed `SmtpRequest`
plus an already-trusted DKIM TXT record and returns a per-step
`EmailVerificationStatus`.

**Note:** This PR targets `main` but includes PR 3838's commits (DNSSEC
verifier) as its base. Review the DKIM-specific changes by looking at
commits after `9bbd8717` (the last PR 3838 commit). Once PR 3838 merges,
this PR's diff will shrink to just the DKIM additions.

## Why hand-rolled

The design originally specified `mail-auth` (Stalwart's well-tested DKIM
library), but mail-auth pulls a non-optional `hickory-resolver` dep that
fails to compile for `wasm32-unknown-unknown` (transitive: tokio + mio).
Forking + patching mail-auth would be possible but creates perpetual
rebase burden. We hand-roll instead — "the right way, no shortcuts" was
the explicit guidance.

## What's in this PR

###
`src/internet_identity_interface/src/internet_identity/types/smtp.rs`
Brings forward the SMTP gateway protocol types from PoC PR 3760:
`SmtpRequest`/`SmtpResponse`/`SmtpHeader`/`SmtpMessage`/`SmtpAddress`/`SmtpEnvelope`,
the size bounds, and the input-bound validation (`format_address`
lowercases both halves; `truncate_at_char_boundary` clamps to the
previous UTF-8 boundary so a multi-byte subject can't trap the
canister). Drops postbox-specific bits (PostboxEmail,
ValidatedSmtpRequest, anchor-number parser).

### `src/internet_identity/src/dkim/`
- **`types.rs`** — Algorithm (RsaSha256, Ed25519Sha256),
HeaderCanon/BodyCanon (Relaxed, Simple),
DkimCheck/DkimCheckName/DkimCheckStatus per-step diagnostics,
EmailVerificationStatus / VerificationFailReason result shape.
- **`parse.rs`** (RFC 6376 §3.5) — DKIM-Signature header tag-list
parser. Splits structurally on `;` first then on the *first* `=` per
element, so a literal `b=` substring inside another tag's base64 doesn't
get misread as a new tag start (the bug class the PoC PR review
specifically flagged). Folded whitespace inside base64 values is
stripped before decoding. Tag names case-insensitive; duplicates
rejected.
- **`canonicalize.rs`** (§3.4.2 / §3.4.4) — relaxed header canon
(lowercase name, unfold continuations, collapse WSP+ to single SP, strip
trailing WSP, strip WSP around colon) and relaxed body canon (per-line
WSP cleanup, drop trailing empty lines, ensure non-empty output ends in
exactly one CRLF).
- **`dns_record.rs`** (§3.6.2) — DKIM TXT record parser. Tag names
case-insensitive (`P=` vs `p=` was a PoC bug), whitespace inside `p=`
tolerated (multi-chunk DNS TXT records), `t=y`/`t=s` flags honoured,
unknown tags ignored.
- **`signature.rs`** — RSA-SHA256 (RFC 5702 / RFC 8301) and
Ed25519-SHA256 (RFC 8463) signature verification on top of
`rsa`+`sha2`+`ed25519-dalek` from PR 1's deps. Enforces 1024-bit RSA
minimum per design §5.6. Ed25519 path wraps in SHA-256 per RFC 8463.
Plus `body_hash_sha256` with optional `l=` truncation per §3.4.5.
- **`verify.rs`** — orchestration. Multi-signature loop per §5.5 (accept
on first pass), tag enforcement per design §5.4 (c=relaxed/* only, x=
expiration, i= alignment with d=, k= match, t=y testing-mode), bottom-up
header selection per §5.4 when h= lists a name multiple times, b=value
blanking that's structural-position-aware so it doesn't mis-target an
internal substring.
- **`test_vectors.rs`** — `#[cfg(test)]` .eml loader + 8 end-to-end
tests against committed fixtures.

### `test_vectors/dkim/`
- 3 synthetic .eml files generated offline with dkimpy + a 2048-bit RSA
key (`relaxed/relaxed`, `relaxed/simple`, `simple/simple`).
- The matching DKIM TXT record (public key only).
- README documenting provenance — the throwaway private key is **not**
committed.

## Test plan

- [x] `cargo check -p internet_identity --target wasm32-unknown-unknown`
— clean.
- [x] `cargo test -p internet_identity --bin internet_identity dkim` —
75 tests pass (parse 14, canonicalize 18, dns_record 16, signature 7,
verify 12, end-to-end 8).
- [x] `cargo test -p internet_identity --bin internet_identity` — 313
tests pass total (was 238 before this PR; +75 DKIM, plus a few in smtp
types).
- [x] `cargo test -p internet_identity_interface --lib` — 52 tests pass
(was 42; +10 SMTP type tests).
- [x] `cargo clippy -p internet_identity --bin internet_identity --tests
-- -D warnings` — clean.
- [x] `cargo fmt --check` — clean (modulo pre-existing diffs unrelated
to this PR).

## Stack

This is PR 2 of a 12-PR series. Includes PR 3838's commits as its base;
once PR 3838 merges, the diff shrinks to just the DKIM additions.

Subsequent PRs:
- **PR 3** — DMARC alignment.
- **PR 4** — DoH outcall fallback for unsigned domains (Gmail / Outlook
/ iCloud — see the design doc §7.6 and the team Slack writeup).
- **PRs 5–9** — storage + Candid + behavior for email recovery.
- **PRs 10–12** — frontend.

## PR Stack
| # | PR | Description | Status |
|---|---|---|---|
| 0 | [dfinity#3836](dfinity#3836) |
Design doc | Open |
| 1 | [dfinity#3838](dfinity#3838) |
DNSSEC verifier scaffold | Open |
| 2 | [dfinity#3877](dfinity#3877) |
DKIM verifier (RFC 6376) | Open |
| 3 | [dfinity#3878](dfinity#3878) |
DMARC alignment (RFC 7489) | Open |
| 4 | [dfinity#3879](dfinity#3879) |
DoH fallback | Open |
| 5+6 | [dfinity#3880](dfinity#3880)
| Setup flow (storage + smtp_request) | Open |
| 7 | [dfinity#3881](dfinity#3881) |
Recovery flow (delegation) | Open |
| 8 | [dfinity#3882](dfinity#3882) |
Frontend + feature flag | Open |
| 9 | [dfinity#3883](dfinity#3883) |
Deploy/upgrade scripts: dnssec_config + doh_config | Open |
| 10 | [dfinity#3884](dfinity#3884) |
Email-recovery UX overhaul | Open |

---------

Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
Co-authored-by: Claude <noreply@anthropic.com>
sea-snake and others added 15 commits May 13, 2026 11:08
Brings the email-recovery flow up to the polish level of the rest of
the (new-styling) app. Driven by the staging-test feedback:

Wizard flow
- Add a 3-step progress indicator at the top of every wizard view
  (`<Steps total={3} current={…}>` from createRecoveryPhrase).
- Replace `SendMagicEmail`'s verbose paragraph + icon-only copy + bottom
  cancel + orange warning block with the mockup's pre-filled email card:
  To / From / Subject / Body rows, whole-row copy buttons mirroring
  ContinueOnNewDevice's tooltip pattern (`navigator.clipboard` →
  `copied = true` → `waitFor(700)` → reset), a primary "Open in mail
  app" mailto button, and a small "Waiting for your email…" indicator.
- Drop Cancel buttons from every wizard view; the Dialog's built-in ×
  closes the flow, and on close the wizard's `onDestroy` flips
  `polling = false` so the closure stops ticking after the unmount.
- Single full-width primary CTA on EnterAddress, Done, FailedView, and
  EnterAddressForRecovery — matching the createRecoveryPhrase style.

Unsupported-domain handling
- New `UnsupportedDomain` view shown when `prepare_add` /
  `prepare_delegation` returns `DomainNotAllowlisted` /
  `DomainNotSupported`. Headed "Can't use this email", with a
  collapsible `<details>` explaining why (no DNSSEC at apex, not on
  allowlist) and what the user can do to fix it (enable DNSSEC at
  registrar, or pick a known provider). Single "Try a different
  address" button routes back to the address-entry view.

Recovery-methods tab + cards
- Rename the access-and-recovery layout's tab from "Recovery phrase"
  to "Recovery methods" (the tab now covers both the phrase and the
  email entry). E2e fixtures use URL navigation, so no surgery needed.
- Add a count badge to the tab: 1 if either phrase/email present, 2 if
  both, hidden when 0 (matching the access-methods tab pattern).
- Page heading on /manage/recovery follows: "Recovery methods" with
  copy that covers both methods.
- Switch the cards' wrapper from the single-column flex stack to a
  `grid-cols-1 sm:grid-cols-2` 2-up grid. Cards stretch to row height
  via grid's default `align-items: stretch`. On mobile they stack
  vertically as before. Cards' internal @container queries already
  handle the narrower-cell case (icon + heading + button stack
  vertically when the cell is below ~32rem).

Remove-email confirmation dialog
- New `RemoveEmailRecovery.svelte` mirroring `RemovePasskey.svelte`:
  warning FeaturedIcon + "Are you sure?" heading + two short
  explanatory paragraphs + stacked danger primary "Remove email" and
  tertiary "Cancel" buttons, with `ProgressRing` during the async
  remove. Wrap the existing `handleRemoveEmail` call in a Dialog/state
  pattern (`removingEmailRecovery: boolean`, `<Dialog>` block at the
  bottom of the page) — currently the email was removed silently with
  no user warning at all.

Out of scope
- No canister changes. No new tests; the existing `emailRecovery.spec.ts`
  fixture still covers the prepare → send → terminal happy path. The
  unsupported-domain view is not yet covered by an e2e test.
CI failed because the new SendConfirmationEmail.svelte and
RemoveEmailRecovery.svelte imported the `Button` component, which was
removed in upstream main (commit f859c0a) — the canonical replacement
is plain `<button>` / `<a>` with `btn` / `btn-primary` / `btn-lg` /
`btn-danger` / `btn-tertiary` / `btn-secondary` classes. Reshape both
files to that pattern (matching the post-removal RemovePasskey and
ContinueOnNewDevice implementations).

Also rename `SendMagicEmail.svelte` → `SendConfirmationEmail.svelte`
and update its two import sites — "magic" wasn't a term I should
have introduced into the codebase.
Two changes match what the new wizard renders:
- Wizard views no longer carry a Cancel button — close the dialog via
  the Dialog's built-in × (`aria-label="Close"`) instead.
- Step 2 heading is "Send your confirmation email" (the previous
  "Send the magic email" string was removed along with the "magic"
  language).

Updates the wizard-surface specs in `routes/emailRecovery.spec.ts`
and the `expectMagicEmailView` / `cancel()` helpers in
`fixtures/emailRecovery.ts` (renamed to `expectSendConfirmationEmailView`
/ `close()` to keep the fixture API honest about what it does).
…adge

Round of feedback from staging A:

- Recovery cards (4 components): action buttons moved to a row below
  the icon+title+subtitle, left-aligned. InactiveRecoveryPhrase drops
  the red error palette and matches the neutral grey of
  InactiveEmailRecovery — both inactive cards read as "not yet
  configured" rather than "something is wrong". Active versions
  aligned to the same icon-and-text shape so the two cards in the
  recovery-methods grid look consistent.
- Wizard views: removed the "ADD EMAIL RECOVERY — STEP N OF M"
  uppercase label and dropped the `<Steps>` progress bar entirely
  (flow is now effectively two steps; the indicator was chrome).
- Done.svelte deleted. On RegistrationSucceeded the wizard fires a
  new `onSuccess(address)` callback; the host shows a toast
  ("Recovery email added — <address> is now a recovery method.") and
  closes the dialog — same shape as the recovery-phrase activation
  toast.
- Email-address inputs (setup + recovery) suppress browser
  autocomplete + autocorrect + autocapitalize + 1Password / LastPass
  overlays via the matching attributes.
- Continue button in busy state now renders a `<ProgressRing />`
  alongside the "Verifying…" label, matching RemovePasskey's pattern.
- SendConfirmationEmail (renamed from SendMagicEmail in the prior
  pass) heavily reshaped:
    * heading → "Verify your email" (was the longer "Send your
      confirmation email")
    * description → "Send the email below. We'll verify it
      automatically." (waiting state communicated by the description
      itself; the spinner + expiration footer are gone)
    * row layout: labels (To/From/Subject/Body) sit ABOVE their
      values, not beside, so the dialog stays narrow
    * To and Subject rows are whole-row click targets that copy to
      the clipboard with the existing Tooltip/manual pattern; copy
      icon trails. From and Body rows are plain text.
    * "Open in mail app" button uses ExternalLinkIcon (the
      universal "this opens outside the app" signal).
    * From row carries a right-aligned shield-check "Verified" badge
      with a hover tooltip that explains the trust story:
        - DNSSEC path → "Your provider's DKIM key is verified
          end-to-end via DNSSEC — no trusted intermediaries."
        - DoH path    → "Your provider's DKIM key is verified by
          independent DNS resolvers — three out of five must agree."
      Path is derived in the wizard glue from whether the FE-built
      DNSSEC skeleton bundle was non-empty, then plumbed through the
      sending stage.
- /recovery sign-in page: the "Recover with email" CTA is now a
  primary button (was secondary), matching the prominence of
  "Recover with phrase".
- E2E tests + fixture: heading-name expectations updated for "Verify
  your email"; the post-success path no longer expects a Done view +
  "Done" button — instead asserts the dialog closes and the bound
  address is visible on the active card.
User-facing copy in three places:

- Verification badge tooltip on the From row of SendConfirmationEmail:
  rephrased into two sentences (no em dash) and dropped the "three of
  five" specificity in favour of "quorum consensus across independent
  DNS resolvers" — the exact threshold is an implementation detail and
  could shift later.
- The body row's "(anything — leave it blank)" is now
  "(anything, leave it blank)".
- The UnsupportedDomain view loses two em dashes in its body copy in
  favour of two-sentence breaks.
- Removes `src/frontend/src/lib/components/ui/Button.svelte`, which
carried a `// TODO: Deprecate this component, use classes directly on
<button> and <a>` comment
- Migrates all 36 callsites to plain `<button>` / `<a>` elements with
the existing `btn-*` utility classes from
`src/frontend/src/lib/styles/button.css`
- One bundled refactor — no behavioral changes intended

| Button prop | Replacement class |
| --- | --- |
| `variant="primary" \| "secondary" \| "tertiary"` | `btn-primary` /
`btn-secondary` / `btn-tertiary` |
| `size="sm" \| "md" \| "lg" \| "xl"` | `btn-sm` / `btn-md` / `btn-lg` /
`btn-xl` |
| `iconOnly` | `btn-icon` |
| `danger` | `btn-danger` |
| `href` present | rendered as `<a>` (otherwise `<button>`) |

- [x] `npx svelte-check check` — 0 errors (19 pre-existing warnings,
unchanged)
- [x] `npx tsc --project ./tsconfig.all.json --noEmit` — passes
- [x] `npm run lint:eslint` — passes
- [x] `npm run format-check` — passes
- [x] `npx vite build` — succeeds
- [ ] Manual visual smoke through auth, manage, authorize flows

Note: `src/try-ii/src/try-ii-frontend/src/lib/components/Button.svelte`
is a separate, hand-rolled component in the `try-ii` sub-project with no
deprecation marker — left untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The success toast description includes the bound address as a
substring ("<addr> is now a recovery method."), which collided
with the active-card subtitle ("<addr>" alone) under
Playwright's default fuzzy-text matcher and tripped strict-mode.
Switch to { exact: true } to only match the card subtitle.
…cker

- SendConfirmationEmail card: add `overflow-hidden` to the rounded
  container so the To/Subject row hover background respects the
  outer corners (was bleeding past the rounded corners).
- Verification badge in the From row: drop the "Verified" text +
  background pill to avoid clashing with the dialog's "Verify your
  email" heading. Now an icon-only ShieldCheckIcon styled with
  `text-fg-success-primary` (renders correctly in both light and
  dark modes — the previous `bg-bg-success-secondary` pairing had
  poor contrast in dark). Tooltip carries the trust story on hover,
  matching the help-icon pattern from CreatePasskey
  (`direction="up" align="end" offset="0rem"`). Tooltip label is
  "Cryptographically authentic" so the hover content is distinct
  from the dialog heading's "Verify".
- Subtitle shortened from "Send the email below. We'll verify it
  automatically." to "Send the email below to confirm." — fits one
  line on the dialog width.
- /recovery picker: replace the two stacked solid buttons with two
  stacked `ButtonCard`s carrying a leading icon, label, descriptive
  subtitle, and a trailing chevron. Eliminates the heavy
  two-primary-buttons stack while keeping both options equally
  prominent. Phrase subtitle is "Type your 24 words." (II uses
  24-word phrases); email subtitle is "Send a signed email from
  your inbox." — drops the word "token".
…rdance

The help-icon Tooltip pattern in CreatePasskey wraps the icon in a
`btn btn-tertiary btn-icon !rounded-full` button so it gets a
circled hover/focus background and matches the rest of the toolbar
icon language. Match exactly here so the verification shield reads
as an interactive trust-info affordance. `!cursor-default` keeps the
cursor at default so users don't expect a click action.
…ability

The /recovery page picker switched from buttons ("Recover with
phrase" / "Recover with email") to ButtonCards whose visible text
is now "Recovery phrase" / "Recovery email" plus a subtitle. The
existing e2e selectors (`getByRole('button', { name: 'Recover with
email' })`) computed the accessible name from the visible content
and stopped matching. Add explicit aria-labels so the buttons still
expose the same accessible name to screen readers and tests, no
fragile selector rewrites needed.
Three rounds of staging feedback rolled into one:

1. Verification badge alignment (SendConfirmationEmail). The shield
   button used `btn-icon` + `size-5` icon (= 20×20 button via
   aspect-square !p-0), while the To/Subject rows ended in plain
   `size-4` CopyIcons (= 16×16). The 4 px size mismatch put the
   shield's right edge to the left of the copy icons. Now the
   button is `size-7!` (28×28) with a `size-4` shield inside; the
   resulting 6 px padding around the icon is offset back with
   `-me-1.5` so the icon's right edge lands at the same x as the
   copy icons. Hover circle is now visible too.

2. Recovery-method cards rebuilt to match the access-method
   `PasskeyItem` density. Each card has:
     - icon row with the action affordance at the trailing edge
       (single action → label button; multiple actions → kebab menu
       via `Select`)
     - bold heading (method name)
     - small status subtitle ("Activated" / "Not activated" /
       "Not verified")
     - divider
     - "Last used" stat (active variants only)
     - description paragraph at the bottom
   `ActiveRecoveryPhrase`, `InactiveRecoveryPhrase`,
   `ActiveEmailRecovery`, `InactiveEmailRecovery`, and
   `UnverifiedRecoveryPhrase` all converted. The active recovery-
   email card moves the address back into the subtitle slot so the
   heading stays "Recovery email" — same shape as the active
   recovery-phrase card.

3. /recovery picker layout. Reflowed each ButtonCard into:
     [icon | title                              | arrow]
            subtitle
   The icon and title share a flex row (icon centered against the
   title text); the subtitle sits in a second row indented by
   `ps-8` (icon size + gap-3) so it lines up with the title's left
   edge. Static chevron replaced with the hover-fade `ArrowRightIcon`
   pattern from the home-page identity picker — invisible at rest
   (`opacity-0 me-3`), fades in and slides leftward
   (`opacity-100 me-2`) on hover, slides further (`me-0`) on
   keyboard focus.
ButtonCard renders as a <button> which inherits the user-agent
`text-align: center` default. The icon+title+arrow row used flex
alignment so it looked OK, but the standalone subtitle span
inherited the centered default. Add `text-start` on the ButtonCard
itself so all descendant text is start-aligned.
The card rebuild mirrored `PasskeyItem`'s use of <div> for the title,
but the e2e fixture queries `getByRole('heading', { name: 'Recovery
email' })`, which only matches semantic h1–h6 elements. Switching to
<h2> across all five recovery cards fixes the test and is the right
semantics anyway — these are section headings within the manage page.
- Both Inactive cards: btn-primary "Activate" with disambiguating
  aria-labels ("Activate recovery phrase" / "Activate recovery email")
  so the visible labels can match without breaking strict-mode test
  selectors.
- ActiveEmailRecovery description: append "Use it to recover when you
  lose all other access methods." anchor sentence to mirror the
  recovery-phrase card and keep the two descriptions structurally
  consistent.
- recovery/+page.svelte: hide the "How to stay secure" articles when
  EMAIL_RECOVERY is on. The phrase-only safety tips read as awkward
  filler once a second method shares the page; revisit if/when we
  write multi-method tips worth the real estate.
- e2e: update fixture + spec selectors from "Add email" to
  "Activate recovery email" to match the new aria-label.
sea-snake added 6 commits May 13, 2026 11:08
The card structure was split into a heading ("Recovery phrase") plus a
separate status element ("Activated" / "Not activated" / "Not verified"),
so the old fixture queries for combined headings like "Recovery phrase
not activated" no longer match.

Scope each assertion to the section containing the "Recovery phrase"
heading, then check for the status text within it. This works whether
the email card is rendered alongside or not.
The recovery cards now share a parallel always-visible first sentence
across active and inactive states, and the inactive cards add a second
paragraph that fills the empty space below the divider (the active
cards already have a "Last used" row in that slot).

- Drop the trailing "Use it to recover when you lose all other access
  methods." sentence from all four cards. The cards live under the
  "Recovery methods" tab, so calling them recovery methods inside the
  cards too was redundant.
- Phrase: replace "seed" with "phrase" — recovery phrases here can be
  reset, which conflicts with the cryptocurrency-world "seed" connotation
  of immutable provenance.
- Inactive phrase paragraph: lean into the trust model (offline, no
  account or provider, only the user can use/lose/replace it).
- Inactive email paragraph: explain that each email carries a
  cryptographic signature proving it came from the user's inbox; the
  only party they trust is their mail provider, with nothing else in
  between. (Avoid naming DNSSEC since the DoH-allowlist path doesn't
  rely on it.)
- /recovery page picker: replace the phrase-only "Before getting
  started, find a private place and have your recovery phrase ready"
  blurb with a method-agnostic "Pick a recovery method below to sign
  back in".
Three small fixes uncovered by the e2e suite:

- ManageRecoveryPage.verify() / .reset() now go through the "More
  options" dropdown on the unverified card. The earlier refactor
  consolidated those two actions into a Select menu, but the fixtures
  still expected directly visible buttons, leading to 60s timeouts on
  the entire `Recovery phrase > can be verified` describe.
- CreateRecoveryPhraseWizard.close() targets the dialog X via its
  aria-label attribute. The role+name lookup matched both the X and
  the verify-step word button labelled "close" whenever the BIP39
  wordlist happened to put "close" in the generated phrase, surfacing
  as a flaky strict-mode violation.
- recovery/+page.svelte: cap the two-up card grid at max-w-5xl
  (~64rem / 1024px) so each card lands at roughly 500px wide on
  desktop instead of stretching to fill the full main column.
The trailing sentence echoed what the surrounding context already
implied (the user just entered the unsupported domain), and isolating
the domain on its own line made long ones wrap clumsily.
- Phrase option icon switches from KeyRound to Shield, matching the
  shield family used on every phrase card on /manage (Active/Inactive/
  Unverified). The icon now consistently signals "recovery phrase" no
  matter which page the user is on.
- Phrase subtitle: "Type your 24 words." -> "Type your 24 recovery
  words." — the prior phrasing was a bit short under the bold heading.
- Email subtitle: "Send a signed email from your inbox." -> "Send an
  email from your inbox." — drop "signed" since it's irrelevant to the
  user-facing instruction at this picker step (the heading is enough).
Match the setup wizard's EnterAddress phrasing ("...to confirm.") so
the recover-flow subtitle reads consistently and fits on the same
number of lines without wrapping.
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from c1bad58 to de9e3d8 Compare May 13, 2026 11:25
@sea-snake sea-snake force-pushed the feat/email-recovery-ux-overhaul branch from 88cf6f5 to 98311e4 Compare May 13, 2026 11:25
aterga added a commit that referenced this pull request May 13, 2026
…of email-recovery stack) (#3878)

## Summary

PR 3 of the email-recovery stack (`docs/ongoing/email-recovery.md` §6).
Stacks on top of #3877 (DKIM verifier). Lands a hand-rolled DMARC
alignment check and reshapes the verifier API: `dkim::verify_dkim`
becomes a DKIM-only primitive, and the new `dmarc::verify_email` is the
public top-level entry point that produces the combined
`EmailVerificationStatus`.

**Note:** This PR targets `main` but includes PRs 1+2's commits as its
base. Review the DMARC-specific changes by looking at commits on top of
`ec371aae3` (PR 2's tip). Once PRs 1+2 merge, this PR's diff shrinks to
just the DMARC additions.

## What's in this PR

### `src/internet_identity/src/dmarc/`

- **`types.rs`** — `DmarcOutcome` (Aligned / Misaligned / NoRecord /
Malformed), `DmarcPolicy` (None / Quarantine / Reject), `AlignmentMode`
(Strict / Relaxed), `DmarcRecord`, plus the combined
`EmailVerificationStatus` that carries both DKIM diagnostics and the
DMARC outcome on success.
- **`parse.rs`** (RFC 7489 §6.3) — DMARC TXT record parser. Enforces
`v=DMARC1` must be first, `p=` must be one of {none, quarantine,
reject}, `pct=` 0..=100, rejects duplicate tags, ignores unknown /
reporting tags. 12 unit tests.
- **`from_header.rs`** (RFC 5322 / RFC 7489 §3.1.1) — single-mailbox
From-header parser. Accepts bare addr-spec, name-addr, and
quoted-display-name forms; rejects zero/multiple From: headers,
address-lists, group syntax. Tolerates comma/colon inside quoted display
names. 16 unit tests.
- **`alignment.rs`** — strict (exact match) + relaxed (exact match OR
label-aligned subdomain in either direction). Stricter than
RFC-compliant relaxed alignment because we deliberately don't consult
the PSL — design doc §6.4 documents the trust + asymmetric-failure-mode
reasoning. The dot anchor on the subdomain check prevents
`evilexample.com` from aliasing `example.com`. 8 unit tests.
- **`verify.rs`** — orchestration. DKIM first; on failure, surface the
DKIM reason verbatim. On DKIM pass, parse From and check DMARC
alignment. Accepted iff Aligned, OR NoRecord with `dkim_domain ==
from_domain`. 8 unit tests.
- **`test_vectors.rs`** — 5 end-to-end tests reusing PR 2's synthetic
.eml fixtures.

### `src/internet_identity/src/dkim/types.rs` (rename + new variants)

- Renamed `EmailVerificationStatus` → `DkimVerifyResult` (DKIM-only).
The combined verdict moved to `dmarc::EmailVerificationStatus` so it can
carry the `DmarcOutcome`.
- Added `MalformedFromHeader(String)`, `DmarcMalformed(String)`,
`DmarcMisaligned` to `VerificationFailReason`.

### `src/internet_identity/src/dkim/mod.rs`

- Re-exports `verify` as `verify_dkim` so downstream callers (the dmarc
layer) don't have to deal with both a `dkim::verify` and `dmarc::verify`
in scope at the same time.

## Test plan

- [x] `cargo check -p internet_identity --target wasm32-unknown-unknown`
— clean.
- [x] `cargo test -p internet_identity --bin internet_identity dmarc` —
49 tests pass (12 parse + 16 from_header + 8 alignment + 8 verify + 5
e2e).
- [x] `cargo test -p internet_identity --bin internet_identity` — 365
tests pass total (was 313 with PR 2; +49 dmarc + 3 small reshape
adjustments).
- [x] `cargo clippy -p internet_identity --bin internet_identity --tests
-- -D warnings` — clean.
- [x] `cargo fmt --check` — clean (modulo pre-existing unrelated diffs).

## PR Stack
| # | PR | Description | Status |
|---|---|---|---|
| 0 | [#3836](#3836) |
Design doc | Open |
| 1 | [#3838](#3838) |
DNSSEC verifier scaffold | Open |
| 2 | [#3877](#3877) |
DKIM verifier (RFC 6376) | Open |
| 3 | [#3878](#3878) |
DMARC alignment (RFC 7489) | Open |
| 4 | [#3879](#3879) |
DoH fallback | Open |
| 5+6 | [#3880](#3880)
| Setup flow (storage + smtp_request) | Open |
| 7 | [#3881](#3881) |
Recovery flow (delegation) | Open |
| 8 | [#3882](#3882) |
Frontend + feature flag | Open |
| 9 | [#3883](#3883) |
Deploy/upgrade scripts: dnssec_config + doh_config | Open |
| 10 | [#3884](#3884) |
Email-recovery UX overhaul | Open |

---------

Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
Co-authored-by: Claude <noreply@anthropic.com>
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