diff --git a/cron/scheduler.py b/cron/scheduler.py index a51ade8efe65..1d23c915efc7 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -647,24 +647,69 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option except Exception: pass + # Track whether content is HTML so we can pass a proper email subject. + is_html = False + if wrap_response: - task_name = job.get("name", job["id"]) - job_id = job.get("id", "") - delivery_content = ( - f"Cronjob Response: {task_name}\n" - f"(job_id: {job_id})\n" - f"-------------\n\n" - f"{content}\n\n" - f"To stop or manage this job, send me a new message (e.g. \"stop reminder {task_name}\")." - ) + # Detect HTML content for two purposes: (1) skip the cron wrapper + # (it would break HTML detection downstream), and (2) build a + # proper email subject from the job name instead of "Hermes Agent". + # Don't wrap HTML email content — the wrapper prefix would break + # HTML detection (body must start with 0: + # Preamble text before HTML — strip it + delivery_content = stripped[start_idx:] + is_html = True + logger.debug("Job '%s': stripped preamble before HTML (kept %d chars from position %d)", + job["id"], len(delivery_content), start_idx) + else: + # No HTML — wrap normally + task_name = job.get("name", job["id"]) + job_id = job.get("id", "") + delivery_content = ( + f"Cronjob Response: {task_name}\n" + f"(job_id: {job_id})\n" + f"-------------\n\n" + f"{content}\n\n" + f'To stop or manage this job, send me a new message (e.g. "stop reminder {task_name}").' + ) else: delivery_content = content + # Detect HTML even when wrapping is off so we can set a proper subject + stripped = content.strip().lower() + if stripped.startswith(" Option delivered = False if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)(): send_metadata = {"thread_id": thread_id} if thread_id else None + # Attach email subject for HTML deliveries + if email_subject and platform == Platform.EMAIL: + if send_metadata is None: + send_metadata = {} + send_metadata["subject"] = email_subject try: # Send cleaned text (MEDIA tags stripped) — not the raw content text_to_send = cleaned_delivery_content.strip() @@ -779,7 +829,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option if not delivered: # Standalone path: run the async send in a fresh event loop (safe from any thread) - coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files) + coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files, email_subject=email_subject) try: result = asyncio.run(coro) except RuntimeError: @@ -789,7 +839,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option # fresh thread that has no running loop. coro.close() with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)) + future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files, email_subject=email_subject)) result = future.result(timeout=30) except Exception as e: msg = f"delivery to {platform_name}:{chat_id} failed: {e}" diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 0fffb82d0b94..3176c8a1ab63 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -165,6 +165,8 @@ def _strip_html(html: str) -> str: text = re.sub(r"", "\n", html, flags=re.IGNORECASE) text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>.*?", "", text, flags=re.IGNORECASE | re.DOTALL) + text = re.sub(r"]*>.*?", "", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"<[^>]+>", "", text) text = re.sub(r" ", " ", text) text = re.sub(r"&", "&", text) @@ -174,6 +176,12 @@ def _strip_html(html: str) -> str: return text.strip() +def _is_html_body(body: str) -> bool: + """Return True if the body looks like an HTML document.""" + stripped = body.strip().lower() + return stripped.startswith(" str: """Extract bare email address from 'Name ' format.""" match = re.search(r"<([^>]+)>", raw) @@ -507,11 +515,21 @@ async def send( reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an email reply to the given address.""" + """Send an email reply to the given address. + + When the body is HTML (starts with str: - """Send an email via SMTP. Runs in executor thread.""" - msg = MIMEMultipart() + """Send an email via SMTP. Runs in executor thread. + + When *body* starts with ``" msg["Message-ID"] = msg_id - msg.attach(MIMEText(body, "plain", "utf-8")) + if is_html: + # HTML email — attach HTML part first, then plain-text fallback + plain_body = _strip_html(body) + msg.attach(MIMEText(plain_body, "plain", "utf-8")) + msg.attach(MIMEText(body, "html", "utf-8")) + else: + msg.attach(MIMEText(body, "plain", "utf-8")) smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) try: @@ -559,7 +599,8 @@ def _send_email( except Exception: smtp.close() - logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject) + logger.info("[Email] Sent reply to %s (subject: %s%s)", to_addr, subject, + ", HTML" if is_html else "") return msg_id async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9ea0b9af41b5..6103a441796b 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -554,7 +554,7 @@ async def _send_via_adapter( } -async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False): +async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False, email_subject=None): """Route a message to the appropriate platform sender. Long messages are automatically chunked to fit within platform limits @@ -757,7 +757,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, elif platform == Platform.SIGNAL: result = await _send_signal(pconfig.extra, chat_id, chunk) elif platform == Platform.EMAIL: - result = await _send_email(pconfig.extra, chat_id, chunk) + result = await _send_email(pconfig.extra, chat_id, chunk, subject=email_subject) elif platform == Platform.SMS: result = await _send_sms(pconfig.api_key, chat_id, chunk) elif platform == Platform.MATRIX: @@ -1265,9 +1265,16 @@ async def _send_inline_notice(text: str) -> None: return _error(f"Signal send failed: {e}") -async def _send_email(extra, chat_id, message): - """Send via SMTP (one-shot, no persistent connection needed).""" +async def _send_email(extra, chat_id, message, subject=None): + """Send via SMTP (one-shot, no persistent connection needed). + + When *message* starts with `` 0: + # Strip preamble text so email starts with HTML + message = message.strip()[start_idx:] + try: - msg = MIMEText(message, "plain", "utf-8") + if is_html: + # HTML email — multipart/alternative with plain-text fallback + plain_body = re.sub(r"<[^>]+>", "", message) + plain_body = re.sub(r" ", " ", plain_body) + plain_body = re.sub(r"&", "&", plain_body) + plain_body = re.sub(r"<", "<", plain_body) + plain_body = re.sub(r">", ">", plain_body) + plain_body = re.sub(r"\n{3,}", "\n\n", plain_body) + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(plain_body.strip(), "plain", "utf-8")) + msg.attach(MIMEText(message, "html", "utf-8")) + else: + msg = MIMEText(message, "plain", "utf-8") msg["From"] = address msg["To"] = chat_id - msg["Subject"] = "Hermes Agent" + if subject: + msg["Subject"] = subject + else: + msg["Subject"] = "Hermes Agent" msg["Date"] = formatdate(localtime=True) server = smtplib.SMTP(smtp_host, smtp_port)