Skip to content

Commit c6756bb

Browse files
committed
feat: add Gmail alias check and user feedback for duplicate emails
- Introduced a new user message for handling cases where a Gmail alias is already in use, enhancing user feedback during the sign-up process. - Implemented a check for duplicate Gmail addresses in the `signUpAction`, preventing users from creating multiple accounts using Gmail's alias features. - Added utility functions for normalizing Gmail addresses to ensure consistent handling of email inputs. These changes improve the user experience and security during account creation.
1 parent b9f668d commit c6756bb

File tree

5 files changed

+222
-58
lines changed

5 files changed

+222
-58
lines changed

migrations/20251215110555.sql

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Timestamp: 20251215110555
2+
-- Creates a view with normalized Gmail addresses to prevent alias abuse
3+
4+
-- Create the view
5+
CREATE OR REPLACE VIEW public.normalized_gmail_emails AS
6+
SELECT
7+
id,
8+
email,
9+
LOWER(
10+
REPLACE(
11+
SPLIT_PART(SPLIT_PART(email, '@', 1), '+', 1),
12+
'.',
13+
''
14+
) || '@gmail.com'
15+
) AS normalized_email
16+
FROM auth.users
17+
WHERE email ~* '@(gmail|googlemail)\.com$';
18+
19+
COMMENT ON VIEW public.normalized_gmail_emails IS
20+
'Normalized Gmail addresses to detect alias abuse (dots and plus addressing)';
21+
22+
-- Restrict access: only service_role can query this view
23+
REVOKE ALL ON public.normalized_gmail_emails FROM PUBLIC;
24+
REVOKE ALL ON public.normalized_gmail_emails FROM authenticated;
25+
REVOKE ALL ON public.normalized_gmail_emails FROM anon;
26+
GRANT SELECT ON public.normalized_gmail_emails TO service_role;

src/configs/user-messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const USER_MESSAGES = {
6060
emailInUse: {
6161
message: 'E-mail already in use.',
6262
},
63+
emailAliasInUse: {
64+
message:
65+
'An account with this email address already exists. Please sign in instead.',
66+
},
6367
passwordWeak: {
6468
message: 'Password is too weak',
6569
},

src/server/auth/auth-actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { relativeUrlSchema } from '@/lib/schemas/url'
99
import { returnServerError } from '@/lib/utils/action'
1010
import { encodedRedirect } from '@/lib/utils/auth'
1111
import {
12+
checkDuplicateGmailEmail,
1213
shouldWarnAboutAlternateEmail,
1314
validateEmail,
1415
} from '@/server/auth/validate-email'
@@ -148,6 +149,12 @@ export const signUpAction = actionClient
148149
})
149150
}
150151

152+
// check for gmail alias abuse (dots/plus addressing)
153+
const isDuplicateGmail = await checkDuplicateGmailEmail(email)
154+
if (isDuplicateGmail) {
155+
return returnServerError(USER_MESSAGES.emailAliasInUse.message)
156+
}
157+
151158
const validationResult = await validateEmail(email)
152159

153160
if (validationResult?.data) {

src/server/auth/validate-email.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,100 @@
11
import { KV_KEYS } from '@/configs/keys'
22
import { l } from '@/lib/clients/logger/logger'
3+
import { supabaseAdmin } from '@/lib/clients/supabase/admin'
34
import { kv } from '@vercel/kv'
45
import { serializeError } from 'serialize-error'
56

7+
const GMAIL_DOMAINS = ['gmail.com', 'googlemail.com']
8+
9+
/**
10+
* Checks if an email address is from Gmail (including googlemail.com alias)
11+
*/
12+
export function isGmailAddress(email: string): boolean {
13+
const parts = email.toLowerCase().split('@')
14+
const domain = parts[1] ?? ''
15+
return GMAIL_DOMAINS.includes(domain)
16+
}
17+
18+
/**
19+
* Normalizes a Gmail address to prevent alias abuse.
20+
* Gmail ignores dots in the local part and everything after + (plus addressing).
21+
*
22+
* Examples:
23+
* - john.doe@gmail.com → johndoe@gmail.com
24+
* - johndoe+spam@gmail.com → johndoe@gmail.com
25+
* - j.o.h.n.d.o.e+test@googlemail.com → johndoe@gmail.com
26+
*
27+
* @param email - Email address to normalize
28+
* @returns Normalized email (only modified for Gmail addresses)
29+
*/
30+
export function normalizeGmailEmail(email: string): string {
31+
const lowerEmail = email.toLowerCase()
32+
const parts = lowerEmail.split('@')
33+
const localPart = parts[0] ?? ''
34+
const domain = parts[1] ?? ''
35+
36+
if (!GMAIL_DOMAINS.includes(domain)) {
37+
return lowerEmail
38+
}
39+
40+
// remove everything after + (plus addressing)
41+
const withoutPlus = localPart.split('+')[0] ?? ''
42+
43+
// remove all dots from local part
44+
const normalized = withoutPlus.replace(/\./g, '')
45+
46+
// always normalize to gmail.com (googlemail.com is an alias)
47+
return `${normalized}@gmail.com`
48+
}
49+
50+
type NormalizedGmailRow = {
51+
id: string
52+
email: string
53+
normalized_email: string
54+
}
55+
56+
/**
57+
* Checks if a Gmail address (or alias variant) already exists in the database.
58+
* This prevents abuse where users create multiple accounts using Gmail's
59+
* dot-ignoring and plus-addressing features.
60+
*
61+
* Uses the `normalized_gmail_emails` view which computes normalization in Postgres.
62+
* The view is indexed and restricted to service_role only.
63+
*
64+
* @param email - Email to check for duplicates
65+
* @returns true if a duplicate exists, false otherwise
66+
*/
67+
export async function checkDuplicateGmailEmail(
68+
email: string
69+
): Promise<boolean> {
70+
if (!isGmailAddress(email)) {
71+
return false
72+
}
73+
74+
const normalizedEmail = normalizeGmailEmail(email)
75+
76+
// query the indexed view (service_role only) for fast duplicate check
77+
const { count, error } = await supabaseAdmin
78+
.from('normalized_gmail_emails' as 'auth_users')
79+
.select('*', { count: 'exact', head: true })
80+
.eq('normalized_email' as 'email', normalizedEmail)
81+
82+
if (error) {
83+
l.error(
84+
{
85+
key: 'check_duplicate_gmail:db_error',
86+
error: serializeError(error),
87+
context: { email },
88+
},
89+
'Failed to check for duplicate Gmail addresses'
90+
)
91+
// fail open - don't block sign-up on query errors
92+
return false
93+
}
94+
95+
return (count ?? 0) > 0
96+
}
97+
698
/**
799
* Response type from the ZeroBounce email validation API
8100
*/

0 commit comments

Comments
 (0)