Created
April 12, 2025 11:15
-
-
Save AndrewDongminYoo/27780785e90e4af78b0fea1572d1abde to your computer and use it in GitHub Desktop.
Dart class that uses a strategy pattern to check the validity of an email
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// Written with “powerful” inspiration from the https://pub.dev/packages/email_validator. | |
| /// An abstract strategy for validating objects of type [T]. | |
| /// | |
| /// Implementations of this class define specific validation rules | |
| /// for a given type, allowing flexible and reusable validation logic. | |
| abstract class ValidationStrategy<T, E extends Exception> { | |
| /// Validates the given [value] of type [T]. | |
| /// | |
| /// If the validation fails, an exception of type [E] is thrown. | |
| void call(T value); | |
| } | |
| // Maximum overall email length (less than 255 characters) | |
| const int kMaxEmailLength = 254; | |
| // Maximum length for the local-part | |
| const int kMaxLocalPartLength = 64; | |
| // Maximum length for each domain component | |
| const int kMaxDomainComponentLength = 64; | |
| // Minimum required length for IPv4/IPv6 literal | |
| const int kMinIpLiteralLength = 8; | |
| /// Email validation strategy class. | |
| /// Throws [EmailValidationException] if the email is invalid. | |
| class EmailValidationStrategy implements ValidationStrategy<String, EmailValidationException> { | |
| EmailValidationStrategy({ | |
| this.allowTopLevelDomains = false, | |
| this.allowInternational = true, | |
| this.debug = false, | |
| }); | |
| /// Whether top-level domains are allowed. | |
| final bool allowTopLevelDomains; | |
| /// Whether international email addresses are allowed. | |
| final bool allowInternational; | |
| /// Whether the parser is in debug mode. | |
| final bool debug; | |
| @override | |
| void call(String email) { | |
| final parser = _EmailValidatorParser(email, allowTopLevelDomains, allowInternational, debug: debug); | |
| if (!parser.validate()) { | |
| // In debug mode, detailed error messages are available via parser.errorMessage | |
| throw EmailValidationException(parser.errorMessage ?? 'Invalid email address.'); | |
| } | |
| } | |
| } | |
| /// Exception thrown when email validation fails. | |
| class EmailValidationException implements Exception { | |
| EmailValidationException(this.message); | |
| /// The error message associated with the validation failure. | |
| final String message; | |
| @override | |
| String toString() { | |
| return 'EmailValidationException: $message'; | |
| } | |
| } | |
| /// Enumeration for subdomain types: [none], [alphabetic], [numeric], [alphanumeric] | |
| enum SubdomainType { none, alphabetic, numeric, alphanumeric } | |
| /// Internal implementation of email validation using a parser. | |
| /// Each validation step relies on state ([_position], [_domainType]) ensuring thread safety. | |
| /// When debug mode is enabled, detailed failure reasons are recorded. | |
| class _EmailValidatorParser { | |
| _EmailValidatorParser(this.email, this.allowTopLevelDomains, this.allowInternational, {this.debug = false}); | |
| /// The email address being validated. | |
| final String email; | |
| /// Whether top-level domains are allowed. | |
| final bool allowTopLevelDomains; | |
| /// Whether international email addresses are allowed. | |
| final bool allowInternational; | |
| /// Whether the parser is in debug mode. | |
| final bool debug; | |
| /// The current position in the email address. | |
| int _position = 0; | |
| /// The type of the current domain. | |
| SubdomainType _domainType = SubdomainType.none; | |
| /// The error message, if any. | |
| String? _errorMessage; | |
| /// Helper function that sets an error message and returns false upon failure. | |
| bool _fail(String message) { | |
| if (debug && _errorMessage == null) { | |
| _errorMessage = message; | |
| } | |
| return false; | |
| } | |
| /// Provides the first failure reason when debug mode is active. | |
| String? get errorMessage => _errorMessage; | |
| /// Checks if the character [char] is a digit. | |
| bool _isDigit(String char) { | |
| final code = char.codeUnitAt(0); | |
| return code >= 48 && code <= 57; | |
| } | |
| /// Checks if the character [char] is an alphabetic letter. | |
| bool _isLetter(String char) { | |
| final code = char.codeUnitAt(0); | |
| return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); | |
| } | |
| /// Checks if the character [char] is either a letter or a digit. | |
| bool _isLetterOrDigit(String char) { | |
| return _isLetter(char) || _isDigit(char); | |
| } | |
| /// Determines if the character [char] is allowed in the local-part atom. | |
| bool _isAtomChar(String char) { | |
| const atomCharacters = r"!#$%&'*+-/=?^_`{|}~"; | |
| if (char.codeUnitAt(0) < 128) { | |
| return _isLetterOrDigit(char) || atomCharacters.contains(char); | |
| } | |
| return allowInternational; | |
| } | |
| /// Checks domain characters while updating [_domainType]. | |
| bool _isDomainChar(String char) { | |
| if (char.codeUnitAt(0) < 128) { | |
| if (_isLetter(char) || char == '-') { | |
| _domainType = SubdomainType.alphabetic; | |
| return true; | |
| } | |
| if (_isDigit(char)) { | |
| _domainType = SubdomainType.numeric; | |
| return true; | |
| } | |
| return false; | |
| } | |
| if (allowInternational) { | |
| _domainType = SubdomainType.alphabetic; | |
| return true; | |
| } | |
| return false; | |
| } | |
| /// Checks the starting character of a domain. | |
| bool _isDomainStartChar(String char) { | |
| if (char.codeUnitAt(0) < 128) { | |
| if (_isLetter(char)) { | |
| _domainType = SubdomainType.alphabetic; | |
| return true; | |
| } | |
| if (_isDigit(char)) { | |
| _domainType = SubdomainType.numeric; | |
| return true; | |
| } | |
| _domainType = SubdomainType.none; | |
| return false; | |
| } | |
| if (allowInternational) { | |
| _domainType = SubdomainType.alphabetic; | |
| return true; | |
| } | |
| _domainType = SubdomainType.none; | |
| return false; | |
| } | |
| /// Parses the atom part of the local-part. | |
| bool _skipAtom() { | |
| final start = _position; | |
| while (_position < email.length && _isAtomChar(email[_position])) { | |
| _position++; | |
| } | |
| if (_position == start) { | |
| return _fail('Failed to parse local-part atom at position $_position'); | |
| } | |
| if (_position > kMaxLocalPartLength) { | |
| return _fail('Local-part exceeds maximum length of $kMaxLocalPartLength characters'); | |
| } | |
| return true; | |
| } | |
| /// Parses a single subdomain. | |
| bool _skipSubDomain() { | |
| final start = _position; | |
| if (_position >= email.length || !_isDomainStartChar(email[_position])) { | |
| return _fail('Failed domain start character validation at position $_position'); | |
| } | |
| // Consume the starting domain character | |
| _position++; | |
| while (_position < email.length && _isDomainChar(email[_position])) { | |
| _position++; | |
| } | |
| // A single-character TLD is not valid. | |
| if (_position == email.length && (_position - start) == 1) { | |
| return _fail('Single-character domain is not valid at position $start'); | |
| } | |
| if ((_position - start) >= kMaxDomainComponentLength) { | |
| return _fail('Domain component exceeds maximum length of $kMaxDomainComponentLength characters'); | |
| } | |
| if (email[_position - 1] == '-') { | |
| return _fail('Domain cannot end with "-" at position ${_position - 1}'); | |
| } | |
| return true; | |
| } | |
| /// Parses the entire domain (subdomains and top-level domain). | |
| bool _skipDomain() { | |
| if (!_skipSubDomain()) { | |
| return false; | |
| } | |
| if (_position < email.length && email[_position] == '.') { | |
| do { | |
| // Skip the dot ('.') | |
| _position++; | |
| if (_position >= email.length) { | |
| return _fail('Insufficient content after dot in domain parsing'); | |
| } | |
| if (!_skipSubDomain()) { | |
| return false; | |
| } | |
| } while (_position < email.length && email[_position] == '.'); | |
| } else if (!allowTopLevelDomains) { | |
| return _fail('Top-level domains are not allowed'); | |
| } | |
| if (_domainType == SubdomainType.numeric) { | |
| return _fail('Numeric domains are not valid'); | |
| } | |
| return true; | |
| } | |
| /// Parses the content within a quoted string. | |
| bool _skipQuoted() { | |
| var escaped = false; | |
| // Skip the starting double quote | |
| _position++; | |
| while (_position < email.length) { | |
| if (email[_position].codeUnitAt(0) >= 128 && !allowInternational) { | |
| return _fail('International characters are not allowed in quoted string at position $_position'); | |
| } | |
| if (email[_position] == r'\') { | |
| escaped = !escaped; | |
| } else if (!escaped) { | |
| if (email[_position] == '"') { | |
| break; | |
| } | |
| } else { | |
| escaped = false; | |
| } | |
| _position++; | |
| } | |
| if (_position >= email.length || email[_position] != '"') { | |
| return _fail('Missing ending double quote in quoted string'); | |
| } | |
| // Skip the ending double quote | |
| _position++; | |
| return true; | |
| } | |
| /// Parses an IPv4 literal. | |
| bool _skipIPv4Literal() { | |
| var groups = 0; | |
| while (_position < email.length && groups < 4) { | |
| final groupStart = _position; | |
| var value = 0; | |
| while (_position < email.length && _isDigit(email[_position])) { | |
| value = (value * 10) + (email[_position].codeUnitAt(0) - 48); | |
| _position++; | |
| } | |
| if (_position == groupStart || (_position - groupStart) > 3 || value > 255) { | |
| return _fail('Failed to parse IPv4 group starting at $groupStart'); | |
| } | |
| groups++; | |
| if (groups < 4) { | |
| if (_position < email.length && email[_position] == '.') { | |
| // Skip the dot ('.') | |
| _position++; | |
| } else { | |
| return _fail('Missing IPv4 group delimiter (.) at position $_position'); | |
| } | |
| } | |
| } | |
| if (groups != 4) { | |
| return _fail('IPv4 address does not have 4 groups'); | |
| } | |
| return true; | |
| } | |
| /// Checks if the character [char] is a hexadecimal digit. | |
| bool _isHexDigit(String char) { | |
| final code = char.codeUnitAt(0); | |
| return (code >= 65 && code <= 70) || (code >= 97 && code <= 102) || (code >= 48 && code <= 57); | |
| } | |
| /// Parses an IPv6 literal. | |
| bool _skipIPv6Literal() { | |
| var compact = false; | |
| var colons = 0; | |
| while (_position < email.length) { | |
| final segmentStart = _position; | |
| while (_position < email.length && _isHexDigit(email[_position])) { | |
| _position++; | |
| } | |
| if (_position >= email.length) { | |
| break; | |
| } | |
| // Handle mixed IPv6/IPv4: if enough hex digits are followed by '.', switch to IPv4 parsing | |
| if (_position > segmentStart && colons > 2 && email[_position] == '.') { | |
| _position = segmentStart; | |
| if (!_skipIPv4Literal()) { | |
| return _fail('Failed to parse mixed IPv4 within IPv6'); | |
| } | |
| return compact ? colons < 6 : colons == 6; | |
| } | |
| final count = _position - segmentStart; | |
| if (count > 4) { | |
| return _fail('IPv6 segment exceeds 4 characters at $segmentStart'); | |
| } | |
| if (email[_position] != ':') { | |
| break; | |
| } | |
| final colonStart = _position; | |
| while (_position < email.length && email[_position] == ':') { | |
| _position++; | |
| } | |
| final colonCount = _position - colonStart; | |
| if (colonCount > 2) { | |
| return _fail('Too many consecutive colons (:) starting at $colonStart'); | |
| } | |
| if (colonCount == 2) { | |
| if (compact) { | |
| return _fail('Multiple uses of compression (::) at $colonStart'); | |
| } | |
| compact = true; | |
| colons += 2; | |
| } else { | |
| colons++; | |
| } | |
| } | |
| if (colons < 2) { | |
| return _fail('Insufficient number of colons in IPv6 address'); | |
| } | |
| return compact ? colons < 7 : colons == 7; | |
| } | |
| /// Performs full email validation. | |
| bool validate() { | |
| // Check if the email is empty or exceeds the maximum length. | |
| if (email.isEmpty || email.length >= kMaxEmailLength) { | |
| return _fail('Email is empty or exceeds $kMaxEmailLength characters'); | |
| } | |
| // Parse the local-part: if it starts with a double quote, treat it as a quoted string. | |
| if (email[_position] == '"') { | |
| if (!_skipQuoted() || _position >= email.length) { | |
| return _fail('Failed to parse quoted string in local-part'); | |
| } | |
| } else { | |
| if (!_skipAtom()) { | |
| return _fail('Failed to parse local-part atom'); | |
| } | |
| while (_position < email.length && email[_position] == '.') { | |
| // Skip the dot ('.') | |
| _position++; | |
| if (_position >= email.length || !_skipAtom()) { | |
| return _fail('Failed to parse local-part segment after dot'); | |
| } | |
| } | |
| } | |
| // Validate the '@' delimiter and local-part length. | |
| if (_position + 1 >= email.length || _position > kMaxLocalPartLength || email[_position] != '@') { | |
| return _fail('Failed to validate the local-part and domain delimiter (@) at position $_position'); | |
| } | |
| // Skip the '@' character. | |
| _position++; | |
| if (_position >= email.length) { | |
| return _fail('Insufficient characters for domain parsing'); | |
| } | |
| if (email[_position] != '[') { | |
| // Parse a standard domain. | |
| if (!_skipDomain()) { | |
| return false; | |
| } | |
| return _position == email.length; | |
| } | |
| // Parse the address literal ([...]) | |
| // Skip the '[' character. | |
| _position++; | |
| if (_position + kMinIpLiteralLength >= email.length) { | |
| return _fail('IP literal length is insufficient'); | |
| } | |
| final literal = email.substring(_position - 1).toLowerCase(); | |
| if (literal.contains('ipv6:')) { | |
| _position += 'ipv6:'.length; | |
| if (!_skipIPv6Literal()) { | |
| return false; | |
| } | |
| } else { | |
| if (!_skipIPv4Literal()) { | |
| return false; | |
| } | |
| } | |
| if (_position >= email.length || email[_position] != ']') { | |
| return _fail("Missing closing bracket (']') for IP literal"); | |
| } | |
| // Skip the closing bracket. | |
| _position++; | |
| return _position == email.length; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for your medium write up on this code 👍 Fun to see others gain usage and knowledge