enlace_auth.emails#

One normal form for an email address, wherever one is compared.

Accounts are keyed by the address the HTTP routes accept (pydantic EmailStr, then lowercased): EmailStr decodes a punycode domain to Unicode, so admin@xn--bcher-kva.example is stored as admin@bücher.example. Every list an address is compared against (the admin list, the registration allowlist, a connector’s resource allowlist) must be put in the same form, or an entry spelled the other way silently never matches.

One fold is refused rather than applied: EmailStr NFC-composes the local part, but a mail system may deliver a composed and a decomposed local part to two mailboxes, which one key would merge (#51). normalize_email() refuses a local part that composing would change. So is one that lowercasing would change by more than case (İ -> i̇, ẞ -> ß, #57).

Two rules keep that safe. A written list entry is widened to both canonical spellings of its address (account_spellings()); the address an account is keyed under, which may come from an untrusted party (an OAuth provider), is compared exactly as stored and never normalized.

Functions

account_spellings(email)

The keys an account for a written email may be stored under, as written first.

bare_address_problem(email)

Why email is not a bare local@domain address, or None.

local_part_problem(email)

Why keying email would change its local part's characters, or None.

lowercase_changes_letter(text)

The first character of text that lowercasing changes by more than case.

normalize_email(email)

The key an account for email is stored under; ValueError if invalid.

resolve_account(email, exists)

The existing account key a written email names, or None.

enlace_auth.emails.account_spellings(email)[source]#

The keys an account for a written email may be stored under, as written first.

A punycode domain and its Unicode form are one DNS name, so they name one mailbox, but an account is keyed in whichever spelling made it: add-user and /auth/register store the Unicode form, an OAuth sign-in stores the provider’s spelling, lowercased. A list entry written in either canonical spelling therefore admits both.

>>> account_spellings("Ann@XN--BCHER-KVA.example")
('ann@xn--bcher-kva.example', 'ann@bücher.example')
>>> account_spellings("ann@bücher.example")
('ann@bücher.example', 'ann@xn--bcher-kva.example')
>>> account_spellings("ann@corp.local")  # a domain EmailStr refuses
('ann@corp.local',)

Only an entry already in one of the two canonical spellings gets the other. Normalizing also folds look-alikes (fullwidth letters, the Kelvin sign) onto plain ASCII, so an entry that only becomes canonical by normalizing stays as written: it must not name someone else’s account. That makes this safe to apply to any written list, an untrusted user’s included. It is never applied to the address an account is keyed under.

>>> account_spellings("ann@bücher.example")  # fullwidth b
('ann@bücher.example',)

Lowercasing alone folds a few letters (U+212A KELVIN SIGN -> k); such an entry is not widened either. Over-long strings are not looked at (nor cached).

Return type:

tuple[str, ...]

enlace_auth.emails.bare_address_problem(email)[source]#

Why email is not a bare local@domain address, or None.

Deliberately lenient about the domain (.local, localhost): it only refuses what is not one plain address, such as the display-name form.

>>> bare_address_problem("ann@corp.local") is None
True
>>> bare_address_problem("ann <mallory@evil.com>")
'not a bare email address'
Return type:

str | None

enlace_auth.emails.local_part_problem(email)[source]#

Why keying email would change its local part’s characters, or None.

EmailStr NFC-composes the local part. Mail systems that compare local parts as bytes (a Postfix/Dovecot host with SMTPUTF8, for one) deliver josé composed and josé decomposed to two mailboxes, so composing would map both onto one account (#51). A decomposed local part is refused, not folded, as an OAuth callback’s address is never folded.

>>> local_part_problem("josé@example.com") is None
True
>>> local_part_problem("josé@example.com")
'the part before @ is not in composed (NFC) form (U+0301); retype the address'

The two spellings look identical, so the message names the first character to blame.

Keys are also lowercased, and for a few letters that changes the letter, not only its case (#57): İ becomes i plus a combining dot, ẞ becomes ß. A host that compares local parts as bytes reads another mailbox, and a password reset is mailed to the key. Such a local part is refused too.

>>> local_part_problem("Ann@example.com") is None
True
>>> local_part_problem("İstanbul@example.com")
'the part before @ changes letters when lowercased (U+0130); retype the address'
Return type:

str | None

enlace_auth.emails.lowercase_changes_letter(text)[source]#

The first character of text that lowercasing changes by more than case.

A case map is one character for one, and goes back: "A".lower().upper() is "A" (.title() for a titlecase digraph such as Dž). İ lowercases to two characters, ẞ to ß (which uppercases to SS), the Kelvin sign to k (which uppercases to K).

>>> lowercase_changes_letter("Ann-Marie") is None
True
>>> [hex(ord(lowercase_changes_letter(s))) for s in ("İ", "ẞ", "K")]
['0x130', '0x1e9e', '0x212a']
Return type:

str | None

enlace_auth.emails.normalize_email(email)[source]#

The key an account for email is stored under; ValueError if invalid.

>>> normalize_email("  Admin@XN--BCHER-KVA.example ")
'admin@bücher.example'
>>> normalize_email("A@Example.COM")
'a@example.com'

Only a bare address is accepted. EmailStr also parses the display-name form, so ann <mallory@evil.com> would otherwise normalize to an address the text does not appear to name; in a list that grants access, that is a grant to someone other than the one written down.

>>> normalize_email("ann <mallory@evil.com>")
Traceback (most recent call last):
...
ValueError: not a bare email address: 'ann <mallory@evil.com>'

Nor is a local part that composing would change (see local_part_problem()): the key would name another mailbox’s spelling.

>>> normalize_email("josé@example.com")
Traceback (most recent call last):
...
ValueError: the part before @ is not in composed (NFC) form...
Return type:

str

enlace_auth.emails.resolve_account(email, exists)[source]#

The existing account key a written email names, or None.

Tries account_spellings() in order, so an exact match wins over the other spelling. exists is key -> bool (e.g. users.__contains__).

>>> users = {"ann@bücher.example"}
>>> resolve_account("Ann@XN--BCHER-KVA.example", users.__contains__)
'ann@bücher.example'
>>> resolve_account("bob@example.com", set().__contains__) is None
True
Return type:

str | None