diff --git a/changedetectionio/notification/email_helpers.py b/changedetectionio/notification/email_helpers.py new file mode 100644 index 00000000..feab5b14 --- /dev/null +++ b/changedetectionio/notification/email_helpers.py @@ -0,0 +1,42 @@ +def as_monospaced_html_email(content: str, title: str) -> str: + """ + Wraps `content` in a minimal, email-safe HTML template + that forces monospace rendering across Gmail, Hotmail, Apple Mail, etc. + + Args: + content: The body text (plain text or HTML-like). + title: The title plaintext + Returns: + A complete HTML document string suitable for sending as an email body. + """ + + # All line feed types should be removed and then this function should only be fed
's + # Then it works with our
 styling without double linefeeds
+    content = content.translate(str.maketrans('', '', '\r\n'))
+
+    if title:
+        import html
+        title = html.escape(title)
+    else:
+        title = ''
+    # 2. Full email-safe HTML
+    html_email = f"""
+
+
+  
+  
+  
+  
+  {title}
+
+
+  
{content}
+ +""" + return html_email \ No newline at end of file diff --git a/changedetectionio/notification/handler.py b/changedetectionio/notification/handler.py index e815d2ff..cd2782cb 100644 --- a/changedetectionio/notification/handler.py +++ b/changedetectionio/notification/handler.py @@ -6,6 +6,7 @@ from loguru import logger from urllib.parse import urlparse from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL from .apprise_plugin.custom_handlers import SUPPORTED_HTTP_METHODS +from .email_helpers import as_monospaced_html_email from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \ ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \ CHANGED_PLACEMARKER_CLOSED, HTML_CHANGED_STYLE, HTML_CHANGED_INTO_STYLE @@ -76,6 +77,55 @@ def notification_format_align_with_apprise(n_format : str): return n_format +def apply_discord_markdown_to_body(n_body): + """ + Discord does not support but it supports non-standard ~~strikethrough~~ + :param n_body: + :return: + """ + import re + # Define the mapping between your placeholders and markdown markers + replacements = [ + (REMOVED_PLACEMARKER_OPEN, '~~', REMOVED_PLACEMARKER_CLOSED, '~~'), + (ADDED_PLACEMARKER_OPEN, '**', ADDED_PLACEMARKER_CLOSED, '**'), + (CHANGED_PLACEMARKER_OPEN, '~~', CHANGED_PLACEMARKER_CLOSED, '~~'), + (CHANGED_INTO_PLACEMARKER_OPEN, '**', CHANGED_INTO_PLACEMARKER_CLOSED, '**'), + ] + # So that the markdown gets added without any whitespace following it which would break it + for open_tag, open_md, close_tag, close_md in replacements: + # Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag + pattern = re.compile( + re.escape(open_tag) + r'(\s*)(.*?)?(\s*)' + re.escape(close_tag), + flags=re.DOTALL + ) + n_body = pattern.sub(lambda m: f"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}", n_body) + return n_body + +def apply_standard_markdown_to_body(n_body): + """ + Apprise does not support ~~strikethrough~~ but it will convert to HTML strikethrough. + :param n_body: + :return: + """ + import re + # Define the mapping between your placeholders and markdown markers + replacements = [ + (REMOVED_PLACEMARKER_OPEN, '', REMOVED_PLACEMARKER_CLOSED, ''), + (ADDED_PLACEMARKER_OPEN, '**', ADDED_PLACEMARKER_CLOSED, '**'), + (CHANGED_PLACEMARKER_OPEN, '', CHANGED_PLACEMARKER_CLOSED, ''), + (CHANGED_INTO_PLACEMARKER_OPEN, '**', CHANGED_INTO_PLACEMARKER_CLOSED, '**'), + ] + + # So that the markdown gets added without any whitespace following it which would break it + for open_tag, open_md, close_tag, close_md in replacements: + # Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag + pattern = re.compile( + re.escape(open_tag) + r'(\s*)(.*?)?(\s*)' + re.escape(close_tag), + flags=re.DOTALL + ) + n_body = pattern.sub(lambda m: f"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}", n_body) + return n_body + def apply_service_tweaks(url, n_body, n_title, requested_output_format): @@ -106,7 +156,7 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format): n_body = n_body.replace('
', '\n') n_body = n_body.replace('
', '\n') n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\n') - + # Use strikethrough for removed content, bold for added content n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '') n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '') @@ -140,15 +190,7 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format): if requested_output_format == 'html': # No diff placeholders, use Discord markdown for any other formatting # Use Discord markdown: strikethrough for removed, bold for added - n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '~~') - n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, '~~') - n_body = n_body.replace(ADDED_PLACEMARKER_OPEN, '**') - n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, '**') - # Handle changed/replaced lines (old → new) - n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN, '~~') - n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, '~~') - n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, '**') - n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, '**') + n_body = apply_discord_markdown_to_body(n_body=n_body) # Apply 2000 char limit for plain content payload_max_size = 1700 @@ -180,6 +222,9 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format): n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'(into) ') n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'') n_body = n_body.replace('\n', f'{CUSTOM_LINEBREAK_PLACEHOLDER}\n') + elif requested_output_format == 'markdown': + # Markdown to HTML - Apprise will convert this to HTML + n_body = apply_standard_markdown_to_body(n_body=n_body) else: #plaintext etc default n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ') @@ -300,17 +345,20 @@ def process_notification(n_object: NotificationContextData, datastore): apprise_input_format = NotifyFormat.TEXT.value elif requested_output_format == NotifyFormat.MARKDOWN.value: - # This actually means we request "Markdown to HTML", we want HTML output + # Convert markdown to HTML ourselves since not all plugins do this + from apprise.conversion import markdown_to_html + # Make sure there are paragraph breaks around horizontal rules + n_body = n_body.replace('---', '\n\n---\n\n') + n_body = markdown_to_html(n_body) url = f"{url}{prefix_add_to_url}format={NotifyFormat.HTML.value}" requested_output_format = NotifyFormat.HTML.value - apprise_input_format = NotifyFormat.MARKDOWN.value - + apprise_input_format = NotifyFormat.HTML.value # Changed from MARKDOWN to HTML # Could have arrived at any stage, so we dont end up running .escape on it if 'html' in requested_output_format: - n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '
\n') + n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '
\r\n') else: - # Markup, text types etc + # texty types n_body = n_body.replace(CUSTOM_LINEBREAK_PLACEHOLDER, '\r\n') sent_objs.append({'title': n_title, @@ -318,6 +366,12 @@ def process_notification(n_object: NotificationContextData, datastore): 'url': url}) apobj.add(url) + # Since the output is always based on the plaintext of the 'diff' engine, wrap it nicely. + # It should always be similar to the 'history' part of the UI. + if url.startswith('mail') and 'html' in requested_output_format: + if not 'header

' in html_content - assert '(added) So let\'s see what happens.So let\'s see what happens.
' in html_content # Additions are in markdown delete_all_watches(client) # Custom notification body with HTML, that is either sent as HTML or rendered to plaintext and sent @@ -470,7 +471,7 @@ def test_check_plaintext_document_plaintext_notification_smtp(client, live_serve assert '(added)' in body assert '\r\n(added) And let's talk about <title> tags
' in html_content + assert '
(added) And let's talk about <title> tags
' in html_content assert '<br' not in html_content + assert '
' in html_content
-
+    assert '