Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/email.py: 100%
46 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1import re
2import string
3from encodings import idna
4from viur.core.bones.string import StringBone
5from viur.core import i18n
7_DNS_LABEL_RE = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)", re.IGNORECASE)
8"""A single DNS label per RFC 1035: 1-63 alphanumerics or hyphens, no leading or trailing hyphen."""
10_LOCAL_PART_CHARS = frozenset(string.ascii_letters + string.digits + "!#$%&'*+-/=?^_`{|}~")
11"""Characters allowed in an unquoted local part (RFC 5321 dot-atom); the dot is only valid as a separator."""
13_UNICODE_LOWER_BOUND = chr(0x80)
14"""Local-part characters from this codepoint (U+0080) upwards are accepted as non-ASCII (SMTPUTF8)."""
17class EmailBone(StringBone):
18 """
19 The EmailBone class is a designed to store syntactically validated email addresses.
21 This class provides an email validation method, ensuring that the given email address conforms to the
22 required format and structure.
23 """
24 type = "str.email"
25 """
26 A string representing the type of the bone, in this case "str.email".
27 """
29 def isInvalid(self, value: str) -> str | None:
30 """
31 Checks if the provided email address is valid or not.
33 :param value: The email address to be validated.
34 :returns: An error message if the email address is invalid or None if it is valid.
36 The address must satisfy all of the following:
38 1. It must not be empty and must be shorter than 256 characters.
39 2. It must contain exactly one "@", separating the local part (account) and the domain.
40 3. The local part must be a valid RFC 5321 dot-atom (see :meth:`_is_valid_local_part`).
41 4. The domain must be a sequence of valid IDNA-encoded labels (see :meth:`_is_valid_domain`).
42 """
43 if not value:
44 return i18n.translate("core.bones.error.novalueentered", "No value entered")
46 if not self._is_valid_address(value):
47 return i18n.translate("core.bones.error.invalidemail", "Invalid email entered")
49 return None
51 @classmethod
52 def _is_valid_address(cls, value: str) -> bool:
53 """Validate the overall structure and delegate to the local-part and domain checks."""
54 if len(value) >= 256 or value.count("@") != 1:
55 return False
56 account, _, domain = value.partition("@")
57 return cls._is_valid_local_part(account) and cls._is_valid_domain(domain)
59 @staticmethod
60 def _is_valid_local_part(account: str) -> bool:
61 """
62 Validate the local part (before the "@") as an RFC 5321 dot-atom.
64 It must be 1-64 characters long, must not start or end with a dot and must not contain
65 consecutive dots. Besides the allowed ASCII atom characters, Unicode characters from
66 U+0080 upwards are accepted (SMTPUTF8).
67 """
68 if not account or len(account) > 64:
69 return False
70 if account.startswith(".") or account.endswith(".") or ".." in account:
71 return False
72 return all(
73 char == "." or char in _LOCAL_PART_CHARS or char >= _UNICODE_LOWER_BOUND
74 for char in account
75 )
77 @staticmethod
78 def _is_valid_domain(domain: str) -> bool:
79 """
80 Validate the domain (after the "@") as a sequence of IDNA-encoded RFC 1035 labels.
82 There must be at least two labels and the TLD must not be purely numeric (which rejects
83 bare IP addresses). Each label is IDNA-encoded and then matched against ``_DNS_LABEL_RE``,
84 which also rejects a leading or trailing hyphen and enforces the 63-character label limit.
85 """
86 labels = domain.split(".")
87 if len(labels) < 2 or labels[-1].isdigit():
88 return False
89 for label in labels:
90 try:
91 ascii_label = idna.ToASCII(label).decode("ascii")
92 except Exception:
93 return False
94 if not _DNS_LABEL_RE.fullmatch(ascii_label):
95 return False
96 return True