From b1545074a2cf85c37f81ed72149eaec1a55f31ab Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 27 Nov 2025 15:12:21 +0700 Subject: [PATCH 1/4] fix(mu): email content not displayed due to form tag removal --- .../text/standardize_html_sanitizing_transformers.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart index 902a62ace3..37334ca055 100644 --- a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart @@ -28,6 +28,7 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { 'main', 'footer', 'supress_time_adjustment', + 'form', ]; const StandardizeHtmlSanitizingTransformers(); From ccebcd0d9c6b366f40b3eedd659c96577885d75e Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 27 Nov 2025 15:13:06 +0700 Subject: [PATCH 2/4] fix(mu): avoid unwanted word-break in table elements --- core/lib/utils/html/html_utils.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 3c2eb21be4..85bb3c8ee1 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -151,6 +151,10 @@ class HtmlUtils { white-space: normal !important; } + table, td, th { + word-break: normal !important; + } + ${styleCSS ?? ''} From f513e88ba596d20e36ba5b9b92709e02a71845f3 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 27 Nov 2025 15:58:30 +0700 Subject: [PATCH 3/4] fix(mu): introduce `EMAIL_HTML_TAGS_TO_PRESERVE` for HTML sanitization extensibility --- contact/pubspec.lock | 8 + core/lib/core.dart | 1 + .../html_sanitize_config.dart | 23 +++ ...ndardize_html_sanitizing_transformers.dart | 15 +- core/pubspec.lock | 8 + core/pubspec.yaml | 2 + .../test/utils/html_sanitize_config_test.dart | 146 ++++++++++++++++++ ...rve-for-html-sanitization-extensibility.md | 64 ++++++++ env.file | 3 +- model/pubspec.lock | 8 + 10 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 core/lib/presentation/utils/html_transformer/html_sanitize_config.dart create mode 100644 core/test/utils/html_sanitize_config_test.dart create mode 100644 docs/adr/0069-introduce-email-html-tags-to-preserve-for-html-sanitization-extensibility.md diff --git a/contact/pubspec.lock b/contact/pubspec.lock index 6a28553b56..f06165505e 100644 --- a/contact/pubspec.lock +++ b/contact/pubspec.lock @@ -381,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + flutter_dotenv: + dependency: transitive + description: + name: flutter_dotenv + sha256: d9283d92059a22e9834bc0a31336658ffba77089fb6f3cc36751f1fc7c6661a3 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_image_compress: dependency: transitive description: diff --git a/core/lib/core.dart b/core/lib/core.dart index 26e13b64f2..b86d632176 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -35,6 +35,7 @@ export 'presentation/utils/app_toast.dart'; export 'presentation/utils/html_transformer/html_transform.dart'; export 'presentation/utils/html_transformer/transform_configuration.dart'; export 'presentation/utils/html_transformer/text/persist_preformatted_text_transformer.dart'; +export 'presentation/utils/html_transformer/html_sanitize_config.dart'; export 'data/utils/device_manager.dart'; export 'utils/app_logger.dart'; export 'utils/benchmark.dart'; diff --git a/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart b/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart new file mode 100644 index 0000000000..b97398fed8 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart @@ -0,0 +1,23 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class HtmlSanitizeConfig { + /// Safely load list of HTML tags from `EMAIL_HTML_TAGS_TO_PRESERVE` + static List loadPreservedHtmlTags() { + try { + final raw = dotenv.get('EMAIL_HTML_TAGS_TO_PRESERVE', fallback: ''); + + if (raw.trim().isEmpty) { + return const []; + } + + return raw + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet() + .toList(growable: false); + } catch (e) { + return const []; + } + } +} diff --git a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart index 37334ca055..f699d68339 100644 --- a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:core/presentation/utils/html_transformer/base/text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/html_sanitize_config.dart'; import 'package:core/presentation/utils/html_transformer/sanitize_html.dart'; class StandardizeHtmlSanitizingTransformers extends TextTransformer { @@ -34,10 +35,18 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { const StandardizeHtmlSanitizingTransformers(); @override - String process(String text, HtmlEscape htmlEscape) => - SanitizeHtml().process( + String process(String text, HtmlEscape htmlEscape) { + final preservedTags = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + final mergedTags = { + ...mailAllowedHtmlTags, + ...preservedTags, + }.toList(); + + return SanitizeHtml().process( inputHtml: text, allowAttributes: mailAllowedHtmlAttributes, - allowTags: mailAllowedHtmlTags, + allowTags: mergedTags, ); + } } diff --git a/core/pubspec.lock b/core/pubspec.lock index 27dc731528..4bbf3761ae 100644 --- a/core/pubspec.lock +++ b/core/pubspec.lock @@ -374,6 +374,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d9283d92059a22e9834bc0a31336658ffba77089fb6f3cc36751f1fc7c6661a3 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_image_compress: dependency: "direct main" description: diff --git a/core/pubspec.yaml b/core/pubspec.yaml index beec847b06..1dd595befd 100644 --- a/core/pubspec.yaml +++ b/core/pubspec.yaml @@ -99,6 +99,8 @@ dependencies: html_unescape: 2.0.0 + flutter_dotenv: 5.0.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/core/test/utils/html_sanitize_config_test.dart b/core/test/utils/html_sanitize_config_test.dart new file mode 100644 index 0000000000..388f834b00 --- /dev/null +++ b/core/test/utils/html_sanitize_config_test.dart @@ -0,0 +1,146 @@ +import 'package:core/presentation/utils/html_transformer/html_sanitize_config.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +void main() { + setUp(() { + dotenv.testLoad(); + }); + + group('HtmlSanitizeConfig.loadPreservedHtmlTags', () { + test('returns empty list when env variable is missing', () { + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, isEmpty); + }); + + test('returns empty list when env variable is empty string', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': '', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, isEmpty); + }); + + test('returns empty list when env only contains spaces', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': ' ', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, isEmpty); + }); + + test('parses a valid comma-separated list of tags', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': 'form,section,figure', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, ['form', 'section', 'figure']); + }); + + test('trims whitespace around tags', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': ' form , section , figure ', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, ['form', 'section', 'figure']); + }); + + test('ignores empty entries after splitting', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': 'form,, ,section,,', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect(result, ['form', 'section']); + }); + + test('parses tags containing hyphens, underscores, and uppercase letters', + () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': + 'CUSTOM-TAG, custom_tag , MyTag , X-LARGE, label_item', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect( + result, + [ + 'CUSTOM-TAG', + 'custom_tag', + 'MyTag', + 'X-LARGE', + 'label_item', + ], + ); + }); + + test('handles mixed-case and special-character tags with extra spaces', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': + ' My-Tag , MY_TAG , ultra-WIDE ,Test_TAG ', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect( + result, + [ + 'My-Tag', + 'MY_TAG', + 'ultra-WIDE', + 'Test_TAG', + ], + ); + }); + + test('removes duplicate tags even with different spacing', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': + 'form, form , FORM, form , section , SECTION ', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect( + result, + [ + 'form', + 'FORM', + 'section', + 'SECTION', + ], + ); + }); + + test('parses tags containing numbers', () { + dotenv.testLoad(mergeWith: { + 'EMAIL_HTML_TAGS_TO_PRESERVE': + 'tag123, v2-tag, item_01, BLOCK100, x-apple-123', + }); + + final result = HtmlSanitizeConfig.loadPreservedHtmlTags(); + + expect( + result, + [ + 'tag123', + 'v2-tag', + 'item_01', + 'BLOCK100', + 'x-apple-123', + ], + ); + }); + }); +} diff --git a/docs/adr/0069-introduce-email-html-tags-to-preserve-for-html-sanitization-extensibility.md b/docs/adr/0069-introduce-email-html-tags-to-preserve-for-html-sanitization-extensibility.md new file mode 100644 index 0000000000..57b610ba87 --- /dev/null +++ b/docs/adr/0069-introduce-email-html-tags-to-preserve-for-html-sanitization-extensibility.md @@ -0,0 +1,64 @@ +# 69. Introduce `EMAIL_HTML_TAGS_TO_PRESERVE` for HTML sanitization extensibility + +**Date:** 2025-11-27 + +## Status +Accepted + +## Context + +The email sanitization pipeline uses the class `StandardizeHtmlSanitizingTransformers` to process and clean incoming HTML content before rendering it in the UI. + +This class defines internal static allowlists: + +- `mailAllowedHtmlAttributes` +- `mailAllowedHtmlTags` + +These default allowlists are fixed at compile-time. When new HTML tags appear in real-world emails (for example: `
`, `
`, ``, `
`, or custom tags generated by email gateways or online editors), they may be stripped by the sanitization process. + +Some environments or deployments may require additional custom tags to be preserved depending on operational constraints, customer integrations, or specific email formatting use cases. + +Without configurability, adjusting the sanitization behavior requires code modifications and redeployment, which is not desirable. + +## Decision + +Introduce a new environment variable: **`EMAIL_HTML_TAGS_TO_PRESERVE`**. + +This variable contains a comma-separated list of HTML tags that should be appended to `mailAllowedHtmlTags` at runtime. + +The `StandardizeHtmlSanitizingTransformers` class will be updated to: + +1. Read `EMAIL_HTML_TAGS_TO_PRESERVE` from the env configuration. +2. Parse the list into a trimmed `List`. +3. Merge these tags into the existing `mailAllowedHtmlTags` list. +4. Ensure the final allowlist (default tags + preserve tags) is passed to `SanitizeHtml().process()` via the `allowTags` parameter. + +The order of sanitization remains unchanged: + +1. tags (including merged allowlist) +2. attributes +3. className + +### Example usage inside the transformer: + +```dart +final extendedTags = [ + ...mailAllowedHtmlTags, + ...env.EMAIL_HTML_TAGS_TO_PRESERVE, +]; +``` + +## Consequences + +- The sanitization behavior becomes runtime-configurable and environment-specific. +- Ops teams can adjust preserved HTML tags without modifying source code or triggering a deployment. +- Rendering fidelity improves in cases where forwarded email chains, enterprise gateways, or WYSIWYG editors introduce non-standard HTML tags. +- The default behavior remains safe, while new tags can be adopted gradually and controllably. +- Any future modifications to tag/attribute/class preservation logic must be reflected in this ADR or new ADRs to maintain traceability. + +## Example + +### `.env` +``` +EMAIL_HTML_TAGS_TO_PRESERVE=form,section,figure,figcaption +``` diff --git a/env.file b/env.file index 9c6b135b6e..96cc4058b7 100644 --- a/env.file +++ b/env.file @@ -9,4 +9,5 @@ FORWARD_WARNING_MESSAGE= PLATFORM=other WS_ECHO_PING= COZY_INTEGRATION= -COZY_EXTERNAL_BRIDGE_VERSION= \ No newline at end of file +COZY_EXTERNAL_BRIDGE_VERSION= +EMAIL_HTML_TAGS_TO_PRESERVE= \ No newline at end of file diff --git a/model/pubspec.lock b/model/pubspec.lock index 915ffec9af..725568ea3e 100644 --- a/model/pubspec.lock +++ b/model/pubspec.lock @@ -381,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + flutter_dotenv: + dependency: transitive + description: + name: flutter_dotenv + sha256: d9283d92059a22e9834bc0a31336658ffba77089fb6f3cc36751f1fc7c6661a3 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_image_compress: dependency: transitive description: From f66d8b8516a0d6438146fa6e9013e0d4207604bd Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 28 Nov 2025 12:54:12 +0700 Subject: [PATCH 4/4] fix(mu): add unit test for sanitize html with `form` tag --- .../utils/html_transformer/html_sanitize_config.dart | 3 +++ ...standardize_html_sanitizing_transformers_test.dart | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart b/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart index b97398fed8..54c01ef0ec 100644 --- a/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart +++ b/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart @@ -1,5 +1,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:core/utils/app_logger.dart'; + class HtmlSanitizeConfig { /// Safely load list of HTML tags from `EMAIL_HTML_TAGS_TO_PRESERVE` static List loadPreservedHtmlTags() { @@ -17,6 +19,7 @@ class HtmlSanitizeConfig { .toSet() .toList(growable: false); } catch (e) { + logError('HtmlSanitizeConfig::loadPreservedHtmlTags:Exception = $e'); return const []; } } diff --git a/core/test/utils/standardize_html_sanitizing_transformers_test.dart b/core/test/utils/standardize_html_sanitizing_transformers_test.dart index 48b55b13c6..0bae04aba3 100644 --- a/core/test/utils/standardize_html_sanitizing_transformers_test.dart +++ b/core/test/utils/standardize_html_sanitizing_transformers_test.dart @@ -20,6 +20,7 @@ void main() { 'section', 'google-sheets-html-origin', 'supress_time_adjustment', + 'form', ]; const listOnEventAttributes = [ 'mousedown', @@ -199,5 +200,15 @@ void main() { expect(result, equals('')); }); + + test( + 'SHOULD persist form tag and remove href attribute of A tag ' + 'WHEN href is invalid', + () { + const inputHtml = ''; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('
')); + }); }); }