from typing import Optional # Every character class is hand-rolled rather than expressed as a regular # expression. The Rust sibling has no regex crate available, and the only way # to be sure three implementations agree on an edge case is for all three to # make the same decision in the same place. #: RFC 5321 caps the local part at 64 octets. _MAX_LOCAL = 64 #: RFC 5321 caps a forward path at 256 octets including the angle brackets. _MAX_TOTAL = 254 #: RFC 1035 caps a DNS label at 63 octets. _MAX_LABEL = 63 #: The "atext" specials from RFC 5322, plus the dot handled separately below. _LOCAL_SPECIALS = "!#$%&'*+-/=?^_`{|}~" def _is_digit(ch: str) -> bool: return "0" <= ch <= "9" def _is_letter(ch: str) -> bool: return ("a" <= ch <= "z") or ("A" <= ch <= "Z") def _is_letter_or_digit(ch: str) -> bool: return _is_letter(ch) or _is_digit(ch) def _is_local_char(ch: str) -> bool: return _is_letter_or_digit(ch) or ch in _LOCAL_SPECIALS def is_email(value: str) -> bool: """Is this a plausible email address? This is a deliberate, documented subset of RFC 5322, not an implementation of it. The full grammar admits comments, folded whitespace, quoted strings with embedded spaces and bracketed IP literals; almost nothing downstream of a signup form can handle those, and accepting them would let addresses through that the mail stack then rejects. The only true validation of an email address is sending mail to it and seeing the recipient act on it. Use this to catch typos at the keyboard, then confirm by email. Never use it to decide that an address is real. """ if not isinstance(value, str): return False if len(value) == 0 or len(value) > _MAX_TOTAL: return False # Exactly one @: the last-@ split used by lenient parsers quietly accepts # "a@b@c", which no MTA will route. at = -1 for i, ch in enumerate(value): if ch == "@": if at != -1: return False at = i if at <= 0 or at == len(value) - 1: return False return _is_local_part(value[:at]) and _is_domain(value[at + 1 :]) def _is_local_part(local: str) -> bool: if len(local) == 0 or len(local) > _MAX_LOCAL: return False # A dot is a separator between atoms, so it cannot lead, trail or double up. if local[0] == "." or local[-1] == ".": return False for i, ch in enumerate(local): if ch == ".": if local[i - 1] == ".": return False continue if not _is_local_char(ch): return False return True def _is_domain(domain: str) -> bool: # The total-length cap already bounds this, but stating the domain limit # separately keeps the rule readable and survives a change to the total. if len(domain) == 0 or len(domain) > _MAX_TOTAL - 2: return False labels = domain.split(".") # At least one dot. A bare "localhost" is a valid host but not an address # anyone outside that machine can deliver to, and a signup form is asking # for the latter. if len(labels) < 2: return False for label in labels: if len(label) == 0 or len(label) > _MAX_LABEL: return False if label[0] == "-" or label[-1] == "-": return False for ch in label: if not _is_letter_or_digit(ch) and ch != "-": return False # The top-level label must be two or more letters. This is what rejects # "user@example.123" and the bracketed-IP form, and it is the rule most # likely to need revisiting: it also rejects punycode-free internationalised # TLDs written in their native script. tld = labels[-1] if len(tld) < 2: return False for ch in tld: if not _is_letter(ch): return False return True def email_domain(value: str) -> Optional[str]: """The domain half of an address, lowercased, or None if the address is not one this capability accepts. Domains are case-insensitive; local parts are not, so this deliberately only normalises the half where doing so is safe. """ if not is_email(value): return None domain = value[value.index("@") + 1 :] out = [] for ch in domain: out.append(chr(ord(ch) + 32) if "A" <= ch <= "Z" else ch) return "".join(out)