diff --git a/core/lib/utils/mail/domain.dart b/core/lib/utils/mail/domain.dart index 86acbca7c..f57d210f3 100644 --- a/core/lib/utils/mail/domain.dart +++ b/core/lib/utils/mail/domain.dart @@ -1,33 +1,39 @@ +import 'dart:io'; + +import 'package:core/utils/app_logger.dart'; import 'package:equatable/equatable.dart'; class Domain with EquatableMixin { - static final RegExp _dashMatcher = RegExp(r"[-_]"); - static final RegExp _digitMatcher = RegExp(r"[0-9]"); - static final RegExp _partCharMatcher = RegExp(r"[a-zA-Z0-9\-._]"); + static final RegExp _dashMatcher = RegExp(r'[-_]'); + static final RegExp _digitMatcher = RegExp(r'\d'); + static final RegExp _partCharMatcher = RegExp(r'[A-Za-z0-9_\-.]'); static final Domain localhost = Domain.of('localhost'); static const int maximumDomainLength = 253; static String _removeBrackets(String domainName) { - if (!(domainName.startsWith("[") && domainName.endsWith("]"))) { + if (!(domainName.startsWith('[') && domainName.endsWith(']'))) { return domainName; } return domainName.substring(1, domainName.length - 1); } - static Domain of(String domain) { - assert( - domain.length <= maximumDomainLength, - 'Domain name length should not exceed $maximumDomainLength characters' - ); + static bool _allCharactersMatchRegex(String input, RegExp regex) { + for (int i = 0; i < input.length; i++) { + if (!regex.hasMatch(input[i])) { + return false; + } + } + return true; + } - String domainWithoutBrackets = _removeBrackets(domain); - assert( - _partCharMatcher - .allMatches(domainWithoutBrackets) - .every((match) => match.group(0) != null), - 'Domain parts ASCII chars must be a-z A-Z 0-9 - or _' - ); + static Domain of(String? domain) { + assert(domain != null, 'Domain can not be null'); + assert(domain!.isNotEmpty, 'Domain can not be empty'); + assert(domain!.length <= maximumDomainLength, 'Domain name length should not exceed $maximumDomainLength characters'); + + String domainWithoutBrackets = _removeBrackets(domain!); + assert(_allCharactersMatchRegex(domainWithoutBrackets, _partCharMatcher), 'Domain parts ASCII chars must be a-z A-Z 0-9 - or _'); int pos = 0; int nextDot = domainWithoutBrackets.indexOf('.'); @@ -42,17 +48,15 @@ class Domain with EquatableMixin { } _assertValidPart(domainWithoutBrackets, pos, domainWithoutBrackets.length); _assertValidLastPart(domainWithoutBrackets, pos); + return Domain._(domainWithoutBrackets); } static void _assertValidPart(String domainPart, int begin, int end) { assert(begin != end, "Domain part should not be empty"); - assert(!_dashMatcher.hasMatch(domainPart[begin]), - "Domain part should not start with '-' or '_'"); - assert(!_dashMatcher.hasMatch(domainPart[end - 1]), - "Domain part should not end with '-' or '_'"); - assert( - end - begin <= 63, "Domain part should not not exceed 63 characters"); + assert(!_dashMatcher.hasMatch(domainPart[begin]), "Domain part should not start with '-' or '_'"); + assert(!_dashMatcher.hasMatch(domainPart[end - 1]), "Domain part should not end with '-' or '_'"); + assert(end - begin <= 63, "Domain part should not not exceed 63 characters"); } static void _assertValidLastPart(String domainPart, int pos) { @@ -63,9 +67,10 @@ class Domain with EquatableMixin { static bool _validIPAddress(String value) { try { - Uri.parseIPv6Address(value); + InternetAddress(value); return true; } catch (e) { + logError('Domain::validIPAddress: Exception = $e'); return false; } } @@ -73,16 +78,11 @@ class Domain with EquatableMixin { final String domainName; final String normalizedDomainName; - Domain._(this.domainName) - : normalizedDomainName = _removeBrackets(domainName.toLowerCase()); + Domain._(this.domainName) : normalizedDomainName = _removeBrackets(domainName.toLowerCase()); - String name() { - return domainName; - } + String name() => domainName; - String asString() { - return normalizedDomainName; - } + String asString() => normalizedDomainName; @override bool operator ==(Object other) { @@ -104,4 +104,4 @@ class Domain with EquatableMixin { @override List get props => [domainName]; -} +} \ No newline at end of file diff --git a/core/lib/utils/mail/mail_address.dart b/core/lib/utils/mail/mail_address.dart index f35b91e4b..a79122006 100644 --- a/core/lib/utils/mail/mail_address.dart +++ b/core/lib/utils/mail/mail_address.dart @@ -25,19 +25,15 @@ class MailAddress with EquatableMixin { MailAddress({required this.localPart, required this.domain}); - String asString() { - return '$localPart@${domain.asString()}'; - } - - String asPrettyString() { - return '<${asString()}>'; - } - - static MailAddress validate(String address) { + factory MailAddress.validateAddress(String address) { + log('MailAddress::validate: Address = $address'); String localPart; Domain domain; address = address.trim(); + if (address.isEmpty) { + throw AddressException('Addresses should not be empty'); + } int pos = 0; // Test if mail address has source routing information (RFC-821) and get rid of it!! @@ -96,7 +92,7 @@ class MailAddress with EquatableMixin { throw AddressException('No domain found at position ${pos + 1} in "$address"'); } } catch (e) { - log('MailAddress::validate: Exception = $e'); + logError('MailAddress::validate: Exception = $e'); if (e is AddressException) { rethrow; } else { @@ -114,11 +110,38 @@ class MailAddress with EquatableMixin { domain = _createDomain(domainSB.toString()); - log('MailAddress::validate: localPart = $localPart | domain = $domain'); - return MailAddress(localPart: localPart, domain: domain); } + factory MailAddress.validateLocalPartAndDomain({required String localPart, required dynamic domain}) { + if (domain is Domain) { + return MailAddress.validateAddress('$localPart@${domain.name()}'); + } else { + return MailAddress.validateAddress('$localPart@$domain'); + } + } + + String asString() { + return '$localPart@${domain.asString()}'; + } + + String asPrettyString() { + return '<${asString()}>'; + } + + Domain getDomain() { + return domain; + } + + String getLocalPart() { + return localPart; + } + + @override + String toString() { + return '$localPart@${domain.asString()}'; + } + static bool _haveDoubleDot(String localPart) { return localPart.contains('..'); } diff --git a/core/test/utils/domain_test.dart b/core/test/utils/domain_test.dart new file mode 100644 index 000000000..17c804dbe --- /dev/null +++ b/core/test/utils/domain_test.dart @@ -0,0 +1,79 @@ + +import 'package:core/utils/mail/domain.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Domain.of(args) should not be case sensitive', () { + expect(Domain.of('Domain'), equals(Domain.of('domain'))); + }); + + group('Domain.of(arg) should throw an AssertionError with a list of invalid domains', () { + final listDomainInValid = [ + 'domain\$bad.com', + '', + 'aab..ddd', + 'aab.cc.1com', + 'abc.abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcd.com', + 'domain\$bad.com', + 'domain/bad.com', + 'domain\\bad.com', + 'domain@bad.com', + 'domain@bad.com', + 'domain%bad.com', + '#domain.com', + 'bad-.com', + 'bad_.com', + '-bad.com', + 'bad_.com', + '[domain.tld', + 'domain.tld]', + 'a[aaa]a', + '[aaa]a', + 'a[aaa]', + '[]' + ]; + for (var arg in listDomainInValid) { + test(arg, () { + expect(() => Domain.of(arg), throwsA(const TypeMatcher())); + }); + } + }); + + group('Domain.of(arg) should not throw any exceptions with the list of valid domains', () { + final listDomainValid = [ + '127.0.0.1', + 'domain.tld', + 'do-main.tld', + 'do_main.tld', + 'ab.dc.de.fr', + '123.456.789.a23', + 'acv.abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc.fr', + 'ab--cv.fr', + 'ab__cd.fr', + 'domain', + '[domain]', + '127.0.0.1' + ]; + for (var arg in listDomainValid) { + test(arg, () { + expect(() => Domain.of(arg), returnsNormally); + }); + } + }); + + test('Domain.of(args) should remove brackets', () { + expect(Domain.of('[domain]'), equals(Domain.of('domain'))); + }); + + test('Domain.of(args) should throw AssertionError when args is null', () { + expect(() => Domain.of(null), throwsA(const TypeMatcher())); + }); + + test('Domain.of(args) should allow 253 long domain', () { + expect(Domain.of('${'aaaaaaaaa.' * 25}aaa').domainName.length, 253); + }); + + test('Domain.of(args) should throw AssertionError when too long', () { + expect(() => Domain.of('a' * 254), throwsA(const TypeMatcher())); + }); +} diff --git a/core/test/utils/mail_address_test.dart b/core/test/utils/mail_address_test.dart index 7df98548a..2265458a8 100644 --- a/core/test/utils/mail_address_test.dart +++ b/core/test/utils/mail_address_test.dart @@ -1,20 +1,85 @@ import 'package:core/domain/exceptions/address_exception.dart'; +import 'package:core/utils/mail/domain.dart'; import 'package:core/utils/mail/mail_address.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('MailAddress test', () { - test('validate method should be return MailAddress when address valid', () { - String validAddress = 'user@example.com'; - MailAddress mailAddress = MailAddress.validate(validAddress); + const String GOOD_LOCAL_PART = "\"quoted@local part\""; + const String GOOD_QUOTED_LOCAL_PART = "\"quoted@local part\"@james.apache.org"; + const String GOOD_ADDRESS = "server-dev@james.apache.org"; + final Domain GOOD_DOMAIN = Domain.of("james.apache.org"); + + final List goodAddresses = [ + GOOD_ADDRESS, + GOOD_QUOTED_LOCAL_PART, + "server-dev@james-apache.org", + "server-dev@[127.0.0.1]", + "server.dev@james.apache.org", + "\\.server-dev@james.apache.org", + "Abc@10.42.0.1", + "Abc.123@example.com", + "user+mailbox/department=shipping@example.com", + "user+mailbox@example.com", + "\"Abc@def\"@example.com", + "\"Fred Bloggs\"@example.com", + "\"Joe.\\Blow\"@example.com", + "!#\$%&'*+-/=?^_`.{|}~@example.com" + ]; + + final List badAddresses = [ + "", + "server-dev", + "server-dev@", + "[]", + "server-dev@[]", + "server-dev@#", + "quoted local-part@james.apache.org", + "quoted@local-part@james.apache.org", + "local-part.@james.apache.org", + ".local-part@james.apache.org", + "local-part@.james.apache.org", + "local-part@james.apache.org.", + "local-part@james.apache..org", + "server-dev@-james.apache.org", + "server-dev@james.apache.org-", + "server-dev@#james.apache.org", + "server-dev@#123james.apache.org", + "server-dev@#-123.james.apache.org", + "server-dev@james. apache.org", + "server-dev@james\\.apache.org", + "server-dev@[300.0.0.1]", + "server-dev@[127.0.1]", + "server-dev@[0127.0.0.1]", + "server-dev@[127.0.1.1a]", + "server-dev@[127\\.0.1.1]", + "server-dev@#123", + "server-dev@#123.apache.org", + "server-dev@[127.0.1.1.1]", + "server-dev@[127.0.1.-1]", + "\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it + "server-dev\\.@james.apache.org", // jakarta.mail is unable to handle this so we better reject it + "a..b@domain.com", + // According to wikipedia these addresses are valid but as jakarta.mail is unable + // to work with them we shall rather reject them (note that this is not breaking retro-compatibility) + "Loïc.Accentué@voilà.fr8", + "pelé@exemple.com", + "δοκιμή@παράδειγμα.δοκιμή", + "我買@屋企.香港", + "二ノ宮@黒川.日本", + "медведь@с-балалайкой.рф", + "संपर्क@डाटामेल.भारत" + ]; + + group('MailAddress simple test', () { + test('MailAddress.validateAddress() should be return MailAddress when address valid', () { + MailAddress mailAddress = MailAddress.validateAddress('user@example.com'); expect(mailAddress.localPart, equals('user')); expect(mailAddress.domain.name(), equals('example.com')); }); - test('validate method should be throw AddressException when address missing @', () { - String invalidAddress = 'userexample.com'; + test('MailAddress.validateAddress() should be throw AddressException when address missing @', () { expect( - () => MailAddress.validate(invalidAddress), + () => MailAddress.validateAddress('userexample.com'), throwsA(isA().having( (e) => e.message, 'message', @@ -23,10 +88,9 @@ void main() { ); }); - test('validate method should be throw AddressException when address empty local-part', () { - String invalidAddress = '@example.com'; + test('MailAddress.validateAddress() should be throw AddressException when address empty local-part', () { expect( - () => MailAddress.validate(invalidAddress), + () => MailAddress.validateAddress('@example.com'), throwsA(isA().having( (e) => e.message, 'message', @@ -35,10 +99,9 @@ void main() { ); }); - test('validate method should be throw AddressException when address empty domain', () { - String invalidAddress = 'user@'; + test('MailAddress.validateAddress() should be throw AddressException when address empty domain', () { expect( - () => MailAddress.validate(invalidAddress), + () => MailAddress.validateAddress('user@'), throwsA(isA().having( (e) => e.message, 'message', @@ -47,10 +110,9 @@ void main() { ); }); - test('validate method should be throw AddressException when address with a hyphen "-"', () { - String invalidAddress = 'user@-example.com'; + test('MailAddress.validateAddress() should be throw AddressException when address with a hyphen "-"', () { expect( - () => MailAddress.validate(invalidAddress), + () => MailAddress.validateAddress('user@-example.com'), throwsA(isA().having( (e) => e.message, 'message', @@ -59,4 +121,49 @@ void main() { ); }); }); + + group('MailAddress advanced test', () { + group('MailAddress.validateAddress() should not throw any exceptions with the list of good address', () { + for (var arg in goodAddresses) { + test(arg, () { + expect(() => MailAddress.validateAddress(arg), returnsNormally); + }); + } + }); + + group('MailAddress.validateAddress() should throw an AddressException with a list of bad address', () { + for (var arg in badAddresses) { + test(arg, () { + expect(() => MailAddress.validateAddress(arg), throwsA(const TypeMatcher())); + }); + } + }); + + test('MailAddress.validateLocalPartAndDomain() should not throw any exceptions with good address have LocalPart and Domain', () { + expect(() => MailAddress.validateLocalPartAndDomain(localPart: 'local-part', domain: 'domain'), returnsNormally); + }); + + test('MailAddress.validateLocalPartAndDomain() should throw an AddressException with bad address have LocalPart and Domain', () { + expect(() => MailAddress.validateLocalPartAndDomain(localPart: 'local-part', domain: '-domain'), throwsA(const TypeMatcher())); + }); + + test('MailAddress.validateAddress() should not throw any exceptions with mail address is GOOD_QUOTED_LOCAL_PART', () { + expect(() => MailAddress.validateAddress(GOOD_QUOTED_LOCAL_PART), returnsNormally); + }); + + test('MailAddress.getDomain() should return GOOD_DOMAIN with address is GOOD_ADDRESS', () { + final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS); + expect(mailAddress.getDomain(), equals(GOOD_DOMAIN)); + }); + + test('MailAddress.getLocalPart() should return GOOD_LOCAL_PART with address is GOOD_QUOTED_LOCAL_PART', () { + final mailAddress = MailAddress.validateAddress(GOOD_QUOTED_LOCAL_PART); + expect(mailAddress.getLocalPart(), equals(GOOD_LOCAL_PART)); + }); + + test('MailAddress.toString() should return GOOD_ADDRESS with address is GOOD_ADDRESS', () { + final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS); + expect(mailAddress.toString(), equals(GOOD_ADDRESS)); + }); + }); } \ No newline at end of file diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index 63877adc2..d4eb18845 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -77,7 +77,7 @@ class EmailUtils { static bool isEmailAddressValid(String address) { try { - return GetUtils.isEmail(address) && MailAddress.validate(address).asString().isNotEmpty; + return GetUtils.isEmail(address) && MailAddress.validateAddress(address).asString().isNotEmpty; } catch(e) { logError('EmailUtils::isEmailAddressValid: Exception = $e'); return false;