', () { + const input = '
https://google.com
Go to https://flutter.dev and https://dart.dev now
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Go to https://flutter.dev and https://dart.dev now
', + ); + }); + + test('keeps non-link text unchanged', () { + const input = 'Hello world!
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect(result.trim(), 'Hello world!
'); + }); + + test('handles mixed content with existing and new links', () { + const input = ''' +Visit https://google.com or Flutter
+Visit https://google.com or Flutter
\nCheck https://google.com, it is great!
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Check https://google.com, it is great!
', + ); + }); + + test('handles link with query parameters', () { + const input = 'Go to https://example.com/search?q=test
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Go to https://example.com/search?q=test
', + ); + }); + + test('wraps link inside span', () { + const input = 'See https://flutter.dev/docs for details'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'See https://flutter.dev/docs for details', + ); + }); + + test('handles multiple paragraphs with links', () { + const input = ''' +Visit https://google.com
+Then go to https://flutter.dev
+Visit https://google.com
\nThen go to https://flutter.dev
\nhttps://google.com is wrapped
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'https://google.com is wrapped
', + ); + }); + + test('handles link with trailing parenthesis correctly', () { + const input = '(https://example.com)
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '', + ); + }); + + test('handles link followed by exclamation mark', () { + const input = 'Wow https://amazing.dev!
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Wow https://amazing.dev!
', + ); + }); + + test('handles link followed by question mark', () { + const input = 'What is https://example.com?
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'What is https://example.com?
', + ); + }); + + test('handles http (non-https) link', () { + const input = 'Visit http://example.org for info
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Visit http://example.org for info
', + ); + }); + + test('handles multiple text segments and links mixed', () { + const input = + 'Before https://link1.com middle https://link2.com end
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Before https://link1.com middle https://link2.com end
', + ); + }); + + test('handles sentence with link between words', () { + const input = 'Check this https://docs.flutter.dev today
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Check this https://docs.flutter.dev today
', + ); + }); + + test('handles link at beginning of text', () { + const input = 'https://start.com is the first link
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'https://start.com is the first link
', + ); + }); + + test('handles link at end of text', () { + const input = 'Go to end https://end.com
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Go to end https://end.com
', + ); + }); + + test('handles text containing only the link', () { + const input = 'https://onlylink.com
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '', + ); + }); + + test('wraps link starting with www', () { + const input = 'Visit www.google.com for info
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Visit www.google.com for info
', + ); + }); + + test('handles link followed by colon', () { + const input = 'URL: https://flutter.dev:
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'URL: https://flutter.dev:
', + ); + }); + + test('handles parentheses around link', () { + const input = '(https://parenthesis.com)
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '', + ); + }); + + test('handles link inside nested span', () { + const input = 'Check https://nested.com inside span
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Check https://nested.com inside span
', + ); + }); + + test('handles multiple links across multiple tags', () { + const input = 'First https://one.com
Second https://two.com
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'First https://one.com
Second https://two.com
', + ); + }); + + test('handles link with port number', () { + const input = 'Visit http://localhost:8080/home
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Visit http://localhost:8080/home
', + ); + }); + + test('handles unicode domain', () { + const input = 'Try https://例子.测试
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Try https://例子.测试
', + ); + }); + + test('handles link with query params & escapes correctly', () { + const input = 'Search https://google.com?q=flutter&lang=en
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'Search https://google.com?q=flutter&lang=en
', + ); + }); + + test('does not wrap text inside existing ', () { + const input = 'https://flutter.dev'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + 'https://flutter.dev', + ); + }); + + test('handles empty html safely', () { + const input = ''; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect(result, ''); + }); + + test('handles text without link', () { + const input = 'No link here
'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect(result.trim(), 'No link here
'); + }); + + test('handles invalid HTML gracefully', () { + const input = 'Some broken
Flutter'), true);
+ });
+
+ test('does not alter src or href attributes', () {
+ const input = '''
+
+''';
+
+ final result = HtmlUtils.wrapPlainTextLinks(input).trim();
+
+ expect(
+ result,
+ '',
+ );
+ });
+
+ test('does NOT wrap URLs inside special tags (img, video, script, link, iframe)', () {
+ const input = '''
+Check www.google.com
+
+
+
+
+
+should be wrapped + expect( + result.contains('www.google.com'), + true, + reason: + 'The text www.google.com inside
should be wrapped into a link', + ); + + // There must NOT be any tag inside these special tags + final forbiddenTags = ['img', 'video', 'script', 'iframe', 'link']; + for (final tag in forbiddenTags) { + final pattern = RegExp('<$tag[^>]*>.*?<\\/\\s*$tag>', dotAll: true); + final matches = pattern.allMatches(result); + for (final match in matches) { + final segment = match.group(0)!; + expect( + segment.contains(' tag inside <$tag>', + ); + } + } + + // There must NOT be any tag inside href/src attributes + final attributePattern = RegExp(r'(src|href)="[^"]* tag inside href or src attributes', + ); + }); + }); } \ No newline at end of file diff --git a/lib/features/composer/data/repository/composer_repository_impl.dart b/lib/features/composer/data/repository/composer_repository_impl.dart index fc69d720e..77d2b795f 100644 --- a/lib/features/composer/data/repository/composer_repository_impl.dart +++ b/lib/features/composer/data/repository/composer_repository_impl.dart @@ -1,6 +1,7 @@ import 'package:core/data/constants/constant.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/application_manager.dart'; +import 'package:core/utils/html/html_utils.dart'; import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; @@ -71,6 +72,8 @@ class ComposerRepositoryImpl extends ComposerRepository { emailContent = await removeCollapsedExpandedSignatureEffect(emailContent: emailContent); + emailContent = HtmlUtils.wrapPlainTextLinks(emailContent); + final userAgent = await _applicationManager.generateApplicationUserAgent(); final emailBodyPartId = PartId(_uuid.v1());