File size: 36,005 Bytes
3e96755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
from .exceptions_types import EmailSyntaxError, ValidatedEmail
from .rfc_constants import EMAIL_MAX_LENGTH, LOCAL_PART_MAX_LENGTH, DOMAIN_MAX_LENGTH, \
    DOT_ATOM_TEXT, DOT_ATOM_TEXT_INTL, ATEXT_RE, ATEXT_INTL_DOT_RE, ATEXT_HOSTNAME_INTL, QTEXT_INTL, \
    DNS_LABEL_LENGTH_LIMIT, DOT_ATOM_TEXT_HOSTNAME, DOMAIN_NAME_REGEX, DOMAIN_LITERAL_CHARS

import re
import unicodedata
import idna  # implements IDNA 2008; Python's codec is only IDNA 2003
import ipaddress
from typing import Optional, Tuple, TypedDict, Union


def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
    # Return the display name, unescaped local part, and domain part
    # of the address, and whether the local part was quoted. If no
    # display name was present and angle brackets do not surround
    # the address, display name will be None; otherwise, it will be
    # set to the display name or the empty string if there were
    # angle brackets but no display name.

    # Typical email addresses have a single @-sign and no quote
    # characters, but the awkward "quoted string" local part form
    # (RFC 5321 4.1.2) allows @-signs and escaped quotes to appear
    # in the local part if the local part is quoted.

    # A `display name <addr>` format is also present in MIME messages
    # (RFC 5322 3.4) and this format is also often recognized in
    # mail UIs. It's not allowed in SMTP commands or in typical web
    # login forms, but parsing it has been requested, so it's done
    # here as a convenience. It's implemented in the spirit but not
    # the letter of RFC 5322 3.4 because MIME messages allow newlines
    # and comments as a part of the CFWS rule, but this is typically
    # not allowed in mail UIs (although comment syntax was requested
    # once too).
    #
    # Display names are either basic characters (the same basic characters
    # permitted in email addresses, but periods are not allowed and spaces
    # are allowed; see RFC 5322 Appendix A.1.2), or or a quoted string with
    # the same rules as a quoted local part. (Multiple quoted strings might
    # be allowed? Unclear.) Optional space (RFC 5322 3.4 CFWS) and then the
    # email address follows in angle brackets.
    #
    # An initial quote is ambiguous between starting a display name or
    # a quoted local part --- fun.
    #
    # We assume the input string is already stripped of leading and
    # trailing CFWS.

    def split_string_at_unquoted_special(text: str, specials: Tuple[str, ...]) -> Tuple[str, str]:
        # Split the string at the first character in specials (an @-sign
        # or left angle bracket) that does not occur within quotes and
        # is not followed by a Unicode combining character.
        # If no special character is found, raise an error.
        inside_quote = False
        escaped = False
        left_part = ""
        for i, c in enumerate(text):
            # < plus U+0338 (Combining Long Solidus Overlay) normalizes to
            # ≮ U+226E (Not Less-Than), and  it would be confusing to treat
            # the < as the start of "<email>" syntax in that case. Liekwise,
            # if anything combines with an @ or ", we should probably not
            # treat it as a special character.
            if unicodedata.normalize("NFC", text[i:])[0] != c:
                left_part += c

            elif inside_quote:
                left_part += c
                if c == '\\' and not escaped:
                    escaped = True
                elif c == '"' and not escaped:
                    # The only way to exit the quote is an unescaped quote.
                    inside_quote = False
                    escaped = False
                else:
                    escaped = False
            elif c == '"':
                left_part += c
                inside_quote = True
            elif c in specials:
                # When unquoted, stop before a special character.
                break
            else:
                left_part += c

        if len(left_part) == len(text):
            raise EmailSyntaxError("An email address must have an @-sign.")

        # The right part is whatever is left.
        right_part = text[len(left_part):]

        return left_part, right_part

    def unquote_quoted_string(text: str) -> Tuple[str, bool]:
        # Remove surrounding quotes and unescape escaped backslashes
        # and quotes. Escapes are parsed liberally. I think only
        # backslashes and quotes can be escaped but we'll allow anything
        # to be.
        quoted = False
        escaped = False
        value = ""
        for i, c in enumerate(text):
            if quoted:
                if escaped:
                    value += c
                    escaped = False
                elif c == '\\':
                    escaped = True
                elif c == '"':
                    if i != len(text) - 1:
                        raise EmailSyntaxError("Extra character(s) found after close quote: "
                                               + ", ".join(safe_character_display(c) for c in text[i + 1:]))
                    break
                else:
                    value += c
            elif i == 0 and c == '"':
                quoted = True
            else:
                value += c

        return value, quoted

    # Split the string at the first unquoted @-sign or left angle bracket.
    left_part, right_part = split_string_at_unquoted_special(email, ("@", "<"))

    # If the right part starts with an angle bracket,
    # then the left part is a display name and the rest
    # of the right part up to the final right angle bracket
    # is the email address, .
    if right_part.startswith("<"):
        # Remove space between the display name and angle bracket.
        left_part = left_part.rstrip()

        # Unquote and unescape the display name.
        display_name, display_name_quoted = unquote_quoted_string(left_part)

        # Check that only basic characters are present in a
        # non-quoted display name.
        if not display_name_quoted:
            bad_chars = {
                safe_character_display(c)
                for c in display_name
                if (not ATEXT_RE.match(c) and c != ' ') or c == '.'
            }
            if bad_chars:
                raise EmailSyntaxError("The display name contains invalid characters when not quoted: " + ", ".join(sorted(bad_chars)) + ".")

        # Check for other unsafe characters.
        check_unsafe_chars(display_name, allow_space=True)

        # Check that the right part ends with an angle bracket
        # but allow spaces after it, I guess.
        if ">" not in right_part:
            raise EmailSyntaxError("An open angle bracket at the start of the email address has to be followed by a close angle bracket at the end.")
        right_part = right_part.rstrip(" ")
        if right_part[-1] != ">":
            raise EmailSyntaxError("There can't be anything after the email address.")

        # Remove the initial and trailing angle brackets.
        addr_spec = right_part[1:].rstrip(">")

        # Split the email address at the first unquoted @-sign.
        local_part, domain_part = split_string_at_unquoted_special(addr_spec, ("@",))

    # Otherwise there is no display name. The left part is the local
    # part and the right part is the domain.
    else:
        display_name = None
        local_part, domain_part = left_part, right_part

    if domain_part.startswith("@"):
        domain_part = domain_part[1:]

    # Unquote the local part if it is quoted.
    local_part, is_quoted_local_part = unquote_quoted_string(local_part)

    return display_name, local_part, domain_part, is_quoted_local_part


def get_length_reason(addr: str, limit: int) -> str:
    """Helper function to return an error message related to invalid length."""
    diff = len(addr) - limit
    suffix = "s" if diff > 1 else ""
    return f"({diff} character{suffix} too many)"


def safe_character_display(c: str) -> str:
    # Return safely displayable characters in quotes.
    if c == '\\':
        return f"\"{c}\""  # can't use repr because it escapes it
    if unicodedata.category(c)[0] in ("L", "N", "P", "S"):
        return repr(c)

    # Construct a hex string in case the unicode name doesn't exist.
    if ord(c) < 0xFFFF:
        h = f"U+{ord(c):04x}".upper()
    else:
        h = f"U+{ord(c):08x}".upper()

    # Return the character name or, if it has no name, the hex string.
    return unicodedata.name(c, h)


class LocalPartValidationResult(TypedDict):
    local_part: str
    ascii_local_part: Optional[str]
    smtputf8: bool


def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_empty_local: bool = False,
                              quoted_local_part: bool = False) -> LocalPartValidationResult:
    """Validates the syntax of the local part of an email address."""

    if len(local) == 0:
        if not allow_empty_local:
            raise EmailSyntaxError("There must be something before the @-sign.")

        # The caller allows an empty local part. Useful for validating certain
        # Postfix aliases.
        return {
            "local_part": local,
            "ascii_local_part": local,
            "smtputf8": False,
        }

    # Check the length of the local part by counting characters.
    # (RFC 5321 4.5.3.1.1)
    # We're checking the number of characters here. If the local part
    # is ASCII-only, then that's the same as bytes (octets). If it's
    # internationalized, then the UTF-8 encoding may be longer, but
    # that may not be relevant. We will check the total address length
    # instead.
    if len(local) > LOCAL_PART_MAX_LENGTH:
        reason = get_length_reason(local, limit=LOCAL_PART_MAX_LENGTH)
        raise EmailSyntaxError(f"The email address is too long before the @-sign {reason}.")

    # Check the local part against the non-internationalized regular expression.
    # Most email addresses match this regex so it's probably fastest to check this first.
    # (RFC 5322 3.2.3)
    # All local parts matching the dot-atom rule are also valid as a quoted string
    # so if it was originally quoted (quoted_local_part is True) and this regex matches,
    # it's ok.
    # (RFC 5321 4.1.2 / RFC 5322 3.2.4).
    if DOT_ATOM_TEXT.match(local):
        # It's valid. And since it's just the permitted ASCII characters,
        # it's normalized and safe. If the local part was originally quoted,
        # the quoting was unnecessary and it'll be returned as normalized to
        # non-quoted form.

        # Return the local part and flag that SMTPUTF8 is not needed.
        return {
            "local_part": local,
            "ascii_local_part": local,
            "smtputf8": False,
        }

    # The local part failed the basic dot-atom check. Try the extended character set
    # for internationalized addresses. It's the same pattern but with additional
    # characters permitted.
    # RFC 6531 section 3.3.
    valid: Optional[str] = None
    requires_smtputf8 = False
    if DOT_ATOM_TEXT_INTL.match(local):
        # But international characters in the local part may not be permitted.
        if not allow_smtputf8:
            # Check for invalid characters against the non-internationalized
            # permitted character set.
            # (RFC 5322 3.2.3)
            bad_chars = {
                safe_character_display(c)
                for c in local
                if not ATEXT_RE.match(c)
            }
            if bad_chars:
                raise EmailSyntaxError("Internationalized characters before the @-sign are not supported: " + ", ".join(sorted(bad_chars)) + ".")

            # Although the check above should always find something, fall back to this just in case.
            raise EmailSyntaxError("Internationalized characters before the @-sign are not supported.")

        # It's valid.
        valid = "dot-atom"
        requires_smtputf8 = True

    # There are no syntactic restrictions on quoted local parts, so if
    # it was originally quoted, it is probably valid. More characters
    # are allowed, like @-signs, spaces, and quotes, and there are no
    # restrictions on the placement of dots, as in dot-atom local parts.
    elif quoted_local_part:
        # Check for invalid characters in a quoted string local part.
        # (RFC 5321 4.1.2. RFC 5322 lists additional permitted *obsolete*
        # characters which are *not* allowed here. RFC 6531 section 3.3
        # extends the range to UTF8 strings.)
        bad_chars = {
            safe_character_display(c)
            for c in local
            if not QTEXT_INTL.match(c)
        }
        if bad_chars:
            raise EmailSyntaxError("The email address contains invalid characters in quotes before the @-sign: " + ", ".join(sorted(bad_chars)) + ".")

        # See if any characters are outside of the ASCII range.
        bad_chars = {
            safe_character_display(c)
            for c in local
            if not (32 <= ord(c) <= 126)
        }
        if bad_chars:
            requires_smtputf8 = True

            # International characters in the local part may not be permitted.
            if not allow_smtputf8:
                raise EmailSyntaxError("Internationalized characters before the @-sign are not supported: " + ", ".join(sorted(bad_chars)) + ".")

        # It's valid.
        valid = "quoted"

    # If the local part matches the internationalized dot-atom form or was quoted,
    # perform additional checks for Unicode strings.
    if valid:
        # Check that the local part is a valid, safe, and sensible Unicode string.
        # Some of this may be redundant with the range U+0080 to U+10FFFF that is checked
        # by DOT_ATOM_TEXT_INTL and QTEXT_INTL. Other characters may be permitted by the
        # email specs, but they may not be valid, safe, or sensible Unicode strings.
        # See the function for rationale.
        check_unsafe_chars(local, allow_space=(valid == "quoted"))

        # Try encoding to UTF-8. Failure is possible with some characters like
        # surrogate code points, but those are checked above. Still, we don't
        # want to have an unhandled exception later.
        try:
            local.encode("utf8")
        except ValueError as e:
            raise EmailSyntaxError("The email address contains an invalid character.") from e

        # If this address passes only by the quoted string form, re-quote it
        # and backslash-escape quotes and backslashes (removing any unnecessary
        # escapes). Per RFC 5321 4.1.2, "all quoted forms MUST be treated as equivalent,
        # and the sending system SHOULD transmit the form that uses the minimum quoting possible."
        if valid == "quoted":
            local = '"' + re.sub(r'(["\\])', r'\\\1', local) + '"'

        return {
            "local_part": local,
            "ascii_local_part": local if not requires_smtputf8 else None,
            "smtputf8": requires_smtputf8,
        }

    # It's not a valid local part. Let's find out why.
    # (Since quoted local parts are all valid or handled above, these checks
    # don't apply in those cases.)

    # Check for invalid characters.
    # (RFC 5322 3.2.3, plus RFC 6531 3.3)
    bad_chars = {
        safe_character_display(c)
        for c in local
        if not ATEXT_INTL_DOT_RE.match(c)
    }
    if bad_chars:
        raise EmailSyntaxError("The email address contains invalid characters before the @-sign: " + ", ".join(sorted(bad_chars)) + ".")

    # Check for dot errors imposted by the dot-atom rule.
    # (RFC 5322 3.2.3)
    check_dot_atom(local, 'An email address cannot start with a {}.', 'An email address cannot have a {} immediately before the @-sign.', is_hostname=False)

    # All of the reasons should already have been checked, but just in case
    # we have a fallback message.
    raise EmailSyntaxError("The email address contains invalid characters before the @-sign.")


def check_unsafe_chars(s: str, allow_space: bool = False) -> None:
    # Check for unsafe characters or characters that would make the string
    # invalid or non-sensible Unicode.
    bad_chars = set()
    for i, c in enumerate(s):
        category = unicodedata.category(c)
        if category[0] in ("L", "N", "P", "S"):
            # Letters, numbers, punctuation, and symbols are permitted.
            pass
        elif category[0] == "M":
            # Combining character in first position would combine with something
            # outside of the email address if concatenated, so they are not safe.
            # We also check if this occurs after the @-sign, which would not be
            # sensible because it would modify the @-sign.
            if i == 0:
                bad_chars.add(c)
        elif category == "Zs":
            # Spaces outside of the ASCII range are not specifically disallowed in
            # internationalized addresses as far as I can tell, but they violate
            # the spirit of the non-internationalized specification that email
            # addresses do not contain ASCII spaces when not quoted. Excluding
            # ASCII spaces when not quoted is handled directly by the atom regex.
            #
            # In quoted-string local parts, spaces are explicitly permitted, and
            # the ASCII space has category Zs, so we must allow it here, and we'll
            # allow all Unicode spaces to be consistent.
            if not allow_space:
                bad_chars.add(c)
        elif category[0] == "Z":
            # The two line and paragraph separator characters (in categories Zl and Zp)
            # are not specifically disallowed in internationalized addresses
            # as far as I can tell, but they violate the spirit of the non-internationalized
            # specification that email addresses do not contain line breaks when not quoted.
            bad_chars.add(c)
        elif category[0] == "C":
            # Control, format, surrogate, private use, and unassigned code points (C)
            # are all unsafe in various ways. Control and format characters can affect
            # text rendering if the email address is concatenated with other text.
            # Bidirectional format characters are unsafe, even if used properly, because
            # they cause an email address to render as a different email address.
            # Private use characters do not make sense for publicly deliverable
            # email addresses.
            bad_chars.add(c)
        else:
            # All categories should be handled above, but in case there is something new
            # to the Unicode specification in the future, reject all other categories.
            bad_chars.add(c)
    if bad_chars:
        raise EmailSyntaxError("The email address contains unsafe characters: "
                               + ", ".join(safe_character_display(c) for c in sorted(bad_chars)) + ".")


def check_dot_atom(label: str, start_descr: str, end_descr: str, is_hostname: bool) -> None:
    # RFC 5322 3.2.3
    if label.endswith("."):
        raise EmailSyntaxError(end_descr.format("period"))
    if label.startswith("."):
        raise EmailSyntaxError(start_descr.format("period"))
    if ".." in label:
        raise EmailSyntaxError("An email address cannot have two periods in a row.")

    if is_hostname:
        # RFC 952
        if label.endswith("-"):
            raise EmailSyntaxError(end_descr.format("hyphen"))
        if label.startswith("-"):
            raise EmailSyntaxError(start_descr.format("hyphen"))
        if ".-" in label or "-." in label:
            raise EmailSyntaxError("An email address cannot have a period and a hyphen next to each other.")


class DomainNameValidationResult(TypedDict):
    ascii_domain: str
    domain: str


def validate_email_domain_name(domain: str, test_environment: bool = False, globally_deliverable: bool = True) -> DomainNameValidationResult:
    """Validates the syntax of the domain part of an email address."""

    # Check for invalid characters.
    # (RFC 952 plus RFC 6531 section 3.3 for internationalized addresses)
    bad_chars = {
        safe_character_display(c)
        for c in domain
        if not ATEXT_HOSTNAME_INTL.match(c)
    }
    if bad_chars:
        raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".")

    # Check for unsafe characters.
    # Some of this may be redundant with the range U+0080 to U+10FFFF that is checked
    # by DOT_ATOM_TEXT_INTL. Other characters may be permitted by the email specs, but
    # they may not be valid, safe, or sensible Unicode strings.
    check_unsafe_chars(domain)

    # Perform UTS-46 normalization, which includes casefolding, NFC normalization,
    # and converting all label separators (the period/full stop, fullwidth full stop,
    # ideographic full stop, and halfwidth ideographic full stop) to regular dots.
    # It will also raise an exception if there is an invalid character in the input,
    # such as "⒈" which is invalid because it would expand to include a dot and
    # U+1FEF which normalizes to a backtick, which is not an allowed hostname character.
    # Since several characters *are* normalized to a dot, this has to come before
    # checks related to dots, like check_dot_atom which comes next.
    original_domain = domain
    try:
        domain = idna.uts46_remap(domain, std3_rules=False, transitional=False)
    except idna.IDNAError as e:
        raise EmailSyntaxError(f"The part after the @-sign contains invalid characters ({e}).") from e

    # Check for invalid characters after Unicode normalization which are not caught
    # by uts46_remap (see tests for examples).
    bad_chars = {
        safe_character_display(c)
        for c in domain
        if not ATEXT_HOSTNAME_INTL.match(c)
    }
    if bad_chars:
        raise EmailSyntaxError("The part after the @-sign contains invalid characters after Unicode normalization: " + ", ".join(sorted(bad_chars)) + ".")

    # The domain part is made up dot-separated "labels." Each label must
    # have at least one character and cannot start or end with dashes, which
    # means there are some surprising restrictions on periods and dashes.
    # Check that before we do IDNA encoding because the IDNA library gives
    # unfriendly errors for these cases, but after UTS-46 normalization because
    # it can insert periods and hyphens (from fullwidth characters).
    # (RFC 952, RFC 1123 2.1, RFC 5322 3.2.3)
    check_dot_atom(domain, 'An email address cannot have a {} immediately after the @-sign.', 'An email address cannot end with a {}.', is_hostname=True)

    # Check for RFC 5890's invalid R-LDH labels, which are labels that start
    # with two characters other than "xn" and two dashes.
    for label in domain.split("."):
        if re.match(r"(?!xn)..--", label, re.I):
            raise EmailSyntaxError("An email address cannot have two letters followed by two dashes immediately after the @-sign or after a period, except Punycode.")

    if DOT_ATOM_TEXT_HOSTNAME.match(domain):
        # This is a valid non-internationalized domain.
        ascii_domain = domain
    else:
        # If international characters are present in the domain name, convert
        # the domain to IDNA ASCII. If internationalized characters are present,
        # the MTA must either support SMTPUTF8 or the mail client must convert the
        # domain name to IDNA before submission.
        #
        # For ASCII-only domains, the transformation does nothing and is safe to
        # apply. However, to ensure we don't rely on the idna library for basic
        # syntax checks, we don't use it if it's not needed.
        #
        # idna.encode also checks the domain name length after encoding but it
        # doesn't give a nice error, so we call the underlying idna.alabel method
        # directly. idna.alabel checks label length and doesn't give great messages,
        # but we can't easily go to lower level methods.
        try:
            ascii_domain = ".".join(
                idna.alabel(label).decode("ascii")
                for label in domain.split(".")
            )
        except idna.IDNAError as e:
            # Some errors would have already been raised by idna.uts46_remap.
            raise EmailSyntaxError(f"The part after the @-sign is invalid ({e}).") from e

        # Check the syntax of the string returned by idna.encode.
        # It should never fail.
        if not DOT_ATOM_TEXT_HOSTNAME.match(ascii_domain):
            raise EmailSyntaxError("The email address contains invalid characters after the @-sign after IDNA encoding.")

    # Check the length of the domain name in bytes.
    # (RFC 1035 2.3.4 and RFC 5321 4.5.3.1.2)
    # We're checking the number of bytes ("octets") here, which can be much
    # higher than the number of characters in internationalized domains,
    # on the assumption that the domain may be transmitted without SMTPUTF8
    # as IDNA ASCII. (This is also checked by idna.encode, so this exception
    # is never reached for internationalized domains.)
    if len(ascii_domain) > DOMAIN_MAX_LENGTH:
        if ascii_domain == original_domain:
            reason = get_length_reason(ascii_domain, limit=DOMAIN_MAX_LENGTH)
            raise EmailSyntaxError(f"The email address is too long after the @-sign {reason}.")
        else:
            diff = len(ascii_domain) - DOMAIN_MAX_LENGTH
            s = "" if diff == 1 else "s"
            raise EmailSyntaxError(f"The email address is too long after the @-sign ({diff} byte{s} too many after IDNA encoding).")

    # Also check the label length limit.
    # (RFC 1035 2.3.1)
    for label in ascii_domain.split("."):
        if len(label) > DNS_LABEL_LENGTH_LIMIT:
            reason = get_length_reason(label, limit=DNS_LABEL_LENGTH_LIMIT)
            raise EmailSyntaxError(f"After the @-sign, periods cannot be separated by so many characters {reason}.")

    if globally_deliverable:
        # All publicly deliverable addresses have domain names with at least
        # one period, at least for gTLDs created since 2013 (per the ICANN Board
        # New gTLD Program Committee, https://www.icann.org/en/announcements/details/new-gtld-dotless-domain-names-prohibited-30-8-2013-en).
        # We'll consider the lack of a period a syntax error
        # since that will match people's sense of what an email address looks
        # like. We'll skip this in test environments to allow '@test' email
        # addresses.
        if "." not in ascii_domain and not (ascii_domain == "test" and test_environment):
            raise EmailSyntaxError("The part after the @-sign is not valid. It should have a period.")

        # We also know that all TLDs currently end with a letter.
        if not DOMAIN_NAME_REGEX.search(ascii_domain):
            raise EmailSyntaxError("The part after the @-sign is not valid. It is not within a valid top-level domain.")

    # Check special-use and reserved domain names.
    # Some might fail DNS-based deliverability checks, but that
    # can be turned off, so we should fail them all sooner.
    # See the references in __init__.py.
    from . import SPECIAL_USE_DOMAIN_NAMES
    for d in SPECIAL_USE_DOMAIN_NAMES:
        # See the note near the definition of SPECIAL_USE_DOMAIN_NAMES.
        if d == "test" and test_environment:
            continue

        if ascii_domain == d or ascii_domain.endswith("." + d):
            raise EmailSyntaxError("The part after the @-sign is a special-use or reserved name that cannot be used with email.")

    # We may have been given an IDNA ASCII domain to begin with. Check
    # that the domain actually conforms to IDNA. It could look like IDNA
    # but not be actual IDNA. For ASCII-only domains, the conversion out
    # of IDNA just gives the same thing back.
    #
    # This gives us the canonical internationalized form of the domain,
    # which we return to the caller as a part of the normalized email
    # address.
    try:
        domain_i18n = idna.decode(ascii_domain.encode('ascii'))
    except idna.IDNAError as e:
        raise EmailSyntaxError(f"The part after the @-sign is not valid IDNA ({e}).") from e

    # Check that this normalized domain name has not somehow become
    # an invalid domain name. All of the checks before this point
    # using the idna package probably guarantee that we now have
    # a valid international domain name in most respects. But it
    # doesn't hurt to re-apply some tests to be sure. See the similar
    # tests above.

    # Check for invalid and unsafe characters. We have no test
    # case for this.
    bad_chars = {
        safe_character_display(c)
        for c in domain
        if not ATEXT_HOSTNAME_INTL.match(c)
    }
    if bad_chars:
        raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".")
    check_unsafe_chars(domain)

    # Check that it can be encoded back to IDNA ASCII. We have no test
    # case for this.
    try:
        idna.encode(domain_i18n)
    except idna.IDNAError as e:
        raise EmailSyntaxError(f"The part after the @-sign became invalid after normalizing to international characters ({e}).") from e

    # Return the IDNA ASCII-encoded form of the domain, which is how it
    # would be transmitted on the wire (except when used with SMTPUTF8
    # possibly), as well as the canonical Unicode form of the domain,
    # which is better for display purposes. This should also take care
    # of RFC 6532 section 3.1's suggestion to apply Unicode NFC
    # normalization to addresses.
    return {
        "ascii_domain": ascii_domain,
        "domain": domain_i18n,
    }


def validate_email_length(addrinfo: ValidatedEmail) -> None:
    # There are three forms of the email address whose length must be checked:
    #
    # 1) The original email address string. Since callers may continue to use
    #    this string, even though we recommend using the normalized form, we
    #    should not pass validation when the original input is not valid. This
    #    form is checked first because it is the original input.
    # 2) The normalized email address. We perform Unicode NFC normalization of
    #    the local part, we normalize the domain to internationalized characters
    #    (if originaly IDNA ASCII) which also includes Unicode normalization,
    #    and we may remove quotes in quoted local parts. We recommend that
    #    callers use this string, so it must be valid.
    # 3) The email address with the IDNA ASCII representation of the domain
    #    name, since this string may be used with email stacks that don't
    #    support UTF-8. Since this is the least likely to be used by callers,
    #    it is checked last. Note that ascii_email will only be set if the
    #    local part is ASCII, but conceivably the caller may combine a
    #    internationalized local part with an ASCII domain, so we check this
    #    on that combination also. Since we only return the normalized local
    #    part, we use that (and not the unnormalized local part).
    #
    # In all cases, the length is checked in UTF-8 because the SMTPUTF8
    # extension to SMTP validates the length in bytes.

    addresses_to_check = [
        (addrinfo.original, None),
        (addrinfo.normalized, "after normalization"),
        ((addrinfo.ascii_local_part or addrinfo.local_part or "") + "@" + addrinfo.ascii_domain, "when the part after the @-sign is converted to IDNA ASCII"),
    ]

    for addr, reason in addresses_to_check:
        addr_len = len(addr)
        addr_utf8_len = len(addr.encode("utf8"))
        diff = addr_utf8_len - EMAIL_MAX_LENGTH
        if diff > 0:
            if reason is None and addr_len == addr_utf8_len:
                # If there is no normalization or transcoding,
                # we can give a simple count of the number of
                # characters over the limit.
                reason = get_length_reason(addr, limit=EMAIL_MAX_LENGTH)
            elif reason is None:
                # If there is no normalization but there is
                # some transcoding to UTF-8, we can compute
                # the minimum number of characters over the
                # limit by dividing the number of bytes over
                # the limit by the maximum number of bytes
                # per character.
                mbpc = max(len(c.encode("utf8")) for c in addr)
                mchars = max(1, diff // mbpc)
                suffix = "s" if diff > 1 else ""
                if mchars == diff:
                    reason = f"({diff} character{suffix} too many)"
                else:
                    reason = f"({mchars}-{diff} character{suffix} too many)"
            else:
                # Since there is normalization, the number of
                # characters in the input that need to change is
                # impossible to know.
                suffix = "s" if diff > 1 else ""
                reason += f" ({diff} byte{suffix} too many)"
            raise EmailSyntaxError(f"The email address is too long {reason}.")


class DomainLiteralValidationResult(TypedDict):
    domain_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
    domain: str


def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidationResult:
    # This is obscure domain-literal syntax. Parse it and return
    # a compressed/normalized address.
    # RFC 5321 4.1.3 and RFC 5322 3.4.1.

    addr: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]

    # Try to parse the domain literal as an IPv4 address.
    # There is no tag for IPv4 addresses, so we can never
    # be sure if the user intends an IPv4 address.
    if re.match(r"^[0-9\.]+$", domain_literal):
        try:
            addr = ipaddress.IPv4Address(domain_literal)
        except ValueError as e:
            raise EmailSyntaxError(f"The address in brackets after the @-sign is not valid: It is not an IPv4 address ({e}) or is missing an address literal tag.") from e

        # Return the IPv4Address object and the domain back unchanged.
        return {
            "domain_address": addr,
            "domain": f"[{addr}]",
        }

    # If it begins with "IPv6:" it's an IPv6 address.
    if domain_literal.startswith("IPv6:"):
        try:
            addr = ipaddress.IPv6Address(domain_literal[5:])
        except ValueError as e:
            raise EmailSyntaxError(f"The IPv6 address in brackets after the @-sign is not valid ({e}).") from e

        # Return the IPv6Address object and construct a normalized
        # domain literal.
        return {
            "domain_address": addr,
            "domain": f"[IPv6:{addr.compressed}]",
        }

    # Nothing else is valid.

    if ":" not in domain_literal:
        raise EmailSyntaxError("The part after the @-sign in brackets is not an IPv4 address and has no address literal tag.")

    # The tag (the part before the colon) has character restrictions,
    # but since it must come from a registry of tags (in which only "IPv6" is defined),
    # there's no need to check the syntax of the tag. See RFC 5321 4.1.2.

    # Check for permitted ASCII characters. This actually doesn't matter
    # since there will be an exception after anyway.
    bad_chars = {
        safe_character_display(c)
        for c in domain_literal
        if not DOMAIN_LITERAL_CHARS.match(c)
    }
    if bad_chars:
        raise EmailSyntaxError("The part after the @-sign contains invalid characters in brackets: " + ", ".join(sorted(bad_chars)) + ".")

    # There are no other domain literal tags.
    # https://www.iana.org/assignments/address-literal-tags/address-literal-tags.xhtml
    raise EmailSyntaxError("The part after the @-sign contains an invalid address literal tag in brackets.")