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..54c01ef0ec --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/html_sanitize_config.dart @@ -0,0 +1,26 @@ +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() { + 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) { + logError('HtmlSanitizeConfig::loadPreservedHtmlTags:Exception = $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 902a62ace3..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 { @@ -28,15 +29,24 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { 'main', 'footer', 'supress_time_adjustment', + 'form', ]; 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/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 ?? ''} 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/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('
')); + }); }); } 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: