# 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()`](#enlace_auth.emails.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()`](#enlace_auth.emails.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`](#enlace_auth.emails.account_spellings)(email)       | The keys an account for a written *email* may be stored under, as written first.   |
|---------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [`bare_address_problem`](#enlace_auth.emails.bare_address_problem)(email)    | Why *email* is not a bare `local@domain` address, or `None`.                       |
| [`local_part_problem`](#enlace_auth.emails.local_part_problem)(email)      | Why keying *email* would change its local part's characters, or `None`.            |
| [`lowercase_changes_letter`](#enlace_auth.emails.lowercase_changes_letter)(text) | The first character of *text* that lowercasing changes by more than case.          |
| [`normalize_email`](#enlace_auth.emails.normalize_email)(email)         | The key an account for *email* is stored under; `ValueError` if invalid.           |
| [`resolve_account`](#enlace_auth.emails.resolve_account)(email, exists) | The existing account key a written *email* names, or `None`.                       |

### enlace_auth.emails.account_spellings(email)

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.

```pycon
>>> 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.

```pycon
>>> account_spellings("ann@ｂücher.example")  # fullwidth b
('ann@ｂü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`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### enlace_auth.emails.bare_address_problem(email)

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.

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

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.local_part_problem(email)

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.

```pycon
>>> local_part_problem("josé@example.com") is None
True
>>> local_part_problem("josé@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.

```pycon
>>> 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`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.lowercase_changes_letter(text)

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 `ǅ`). `İ`
lowercases to two characters, `ẞ` to `ß` (which uppercases to `SS`),
the Kelvin sign to `k` (which uppercases to `K`).

```pycon
>>> lowercase_changes_letter("Ann-Marie") is None
True
>>> [hex(ord(lowercase_changes_letter(s))) for s in ("İ", "ẞ", "K")]
['0x130', '0x1e9e', '0x212a']
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### enlace_auth.emails.normalize_email(email)

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

```pycon
>>> 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.

```pycon
>>> 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()`](#enlace_auth.emails.local_part_problem)): the key would name another mailbox’s spelling.

```pycon
>>> normalize_email("josé@example.com")
Traceback (most recent call last):
...
ValueError: the part before @ is not in composed (NFC) form...
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### enlace_auth.emails.resolve_account(email, exists)

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

Tries [`account_spellings()`](#enlace_auth.emails.account_spellings) in order, so an exact match wins over the
other spelling. *exists* is `key -> bool` (e.g. `users.__contains__`).

```pycon
>>> 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`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)
