Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!doctype html or <html).
# Also handle the common failure mode where the agent inserts a
# single-line preamble ("Here's the HTML:") before the doctype.
stripped = content.strip()
doctype_idx = stripped.lower().find("<!doctype html")
html_idx = stripped.lower().find("<html")
if doctype_idx != -1 and (html_idx == -1 or doctype_idx < html_idx):
start_idx = doctype_idx
elif html_idx != -1:
start_idx = html_idx
else:
start_idx = -1

if start_idx == 0:
# Content starts with HTML — deliver unwrapped
delivery_content = content
is_html = True
elif start_idx > 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("<!doctype html") or stripped.startswith("<html"):
is_html = True

# Extract MEDIA: tags so attachments are forwarded as files, not raw text
from gateway.platforms.base import BasePlatformAdapter
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)

# Build email subject for HTML deliveries. If the job name contains
# "{date}" it is substituted with today's date; otherwise the job
# name is used as-is (common for one-shot / non-weather jobs).
from datetime import date as dt_date
job_name = job.get("name", "")
email_subject = None
if is_html and job_name:
email_subject = job_name.replace("{date}", dt_date.today().strftime("%B %d, %Y"))

try:
config = load_gateway_config()
except Exception as e:
Expand Down Expand Up @@ -717,6 +762,11 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> 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()
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand Down
77 changes: 59 additions & 18 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ def _strip_html(html: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<head[^>]*>.*?</head>", "", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&amp;", "&", text)
Expand All @@ -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("<!doctype html") or stripped.startswith("<html")


def _extract_email_address(raw: str) -> str:
"""Extract bare email address from 'Name <addr>' format."""
match = re.search(r"<([^>]+)>", raw)
Expand Down Expand Up @@ -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 <!doctype html or <html), the
email is sent as a fresh message with no threading headers so it
appears as its own thread in Gmail.

Pass ``metadata={\"subject\": \"...\"}`` to override the subject.
"""
subject = metadata.get("subject") if metadata else None
if subject is None and _is_html_body(content):
subject = "Hermes Agent"
try:
loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(
None, self._send_email, chat_id, content, reply_to
None, self._send_email, chat_id, content, reply_to, subject
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
Expand All @@ -523,30 +541,52 @@ def _send_email(
to_addr: str,
body: str,
reply_to_msg_id: Optional[str] = None,
subject: Optional[str] = None,
) -> 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 ``<!doctype html`` or ``<html``, the email
is sent as ``multipart/alternative`` with both HTML and a plain-text
fallback (auto-stripped). Otherwise it is sent as ``text/plain`` as
before, preserving backward compatibility with existing callers.
"""
is_html = _is_html_body(body)

msg = MIMEMultipart("alternative" if is_html else "mixed")
msg["From"] = self._address
msg["To"] = to_addr

# Thread context for reply
ctx = self._thread_context.get(to_addr, {})
subject = ctx.get("subject", "Hermes Agent")
if not subject.startswith("Re:"):
subject = f"Re: {subject}"
msg["Subject"] = subject

# Threading headers
original_msg_id = reply_to_msg_id or ctx.get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id
# Use explicit subject if provided, otherwise fall back to thread context
if subject:
msg["Subject"] = subject
else:
ctx = self._thread_context.get(to_addr, {})
subject = ctx.get("subject", "Hermes Agent")
if not subject.startswith("Re:"):
subject = f"Re: {subject}"
msg["Subject"] = subject

# Threading headers — only when replying to existing thread
if not subject or not msg["Subject"].startswith("Re:"):
# Fresh email — no threading headers
pass
else:
original_msg_id = reply_to_msg_id or self._thread_context.get(to_addr, {}).get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id

msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
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:
Expand All @@ -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:
Expand Down
52 changes: 46 additions & 6 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ async def _send_via_adapter(
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only adds an internal parameter: the PR does not add email_subject to SEND_MESSAGE_SCHEMA or extract it in _handle_send, so send_message callers cannot provide the subject described in the PR. Please wire the public argument through the actual send path.



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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 ``<!doctype html`` or ``<html``, the email
is sent as ``multipart/alternative`` with both HTML and a plain-text
fallback. Otherwise it is sent as ``text/plain``.
"""
import re
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
Expand All @@ -1281,11 +1288,44 @@ async def _send_email(extra, chat_id, message):
if not all([address, password, smtp_host]):
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}

stripped = message.strip().lower()
start_idx = -1
# Find HTML content even if preamble text precedes it
doctype_idx = stripped.find("<!doctype html")
html_idx = stripped.find("<html")
if doctype_idx != -1 and (html_idx == -1 or doctype_idx < html_idx):
start_idx = doctype_idx
is_html = True
elif html_idx != -1:
start_idx = html_idx
is_html = True
else:
is_html = False

if is_html and start_idx > 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"&nbsp;", " ", plain_body)
plain_body = re.sub(r"&amp;", "&", plain_body)
plain_body = re.sub(r"&lt;", "<", plain_body)
plain_body = re.sub(r"&gt;", ">", 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)
Expand Down