Skip to content

Commit de9db13

Browse files
authored
Email verification: keep checking for the confirmation, and back it off (#113250)
1 parent 0d682ed commit de9db13

6 files changed

Lines changed: 291 additions & 148 deletions

File tree

client/landing/stepper/declarative-flow/internals/steps-repository/__user/email-verification/inbox-links.ts

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,84 @@ export interface InboxLink {
77
url: string;
88
}
99

10-
// Inbox URLs by provider, built from the signup address. Gmail routes through the account
11-
// chooser so multi-account users land on the right mailbox; the rest are the vetted URLs from
10+
interface Provider {
11+
// Every domain this provider actually runs mail for. A brand in the name proves nothing:
12+
// live.io is Google-hosted, so `live.*` would send its owner to a mailbox they have no
13+
// account on — worse than the no link an unlisted domain gets. Verify MX before adding one.
14+
domains: string[];
15+
inboxUrl: ( email: string ) => string;
16+
}
17+
18+
// Deliberately short: the top handful of domains cover almost every signup, and a long tail
19+
// invites entries that have since been parked. URLs other than Gmail's come from
1220
// client/login/magic-login/magic-login-email.
13-
const PROVIDER_INBOX_URL: Record< string, ( email: string ) => string > = {
14-
gmail: ( email ) => getGmailUrl( email ),
15-
outlook: () => 'https://outlook.live.com/mail/',
16-
yahoo: () => 'https://mail.yahoo.com/',
17-
icloud: () => 'https://www.icloud.com/mail/',
18-
aol: () => 'https://mail.aol.com/',
19-
proton: () => 'https://mail.proton.me/',
21+
const PROVIDERS: Record< string, Provider > = {
22+
gmail: {
23+
domains: [ 'gmail.com', 'googlemail.com' ],
24+
// The account chooser, so multi-account users land on the right mailbox.
25+
inboxUrl: ( email ) => getGmailUrl( email ),
26+
},
27+
outlook: {
28+
domains: [
29+
'outlook.com',
30+
'hotmail.com',
31+
'hotmail.be',
32+
'hotmail.co.uk',
33+
'hotmail.de',
34+
'hotmail.es',
35+
'hotmail.fr',
36+
'hotmail.it',
37+
'hotmail.nl',
38+
'live.com',
39+
'live.co.uk',
40+
'live.fr',
41+
'live.nl',
42+
'msn.com',
43+
],
44+
inboxUrl: () => 'https://outlook.live.com/mail/',
45+
},
46+
yahoo: {
47+
domains: [
48+
'yahoo.com',
49+
'yahoo.ca',
50+
'yahoo.co.uk',
51+
'yahoo.com.br',
52+
'yahoo.de',
53+
'yahoo.es',
54+
'yahoo.fr',
55+
'yahoo.it',
56+
'ymail.com',
57+
],
58+
inboxUrl: () => 'https://mail.yahoo.com/',
59+
},
60+
// A separate service from the rest of Yahoo, with its own mailbox.
61+
yahoojp: {
62+
domains: [ 'yahoo.co.jp' ],
63+
inboxUrl: () => 'https://mail.yahoo.co.jp/',
64+
},
65+
icloud: {
66+
domains: [ 'icloud.com', 'me.com', 'mac.com' ],
67+
inboxUrl: () => 'https://www.icloud.com/mail/',
68+
},
69+
aol: {
70+
domains: [ 'aol.com' ],
71+
inboxUrl: () => 'https://mail.aol.com/',
72+
},
73+
proton: {
74+
domains: [ 'proton.me', 'protonmail.com', 'pm.me' ],
75+
inboxUrl: () => 'https://mail.proton.me/',
76+
},
2077
};
2178

22-
const DOMAIN_TO_PROVIDER: Record< string, string > = {
23-
'gmail.com': 'gmail',
24-
'googlemail.com': 'gmail',
25-
'outlook.com': 'outlook',
26-
'hotmail.com': 'outlook',
27-
'live.com': 'outlook',
28-
'msn.com': 'outlook',
29-
'yahoo.com': 'yahoo',
30-
'icloud.com': 'icloud',
31-
'me.com': 'icloud',
32-
'mac.com': 'icloud',
33-
'aol.com': 'aol',
34-
'proton.me': 'proton',
35-
'protonmail.com': 'proton',
36-
};
79+
const DOMAIN_TO_PROVIDER: Record< string, keyof typeof PROVIDERS > = Object.fromEntries(
80+
Object.entries( PROVIDERS ).flatMap( ( [ provider, { domains } ] ) =>
81+
domains.map( ( domain ) => [ domain, provider ] )
82+
)
83+
);
3784

3885
// The inbox link for an email's provider, or null for an unrecognized/self-hosted domain.
3986
export function getInboxLink( email: string | undefined ): InboxLink | null {
4087
const domain = email ? extractDomainWithExtension( email ) : undefined;
4188
const provider = domain ? DOMAIN_TO_PROVIDER[ domain ] : undefined;
42-
return provider && email ? { provider, url: PROVIDER_INBOX_URL[ provider ]( email ) } : null;
89+
return provider && email ? { provider, url: PROVIDERS[ provider ].inboxUrl( email ) } : null;
4390
}

client/landing/stepper/declarative-flow/internals/steps-repository/__user/email-verification/index.tsx

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ interface Props {
3434
const EmailVerificationGate = ( { flow, scope, logo, onDone }: Props ) => {
3535
const { __ } = useI18n();
3636
const email = useSelector( getCurrentUserEmail );
37-
const { isVerified, sendStatus, secondsUntilResend, checkStatus, checkNow, resend } =
38-
useEmailVerification( flow, scope );
37+
const { isVerified, sendStatus, secondsUntilResend, resend } = useEmailVerification(
38+
flow,
39+
scope
40+
);
3941

4042
const hasSubmitted = useRef( false );
4143
const headingRef = useRef< HTMLDivElement >( null );
4244
const inboxLink = getInboxLink( email ?? undefined );
45+
// A stable dependency: `inboxLink` is a fresh object every render.
46+
const provider = inboxLink?.provider ?? 'none';
4347

4448
const title = __( 'Verify your email' );
4549

@@ -52,10 +56,13 @@ const EmailVerificationGate = ( { flow, scope, logo, onDone }: Props ) => {
5256
)
5357
: __( 'Resend' );
5458

55-
// Stamp the shown-at time now the gate is actually on screen (see storage.ts).
59+
// The denominator for everything that follows: `provider` names which variant was shown
60+
// (`none` when the address has no inbox link), and a view with no confirmation is a drop-off.
5661
useEffect( () => {
57-
markGateShown( scope );
58-
}, [ scope ] );
62+
if ( markGateShown( scope ) ) {
63+
recordTracksEvent( 'calypso_signup_email_verification_view', { flow, provider } );
64+
}
65+
}, [ scope, flow, provider ] );
5966

6067
// The gate replaces the account form without a route change, so move focus onto its heading
6168
// — otherwise it strands on the unmounted submit button and the screen change goes unsaid.
@@ -139,21 +146,18 @@ const EmailVerificationGate = ( { flow, scope, logo, onDone }: Props ) => {
139146

140147
<VStack spacing={ 3 }>
141148
{ /* Calypso's Button, not the Step.* ones: the design follows its outline, radius,
142-
and weight. A known provider gets an inbox deep link — confirming there
143-
resolves the gate by polling; the rest get a manual re-check. */ }
144-
{ inboxLink ? (
145-
<Button primary href={ inboxLink.url } target="_blank" onClick={ openInbox }>
146-
{ __( 'Open email inbox' ) }
147-
<Icon icon={ arrowUpRight } size={ 16 } fill="currentColor" />
148-
</Button>
149-
) : (
149+
and weight. A known provider gets an inbox deep link; confirming there — or
150+
anywhere else — resolves the gate by polling, so nothing else is needed. */ }
151+
{ inboxLink && (
150152
<Button
151153
primary
152-
onClick={ checkNow }
153-
busy={ checkStatus === 'checking' }
154-
disabled={ checkStatus === 'checking' }
154+
href={ inboxLink.url }
155+
target="_blank"
156+
rel="noopener noreferrer"
157+
onClick={ openInbox }
155158
>
156-
{ __( 'I’ve confirmed my email' ) }
159+
{ __( 'Open email inbox' ) }
160+
<Icon icon={ arrowUpRight } size={ 16 } fill="currentColor" />
157161
</Button>
158162
) }
159163

@@ -165,20 +169,6 @@ const EmailVerificationGate = ( { flow, scope, logo, onDone }: Props ) => {
165169
{ resendLabel }
166170
</Button>
167171

168-
{ checkStatus === 'unconfirmed' && (
169-
<p className="onboarding-email-verification__notice" role="status">
170-
{ __(
171-
'We haven’t received your confirmation yet. Open the link in your inbox, then try again.'
172-
) }
173-
</p>
174-
) }
175-
176-
{ checkStatus === 'error' && (
177-
<p className="onboarding-email-verification__notice is-error" role="alert">
178-
{ __( 'We couldn’t check right now. Please try again in a moment.' ) }
179-
</p>
180-
) }
181-
182172
{ sendStatus === 'error' && (
183173
<p className="onboarding-email-verification__notice is-error" role="alert">
184174
{ __( 'We couldn’t send the email. Please try again in a moment.' ) }

client/landing/stepper/declarative-flow/internals/steps-repository/__user/email-verification/storage.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,24 @@ export function beginGate( scope: string ): void {
3939
write( scope, { resendAvailableAt: 0, shownAt: 0 } );
4040
}
4141

42-
// Stamped when the gate first renders, so the duration metric excludes the token-load and
43-
// user-hydration wait before it.
44-
export function markGateShown( scope: string ): void {
42+
/**
43+
* Stamps the gate as shown, so the duration metric excludes the token-load and user-hydration
44+
* wait before it, and reports whether this call was the one that stamped it — which is what
45+
* makes a per-gate event fire once rather than once per mount.
46+
*
47+
* True when nothing is stored, so a caller isn't silenced by unavailable storage; it falls back
48+
* to once per mount rather than never.
49+
*/
50+
export function markGateShown( scope: string ): boolean {
4551
const record = read( scope );
46-
if ( record && ! record.shownAt ) {
47-
write( scope, { ...record, shownAt: Date.now() } );
52+
if ( ! record ) {
53+
return true;
4854
}
55+
if ( record.shownAt ) {
56+
return false;
57+
}
58+
write( scope, { ...record, shownAt: Date.now() } );
59+
return true;
4960
}
5061

5162
export function isGatePending( scope: string ): boolean {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getInboxLink } from '../inbox-links';
2+
3+
describe( 'getInboxLink', () => {
4+
it( 'matches a provider across the country domains it runs', () => {
5+
expect( getInboxLink( 'a@hotmail.co.uk' )?.provider ).toBe( 'outlook' );
6+
expect( getInboxLink( 'a@live.fr' )?.provider ).toBe( 'outlook' );
7+
expect( getInboxLink( 'a@yahoo.de' )?.provider ).toBe( 'yahoo' );
8+
expect( getInboxLink( 'a@ymail.com' )?.provider ).toBe( 'yahoo' );
9+
} );
10+
11+
it( 'sends Yahoo Japan to its own mailbox rather than the shared one', () => {
12+
expect( getInboxLink( 'a@yahoo.co.jp' )?.url ).toBe( 'https://mail.yahoo.co.jp/' );
13+
expect( getInboxLink( 'a@yahoo.com' )?.url ).toBe( 'https://mail.yahoo.com/' );
14+
} );
15+
16+
// A brand in the name says nothing about who runs the mail, and a domain a provider once ran
17+
// may since have been parked. Either way a wrong link is worse than none: hotmail.com.mx and
18+
// live.pt publish no working MX, and live.io is Google-hosted.
19+
it( 'does not claim a domain the provider does not run mail for', () => {
20+
expect( getInboxLink( 'a@live.io' ) ).toBeNull();
21+
expect( getInboxLink( 'a@hotmail.com.mx' ) ).toBeNull();
22+
expect( getInboxLink( 'a@live.pt' ) ).toBeNull();
23+
expect( getInboxLink( 'a@outlook.somecompany.com' ) ).toBeNull();
24+
expect( getInboxLink( 'a@gmail.io' ) ).toBeNull();
25+
} );
26+
27+
it( 'returns null for a self-hosted domain and for no address at all', () => {
28+
expect( getInboxLink( 'a@example.com' ) ).toBeNull();
29+
expect( getInboxLink( undefined ) ).toBeNull();
30+
} );
31+
} );

0 commit comments

Comments
 (0)