From 048ba2c5512ff7325990f7675a250199cf617ae6 Mon Sep 17 00:00:00 2001 From: Junior Ogun Date: Sat, 11 Jul 2026 15:19:41 -0700 Subject: [PATCH 1/2] Fold provider alias domains to a canonical form googlemail.com now folds to gmail.com, and me.com/mac.com fold to icloud.com, so addresses that reach the same mailbox normalize to the same value. Aliasing is opt-in per provider via a new CanonicalDomains mapping, applied only to a provider's own consumer alias domains and never to custom domains that merely share MX servers. Domains that share a provider but are separate mailboxes (Microsoft outlook/hotmail, Yahoo yahoo/aol) are intentionally left unchanged. --- README.md | 30 +++++---- email_normalize/__init__.py | 3 + email_normalize/providers.py | 22 +++++++ tests/test_normalize.py | 117 ++++++++++++++++++++++++++++++++++- tests/test_normalizer.py | 17 +++++ 5 files changed, 176 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6373acf..c1d36cf 100644 --- a/README.md +++ b/README.md @@ -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/). diff --git a/email_normalize/__init__.py b/email_normalize/__init__.py index f330e52..f65d5e5 100644 --- a/email_normalize/__init__.py +++ b/email_normalize/__init__.py @@ -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}', diff --git a/email_normalize/providers.py b/email_normalize/providers.py index 3c0695c..cf73342 100644 --- a/email_normalize/providers.py +++ b/email_normalize/providers.py @@ -1,6 +1,8 @@ """Provider Specific Rules""" +import collections.abc import enum +import types import typing @@ -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): @@ -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): diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 919d106..1b40b35 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -134,17 +134,78 @@ 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): + """The canonical gmail.com domain is left untouched by folding.""" + local_part = str(uuid.uuid4()).replace('-', '.') + address = f'{local_part}+test@gmail.com' + mx_records = [(1, 'aspmx.l.google.com')] + self._perform_test( + address, + f'{local_part.replace(".", "")}@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('-', '.') @@ -302,6 +363,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') @@ -326,3 +407,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) diff --git a/tests/test_normalizer.py b/tests/test_normalizer.py index a99e497..80c9fff 100644 --- a/tests/test_normalizer.py +++ b/tests/test_normalizer.py @@ -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, From 98e973fea9248d9a888fc41b0f7c374256f56032 Mon Sep 17 00:00:00 2001 From: Junior Ogun Date: Sat, 11 Jul 2026 17:03:24 -0700 Subject: [PATCH 2/2] test: isolate gmail.com no-fold assertion from period-stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_google_gmail_canonical_unchanged used a period-laden local part, making it functionally identical to test_google_consumer_gmail — dot stripping masked whether the domain was actually folded. Use a plain local part (no periods, no plus) so the test verifies exactly one thing: gmail.com is the canonical Google domain and is never folded away. Verified via mutation (injecting a gmail.com fold now fails it). --- tests/test_normalize.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 1b40b35..35c70f3 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -151,15 +151,17 @@ def test_google_consumer_googlemail(self): ) def test_google_gmail_canonical_unchanged(self): - """The canonical gmail.com domain is left untouched by folding.""" - local_part = str(uuid.uuid4()).replace('-', '.') - address = f'{local_part}+test@gmail.com' + """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.replace(".", "")}@gmail.com', - mx_records, - 'Google', + address, f'{local_part}@gmail.com', mx_records, 'Google' ) def test_apple_me_folds_to_icloud(self):