diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py
index d983fd81c79d5..0ea0423d8d703 100644
--- a/plugins/platforms/email/adapter.py
+++ b/plugins/platforms/email/adapter.py
@@ -33,6 +33,7 @@
from gateway.config import Platform, PlatformConfig
from utils import is_truthy_value
from gateway.platforms._shared import get_scoped_secret as _get_secret, coerce_port, decode_json_list_literal, send_error
+from plugins.platforms.email.markdown_html import markdown_to_email_html
logger = logging.getLogger(__name__)
@@ -436,6 +437,14 @@ def _extract_attachments(msg: email_lib.message.Message, skip_attachments: bool
return attachments
+def _body_alternative(body: str) -> MIMEMultipart:
+ """Plain part first, HTML second: a client that cannot render HTML keeps the original text."""
+ alternative = MIMEMultipart("alternative")
+ alternative.attach(MIMEText(body, "plain", "utf-8"))
+ alternative.attach(MIMEText(markdown_to_email_html(body), "html", "utf-8"))
+ return alternative
+
+
def _attach_file(msg: MIMEMultipart, path: Path, filename: str) -> None:
"""Attach *path* to *msg* as base64 application/octet-stream."""
with open(path, "rb") as f:
@@ -871,7 +880,7 @@ def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = N
("Date", formatdate(localtime=True)), ("Message-ID", msg_id)):
msg[key] = value
if body or attach_empty_body:
- msg.attach(MIMEText(body, "plain", "utf-8"))
+ msg.attach(_body_alternative(body))
return msg, msg_id, subject
def _smtp_send(self, msg: MIMEMultipart) -> None:
@@ -972,7 +981,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f
if not all([address, password, smtp_host]):
return send_error("Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)")
try:
- msg = MIMEText(message, "plain", "utf-8")
+ msg = _body_alternative(message)
for key, value in (("From", address), ("To", chat_id), ("Subject", t("platform.email.standalone_subject")), ("Date", formatdate(localtime=True))):
msg[key] = value
server = _open_smtp(smtp_host, smtp_port, smtp_security, _tls_context(smtp_tls_verify, smtp_host), smtplib.SMTP, smtplib.SMTP_SSL)
diff --git a/plugins/platforms/email/markdown_html.py b/plugins/platforms/email/markdown_html.py
new file mode 100644
index 0000000000000..c5460ebf78f62
--- /dev/null
+++ b/plugins/platforms/email/markdown_html.py
@@ -0,0 +1,115 @@
+"""Render agent Markdown as inline-styled email HTML.
+
+The email adapter sends ``text/plain`` only, so Markdown the agent writes for chat
+(``**bold**``, ``## Headings``, bullets) reaches a mail reader as literal asterisks and
+hashes. This converts the subset the agent actually emits into simple HTML, which the
+adapter attaches as a ``text/html`` alternative *beside* the unchanged plain part:
+HTML-capable clients render it, and every other client still gets the original text.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+
+# Anchored block markers. A heading requires its space, so "#hashtag" stays prose.
+_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
+_BULLET = re.compile(r"^[-*+]\s+(.*)$")
+_NUMBERED = re.compile(r"^\d+[.)]\s+(.*)$")
+
+_BOLD = re.compile(r"\*\*(.+?)\*\*")
+_ITALIC = re.compile(r"(?()]+)""")
+
+_SENTENCE_END = ".,;:!?"
+_HEADING_STYLE = {
+ 2: "margin:18px 0 8px;font-size:1.25em;font-weight:600",
+ 3: "margin:16px 0 8px;font-size:1.15em;font-weight:600",
+ 4: "margin:14px 0 6px;font-size:1.05em;font-weight:600",
+ 5: "margin:12px 0 6px;font-size:1em;font-weight:600",
+ 6: "margin:12px 0 6px;font-size:1em;font-weight:600",
+}
+_PARAGRAPH_STYLE = "margin:0 0 12px"
+_LIST_STYLE = "margin:0 0 12px;padding-left:22px"
+_ITEM_STYLE = "margin:0 0 4px"
+
+
+def _linkify(match: re.Match) -> str:
+ """Wrap a bare URL, leaving sentence punctuation outside the anchor."""
+ url, trailing = match.group(1), ""
+ while url and url[-1] in _SENTENCE_END:
+ trailing = url[-1] + trailing
+ url = url[:-1]
+ return f'{url}{trailing}' if url else match.group(0)
+
+
+def _inline(text: str) -> str:
+ """Escape, then apply inline Markdown. Escaping first means content cannot inject markup."""
+ text = html.escape(text)
+ text = _CODE.sub(r"\1", text)
+ text = _BOLD.sub(r"\1", text)
+ text = _ITALIC.sub(r"\1", text)
+ text = _LINK.sub(r'\1', text)
+ return _BARE_URL.sub(_linkify, text)
+
+
+def markdown_to_email_html(text: str) -> str:
+ """Convert the Markdown subset the agent emits into inline-styled email HTML."""
+ if not text.strip():
+ return ""
+ blocks: list[str] = []
+ paragraph: list[str] = []
+ items: list[str] = []
+ list_tag = ""
+
+ def flush_paragraph() -> None:
+ if paragraph:
+ blocks.append(f'
{_inline(" ".join(paragraph))}
') + paragraph.clear() + + def flush_list() -> None: + nonlocal list_tag + if items: + rendered = "".join(f'