From 45581e4177c5ef72fe14b9148fbf74f0fb5ada42 Mon Sep 17 00:00:00 2001 From: DatDang Date: Fri, 25 Oct 2024 11:28:59 +0700 Subject: [PATCH] TF-3221 Handle mailto with additional cc and bcc --- ...ailto-uri-chemes-to-interact-twake-mail.md | 4 +- .../presentation/composer_controller.dart | 17 ++- .../controller/single_email_controller.dart | 20 ++- .../model/composer_arguments.dart | 33 +++- .../presentation/mailbox_controller.dart | 2 + .../presentation/mailto_url_controller.dart | 2 +- lib/main/routes/navigation_router.dart | 6 + lib/main/routes/route_utils.dart | 126 +++++++++++----- test/main/routes/route_utils_test.dart | 141 ++++++++++++++++++ 9 files changed, 292 insertions(+), 59 deletions(-) diff --git a/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md b/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md index 1f75e8081..2faa80542 100644 --- a/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md +++ b/docs/adr/0036-mailto-uri-chemes-to-interact-twake-mail.md @@ -18,13 +18,13 @@ Summary of URI schemes that can interact with Twake Mail: - `/mailto?uri=user@example.com` - `/mailto/?uri=mailto:user@example.com&subject=TwakeMail&body=HelloWorld` - - `/mailto/?uri=mailto:user1@example.com,user2@example.com,user3@example.com&subject=TwakeMail&body=HelloWorld` + - `/mailto/?uri=mailto:user1@example.com,user2@example.com,user3@example.com&to=user1@example.com,user2@example.com,user3@example.com&cc=user1@example.com,user2@example.com,user3@example.com&bcc=user1@example.com,user2@example.com,user3@example.com&subject=TwakeMail&body=HelloWorld` 2. URI scheme encoded - `%2Fmailto%3Furi%3Duser%40example.com` - `%2Fmailto%2F%3Furi%3Dmailto%3Auser%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` - - `%2Fmailto%2F%3Furi%3Dmailto%3Auser1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` + - `%2Fmailto%2F%3Furi%3Dmailto%3Auser1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26to=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26cc=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26bcc=user1%40example.com%2Cuser2%40example.com%2Cuser3%40example.com%26subject%3DTwakeMail%26body%3DHelloWorld` ## Consequences diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index 55a71420e..4e843153d 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -629,6 +629,16 @@ class ComposerController extends BaseController isInitialRecipient.value = true; toAddressExpandMode.value = ExpandMode.COLLAPSE; } + if (arguments.cc?.isNotEmpty == true) { + listCcEmailAddress = arguments.cc!; + ccRecipientState.value = PrefixRecipientState.enabled; + ccAddressExpandMode.value = ExpandMode.COLLAPSE; + } + if (arguments.bcc?.isNotEmpty == true) { + bccRecipientState.value = PrefixRecipientState.enabled; + bccAddressExpandMode.value = ExpandMode.COLLAPSE; + listBccEmailAddress = arguments.bcc!; + } _getEmailContentFromMailtoUri(arguments.body ?? ''); _updateStatusEmailSendButton(); break; @@ -1729,10 +1739,15 @@ class ComposerController extends BaseController if (bccRecipientState.value == PrefixRecipientState.disabled) { bccRecipientState.value = PrefixRecipientState.enabled; } - listBccEmailAddress = listEmailAddress.toList(); + if (composerArguments.value?.emailActionType == EmailActionType.composeFromMailtoUri) { + listBccEmailAddress = {...listEmailAddress, ...?composerArguments.value?.bcc}.toList(); + } else { + listBccEmailAddress = listEmailAddress.toList(); + } toAddressExpandMode.value = ExpandMode.COLLAPSE; ccAddressExpandMode.value = ExpandMode.COLLAPSE; bccAddressExpandMode.value = ExpandMode.COLLAPSE; + bccAddressExpandMode.refresh(); _updateStatusEmailSendButton(); } diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 2b9e09d76..e40e78fca 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -1159,13 +1159,21 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } Future openMailToLink(Uri? uri) async { + if (uri == null) return; + + final navigationRouter = RouteUtils.generateNavigationRouterFromMailtoLink(uri.toString()); log('SingleEmailController::openMailToLink(): ${uri.toString()}'); - String address = uri?.path ?? ''; - log('SingleEmailController::openMailToLink(): address: $address'); - if (address.isNotEmpty) { - final emailAddress = EmailAddress(null, address); - mailboxDashBoardController.goToComposer(ComposerArguments.fromEmailAddress(emailAddress)); - } + if (!RouteUtils.canOpenComposerFromNavigationRouter(navigationRouter)) return; + + mailboxDashBoardController.goToComposer( + ComposerArguments.fromMailtoUri( + listEmailAddress: navigationRouter.listEmailAddress, + cc: navigationRouter.cc, + bcc: navigationRouter.bcc, + subject: navigationRouter.subject, + body: navigationRouter.body + ) + ); } void deleteEmailPermanently(BuildContext context, PresentationEmail email) { diff --git a/lib/features/email/presentation/model/composer_arguments.dart b/lib/features/email/presentation/model/composer_arguments.dart index fc9b9f281..479d0e617 100644 --- a/lib/features/email/presentation/model/composer_arguments.dart +++ b/lib/features/email/presentation/model/composer_arguments.dart @@ -30,6 +30,8 @@ class ComposerArguments extends RouterArguments { final List? inlineImages; final bool? hasRequestReadReceipt; final ScreenDisplayMode displayMode; + final List? cc; + final List? bcc; ComposerArguments({ this.emailActionType = EmailActionType.compose, @@ -49,7 +51,9 @@ class ComposerArguments extends RouterArguments { this.selectedIdentityId, this.inlineImages, this.hasRequestReadReceipt, - this.displayMode = ScreenDisplayMode.normal + this.displayMode = ScreenDisplayMode.normal, + this.cc, + this.bcc, }); factory ComposerArguments.fromSendingEmail(SendingEmail sendingEmail) => @@ -76,13 +80,20 @@ class ComposerArguments extends RouterArguments { listEmailAddress: [emailAddress] ); - factory ComposerArguments.fromMailtoUri({List? listEmailAddress, String? subject, String? body}) => - ComposerArguments( - emailActionType: EmailActionType.composeFromMailtoUri, - listEmailAddress: listEmailAddress, - subject: subject, - body: body, - ); + factory ComposerArguments.fromMailtoUri({ + List? listEmailAddress, + String? subject, + String? body, + List? cc, + List? bcc + }) => ComposerArguments( + emailActionType: EmailActionType.composeFromMailtoUri, + listEmailAddress: listEmailAddress, + subject: subject, + body: body, + cc: cc, + bcc: bcc, + ); factory ComposerArguments.editDraftEmail(PresentationEmail presentationEmail) => ComposerArguments( @@ -193,6 +204,8 @@ class ComposerArguments extends RouterArguments { inlineImages, hasRequestReadReceipt, displayMode, + cc, + bcc, ]; ComposerArguments copyWith({ @@ -214,6 +227,8 @@ class ComposerArguments extends RouterArguments { List? inlineImages, bool? hasRequestReadReceipt, ScreenDisplayMode? displayMode, + List? cc, + List? bcc, }) { return ComposerArguments( emailActionType: emailActionType ?? this.emailActionType, @@ -234,6 +249,8 @@ class ComposerArguments extends RouterArguments { inlineImages: inlineImages ?? this.inlineImages, hasRequestReadReceipt: hasRequestReadReceipt ?? this.hasRequestReadReceipt, displayMode: displayMode ?? this.displayMode, + cc: cc ?? this.cc, + bcc: bcc ?? this.bcc, ); } } diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index 62b902710..b0501879e 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -460,6 +460,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM mailboxDashBoardController.goToComposer( ComposerArguments.fromMailtoUri( listEmailAddress: _navigationRouter?.listEmailAddress, + cc: _navigationRouter?.cc, + bcc: _navigationRouter?.bcc, subject: _navigationRouter?.subject, body: _navigationRouter?.body ) diff --git a/lib/features/mailto/presentation/mailto_url_controller.dart b/lib/features/mailto/presentation/mailto_url_controller.dart index a999b7e19..29d3e1f13 100644 --- a/lib/features/mailto/presentation/mailto_url_controller.dart +++ b/lib/features/mailto/presentation/mailto_url_controller.dart @@ -28,7 +28,7 @@ class MailtoUrlController extends ReloadableController { if (parameters.containsKey('uri')) { final mailtoArgument = MailtoArguments( session: session, - mailtoUri: parameters['uri'] + mailtoUri: Uri.base.toString(), ); popAndPush( RouteUtils.generateNavigationRoute(AppRoutes.dashboard), diff --git a/lib/main/routes/navigation_router.dart b/lib/main/routes/navigation_router.dart index 50c120b6b..9d43beeaf 100644 --- a/lib/main/routes/navigation_router.dart +++ b/lib/main/routes/navigation_router.dart @@ -21,6 +21,8 @@ class NavigationRouter with EquatableMixin { final String? subject; final String? body; final AccountMenuItem accountMenuItem; + final List? cc; + final List? bcc; NavigationRouter({ this.emailId, @@ -32,6 +34,8 @@ class NavigationRouter with EquatableMixin { this.subject, this.body, this.accountMenuItem = AccountMenuItem.none, + this.cc, + this.bcc, }); factory NavigationRouter.initial() => NavigationRouter(); @@ -47,5 +51,7 @@ class NavigationRouter with EquatableMixin { subject, body, accountMenuItem, + cc, + bcc, ]; } \ No newline at end of file diff --git a/lib/main/routes/route_utils.dart b/lib/main/routes/route_utils.dart index 3f4e03f1e..28e25356e 100644 --- a/lib/main/routes/route_utils.dart +++ b/lib/main/routes/route_utils.dart @@ -25,8 +25,12 @@ abstract class RouteUtils { static const String paramMailtoAddress = 'mailtoAddress'; static const String paramSubject = 'subject'; static const String paramBody = 'body'; + static const String paramTo = 'to'; + static const String paramCc = 'cc'; + static const String paramBcc = 'bcc'; - static const String mailtoPrefix = 'mailto:'; + static const String mailtoPrefix = 'mailto'; + static const String uriPrefix = 'uri'; static const String ADDRESS_SEPARATOR = ','; static const String INVALID_VALUE = 'invalid'; @@ -107,6 +111,8 @@ abstract class RouteUtils { final queryParam = parameters[paramQuery]; final routeName = parameters[paramRouteName]; final mailtoAddress = parameters[paramMailtoAddress]; + final mailtoCc = parameters[paramCc]; + final mailtoBcc = parameters[paramBcc]; final subject = parameters[paramSubject]; final body = parameters[paramBody]; @@ -116,18 +122,11 @@ abstract class RouteUtils { final dashboardType = DashboardType.values.firstWhereOrNull((type) => type.name == typeParam) ?? DashboardType.normal; final settingType = AccountMenuItem.values.firstWhereOrNull((type) => type.getAliasBrowser() == typeParam) ?? AccountMenuItem.none; List? listEmailAddress; - if (mailtoAddress is List) { - listEmailAddress = mailtoAddress - .map((address) => EmailAddress( - null, - GetUtils.isEmail(address) ? address : INVALID_VALUE - )) - .toList(); - } else if (mailtoAddress is String) { - listEmailAddress = [ - EmailAddress(null, GetUtils.isEmail(mailtoAddress) ? mailtoAddress : INVALID_VALUE) - ]; - } + List? cc; + List? bcc; + listEmailAddress = _emailAddressesFromMailtoAddress(mailtoAddress); + cc = _emailAddressesFromMailtoAddress(mailtoCc); + bcc = _emailAddressesFromMailtoAddress(mailtoBcc); log('RouteUtils::parsingRouteParametersToNavigationRouter:listEmailAddress = $listEmailAddress'); return NavigationRouter( emailId: emailId, @@ -136,12 +135,27 @@ abstract class RouteUtils { dashboardType: dashboardType, routeName: routeName, listEmailAddress: listEmailAddress, + cc: cc, + bcc: bcc, subject: subject, body: body, accountMenuItem: settingType, ); } + static List? _emailAddressesFromMailtoAddress(dynamic mailtoAddress) { + if (mailtoAddress is List) { + return mailtoAddress + .map((address) => EmailAddress(null, address)) + .toList(); + } else if (mailtoAddress is String) { + return [ + EmailAddress(null, mailtoAddress) + ]; + } + return null; + } + static void replaceBrowserHistory({required String title, required Uri url}) { log('RouteUtils::replaceBrowserHistory(): title: $title | url: $url'); html.window.history.replaceState(null, title, url.toString()); @@ -152,37 +166,59 @@ abstract class RouteUtils { final mapMailto = { RouteUtils.paramRouteName: AppRoutes.mailtoURL, }; - if (mailtoUri?.startsWith(mailtoPrefix) == true) { - final mailtoUrlDecoded = Uri.decodeFull(mailtoUri!); - log('RouteUtils::parseMapMailtoFromUri:mailtoUrlDecoded = $mailtoUrlDecoded'); - final uri = Uri.tryParse(mailtoUrlDecoded); - if (uri == null) return mapMailto; + mailtoUri = mailtoUri == null ? null : Uri.decodeFull(mailtoUri); + final parsedMailToUri = Uri.tryParse(mailtoUri ?? ''); - final mailtoAddress = uri.path; - final mapQueryParam = uri.queryParameters; - - if (mailtoAddress.contains(ADDRESS_SEPARATOR)) { - final listAddress = mailtoAddress.split(ADDRESS_SEPARATOR); - log('RouteUtils::parseMapMailtoFromUri:listAddress = $listAddress'); - mapMailto[paramMailtoAddress] = listAddress; - } else { - log('RouteUtils::parseMapMailtoFromUri:mailtoAddress = $mailtoAddress'); - mapMailto[paramMailtoAddress] = mailtoAddress; - } - if (mapQueryParam.containsKey(paramSubject)) { - mapMailto[paramSubject] = mapQueryParam[paramSubject]; - } - if (mapQueryParam.containsKey(paramBody)) { - mapMailto[paramBody] = mapQueryParam[paramBody]; - } - } else if (mailtoUri != null) { - final mailtoUrlDecoded = Uri.decodeFull(mailtoUri); - log('RouteUtils::parseMapMailtoFromUri:mailtoUrlDecoded = $mailtoUrlDecoded'); - mapMailto[paramMailtoAddress] = mailtoUrlDecoded; + if (parsedMailToUri?.scheme == mailtoPrefix) { + final to = { + ...?parsedMailToUri?.path.split(ADDRESS_SEPARATOR), + ...?parsedMailToUri?.queryParameters[paramTo]?.split(ADDRESS_SEPARATOR) + }.toList(); + final cc = { + ...?parsedMailToUri?.queryParameters[paramCc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final bcc = { + ...?parsedMailToUri?.queryParameters[paramBcc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final subject = parsedMailToUri?.queryParameters[paramSubject]; + final body = parsedMailToUri?.queryParameters[paramBody]; + mapMailto[paramMailtoAddress] = to; + mapMailto[paramCc] = cc; + mapMailto[paramBcc] = bcc; + mapMailto[paramSubject] = subject; + mapMailto[paramBody] = body; + } else if (parsedMailToUri?.path == "/$mailtoPrefix" || parsedMailToUri?.path == "/$mailtoPrefix/") { + final to = { + ...?parsedMailToUri?.queryParameters[uriPrefix]?.split('$mailtoPrefix:').last.split(ADDRESS_SEPARATOR), + ...?parsedMailToUri?.queryParameters[paramTo]?.split(ADDRESS_SEPARATOR) + }.toList(); + final cc = { + ...?parsedMailToUri?.queryParameters[paramCc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final bcc = { + ...?parsedMailToUri?.queryParameters[paramBcc]?.split(ADDRESS_SEPARATOR), + }.toList(); + final subject = parsedMailToUri?.queryParameters[paramSubject]; + final body = parsedMailToUri?.queryParameters[paramBody]; + mapMailto[paramMailtoAddress] = to; + mapMailto[paramCc] = cc; + mapMailto[paramBcc] = bcc; + mapMailto[paramSubject] = subject; + mapMailto[paramBody] = body; } else { - mapMailto[paramMailtoAddress] = mailtoUri; + mapMailto[paramMailtoAddress] = mailtoUri?.split(ADDRESS_SEPARATOR); } - log('RouteUtils::parseMapMailtoFromUri:mapMailto: $mapMailto'); + + if (mapMailto[paramMailtoAddress]?.length == 1) { + mapMailto[paramMailtoAddress] = mapMailto[paramMailtoAddress].first; + } + + log('RouteUtils::parseMapMailtoFromUri:paramMailtoAddress = ${mapMailto[paramMailtoAddress]}'); + log('RouteUtils::parseMapMailtoFromUri:paramCc = ${mapMailto[paramCc]}'); + log('RouteUtils::parseMapMailtoFromUri:paramBcc = ${mapMailto[paramBcc]}'); + log('RouteUtils::parseMapMailtoFromUri:paramSubject = ${mapMailto[paramSubject]}'); + log('RouteUtils::parseMapMailtoFromUri:paramBody = ${mapMailto[paramBody]}'); + return mapMailto; } @@ -192,4 +228,12 @@ abstract class RouteUtils { log('RouteUtils::generateNavigationRouterFromMailtoLink:navigationRouter: $navigationRouter'); return navigationRouter; } + + static bool canOpenComposerFromNavigationRouter(NavigationRouter navigationRouter) { + return navigationRouter.listEmailAddress?.isNotEmpty == true + || navigationRouter.cc?.isNotEmpty == true + || navigationRouter.bcc?.isNotEmpty == true + || navigationRouter.subject?.isNotEmpty == true + || navigationRouter.body?.isNotEmpty == true; + } } \ No newline at end of file diff --git a/test/main/routes/route_utils_test.dart b/test/main/routes/route_utils_test.dart index 7e0f9ebd1..5227bbaa1 100644 --- a/test/main/routes/route_utils_test.dart +++ b/test/main/routes/route_utils_test.dart @@ -83,5 +83,146 @@ void main() { expect(result[RouteUtils.paramMailtoAddress], containsAll(['test@example.com', 'test2@example.com', 'test3@example.com'])); expect(result[RouteUtils.paramSubject], equals('Hello')); }); + + test( + 'should parse a valid mailto URI encoded contains every possible parameters', + () { + // arrange + const to1 = 'to1@example.com'; + const to2 = 'to2@example.com'; + const to3 = 'to3@example.com'; + const cc1 = 'cc1@example.com'; + const cc2 = 'cc2@example.com'; + const bcc1 = 'bcc1@example.com'; + const bcc2 = 'bcc2@example.com'; + const subject = 'Hello'; + const body = 'Bye'; + const mailtoSchemeUri = 'mailto:$to1,$to2' + '?to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathUri = 'https://example.com/mailto' + '?uri=$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathWithNestedMailtoUri = 'https://example.com/mailto/' + '?uri=mailto:$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + // act + final mailtoSchemeResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoSchemeUri)); + final mailtoPathResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoPathUri)); + final mailtoPathWithNestedMailtoResult = RouteUtils.parseMapMailtoFromUri( + Uri.encodeFull(mailtoPathWithNestedMailtoUri)); + + // assert + expect(mailtoSchemeResult, equals(mailtoPathResult)); + expect(mailtoSchemeResult, equals(mailtoPathWithNestedMailtoResult)); + expect(mailtoPathResult, equals(mailtoPathWithNestedMailtoResult)); + + expect( + mailtoSchemeResult[RouteUtils.paramMailtoAddress], + containsAll([to1, to2, to3,]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramCc], + containsAll([cc1, cc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBcc], + containsAll([bcc1, bcc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramSubject], + equals(subject) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBody], + equals(body) + ); + }); + + test( + 'should parse a valid mailto URI contains every possible parameters', + () { + // arrange + const to1 = 'to1@example.com'; + const to2 = 'to2@example.com'; + const to3 = 'to3@example.com'; + const cc1 = 'cc1@example.com'; + const cc2 = 'cc2@example.com'; + const bcc1 = 'bcc1@example.com'; + const bcc2 = 'bcc2@example.com'; + const subject = 'Hello'; + const body = 'Bye'; + const mailtoSchemeUri = 'mailto:$to1,$to2' + '?to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathUri = 'https://example.com/mailto' + '?uri=$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + const mailtoPathWithNestedMailtoUri = 'https://example.com/mailto/' + '?uri=mailto:$to1,$to2' + '&to=$to2,$to3' + '&cc=$cc1,$cc2' + '&bcc=$bcc1,$bcc2' + '&subject=$subject' + '&body=$body'; + + // act + final mailtoSchemeResult = RouteUtils.parseMapMailtoFromUri( + mailtoSchemeUri); + final mailtoPathResult = RouteUtils.parseMapMailtoFromUri(mailtoPathUri); + final mailtoPathWithNestedMailtoResult = RouteUtils.parseMapMailtoFromUri( + mailtoPathWithNestedMailtoUri); + + // assert + expect(mailtoSchemeResult, equals(mailtoPathResult)); + expect(mailtoSchemeResult, equals(mailtoPathWithNestedMailtoResult)); + expect(mailtoPathResult, equals(mailtoPathWithNestedMailtoResult)); + + expect( + mailtoSchemeResult[RouteUtils.paramMailtoAddress], + containsAll([to1, to2, to3,]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramCc], + containsAll([cc1, cc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBcc], + containsAll([bcc1, bcc2]) + ); + expect( + mailtoSchemeResult[RouteUtils.paramSubject], + equals(subject) + ); + expect( + mailtoSchemeResult[RouteUtils.paramBody], + equals(body) + ); + }); }); } \ No newline at end of file