Коллеги жаловались на дубли в базе пользователей: людей меньше, чем учётных записей, а откуда берётся расхождение - непонятно. Объяснилось оно одной строкой в коде, которая сравнивает адреса посимвольно.

В базе четыре учётные записи: 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;

Он покажет домены, где число учётных записей не совпадает с числом уникальных нормализованных адресов. Если у вас были акции для новых пользователей, результат бывает познавательным.