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

1import re 

2import string 

3from encodings import idna 

4from viur.core.bones.string import StringBone 

5from viur.core import i18n 

6 

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.""" 

9 

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.""" 

12 

13_UNICODE_LOWER_BOUND = chr(0x80) 

14"""Local-part characters from this codepoint (U+0080) upwards are accepted as non-ASCII (SMTPUTF8).""" 

15 

16 

17class EmailBone(StringBone): 

18 """ 

19 The EmailBone class is a designed to store syntactically validated email addresses. 

20 

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 """ 

28 

29 def isInvalid(self, value: str) -> str | None: 

30 """ 

31 Checks if the provided email address is valid or not. 

32 

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. 

35 

36 The address must satisfy all of the following: 

37 

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") 

45 

46 if not self._is_valid_address(value): 

47 return i18n.translate("core.bones.error.invalidemail", "Invalid email entered") 

48 

49 return None 

50 

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) 

58 

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. 

63 

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 ) 

76 

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. 

81 

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