From b3b1da052f79587c7142387aa6cae643d309fc51 Mon Sep 17 00:00:00 2001 From: zeus229 <69399886+zeus229@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:53:45 +0000 Subject: [PATCH] feat(email): add rich scheduled report delivery Signed-off-by: zeus229 <69399886+zeus229@users.noreply.github.com> --- agent/requirements.txt | 2 + agent/src/api/scheduled_routes.py | 21 +++- agent/src/channels/email.py | 33 ++++- agent/src/channels/registry.py | 1 + agent/src/channels/rich_text.py | 55 ++++++++ agent/src/scheduled_research/executor.py | 20 +-- agent/src/scheduled_research/models.py | 6 + agent/tests/test_email_rich_delivery.py | 118 ++++++++++++++++++ .../test_scheduled_email_delivery_format.py | 56 +++++++++ .../electron/requirements-windows-lock.txt | 33 +++++ frontend/src/i18n/locales/ar.json | 7 ++ frontend/src/i18n/locales/de.json | 7 ++ frontend/src/i18n/locales/en.json | 7 ++ frontend/src/i18n/locales/es.json | 7 ++ frontend/src/i18n/locales/id.json | 7 ++ frontend/src/i18n/locales/ja.json | 7 ++ frontend/src/i18n/locales/ko.json | 7 ++ frontend/src/i18n/locales/pt-BR.json | 7 ++ frontend/src/i18n/locales/zh-CN.json | 7 ++ frontend/src/lib/api.ts | 2 + frontend/src/pages/Scheduled.tsx | 34 ++++- .../src/pages/__tests__/Scheduled.test.tsx | 24 ++++ pyproject.toml | 2 + requirements-lock.txt | 33 +++++ 24 files changed, 491 insertions(+), 12 deletions(-) create mode 100644 agent/src/channels/rich_text.py create mode 100644 agent/tests/test_email_rich_delivery.py create mode 100644 agent/tests/test_scheduled_email_delivery_format.py diff --git a/agent/requirements.txt b/agent/requirements.txt index 323dca26..5f093b55 100644 --- a/agent/requirements.txt +++ b/agent/requirements.txt @@ -65,4 +65,6 @@ ddgs>=6.0.0 # Shadow Account HTML/PDF Report jinja2>=3.1.0 matplotlib>=3.7.0 +mistune>=3.0.0 +nh3>=0.2.18 weasyprint>=60.0 diff --git a/agent/src/api/scheduled_routes.py b/agent/src/api/scheduled_routes.py index 2806c25d..e9fa2f70 100644 --- a/agent/src/api/scheduled_routes.py +++ b/agent/src/api/scheduled_routes.py @@ -10,7 +10,7 @@ import re import sys as _sys import time import uuid -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Optional from fastapi import Depends, FastAPI, HTTPException, Query, status from pydantic import BaseModel, Field @@ -115,13 +115,16 @@ def _read_scheduled_briefing(session_id: str) -> Optional[tuple[str, str]]: return None -async def _send_scheduled_briefing(channel: str, target: Optional[str], text: str): +async def _send_scheduled_briefing( + channel: str, target: Optional[str], text: str, delivery_format: Optional[str] = None +): """Deliver one briefing through the configured IM channel. Args: channel: Channel id as configured in the channel runtime. target: Address within that channel, or ``None`` for its default. text: The briefing to deliver. + delivery_format: Optional presentation hint for channels that support it. Raises: RuntimeError: If the channel runtime is unavailable or has no such @@ -139,8 +142,9 @@ async def _send_scheduled_briefing(channel: str, target: Optional[str], text: st raise RuntimeError(f"channel {channel!r} is not configured") if not target: raise RuntimeError(f"channel {channel!r} has no delivery target configured") + metadata = {"delivery_format": delivery_format} if delivery_format else {} return await adapter.send_with_receipt( - OutboundMessage(channel=channel, chat_id=target, content=text) + OutboundMessage(channel=channel, chat_id=target, content=text, metadata=metadata) ) @@ -226,6 +230,9 @@ class CreateScheduledRunRequest(BaseModel): delivery_target_ref: Optional[str] = Field( None, description="Opaque operator-configured target ref; preferred over raw target ids" ) + delivery_format: Optional[Literal["html", "pdf"]] = Field( + None, description="Email report presentation: HTML body or PDF attachment" + ) end_at: Optional[int] = Field( None, description="Epoch-ms boundary after which no further run is dispatched" ) @@ -316,6 +323,7 @@ class ScheduledRunResponse(BaseModel): delivery_target: Optional[str] = None delivery_target_ref: Optional[str] = None delivery_target_label: Optional[str] = None + delivery_format: Optional[str] = None delivery_status: str = "none" delivery_error: Optional[str] = None delivery_updated_at: Optional[int] = None @@ -471,6 +479,12 @@ def register_scheduled_routes( delivery_target = resolved_target.target delivery_target_label = resolved_target.label + if request.delivery_format is not None and delivery_channel != "email": + raise HTTPException( + status_code=422, + detail="delivery_format is supported only for email delivery", + ) + job = ScheduledResearchJob( id=request.id or str(uuid.uuid4()), title=request.title or "", @@ -486,6 +500,7 @@ def register_scheduled_routes( delivery_target=delivery_target, delivery_target_ref=request.delivery_target_ref, delivery_target_label=delivery_target_label, + delivery_format=request.delivery_format, ) _get_scheduled_research_store().upsert(job) return _job_to_response(job) diff --git a/agent/src/channels/email.py b/agent/src/channels/email.py index d4fc541d..7aa19b46 100644 --- a/agent/src/channels/email.py +++ b/agent/src/channels/email.py @@ -18,6 +18,8 @@ from fnmatch import fnmatch from pathlib import Path from typing import Any, Literal +from src.channels.rich_text import render_email_html + import logging; logger = logging.getLogger(__name__) from pydantic import Field @@ -305,11 +307,33 @@ class EmailChannel(BaseChannel): fallback = "\n".join(failed_attachments) content = f"{content.rstrip()}\n\n{fallback}" if content.strip() else fallback + delivery_format = (msg.metadata or {}).get("delivery_format") + if delivery_format not in {"html", "pdf"}: + delivery_format = None + email_msg = EmailMessage() email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username email_msg["To"] = to_addr email_msg["Subject"] = subject - email_msg.set_content(content) + + generated_pdf: bytes | None = None + if delivery_format == "pdf": + # Keep the report out of the message body when PDF delivery is + # requested; a render failure must not silently send it as plain text. + notice = "Report attached as PDF." + email_msg.set_content(notice) + email_msg.add_alternative(render_email_html(notice), subtype="html") + try: + from weasyprint import HTML + + generated_pdf = HTML(string=render_email_html(content)).write_pdf() + except Exception: + self.logger.exception("Failed to render required PDF attachment") + raise + else: + email_msg.set_content(content) + if delivery_format == "html": + email_msg.add_alternative(render_email_html(content), subtype="html") for data, maintype, subtype, filename in attachments: email_msg.add_attachment( @@ -318,6 +342,13 @@ class EmailChannel(BaseChannel): subtype=subtype, filename=filename, ) + if generated_pdf is not None: + email_msg.add_attachment( + generated_pdf, + maintype="application", + subtype="pdf", + filename="vibe-trading-report.pdf", + ) in_reply_to = self._last_message_id_by_chat.get(to_addr) if in_reply_to: diff --git a/agent/src/channels/registry.py b/agent/src/channels/registry.py index 824a1ed7..6b8cb68b 100644 --- a/agent/src/channels/registry.py +++ b/agent/src/channels/registry.py @@ -33,6 +33,7 @@ _INTERNAL = frozenset( "pairing", "qq_probe", "registry", + "rich_text", "runtime", "targets", "token_probe", diff --git a/agent/src/channels/rich_text.py b/agent/src/channels/rich_text.py new file mode 100644 index 00000000..af4f05a0 --- /dev/null +++ b/agent/src/channels/rich_text.py @@ -0,0 +1,55 @@ +"""Safe rich-text rendering for outbound email reports.""" + +from __future__ import annotations + +import nh3 +from mistune import create_markdown + +_MARKDOWN = create_markdown(escape=True, plugins=["table", "strikethrough", "url"]) + +_ALLOWED_TAGS = { + "p", "a", "strong", "em", "del", "code", "pre", "blockquote", + "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "hr", "br", "table", "thead", "tbody", "tr", "th", "td", +} +_ALLOWED_ATTRIBUTES: dict[str, set[str]] = { + "a": {"href"}, + "code": {"class"}, + "ol": {"start"}, +} +_CLEANER = nh3.Cleaner( + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRIBUTES, + url_schemes={"https", "http", "mailto"}, + strip_comments=True, + link_rel="noopener noreferrer", +) + +_EMAIL_CSS = """ +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; + color: #1f2937; line-height: 1.55; max-width: 860px; margin: 0 auto; padding: 24px; } +h1, h2, h3 { color: #111827; line-height: 1.25; } +h1 { font-size: 26px; } h2 { font-size: 21px; margin-top: 28px; } +h3 { font-size: 17px; margin-top: 22px; } +a { color: #2563eb; } +table { border-collapse: collapse; width: 100%; margin: 16px 0; } +th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; } +th { background: #f3f4f6; } +blockquote { border-left: 4px solid #d1d5db; margin-left: 0; padding-left: 14px; color: #4b5563; } +code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; } +pre { background: #f3f4f6; padding: 12px; overflow-wrap: anywhere; white-space: pre-wrap; } +""" + + +def render_markdown_fragment(text: str) -> str: + """Render Markdown to a conservative, sanitized HTML fragment.""" + return _CLEANER.clean(_MARKDOWN(text or "")).strip() + + +def render_email_html(text: str) -> str: + """Render Markdown as a standalone HTML document suitable for email/PDF.""" + body = render_markdown_fragment(text) + return ( + '' + f"{body}" + ) diff --git a/agent/src/scheduled_research/executor.py b/agent/src/scheduled_research/executor.py index 987fb7c3..0abd2299 100644 --- a/agent/src/scheduled_research/executor.py +++ b/agent/src/scheduled_research/executor.py @@ -53,10 +53,8 @@ NowFn = Callable[[], int] DispatchCallback = Callable[[ScheduledResearchJob], Awaitable[Optional[str]]] #: session_id -> (terminal status, briefing text), or None while in flight. BriefingReader = Callable[[str], Optional[tuple[str, str]]] -#: (channel, target, text) -> delivered. -ChannelSender = Callable[ - [str, Optional[str], str], Awaitable[DeliveryReceipt | None] -] +#: (channel, target, text[, delivery_format]) -> delivered. +ChannelSender = Callable[..., Awaitable[DeliveryReceipt | None]] _TRUE_VALUES = {"1", "true", "yes", "on"} # Search by day, not by minute, so an impossible date (e.g. Feb 31) fails fast @@ -649,9 +647,17 @@ class ScheduledResearchExecutor: self._store.upsert(current, validate=False) try: - receipt = await self._channel_sender( - job.delivery_channel, job.delivery_target, text - ) + if job.delivery_format is None: + receipt = await self._channel_sender( + job.delivery_channel, job.delivery_target, text + ) + else: + receipt = await self._channel_sender( + job.delivery_channel, + job.delivery_target, + text, + job.delivery_format, + ) except asyncio.CancelledError: raise except Exception as exc: diff --git a/agent/src/scheduled_research/models.py b/agent/src/scheduled_research/models.py index d1488f06..9d138f83 100644 --- a/agent/src/scheduled_research/models.py +++ b/agent/src/scheduled_research/models.py @@ -380,6 +380,7 @@ class ScheduledResearchJob: delivery_target: Optional[str] = None delivery_target_ref: Optional[str] = None delivery_target_label: Optional[str] = None + delivery_format: Optional[str] = None delivery: DeliveryRecord = field(default_factory=DeliveryRecord) last_verdict: Optional[VerdictRecord] = None @@ -411,6 +412,7 @@ class ScheduledResearchJob: "delivery_target": self.delivery_target, "delivery_target_ref": self.delivery_target_ref, "delivery_target_label": self.delivery_target_label, + "delivery_format": self.delivery_format, "delivery": self.delivery.to_dict(), "last_verdict": self.last_verdict.to_dict() if self.last_verdict else None, } @@ -488,6 +490,9 @@ class ScheduledResearchJob: delivery_target = data.get("delivery_target") delivery_target_ref = data.get("delivery_target_ref") delivery_target_label = data.get("delivery_target_label") + delivery_format = data.get("delivery_format") + if delivery_format not in {None, "html", "pdf"}: + raise ValueError("'delivery_format' must be 'html', 'pdf', or null") for name, value in ( ("delivery_channel", delivery_channel), ("delivery_target", delivery_target), @@ -517,6 +522,7 @@ class ScheduledResearchJob: delivery_target=delivery_target, delivery_target_ref=delivery_target_ref, delivery_target_label=delivery_target_label, + delivery_format=delivery_format, delivery=DeliveryRecord.from_dict(data.get("delivery")), last_verdict=_verdict_record_or_none(data.get("last_verdict"), job_id), ) diff --git a/agent/tests/test_email_rich_delivery.py b/agent/tests/test_email_rich_delivery.py new file mode 100644 index 00000000..22227fda --- /dev/null +++ b/agent/tests/test_email_rich_delivery.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace + +from src.channels.bus.events import OutboundMessage +from src.channels.bus.queue import MessageBus +from src.channels.email import EmailChannel +from src.channels.rich_text import render_email_html + + +def _channel() -> EmailChannel: + return EmailChannel( + { + "consent_granted": True, + "smtp_host": "smtp.example.test", + "smtp_username": "bot@example.test", + "smtp_password": "secret", + "from_address": "bot@example.test", + }, + MessageBus(), + ) + + +def test_rich_email_renderer_preserves_markdown_and_sanitizes_html(): + rendered = render_email_html( + "# Portfolio\n\n**Return:** 1.2%\n\n| Asset | Weight |\n|---|---:|\n| ABC | 10% |\n" + "\n[jump](javascript:alert(1))" + ) + + assert "

Portfolio

" in rendered + assert "Return:" in rendered + assert "" in rendered + assert "