feat(registration): 支持邮箱白名单后缀通配符

This commit is contained in:
wucm667
2026-05-21 21:02:26 +08:00
parent 16793d3af0
commit a5b9b68b76
11 changed files with 185 additions and 46 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
formatRegistrationEmailSuffixWhitelistForMessage,
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
@@ -11,6 +12,7 @@ import {
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
expect(normalizeRegistrationEmailSuffixDomain(' *.EDU!.CN ')).toBe('*.edu.cn')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
@@ -22,14 +24,20 @@ describe('registrationEmailPolicy utils', () => {
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
'@foo.bar',
'*.EDU.CN',
'*.edu.cn'
])
).toEqual(['example.com', 'foo.bar'])
).toEqual(['example.com', 'foo.bar', '*.edu.cn'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar *.EDU.CN '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual([
'example.com',
'foo.bar',
'*.edu.cn'
])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
@@ -38,7 +46,7 @@ describe('registrationEmailPolicy utils', () => {
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com, *., *, *.@, *.foo'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
@@ -53,17 +61,22 @@ describe('registrationEmailPolicy utils', () => {
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
' @foo.bar ',
'*.EDU.CN'
])
).toEqual(['@example.com', '@foo.bar'])
).toEqual(['@example.com', '@foo.bar', '*.edu.cn'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('*.edu.cn')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('*.foo')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('*')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('*.@')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
@@ -73,5 +86,36 @@ describe('registrationEmailPolicy utils', () => {
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
expect(isRegistrationEmailSuffixAllowed('user@qq.com', ['@qq.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.qq.com', ['@qq.com'])).toBe(false)
})
it('isRegistrationEmailSuffixAllowed applies wildcard suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('student@cs.edu.cn', ['*.edu.cn'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('student@edu.cn', ['*.edu.cn'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('student@foo.cn', ['*.edu.cn'])).toBe(false)
})
it('isRegistrationEmailSuffixAllowed supports mixed exact and wildcard entries', () => {
const whitelist = ['@a.com', '*.b.cn']
expect(isRegistrationEmailSuffixAllowed('user@a.com', whitelist)).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@school.b.cn', whitelist)).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@b.cn', whitelist)).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@c.cn', whitelist)).toBe(false)
})
it('formatRegistrationEmailSuffixWhitelistForMessage lists up to five entries', () => {
expect(
formatRegistrationEmailSuffixWhitelistForMessage(
['@a.com', '@b.com', '@c.com', '@d.com', '@e.com'],
{ separator: ', ', more: (count) => `and ${count} more` }
)
).toBe('@a.com, @b.com, @c.com, @d.com, @e.com')
expect(
formatRegistrationEmailSuffixWhitelistForMessage(
['@a.com', '@b.com', '@c.com', '@d.com', '@e.com', '*.edu.cn', '@f.com'],
{ separator: ', ', more: (count) => `and ${count} more` }
)
).toBe('@a.com, @b.com, @c.com, @d.com, @e.com, and 2 more')
})
})
+60 -10
View File
@@ -2,19 +2,21 @@ const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,,]+/
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
const EMAIL_SUFFIX_WILDCARD_PREFIX = '*.'
const EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT = 5
const EMAIL_SUFFIX_DOMAIN_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
// It removes leading "@", lowercases input, and strips all invalid characters.
// Exact domains are returned without "@"; wildcard domains keep the "*." prefix.
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
return value
return normalizeRegistrationEmailSuffixToken(value, false)
}
export function normalizeRegistrationEmailSuffixDomains(
@@ -60,7 +62,7 @@ export function parseRegistrationEmailSuffixWhitelistInput(input: string): strin
export function normalizeRegistrationEmailSuffixWhitelist(
items: string[] | null | undefined
): string[] {
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
return normalizeRegistrationEmailSuffixDomains(items).map(toCanonicalRegistrationEmailSuffix)
}
function extractRegistrationEmailDomain(email: string): string {
@@ -91,7 +93,32 @@ export function isRegistrationEmailSuffixAllowed(
return false
}
const emailSuffix = `@${emailDomain}`
return normalizedWhitelist.includes(emailSuffix)
return normalizedWhitelist.some((allowed) => {
if (allowed.startsWith('@')) {
return allowed === emailSuffix
}
if (allowed.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
const base = allowed.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
return emailDomain === base || emailDomain.endsWith(`.${base}`)
}
return false
})
}
export function formatRegistrationEmailSuffixWhitelistForMessage(
whitelist: string[] | null | undefined,
options: {
separator: string
more: (count: number) => string
}
): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
const visible = normalizedWhitelist.slice(0, EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT)
const hiddenCount = normalizedWhitelist.length - visible.length
if (hiddenCount > 0) {
visible.push(options.more(hiddenCount))
}
return visible.join(options.separator)
}
// Pasted domains should be strict: any invalid character drops the whole token.
@@ -101,15 +128,38 @@ function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
return ''
}
return value
return normalizeRegistrationEmailSuffixToken(value, true)
}
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
if (!domain) {
return false
}
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
if (domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length))
}
return !domain.includes('*') && EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
}
function normalizeRegistrationEmailSuffixToken(value: string, strict: boolean): string {
if (value.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
const domain = value.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
if (strict && (!domain || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(domain))) {
return ''
}
return `${EMAIL_SUFFIX_WILDCARD_PREFIX}${domain.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')}`
}
if (value === '*') {
return strict ? '' : value
}
if (strict && EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
return ''
}
return value.replace(/[*]/g, '').replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
}
function toCanonicalRegistrationEmailSuffix(domain: string): string {
return domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX) ? domain : `@${domain}`
}