| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Globalization; |
| | | 3 | | using ArturRios.Util.RegularExpressions; |
| | | 4 | | |
| | | 5 | | namespace ArturRios.Util.Text; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Normalization and validation for email addresses, layered on top of |
| | | 9 | | /// <see cref="RegexCollection.EmailPattern"/>. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// <para> |
| | | 13 | | /// Validating an address happens at three levels, and this type covers the first two. The pattern in |
| | | 14 | | /// <see cref="RegexCollection"/> answers whether the text is shaped like an address. This type additionally |
| | | 15 | | /// normalizes it, so that two spellings of one mailbox compare equal. Whether the mailbox actually exists |
| | | 16 | | /// can only be settled by an MX lookup or a confirmation email. |
| | | 17 | | /// </para> |
| | | 18 | | /// <para> |
| | | 19 | | /// <see cref="System.Net.Mail.MailAddress"/> is deliberately not used here. It is a parser rather than a |
| | | 20 | | /// validator and accepts a great deal the pattern rejects, including "user@-hostname.com", |
| | | 21 | | /// "user@hostname." and display-name forms such as "John Doe <user@host.com>". |
| | | 22 | | /// </para> |
| | | 23 | | /// </remarks> |
| | | 24 | | public static class EmailAddress |
| | | 25 | | { |
| | 1 | 26 | | private static readonly IdnMapping Idn = new(); |
| | | 27 | | |
| | | 28 | | /// <summary> |
| | | 29 | | /// Validates <paramref name="value"/> and rewrites it into a canonical form. |
| | | 30 | | /// </summary> |
| | | 31 | | /// <param name="value">The address to normalize.</param> |
| | | 32 | | /// <param name="normalized"> |
| | | 33 | | /// The canonical address when the method returns <see langword="true"/>, otherwise <see langword="null"/>. |
| | | 34 | | /// The domain is lowercased and, when internationalized, converted to its punycode form; the local part |
| | | 35 | | /// is left exactly as given, because it is case sensitive per RFC 5321. |
| | | 36 | | /// </param> |
| | | 37 | | /// <returns><see langword="true"/> when <paramref name="value"/> is a valid address.</returns> |
| | | 38 | | /// <remarks> |
| | | 39 | | /// Surrounding whitespace is not trimmed: the syntax gate stays authoritative, so an address with |
| | | 40 | | /// stray spaces is rejected rather than silently repaired. |
| | | 41 | | /// </remarks> |
| | | 42 | | public static bool TryNormalize(string? value, [NotNullWhen(true)] out string? normalized) |
| | | 43 | | { |
| | 26 | 44 | | normalized = null; |
| | | 45 | | |
| | 26 | 46 | | if (string.IsNullOrEmpty(value)) |
| | | 47 | | { |
| | 2 | 48 | | return false; |
| | | 49 | | } |
| | | 50 | | |
| | 24 | 51 | | var separator = value.LastIndexOf('@'); |
| | | 52 | | |
| | 24 | 53 | | if (separator <= 0 || separator == value.Length - 1) |
| | | 54 | | { |
| | 4 | 55 | | return false; |
| | | 56 | | } |
| | | 57 | | |
| | 20 | 58 | | var localPart = value[..separator]; |
| | 20 | 59 | | var domain = value[(separator + 1)..]; |
| | 20 | 60 | | var candidate = $"{localPart}@{NormalizeDomain(domain)}"; |
| | | 61 | | |
| | 20 | 62 | | if (!RegexCollection.Email().IsMatch(candidate)) |
| | | 63 | | { |
| | 9 | 64 | | return false; |
| | | 65 | | } |
| | | 66 | | |
| | 11 | 67 | | normalized = candidate; |
| | | 68 | | |
| | 11 | 69 | | return true; |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | /// <summary> |
| | | 73 | | /// Checks whether <paramref name="value"/> is a valid email address. |
| | | 74 | | /// </summary> |
| | | 75 | | /// <param name="value">The address to check.</param> |
| | | 76 | | /// <returns><see langword="true"/> when the address is valid.</returns> |
| | | 77 | | /// <remarks> |
| | | 78 | | /// Accepts everything <see cref="RegexCollection.Email"/> accepts, plus mixed-case and |
| | | 79 | | /// internationalized domains, which are normalized before the pattern is applied. |
| | | 80 | | /// </remarks> |
| | 5 | 81 | | public static bool IsValid(string? value) => TryNormalize(value, out _); |
| | | 82 | | |
| | | 83 | | private static string NormalizeDomain(string domain) |
| | | 84 | | { |
| | | 85 | | // An address literal such as [192.168.1.1] is not a domain name and must not be punycoded. |
| | 20 | 86 | | if (domain.StartsWith('[')) |
| | | 87 | | { |
| | 2 | 88 | | return domain; |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | try |
| | | 92 | | { |
| | 18 | 93 | | return Idn.GetAscii(domain).ToLowerInvariant(); |
| | | 94 | | } |
| | 3 | 95 | | catch (ArgumentException) |
| | | 96 | | { |
| | | 97 | | // Not a convertible domain; hand it back untouched and let the pattern reject it. |
| | 3 | 98 | | return domain; |
| | | 99 | | } |
| | 18 | 100 | | } |
| | | 101 | | } |