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 '