Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,35 @@ custom domains.

## Normalization Rules

| Provider | Plus Addressing | Strip Periods | Local Part as Hostname |
|------------|:---------------:|:-------------:|:----------------------:|
| Apple | x | | |
| Fastmail | x | | x |
| Google | x | x* | |
| Microsoft | x | | |
| ProtonMail | x | | |
| Rackspace | x | | |
| Yahoo | | | |
| Yandex | x | | |
| Zoho | x | | |
| Provider | Plus Addressing | Strip Periods | Local Part as Hostname | Domain Folding |
|------------|:---------------:|:-------------:|:----------------------:|:--------------:|
| Apple | x | | | x† |
| Fastmail | x | | x | |
| Google | x | x* | | x† |
| Microsoft | x | | | |
| ProtonMail | x | | | |
| Rackspace | x | | | |
| Yahoo | | | | |
| Yandex | x | | | |
| Zoho | x | | | |

- **Plus Addressing**: Strips everything after `+` in the local part
- **Strip Periods**: Removes `.` from the local part
- **Local Part as Hostname**: Extracts the subdomain as the local part (Fastmail custom domains)
- **Domain Folding**: Rewrites provider alias domains to a single canonical domain

\* Google strips periods only for consumer Gmail addresses (`gmail.com` and
`googlemail.com`). Google Workspace custom domains route through the same MX
servers but treat periods as significant, so periods are preserved for them
(plus addressing is still stripped).

† Some providers deliver one mailbox under several domain names. These alias
domains are folded to a single canonical form so the same mailbox always
normalizes to the same address: Google's `googlemail.com` → `gmail.com`, and
Apple's `me.com` and `mac.com` → `icloud.com`. Domains that share a provider
but are *separate* mailboxes (e.g. Microsoft's `outlook.com`/`hotmail.com`, or
Yahoo's `yahoo.com`/`aol.com`) are intentionally left unchanged.

## Documentation

Full documentation is available at [gmr.github.io/email-normalize](https://gmr.github.io/email-normalize/).
3 changes: 3 additions & 0 deletions email_normalize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ async def normalize(self, email_address: str) -> Result:
local_part = local_part.replace('.', '')
if provider.Flags & providers.Rules.PLUS_ADDRESSING:
local_part = local_part.split('+')[0]
domain_part = provider.CanonicalDomains.get(
domain_part, domain_part
)
return Result(
email_address,
f'{local_part}@{domain_part}',
Expand Down
22 changes: 22 additions & 0 deletions email_normalize/providers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Provider Specific Rules"""

import collections.abc
import enum
import types
import typing


Expand Down Expand Up @@ -31,10 +33,26 @@ class MailboxProvider:
# to every domain matched to the provider.
StripPeriodDomains: typing.ClassVar[frozenset[str]] = frozenset()

# Map of alias domains to the provider's canonical domain. Alias
# domains deliver to the same mailbox as their canonical target
# (e.g. Apple's me.com/mac.com and icloud.com), so the domain part is
# folded to the canonical form during normalization. This is only
# correct for a provider's own consumer alias domains, never for
# custom domains that merely share the same MX servers. An empty
# mapping means no domain folding is performed.
CanonicalDomains: typing.ClassVar[collections.abc.Mapping[str, str]] = (
types.MappingProxyType({})
)


class Apple(MailboxProvider):
Flags = Rules.PLUS_ADDRESSING
MXDomains = frozenset({'icloud.com'})
# me.com and mac.com are legacy alias domains for the same iCloud
# mailbox. See https://support.apple.com/en-us/118230.
CanonicalDomains = types.MappingProxyType(
{'me.com': 'icloud.com', 'mac.com': 'icloud.com'}
)


class Fastmail(MailboxProvider):
Expand All @@ -48,6 +66,10 @@ class Google(MailboxProvider):
# Only consumer Gmail strips dots; Google Workspace custom domains do
# not. See https://support.google.com/mail/answer/7436150.
StripPeriodDomains = frozenset({'gmail.com', 'googlemail.com'})
# googlemail.com is an alias domain for consumer Gmail; it delivers to
# the same mailbox as gmail.com. See
# https://support.google.com/mail/answer/10313.
CanonicalDomains = types.MappingProxyType({'googlemail.com': 'gmail.com'})


class Microsoft(MailboxProvider):
Expand Down
119 changes: 117 additions & 2 deletions tests/test_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,80 @@ def test_google_consumer_gmail(self):
)

def test_google_consumer_googlemail(self):
"""Consumer googlemail.com strips periods and plus addressing."""
"""googlemail.com strips periods/plus and folds to gmail.com.

googlemail.com is an alias domain for consumer Gmail; both
deliver to the same mailbox, so the domain is canonicalized to
gmail.com.
"""
local_part = str(uuid.uuid4()).replace('-', '.')
address = f'{local_part}+test@googlemail.com'
mx_records = [(1, 'aspmx.l.google.com')]
self._perform_test(
address,
f'{local_part.replace(".", "")}@googlemail.com',
f'{local_part.replace(".", "")}@gmail.com',
mx_records,
'Google',
)

def test_google_gmail_canonical_unchanged(self):
"""gmail.com is the canonical Google domain and is never folded.

Uses a plain local part with no periods or plus addressing so the
only behavior under test is that the gmail.com domain itself is
left unchanged (i.e. it is not an alias that folds elsewhere).
"""
local_part = str(uuid.uuid4()).replace('-', '')
address = f'{local_part}@gmail.com'
mx_records = [(1, 'aspmx.l.google.com')]
self._perform_test(
address, f'{local_part}@gmail.com', mx_records, 'Google'
)

def test_apple_me_folds_to_icloud(self):
"""me.com is an Apple alias domain and folds to icloud.com."""
local_part = str(uuid.uuid4())
address = f'{local_part}+test@me.com'
mx_records = [(10, 'mx01.mail.icloud.com')]
self._perform_test(
address, f'{local_part}@icloud.com', mx_records, 'Apple'
)

def test_apple_mac_folds_to_icloud(self):
"""mac.com is an Apple alias domain and folds to icloud.com."""
local_part = str(uuid.uuid4())
address = f'{local_part}+test@mac.com'
mx_records = [(10, 'mx01.mail.icloud.com')]
self._perform_test(
address, f'{local_part}@icloud.com', mx_records, 'Apple'
)

def test_apple_icloud_canonical_unchanged(self):
"""The canonical icloud.com domain is left untouched by folding."""
local_part = str(uuid.uuid4())
address = f'{local_part}+test@icloud.com'
mx_records = [(10, 'mx01.mail.icloud.com')]
self._perform_test(
address, f'{local_part}@icloud.com', mx_records, 'Apple'
)

def test_apple_me_preserves_periods_when_folding(self):
"""Apple keeps periods (no STRIP_PERIODS) while folding domain."""
address = 'first.last+tag@me.com'
mx_records = [(10, 'mx01.mail.icloud.com')]
self._perform_test(
address, 'first.last@icloud.com', mx_records, 'Apple'
)

def test_microsoft_hotmail_not_folded(self):
"""Microsoft consumer domains are distinct mailboxes, not folded."""
local_part = str(uuid.uuid4())
address = f'{local_part}+test@hotmail.com'
mx_records = [(10, 'hotmail-com.olc.protection.outlook.com')]
self._perform_test(
address, f'{local_part}@hotmail.com', mx_records, 'Microsoft'
)

def test_google_workspace_preserves_periods(self):
"""Google Workspace custom domains keep periods but strip plus."""
local_part = str(uuid.uuid4()).replace('-', '.')
Expand Down Expand Up @@ -302,6 +365,26 @@ def test_google_rules_applied(self):
self.assertEqual(result.normalized_address, 'user@gmail.com')
self.assertEqual(result.mailbox_provider, 'Google')

def test_googlemail_folds_to_gmail(self):
result = self._normalize('u.s.e.r+tag@googlemail.com')
self.assertEqual(result.normalized_address, 'user@gmail.com')
self.assertEqual(result.mailbox_provider, 'Google')

def test_me_folds_to_icloud(self):
result = self._normalize('user+tag@me.com')
self.assertEqual(result.normalized_address, 'user@icloud.com')
self.assertEqual(result.mailbox_provider, 'Apple')

def test_mac_folds_to_icloud(self):
result = self._normalize('user+tag@mac.com')
self.assertEqual(result.normalized_address, 'user@icloud.com')
self.assertEqual(result.mailbox_provider, 'Apple')

def test_hotmail_not_folded(self):
result = self._normalize('user+tag@hotmail.com')
self.assertEqual(result.normalized_address, 'user@hotmail.com')
self.assertEqual(result.mailbox_provider, 'Microsoft')

def test_microsoft_plus_addressing(self):
result = self._normalize('user+tag@outlook.com')
self.assertEqual(result.normalized_address, 'user@outlook.com')
Expand All @@ -326,3 +409,35 @@ def test_async_normalizer(self):
self.assertEqual(result.normalized_address, 'user@gmail.com')
self.assertEqual(result.mailbox_provider, 'Google')
self.assertListEqual(result.mx_records, [])


class CanonicalEquivalenceTestCase(TestCase):
"""Aliased addresses for the same mailbox share a canonical form."""

@staticmethod
def _norm(address):
return email_normalize.normalize(
address, skip_dns=True
).normalized_address

def test_gmail_and_googlemail_collapse(self):
gmail = self._norm('f.o.o+alpha@gmail.com')
googlemail = self._norm('f.o.o+beta@googlemail.com')
self.assertEqual(gmail, 'foo@gmail.com')
self.assertEqual(gmail, googlemail)

def test_icloud_me_mac_collapse(self):
icloud = self._norm('sample+a@icloud.com')
me = self._norm('sample+b@me.com')
mac = self._norm('sample+c@mac.com')
self.assertEqual(icloud, 'sample@icloud.com')
self.assertEqual(icloud, me)
self.assertEqual(icloud, mac)

def test_distinct_microsoft_domains_do_not_collapse(self):
"""Microsoft consumer domains are separate mailboxes."""
hotmail = self._norm('sample+a@hotmail.com')
outlook = self._norm('sample+b@outlook.com')
self.assertEqual(hotmail, 'sample@hotmail.com')
self.assertEqual(outlook, 'sample@outlook.com')
self.assertNotEqual(hotmail, outlook)
17 changes: 17 additions & 0 deletions tests/test_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ async def test_failure_not_cached(self):
records = await self.normalizer.mx_records(key)
self.assertListEqual(records, [])

async def test_e2e_googlemail_folds_to_gmail(self):
result = await self.normalizer.normalize('f.o.o+bar@googlemail.com')
self.assertEqual(result.normalized_address, 'foo@gmail.com')
self.assertEqual(result.mailbox_provider, 'Google')

async def test_e2e_me_folds_to_icloud(self):
result = await self.normalizer.normalize('sample+bar@me.com')
self.assertEqual(result.normalized_address, 'sample@icloud.com')
self.assertEqual(result.mailbox_provider, 'Apple')

async def test_e2e_gmail_googlemail_equivalent(self):
gmail = await self.normalizer.normalize('f.o.o+a@gmail.com')
googlemail = await self.normalizer.normalize('f.o.o+b@googlemail.com')
self.assertEqual(
gmail.normalized_address, googlemail.normalized_address
)

async def test_weird_mx_list(self):
with mock.patch.object(
self.normalizer,
Expand Down
Loading