Коллеги жаловались на дубли в базе пользователей: людей меньше, чем учётных записей, а откуда берётся расхождение - непонятно. Объяснилось оно одной строкой в коде, которая сравнивает адреса посимвольно.
В базе четыре учётные записи: Ivan.Petrov@gmail.com, ivanpetrov@gmail.com, ivan.petrov+shop@gmail.com, IVAN.PETROV@gmail.com. С точки зрения кода это четыре разных строки. С точки зрения почты - один и тот же ящик, все письма придут в одно место.
Дальше начинается интересное: акции «только для новых пользователей», лимиты на бесплатный период, «одна заявка на человека» - всё это обходится без единой уловки, просто точкой в адресе.
Что ломается при сравнении строкой
Проверяю три пары обычным сравнением и нормализацией:
def gmail_norm(addr): local, _, dom = addr.lower().partition('@') if dom in ('gmail.com', 'googlemail.com'): local = local.split('+')[0].replace('.', '') return f'{local}@{dom}'
Ivan.Petrov@gmail.com vs ivanpetrov@gmail.com строкой=False нормализация=True ivan+shop@gmail.com vs ivan@gmail.com строкой=False нормализация=True IVAN@example.ru vs ivan@example.ru строкой=False нормализация=True
Разберу по порядку, откуда берётся каждый случай.
Регистр домена и локальной части. Домен по стандарту регистронезависим всегда. Локальная часть формально может быть чувствительна к регистру, но на практике все крупные провайдеры её приводят к нижнему регистру. Если хранить адрес так, как ввёл пользователь, и сравнивать посимвольно, дубли появятся.
Плюс-адресация. Всё после + до @ игнорируется при доставке. Приём удобный: ivan+shop@ показывает, кто именно слил адрес спамерам. Для вашей системы это означает, что из одного ящика делается бесконечно много «разных» адресов.
Точки в Gmail. У Gmail точки в локальной части не значат ничего: i.van@ и ivan@ - один ящик. У большинства других провайдеров - значат, и это важная деталь: нормализацию точек нельзя применять ко всем доменам подряд, иначе вы склеите учётные записи двух разных людей.
Отдельный случай: домен, который выглядит знакомым
ivan@еxample.ru vs ivan@example.ru → разные, и нормализация не помогает
Первый адрес написан с кириллической е. Визуально отличить нельзя. Проверяется кодированием домена:
'еxample.ru'.encode('idna')
b'xn--xample-2of.ru'
Домен, который выглядел набранным латиницей, а при кодировании превратился в xn--..., - почти наверняка подделка. Это не про дубли пользователей, это про регистрацию под видом сотрудника или контрагента, и проверять такое надо на входе.
Как делать правильно
Хранить два поля. email - как ввёл пользователь, для отображения и отправки писем. email_normalized - приведённая форма, по которой стоит уникальный индекс и идут все проверки. Никогда не показывайте пользователю нормализованный вариант: он не поймёт, почему его адрес «изменился».
Нормализовать аккуратно и по доменам. Нижний регистр - всегда. Отбрасывание плюс-суффикса - можно, если вы сознательно решили считать + дублем (иногда его, наоборот, стоит учитывать: люди используют его для разделения аккаунтов). Точки - только для доменов, где это действительно так, и список таких доменов надо вести явно.
Проверять домен на IDNA. Пара строк на регистрации отсекает целый класс подделок:
try: labels = domain.encode('idna').decode().split('.') if any(l.startswith('xn--') for l in labels): flag_for_review(addr) except UnicodeError: reject(addr)
Не изобретать регулярное выражение для проверки адреса. Полная грамматика адреса из стандарта чудовищна, а популярные «правильные» выражения из интернета отвергают валидные адреса. Практичный подход: проверить наличие ровно одной @, непустые части, отсутствие пробелов - и отправить письмо с подтверждением. Доставленное письмо доказывает валидность лучше любой проверки.
Что проверить в своей базе
Запрос, который занимает секунду и часто удивляет:
SELECT lower(split_part(email, '@', 2)) AS domain, count(*) AS total, count(DISTINCT replace(split_part(lower(email), '@', 1), '.', '')) AS uniq FROM users GROUP BY 1 HAVING count(*) <> count(DISTINCT replace(split_part(lower(email), '@', 1), '.', '')) ORDER BY total DESC;
Он покажет домены, где число учётных записей не совпадает с числом уникальных нормализованных адресов. Если у вас были акции для новых пользователей, результат бывает познавательным.

