import 'dart:convert'; import 'dart:typed_data'; import 'package:core/utils/app_logger.dart'; import 'package:core/domain/exceptions/string_exception.dart'; import 'package:core/utils/mail/named_address.dart'; import 'package:flutter/widgets.dart' show visibleForTesting; import 'package:html/dom.dart'; import 'package:html/parser.dart'; import 'package:http_parser/http_parser.dart'; class StringConvert { // Change the pattern to include newlines directly to avoid the .replaceAll() step static const String emailSeparatorPattern = r'[ ,;\n\r\t]+'; // ReDoS-safe regex patterns as static final static final _base64ValidationRegex = RegExp(r'^[A-Za-z0-9+/=]+$'); static final _mdSeparatorRegex = RegExp( r'^\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-+:?[ \t]*\|?$', multiLine: false, ); static final _asciiArtRegex = RegExp(r'[+\-|/\\=]'); static final _namedAddressRegex = RegExp(r'''(?:(?:"([^"]+)"|'([^']+)')\s*)?<([^>]+)>'''); static final _emailLocalhostRegex = RegExp( r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$', ); @visibleForTesting static RegExp get base64ValidationRegex => _base64ValidationRegex; @visibleForTesting static RegExp get emailLocalhostRegex => _emailLocalhostRegex; /// Checks if the given email address is a localhost email /// Returns true if the email ends with @localhost static bool isEmailLocalhost(String email) { final normalized = email.trim(); return _emailLocalhostRegex.hasMatch(normalized); } static String? writeEmptyToNull(String text) { if (text.isEmpty) return null; return text; } static String writeNullToEmpty(String? text) { return text ?? ''; } static String decodeBase64ToString(String text) { try { return utf8.decode(base64Decode(text)); } catch (e) { logWarning('StringConvert::decodeBase64ToString:Exception = $e'); return text; } } static List extractStrings(String input, String separatorPattern) { try { // 1. URL Decoding if (input.contains('%')) { input = Uri.decodeComponent(input); } // 2. Base64 Check - Using a non-regex check first is faster if (input.length % 4 == 0 && !input.contains(' ')) { // Only run regex if basic length/whitespace checks pass if (_base64ValidationRegex.hasMatch(input)) { try { input = utf8.decode(base64.decode(input)); } catch (_) { // Ignore if decoding fails } } } // 3. Optimized Split // We use the pattern directly and skip the redundant .replaceAll() and .trim() final RegExp separator = RegExp(separatorPattern); final listStrings = input .split(separator) .where((value) => value.isNotEmpty) .toList(); log('StringConvert::extractStrings:listStrings = $listStrings'); return listStrings; } catch (e) { return []; } } static List extractEmailAddress(String input) => extractStrings(input, emailSeparatorPattern); static String decodeFromBytes( Uint8List bytes, { required String? charset, bool isHtml = false, }) { if (isHtml) { return utf8.decode(bytes); } else if (charset == null) { throw const NullCharsetException(); } else if (charset.toLowerCase().contains('utf-8')) { return utf8.decode(bytes); } else if (charset.toLowerCase().contains('latin')) { return latin1.decode(bytes); } else if (charset.toLowerCase().contains('ascii')) { return ascii.decode(bytes); } else { throw const UnsupportedCharsetException(); } } static String toUrlScheme(String hostScheme) { return '$hostScheme://'; } static Uint8List convertBase64ImageTagToBytes(String base64ImageTag) { if (!base64ImageTag.contains('base64,')) { throw ArgumentError('The string is not valid Base64 data from an tag.'); } final base64Data = base64ImageTag.split(',').last; return base64Decode(base64Data); } static MediaType? getMediaTypeFromBase64ImageTag(String base64ImageTag) { try { if (!base64ImageTag.startsWith("data:") || !base64ImageTag.contains(";base64,")) { return null; } final mimeType = base64ImageTag.split(";")[0].split(":")[1]; log('StringConvert::getMediaTypeFromBase64ImageTag:mimeType = $mimeType'); return MediaType.parse(mimeType); } catch (e) { logWarning('StringConvert::getMimeTypeFromBase64ImageTag:Exception = $e'); return null; } } static String getContentOriginal(String content) { try { final emailDocument = parse(content); final contentOriginal = emailDocument.body?.innerHtml ?? content; return contentOriginal; } catch (e) { logWarning('StringConvert::getContentOriginal:Exception = $e'); return content; } } /// Checks if the given text is a table (supports Markdown or ASCII art format). static bool isTextTable(String text) { final lines = text.split('\n').where((line) => line.trim().isNotEmpty).toList(); if (lines.length < 2) return false; bool isMarkdown = false; bool allLinesHaveAscii = true; for (final line in lines) { if (!isMarkdown && _mdSeparatorRegex.hasMatch(line)) { isMarkdown = true; } if (allLinesHaveAscii && !_asciiArtRegex.hasMatch(line)) { allLinesHaveAscii = false; } // Early exit if we found a table but also know it's not ASCII art if (isMarkdown && !allLinesHaveAscii) break; } return isMarkdown || (allLinesHaveAscii && lines.length >= 2); } static List extractNamedAddresses(String input) { try { if (input.contains('%')) { input = Uri.decodeComponent(input); } if (input.length % 4 == 0 && !input.contains(' ') && _base64ValidationRegex.hasMatch(input)) { try { input = utf8.decode(base64.decode(input)); } catch (_) {} } input = input.replaceAll('\n', ' '); final results = []; int currentIndex = 0; // Use a for-in loop directly on the iterable to save memory for (final match in _namedAddressRegex.allMatches(input)) { if (match.start > currentIndex) { final between = input.substring(currentIndex, match.start); results.addAll(_splitPlainAddresses(between, emailSeparatorPattern)); } final name = match.group(1) ?? match.group(2) ?? ''; final email = match.group(3) ?? ''; results.add(NamedAddress(name: name.trim(), address: email.trim())); currentIndex = match.end; } if (currentIndex < input.length) { final tail = input.substring(currentIndex); results.addAll(_splitPlainAddresses(tail, emailSeparatorPattern)); } log('StringConvert::extractNamedAddresses:results = $results'); return results; } catch (_) { return []; } } static List _splitPlainAddresses( String input, String emailSeparatorPattern, ) { final separator = RegExp(emailSeparatorPattern); return input .split(separator) .map((e) => e.trim()) .where((e) => e.isNotEmpty) .map((e) => NamedAddress(name: '', address: e)) .toList(); } static String convertHtmlContentToTextContent(String htmlContent) { try { final document = parse(htmlContent); // Each paragraph is surrounded by block tags so we add a /n for each block tag // Even
are surrounded by block tags so we can ignore
and treat them // as paragraph const blockTags = 'p, div, li, section, blockquote, article, header, footer, h1, h2, h3, h4, h5, h6'; document.querySelectorAll(blockTags).forEach((element) { element.append(Text('\n')); }); final String textContent = document.body?.text ?? ''; return textContent.trim(); } catch (e) { logWarning('StringConvert::convertHtmlContentToTextContent:Exception = $e'); return htmlContent.trim(); } } static String convertTextContentToHtmlContent(String textContent) { // Escape HTML entities first to prevent interpretation as HTML final escapedContent = textContent .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); final htmlContent = escapedContent.replaceAll('\n', '
'); return '
$htmlContent
'; } }