From aa710113b04711dca06d810737271e27039da912 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 1 Oct 2025 00:17:24 +0700 Subject: [PATCH] TF-4054 Fix link detection --- core/lib/utils/html/html_utils.dart | 73 ++++ core/test/utils/html_utils_test.dart | 377 ++++++++++++++++++ .../repository/composer_repository_impl.dart | 3 + 3 files changed, 453 insertions(+) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 992213cb0..8ea5bf035 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:math'; import 'package:core/utils/html/html_template.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as parser; import 'package:html_unescape/html_unescape.dart'; import 'js_interop_stub.dart' if (dart.library.html) 'dart:js_interop'; @@ -713,4 +715,75 @@ class HtmlUtils { return cleaned; } + + static String wrapPlainTextLinks(String htmlString) { + try { + final document = parser.parse(htmlString); + final container = document.body; + + if (container == null) return htmlString; + + final urlRegex = RegExp(r'''(?:(?:https?:\/\/)|(?:www\.))[^\s<]+[^<.,:;\"')\]\s!?]'''); + + _processNode(container, urlRegex); + + return container.innerHtml; + } catch (e) { + logError('HtmlUtils::wrapPlainTextLinks:Exception = $e'); + return htmlString; + } + } + + static final _skipTags = { + 'a', 'img', 'video', 'audio', 'source', 'link', 'script', 'iframe' + }; + + static void _processNode(dom.Node node, RegExp urlRegex) { + for (var child in node.nodes.toList()) { + // Skip if node or parent node is in tag to skip + final parentTag = child.parent?.localName; + if (parentTag != null && _skipTags.contains(parentTag.toLowerCase())) { + continue; + } + + if (child.nodeType == dom.Node.TEXT_NODE) { + final text = child.text ?? ''; + final matches = urlRegex.allMatches(text); + + if (matches.isEmpty) continue; + + final nodes = []; + int lastIndex = 0; + + for (final match in matches) { + final url = match.group(0)!; + final start = match.start; + + if (start > lastIndex) { + nodes.add(dom.Text(text.substring(lastIndex, start))); + } + + final href = url.startsWith('http') ? url : 'https://$url'; + final link = dom.Element.tag('a') + ..attributes['href'] = href + ..text = url; + nodes.add(link); + + lastIndex = match.end; + } + + if (lastIndex < text.length) { + nodes.add(dom.Text(text.substring(lastIndex))); + } + + final parent = child.parent!; + final index = parent.nodes.indexOf(child); + + parent.nodes.removeAt(index); + parent.nodes.insertAll(index, nodes); + } else { + _processNode(child, urlRegex); + } + } + } } diff --git a/core/test/utils/html_utils_test.dart b/core/test/utils/html_utils_test.dart index 8943604b4..180cee2cd 100644 --- a/core/test/utils/html_utils_test.dart +++ b/core/test/utils/html_utils_test.dart @@ -322,4 +322,381 @@ void main() { expect(HtmlUtils.extractPlainText(html), 'dab'); }); }); + + group('HtmlUtils.wrapPlainTextLinks', () { + test('wraps plain text link inside

', () { + const input = '

https://google.com

'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '

https://google.com

', + ); + }); + + test('keeps existing tags unchanged', () { + const input = "
Hello
"; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + "
Hello
", + ); + }); + + test('wraps multiple links in text', () { + const input = '

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

+
+'''; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '
\n

Visit https://google.com or Flutter

\n
', + ); + }); + + test('returns input if body is null', () { + const input = ''; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect(result, input); + }); + + test('handles link with trailing punctuation', () { + const input = '

Check 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

+
+'''; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect( + result.trim(), + '
\n

Visit https://google.com

\n

Then go to https://flutter.dev

\n
', + ); + }); + + test('ignores links already wrapped in ', () { + const input = + '

https://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(), + '

(https://example.com)

', + ); + }); + + 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(), + '

https://onlylink.com

', + ); + }); + + 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(), + '

(https://parenthesis.com)

', + ); + }); + + 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

www.test.com'; + final result = HtmlUtils.wrapPlainTextLinks(input); + expect(result.contains(''), true); + }); + + test('ignores URLs inside img/video/link/script attributes', () { + const input = ''' +
+

Visit www.google.com

+ example + +
Flutter +
+'''; + + final result = HtmlUtils.wrapPlainTextLinks(input); + + expect( + result.contains('www.google.com'), + true, + reason: 'Text link www.google.com should be wrapped', + ); + + expect(result.contains('Flutter'), true); + }); + + test('does not alter src or href attributes', () { + const input = ''' +
+

Visit www.google.com

+ example + + Flutter +
+'''; + + final result = HtmlUtils.wrapPlainTextLinks(input).trim(); + + expect( + result, + '
\n

Visit www.google.com

\n ' + 'example\n ' + '\n ' + 'Flutter\n
', + ); + }); + + test('does NOT wrap URLs inside special tags (img, video, script, link, iframe)', () { + const input = ''' +
+

Check www.google.com

+ Sample image with link google.com + + + + +
+'''; + + final result = HtmlUtils.wrapPlainTextLinks(input); + // The URL in

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());