From dbbbf7d8568b021fd6229d50d45dc44ee7bf9129 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 19 Mar 2024 00:54:21 +0700 Subject: [PATCH] TF-2667 Validate email in recipient form before send email --- core/lib/core.dart | 2 + .../domain/exceptions/address_exception.dart | 13 + core/lib/utils/mail/domain.dart | 107 +++++ core/lib/utils/mail/mail_address.dart | 398 ++++++++++++++++++ core/test/utils/mail_address_test.dart | 62 +++ .../presentation/composer_controller.dart | 3 +- .../widgets/recipient_tag_item_widget.dart | 5 +- .../email/presentation/utils/email_utils.dart | 11 +- 8 files changed, 597 insertions(+), 4 deletions(-) create mode 100644 core/lib/domain/exceptions/address_exception.dart create mode 100644 core/lib/utils/mail/domain.dart create mode 100644 core/lib/utils/mail/mail_address.dart create mode 100644 core/test/utils/mail_address_test.dart diff --git a/core/lib/core.dart b/core/lib/core.dart index b25e073b8..9bc6ca392 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -46,6 +46,8 @@ export 'utils/option_param_mixin.dart'; export 'utils/print_utils.dart'; export 'utils/broadcast_channel/broadcast_channel.dart'; export 'utils/list_utils.dart'; +export 'utils/mail/domain.dart'; +export 'utils/mail/mail_address.dart'; // Views export 'presentation/views/text/slogan_builder.dart'; diff --git a/core/lib/domain/exceptions/address_exception.dart b/core/lib/domain/exceptions/address_exception.dart new file mode 100644 index 000000000..51889bbf7 --- /dev/null +++ b/core/lib/domain/exceptions/address_exception.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; + +class AddressException with EquatableMixin implements Exception { + final String message; + + AddressException(this.message); + + @override + String toString() => message; + + @override + List get props => [message]; +} diff --git a/core/lib/utils/mail/domain.dart b/core/lib/utils/mail/domain.dart new file mode 100644 index 000000000..86acbca7c --- /dev/null +++ b/core/lib/utils/mail/domain.dart @@ -0,0 +1,107 @@ +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 Domain localhost = Domain.of('localhost'); + static const int maximumDomainLength = 253; + + static String _removeBrackets(String domainName) { + 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' + ); + + 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 _' + ); + + int pos = 0; + int nextDot = domainWithoutBrackets.indexOf('.'); + + while (nextDot > -1) { + if (pos + 1 > domainWithoutBrackets.length) { + throw ArgumentError('Last domain part should not be empty'); + } + _assertValidPart(domainWithoutBrackets, pos, nextDot); + pos = nextDot + 1; + nextDot = domainWithoutBrackets.indexOf('.', pos); + } + _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"); + } + + static void _assertValidLastPart(String domainPart, int pos) { + bool onlyDigits = _digitMatcher.hasMatch(domainPart[pos]); + bool invalid = onlyDigits && !_validIPAddress(domainPart); + assert(!invalid, "The last domain part must not start with 0-9"); + } + + static bool _validIPAddress(String value) { + try { + Uri.parseIPv6Address(value); + return true; + } catch (e) { + return false; + } + } + + final String domainName; + final String normalizedDomainName; + + Domain._(this.domainName) + : normalizedDomainName = _removeBrackets(domainName.toLowerCase()); + + String name() { + return domainName; + } + + String asString() { + return normalizedDomainName; + } + + @override + bool operator ==(Object other) { + if (other is Domain) { + return normalizedDomainName == other.normalizedDomainName; + } + return false; + } + + @override + int get hashCode { + return normalizedDomainName.hashCode; + } + + @override + String toString() { + return "Domain : $domainName"; + } + + @override + List get props => [domainName]; +} diff --git a/core/lib/utils/mail/mail_address.dart b/core/lib/utils/mail/mail_address.dart new file mode 100644 index 000000000..f35b91e4b --- /dev/null +++ b/core/lib/utils/mail/mail_address.dart @@ -0,0 +1,398 @@ +import 'package:core/domain/exceptions/address_exception.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/mail/domain.dart'; +import 'package:equatable/equatable.dart'; + +class MailAddress with EquatableMixin { + static final List SPECIAL = [ + '<', + '>', + '(', + ')', + '[', + ']', + '\\', + '.', + ',', + ';', + ':', + '@', + '\"' + ]; + + final String localPart; + final Domain domain; + + MailAddress({required this.localPart, required this.domain}); + + String asString() { + return '$localPart@${domain.asString()}'; + } + + String asPrettyString() { + return '<${asString()}>'; + } + + static MailAddress validate(String address) { + String localPart; + Domain domain; + + address = address.trim(); + int pos = 0; + + // Test if mail address has source routing information (RFC-821) and get rid of it!! + // must be called first!! (or at least prior to updating pos) + _stripSourceRoute(address, pos); + + StringBuffer localPartSB = StringBuffer(); + StringBuffer domainSB = StringBuffer(); + // Begin parsing + // ::= "@" + + try { + // parse local-part + // ::= | + if (address[pos] == '\"') { + pos = _parseQuotedLocalPartOrThrowException(localPartSB, address, pos); + } else { + pos = parseUnquotedLocalPartOrThrowException(localPartSB, address, pos); + } + // find @ + if (pos >= address.length || address[pos] != '@') { + throw AddressException('Did not find @ between local-part and domain at position ${pos + 1} in "$address"'); + } + pos++; + // parse domain + // ::= | "." + // ::= | "#" | "[" "]" + while (true) { + if (pos >= address.length) { + break; + } + var postChar = address[pos]; + if (postChar == '#') { + pos = _parseNumber(domainSB, address, pos); + } else if (postChar == '[') { + pos = _parseDomainLiteral(domainSB, address, pos); + } else { + pos = _parseDomain(domainSB, address, pos); + } + if (pos >= address.length) { + break; + } + postChar = address[pos]; + if (postChar == '.') { + var lastChar = address[pos - 1]; + if (lastChar == '@' || lastChar == '.') { + throw AddressException('Subdomain expected before "." or duplicate "." in "address"'); + } + domainSB.write('.'); + pos++; + continue; + } + break; + } + if (domainSB.length == 0) { + throw AddressException('No domain found at position ${pos + 1} in "$address"'); + } + } catch (e) { + log('MailAddress::validate: Exception = $e'); + if (e is AddressException) { + rethrow; + } else { + throw AddressException('Out of data at position ${pos + 1} in "$address"'); + } + } + + localPart = localPartSB.toString(); + + if (localPart.startsWith('.') || + localPart.endsWith('.') || + _haveDoubleDot(localPart)) { + throw AddressException('Addresses cannot start end with "." or contain two consecutive dots'); + } + + domain = _createDomain(domainSB.toString()); + + log('MailAddress::validate: localPart = $localPart | domain = $domain'); + + return MailAddress(localPart: localPart, domain: domain); + } + + static bool _haveDoubleDot(String localPart) { + return localPart.contains('..'); + } + + static Domain _createDomain(String domain) { + try { + return Domain.of(domain); + } catch (e) { + throw AddressException(e.toString()); + } + } + + static int _parseNumber(StringBuffer dSB, String address, int pos) { + // ::= | + + // we were passed the string with pos pointing the the # char. + // take the first char (#), put it in the result buffer and increment pos + var postChar = address[pos]; + dSB.write(postChar); + pos++; + // We keep the position from the class level pos field + while (true) { + if (pos >= address.length) { + break; + } + // ::= any one of the ten digits 0 through 9 + var d = address[pos]; + if (d == '.') { + break; + } + if (d.compareTo('0') < 0 || d.compareTo('9') > 0) { + throw AddressException('In domain, did not find a number in # address at position ${pos + 1} in "$address"'); + } + dSB.write(d); + pos++; + } + if (dSB.length < 2) { + throw AddressException('In domain, did not find a number in # address at position ${pos + 1} in "$address"'); + } + return pos; + } + + static int _parseDomainLiteral(StringBuffer dSB, String address, int pos) { + // we were passed the string with pos pointing the the [ char. + // take the first char ([), put it in the result buffer and increment pos + var posChar = address[pos]; + dSB.write(posChar); + pos++; + + // ::= "." "." "." + for (int octet = 0; octet < 4; octet++) { + // ::= one, two, or three digits representing a decimal + // integer value in the range 0 through 255 + // ::= any one of the ten digits 0 through 9 + StringBuffer snumSB = StringBuffer(); + for (int digits = 0; digits < 3; digits++) { + String currentChar = address[pos]; + if (currentChar == '.' || currentChar == ']') { + break; + } else if (currentChar.compareTo('0') < 0 || + currentChar.compareTo('9') > 0) { + throw AddressException('Invalid number at position ${pos + 1} in "$address"'); + } + snumSB.write(currentChar); + pos++; + } + if (snumSB.length == 0) { + throw AddressException('Number not found at position ${pos + 1} in "$address"'); + } + try { + int snum = int.parse(snumSB.toString()); + if (snum > 255) { + throw AddressException('Invalid number at position ${pos + 1} in "$address"'); + } + } catch (e) { + throw AddressException('Invalid number at position ${pos + 1} in "$address"'); + } + dSB.write(snumSB.toString()); + var posChar = address[pos]; + if (posChar == ']') { + if (octet < 3) { + throw AddressException('End of number reached too quickly at ${pos + 1} in "$address"'); + } + break; + } + if (posChar == '.') { + dSB.write('.'); + pos++; + } + } + posChar = address[pos]; + if (posChar != ']') { + throw AddressException('Did not find closing bracket \"]\" in domain at position ${pos + 1} in "$address"'); + } + dSB.write(']'); + pos++; + return pos; + } + + static int _parseDomain(StringBuffer dSB, String address, int pos) { + StringBuffer resultSB = StringBuffer(); + // ::= + // ::= | + // ::= | + // ::= | | "-" + // ::= any one of the 52 alphabetic characters A through Z + // in upper case and a through z in lower case + // ::= any one of the ten digits 0 through 9 + + // basically, this is a series of letters, digits, and hyphens, + // but it can't start with a digit or hypthen + // and can't end with a hyphen + + // in practice though, we should relax this as domain names can start + // with digits as well as letters. So only check that doesn't start + // or end with hyphen. + while (true) { + if (pos >= address.length) { + break; + } + var ch = address[pos]; + if ((ch.compareTo('0') >= 0 && ch.compareTo('9') <= 0) || + (ch.compareTo('a') >= 0 && ch.compareTo('z') <= 0) || + (ch.compareTo('A') >= 0 && ch.compareTo('Z') <= 0) || + (ch == '-')) { + resultSB.write(ch); + pos++; + continue; + } + if (ch == '.') { + break; + } + throw AddressException('Invalid character at $pos in "$address"'); + } + String result = resultSB.toString(); + if (result.startsWith('-') || result.endsWith('-')) { + throw AddressException('Domain name cannot begin or end with a hyphen \"-\" at position ${pos + 1} in "$address"'); + } + dSB.write(result); + return pos; + } + + static int _stripSourceRoute(String address, int pos) { + var posChar = address[pos]; + if (pos < address.length && posChar == '@') { + int i = address.indexOf(':'); + if (i != -1) { + pos = i + 1; + } + } + return pos; + } + + static int _parseUnquotedLocalPart(StringBuffer lpSB, String address, int pos) { + // ::= | "." + bool lastCharDot = false; + while (true) { + if (pos >= address.length) { + break; + } + // ::= | + // ::= | "\" + var postChar = address[pos]; + if (postChar == '\\') { + lpSB.write('\\'); + pos++; + // ::= any one of the 128 ASCII characters (no exceptions) + var x = address[pos]; + if (x.codeUnitAt(0) < 0 || x.codeUnitAt(0) > 127) { + throw AddressException('Invalid \\ syntax character at position ${pos + 1} in "$address"'); + } + lpSB.write(x); + pos++; + lastCharDot = false; + } else if (postChar == '.') { + if (pos == 0) { + throw AddressException('Local part must not start with a "."'); + } + lpSB.write('.'); + pos++; + lastCharDot = true; + } else if (postChar == '@') { + // End of local-part + break; + } else { + // ::= any one of the 128 ASCII characters, but not any + // or + // ::= "<" | ">" | "(" | ")" | "[" | "]" | "\" | "." + // | "," | ";" | ":" | "@" """ | the control + // characters (ASCII codes 0 through 31 inclusive and + // 127) + // ::= the space character (ASCII code 32) + var c = address[pos]; + if (c.codeUnitAt(0) <= 31 || c.codeUnitAt(0) >= 127 || c == ' ') { + throw AddressException('Invalid character in local-part (user account) at position ${pos + 1} in "$address"'); + } + int i = 0; + while (i < SPECIAL.length) { + if (c == SPECIAL[i]) { + throw AddressException('Invalid character in local-part (user account) at position ${pos + 1} in "$address"'); + } + i++; + } + lpSB.write(c); + pos++; + lastCharDot = false; + } + } + if (lastCharDot) { + throw AddressException('local-part (user account) ended with a \".\", which is invalid in address "$address"'); + } + return pos; + } + + static int parseUnquotedLocalPartOrThrowException(StringBuffer localPartSB, String address, int pos) { + pos = _parseUnquotedLocalPart(localPartSB, address, pos); + if (localPartSB.length == 0) { + throw AddressException('No local-part (user account) found at position ${pos + 1} in "$address"'); + } + return pos; + } + + static int _parseQuotedLocalPartOrThrowException(StringBuffer localPartSB, String address, int pos) { + pos = _parseQuotedLocalPart(localPartSB, address, pos); + if (localPartSB.length == 2) { + throw AddressException('No quoted local-part (user account) found at position ${pos + 2} in "$address"'); + } + return pos; + } + + static int _parseQuotedLocalPart(StringBuffer lpSB, String address, int pos) { + lpSB.write('\"'); + pos++; + // ::= """ """ + // ::= "\" | "\" | | + while (true) { + if (pos >= address.length) { + break; + } + var postChar = address[pos]; + if (postChar == '\"') { + lpSB.write('\"'); + // end of quoted string... move forward + pos++; + break; + } + if (postChar == '\\') { + lpSB.write('\\'); + pos++; + // ::= any one of the 128 ASCII characters (no exceptions) + var x = address[pos]; + if (x.codeUnitAt(0) < 0 || x.codeUnitAt(0) > 127) { + throw AddressException('Invalid \\ syntax character at position ${pos + 1} in "$address"'); + } + lpSB.write(x); + pos++; + } else { + // ::= any one of the 128 ASCII characters except , + // , quote ("), or backslash (\) + var q = address[pos]; + if (q.codeUnitAt(0) <= 0 || + q == '\n' || + q == '\r' || + q == '\"' || + q == '\\') { + throw AddressException('Unquoted local-part (user account) must be one of the 128 ASCII characters exception , , quote (\"), or backslash (\\) at position ${pos + 1} in "$address"'); + } + lpSB.write(q); + pos++; + } + } + return pos; + } + + @override + List get props => [localPart, domain]; +} diff --git a/core/test/utils/mail_address_test.dart b/core/test/utils/mail_address_test.dart new file mode 100644 index 000000000..7df98548a --- /dev/null +++ b/core/test/utils/mail_address_test.dart @@ -0,0 +1,62 @@ +import 'package:core/domain/exceptions/address_exception.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); + 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'; + expect( + () => MailAddress.validate(invalidAddress), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Did not find @ between local-part and domain at position 16 in "userexample.com"') + ) + ); + }); + + test('validate method should be throw AddressException when address empty local-part', () { + String invalidAddress = '@example.com'; + expect( + () => MailAddress.validate(invalidAddress), + throwsA(isA().having( + (e) => e.message, + 'message', + 'No local-part (user account) found at position 1 in "@example.com"') + ) + ); + }); + + test('validate method should be throw AddressException when address empty domain', () { + String invalidAddress = 'user@'; + expect( + () => MailAddress.validate(invalidAddress), + throwsA(isA().having( + (e) => e.message, + 'message', + 'No domain found at position 6 in "user@"') + ) + ); + }); + + test('validate method should be throw AddressException when address with a hyphen "-"', () { + String invalidAddress = 'user@-example.com'; + expect( + () => MailAddress.validate(invalidAddress), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Domain name cannot begin or end with a hyphen "-" at position 14 in "user@-example.com"') + ) + ); + }); + }); +} \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index f9b7c4224..6d0758f93 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -68,6 +68,7 @@ import 'package:tmail_ui_user/features/email/domain/state/transform_html_email_c import 'package:tmail_ui_user/features/email/domain/usecases/get_email_content_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/transform_html_email_content_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/remove_composer_cache_on_web_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/draggable_app_state.dart'; @@ -721,7 +722,7 @@ class ComposerController extends BaseController with DragDropFileMixin { final allListEmailAddress = listToEmailAddress + listCcEmailAddress + listBccEmailAddress; final listEmailAddressInvalid = allListEmailAddress - .where((emailAddress) => !GetUtils.isEmail(emailAddress.emailAddress)) + .where((emailAddress) => !EmailUtils.isEmailAddressValid(emailAddress.emailAddress)) .toList(); if (listEmailAddressInvalid.isNotEmpty) { showConfirmDialogAction(context, diff --git a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart index 2fea5a509..57b981ed9 100644 --- a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart @@ -16,6 +16,7 @@ import 'package:tmail_ui_user/features/composer/presentation/model/draggable_ema import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_tag_item_widget_style.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_composer_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; class RecipientTagItemWidget extends StatelessWidget { @@ -172,7 +173,7 @@ class RecipientTagItemWidget extends StatelessWidget { Color _getTagBackgroundColor() { if (isLatestTagFocused && isLatestEmail) { return AppColor.colorItemRecipientSelected; - } else if (GetUtils.isEmail(currentEmailAddress.emailAddress)) { + } else if (EmailUtils.isEmailAddressValid(currentEmailAddress.emailAddress)) { return AppColor.colorEmailAddressTag; } else { return Colors.white; @@ -182,7 +183,7 @@ class RecipientTagItemWidget extends StatelessWidget { BorderSide _getTagBorderSide() { if (isLatestTagFocused && isLatestEmail) { return const BorderSide(width: 1, color: AppColor.primaryColor); - } else if (GetUtils.isEmail(currentEmailAddress.emailAddress)) { + } else if (EmailUtils.isEmailAddressValid(currentEmailAddress.emailAddress)) { return const BorderSide(width: 1, color: AppColor.colorEmailAddressTag); } else { return const BorderSide( diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index 6b8bca98c..f75e85f7a 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -68,7 +68,16 @@ class EmailUtils { required String internalDomain }) { log('EmailUtils::isSameDomain: emailAddress = $emailAddress | internalDomain = $internalDomain'); - return GetUtils.isEmail(emailAddress) && + return EmailUtils.isEmailAddressValid(emailAddress) && emailAddress.split('@').last.toLowerCase() == internalDomain.toLowerCase(); } + + static bool isEmailAddressValid(String address) { + try { + return GetUtils.isEmail(address) && MailAddress.validate(address).asString().isNotEmpty; + } catch(e) { + logError('EmailUtils::isEmailAddressValid: Exception = $e'); + return false; + } + } } \ No newline at end of file