From d6b3db261d621fded4e048a86b17c3296b53b165 Mon Sep 17 00:00:00 2001 From: Haozhe Wu Date: Sun, 4 Oct 2026 03:14:25 +0800 Subject: [PATCH] fix(channels): finish trusted email delivery and scheduling workflows Signed-off-by: Haozhe Wu --- Dockerfile | 4 +- agent/cli/commands/research_playbook.py | 14 +- agent/cli/main.py | 5 +- agent/src/api/scheduled_routes.py | 42 ++-- agent/src/channels/config_meta.py | 1 + agent/src/channels/email.py | 207 +++++++++++------- agent/src/channels/manager.py | 2 - agent/src/channels/rich_text.py | 70 +++++- agent/src/channels/runtime.py | 5 + agent/src/channels/slack.py | 1 + agent/src/scheduled_research/playbooks.py | 20 ++ agent/src/scheduled_research/service.py | 8 + agent/src/tools/scheduled_research_tool.py | 5 + agent/tests/test_channel_config_meta.py | 3 +- agent/tests/test_channels_config_api.py | 10 + agent/tests/test_channels_runtime.py | 30 +++ .../test_email_authentication_results.py | 41 +++- agent/tests/test_email_rich_delivery.py | 68 ++++++ agent/tests/test_playbooks_surface.py | 49 ++++- .../test_scheduled_research_confirmation.py | 75 +++++++ agent/tests/test_scheduled_routes.py | 32 +++ .../chat/ScheduledResearchProposalCard.tsx | 10 + .../ScheduledResearchProposalCard.test.tsx | 30 +++ .../settings/ChannelConfigPanel.tsx | 1 + frontend/src/i18n/locales/ar.json | 14 +- frontend/src/i18n/locales/de.json | 14 +- frontend/src/i18n/locales/en.json | 14 +- frontend/src/i18n/locales/es.json | 14 +- frontend/src/i18n/locales/id.json | 14 +- frontend/src/i18n/locales/ja.json | 14 +- frontend/src/i18n/locales/ko.json | 14 +- frontend/src/i18n/locales/pt-BR.json | 14 +- frontend/src/i18n/locales/zh-CN.json | 14 +- frontend/src/lib/api.ts | 2 + frontend/src/pages/Scheduled.tsx | 19 +- .../src/pages/__tests__/Scheduled.test.tsx | 28 ++- 36 files changed, 764 insertions(+), 144 deletions(-) create mode 100644 frontend/src/components/chat/__tests__/ScheduledResearchProposalCard.test.tsx diff --git a/Dockerfile b/Dockerfile index daa09423..3c534413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ # shared libraries (Pango/HarfBuzz/Fontconfig/Cairo/gdk-pixbuf) per its official # Debian install list; without them the lazy `from weasyprint import HTML` in # reporter.py fails and PDF rendering silently downgrades to HTML-only. -# fonts-dejavu-core gives non-blank PDFs. +# Include fonts for all supported report languages, including CJK and Arabic. RUN apt-get update && apt-get install -y --no-install-recommends \ libpango-1.0-0 \ libpangoft2-1.0-0 \ @@ -83,6 +83,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libgdk-pixbuf-2.0-0 \ libcairo2 \ fonts-dejavu-core \ + fonts-noto-core \ + fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* # Bring in the prebuilt venv from the builder stage. diff --git a/agent/cli/commands/research_playbook.py b/agent/cli/commands/research_playbook.py index 094cb8fb..b9527c6b 100644 --- a/agent/cli/commands/research_playbook.py +++ b/agent/cli/commands/research_playbook.py @@ -165,6 +165,8 @@ def build_job_from_playbook( timezone: Any = _KEEP_TZ, variables: Optional[Dict[str, str]] = None, job_id: Optional[str] = None, + delivery_target_ref: Optional[str] = None, + delivery_format: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str]]: """Build (but do not persist) a scheduled job from a template. @@ -180,13 +182,19 @@ def build_job_from_playbook( template's suggestion; pass ``None`` to force UTC. variables: Placeholder overrides. job_id: Explicit job id, or ``None`` for a generated one. + delivery_target_ref: Configured delivery destination reference. + delivery_format: Email format (html/pdf), or None for plain text. Returns: A ``(job, error)`` pair; exactly one side is populated. """ from src.scheduled_research.playbooks import build_job - kwargs: Dict[str, Any] = {"variables": variables or {}} + kwargs: Dict[str, Any] = { + "variables": variables or {}, + "delivery_target_ref": delivery_target_ref, + "delivery_format": delivery_format, + } if schedule is not None: kwargs["schedule"] = schedule if timezone is not _KEEP_TZ: @@ -521,6 +529,8 @@ def add_subparser(subparsers: Any) -> argparse.ArgumentParser: help="Set a declared template variable (repeatable)", ) p_create.add_argument("--id", dest="playbook_id", default=None, help="Explicit job id") + p_create.add_argument("--delivery-target-ref", default=None, help="Configured delivery destination reference") + p_create.add_argument("--delivery-format", choices=("html", "pdf"), default=None, help="Email format; omit for plain text") p_create.add_argument( "--dry-run", dest="playbook_dry_run", @@ -596,6 +606,8 @@ def _cmd_create(args: argparse.Namespace) -> int: timezone=timezone, variables=variables, job_id=getattr(args, "playbook_id", None), + delivery_target_ref=getattr(args, "delivery_target_ref", None), + delivery_format=getattr(args, "delivery_format", None), ) if job is None: _print(f"[bold red]{build_error}[/bold red]") diff --git a/agent/cli/main.py b/agent/cli/main.py index 3c4fae04..ec6fd8c4 100644 --- a/agent/cli/main.py +++ b/agent/cli/main.py @@ -1082,7 +1082,10 @@ def _render_scheduled_proposal(console: Any, proposal: Dict[str, Any]) -> None: console.print( f" 投递: {delivery.get('target_label') or '仅应用内'}{channel_suffix}" ) - console.print("[bold]确认执行? [y/N][/bold]") + if delivery.get("channel") == "email": + label = {"html": "HTML", "pdf": "PDF"}.get(delivery.get("format"), "纯文本") + console.print(f" 邮件格式: {label}") + console.print("确认执行? [y/N]", style="bold", markup=False) def _handle_scheduled_proposal_reply(text: str, ctx: InteractiveContext) -> bool: diff --git a/agent/src/api/scheduled_routes.py b/agent/src/api/scheduled_routes.py index 1b73c1c1..c02faf35 100644 --- a/agent/src/api/scheduled_routes.py +++ b/agent/src/api/scheduled_routes.py @@ -142,7 +142,9 @@ async def _send_scheduled_briefing( 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 {} + metadata = {"force_send": True} + if delivery_format: + metadata["delivery_format"] = delivery_format return await adapter.send_with_receipt( OutboundMessage(channel=channel, chat_id=target, content=text, metadata=metadata) ) @@ -268,6 +270,9 @@ class UpdateScheduledRunRequest(BaseModel): delivery_target_ref: Optional[str] = Field( None, description="Replacement opaque configured target ref" ) + delivery_format: Optional[Literal["html", "pdf"]] = Field( + None, description="Email presentation; null restores plain text" + ) end_at: Optional[int] = Field( None, description="Replacement epoch-ms end boundary; null removes it" ) @@ -333,6 +338,7 @@ class CreateRunFromPlaybookRequest(BaseModel): title: Optional[str] = None end_at: Optional[int] = None delivery_target_ref: Optional[str] = None + delivery_format: Optional[Literal["html", "pdf"]] = None class ScheduledRunResponse(BaseModel): @@ -607,6 +613,7 @@ def register_scheduled_routes( from src.scheduled_research.executor import next_due from src.scheduled_research.models import ( DeliveryRecord, + DeliveryStatus, JobStatus, is_interval_schedule, validate_schedule, @@ -621,10 +628,10 @@ def register_scheduled_routes( raise HTTPException( status_code=404, detail=f"scheduled run {job_id} not found" ) - if job.status == JobStatus.RUNNING: + if job.status == JobStatus.RUNNING or job.delivery.status == DeliveryStatus.SENDING: raise HTTPException( status_code=409, - detail="a running scheduled run cannot be edited; retry after it finishes", + detail="a running scheduled run or active delivery cannot be edited; retry after it finishes", ) fields = request.model_fields_set @@ -730,7 +737,17 @@ def register_scheduled_routes( detail="delivery_target is required when delivery_channel is set", ) - delivery_changed = delivery_requested and ( + delivery_format = job.delivery_format + if "delivery_format" in fields: + delivery_format = request.delivery_format + elif delivery_channel != "email": + delivery_format = None + if delivery_format is not None and delivery_channel != "email": + raise HTTPException( + status_code=422, detail="delivery_format is supported only for email delivery" + ) + + delivery_changed = delivery_format != job.delivery_format or delivery_requested and ( delivery_channel != job.delivery_channel or delivery_target != job.delivery_target or delivery_target_ref != job.delivery_target_ref @@ -745,6 +762,7 @@ def register_scheduled_routes( job.end_at = end_at job.next_run_at = next_run_at if delivery_changed: + job.delivery_format = delivery_format job.delivery_channel = delivery_channel job.delivery_target = delivery_target job.delivery_target_ref = delivery_target_ref @@ -752,7 +770,7 @@ def register_scheduled_routes( # The previous outbox receipt describes the old destination. Clear # it rather than presenting that receipt as if it belonged to the # newly-authored delivery configuration. - job.delivery = DeliveryRecord() + job.delivery = DeliveryRecord(session_id=job.delivery.session_id) store.upsert(job) return _job_to_response(job) @@ -878,6 +896,8 @@ def register_scheduled_routes( variables=request.variables, config=request.config, next_run_at=request.next_run_at, + delivery_target_ref=request.delivery_target_ref, + delivery_format=request.delivery_format, **kwargs, ) except ValueError as exc: @@ -896,18 +916,6 @@ def register_scheduled_routes( job.title = request.title or playbook.name job.source_type = "playbook" job.playbook_slug = playbook.slug - if request.delivery_target_ref: - from src.channels.targets import resolve_delivery_target - - try: - target = resolve_delivery_target(request.delivery_target_ref) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - job.delivery_channel = target.channel - job.delivery_target = target.target - job.delivery_target_ref = target.ref - job.delivery_target_label = target.label - _get_scheduled_research_store().upsert(job) return _job_to_response(job) diff --git a/agent/src/channels/config_meta.py b/agent/src/channels/config_meta.py index 71a4786e..e7c98db8 100644 --- a/agent/src/channels/config_meta.py +++ b/agent/src/channels/config_meta.py @@ -183,6 +183,7 @@ def _email_hints() -> list[FieldHint]: ("allow_from", "list", False, False), ("verify_dkim", "bool", False, False), ("verify_spf", "bool", False, False), + ("trusted_authserv_id", "text", False, False), ("allowed_attachment_types", "list", False, False), ("max_attachment_size", "text", False, False), ("max_attachments_per_email", "text", False, False), diff --git a/agent/src/channels/email.py b/agent/src/channels/email.py index 7b0a72e3..951fbf2a 100644 --- a/agent/src/channels/email.py +++ b/agent/src/channels/email.py @@ -3,8 +3,10 @@ import asyncio import html import imaplib +import logging import mimetypes import re +import shlex import smtplib from contextlib import suppress from dataclasses import dataclass @@ -20,7 +22,6 @@ from typing import Any, Literal from src.channels.rich_text import render_email_html -import logging; logger = logging.getLogger(__name__) from pydantic import Field from src.channels import email_probe @@ -31,6 +32,8 @@ from src.channels.utils import get_media_dir from pydantic import BaseModel from src.channels.utils import email_tls_context, safe_filename, send_imap_id +logger = logging.getLogger(__name__) + class EmailConfig(BaseModel): """Email channel configuration (IMAP inbound + SMTP outbound).""" @@ -80,7 +83,8 @@ class EmailConfig(BaseModel): # from one an attacker forged in the message before it ever reached a # real authenticating server, so verification fails closed until it is # set. Find it by looking at the Authentication-Results header on any - # genuine email already in the inbox. + # genuine email already in the inbox. The receiver must strip untrusted + # copies bearing this ID (RFC 8601 section 5); the string is not a signature. trusted_authserv_id: str = "" # Attachment handling — set allowed types to enable (e.g. ["application/pdf", "image/*"], or ["*"] for all) @@ -255,12 +259,17 @@ class EmailChannel(BaseChannel): return await email_probe.test_connection(self.config) async def send(self, msg: OutboundMessage) -> None: - """Send email via SMTP.""" + """Send email via SMTP; explicit scheduled sends fail visibly if disabled.""" + force_send = bool((msg.metadata or {}).get("force_send")) if not self.config.consent_granted: + if force_send: + raise RuntimeError("Email delivery requires consent_granted") self.logger.warning("Skip email send: consent_granted is false") return if not self.config.smtp_host: + if force_send: + raise RuntimeError("Email delivery requires smtp_host") self.logger.warning("SMTP host not configured") return @@ -276,7 +285,6 @@ class EmailChannel(BaseChannel): # Determine if this is a reply (recipient has sent us an email before) is_reply = to_addr in self._last_subject_by_chat - force_send = bool((msg.metadata or {}).get("force_send")) # autoReplyEnabled only controls automatic replies, not proactive sends if is_reply and not self.config.auto_reply_enabled and not force_send: @@ -349,9 +357,9 @@ class EmailChannel(BaseChannel): email_msg.set_content(notice) email_msg.add_alternative(render_email_html(notice), subtype="html") try: - from weasyprint import HTML + from src.channels.rich_text import render_email_pdf - generated_pdf = HTML(string=render_email_html(content)).write_pdf() + generated_pdf = await asyncio.to_thread(render_email_pdf, content) except Exception: self.logger.exception("Failed to render required PDF attachment") raise @@ -915,104 +923,141 @@ class EmailChannel(BaseChannel): return cls._html_to_text(payload).strip() return payload.strip() - # One ";"-separated resinfo clause: optional leading comment, the - # method (optionally "/version"), "=", and the result token. - _AR_CLAUSE_METHOD_RE = re.compile( - r"(?:\(.*?\)\s*)?([a-zA-Z][a-zA-Z0-9_.-]*)(?:/[\d.]+)?\s*=\s*([a-zA-Z0-9_-]+)" - ) - # A "ptype.property=value" pair inside a clause, e.g. smtp.mailfrom=..., - # header.d=..., header.from=.... - _AR_PROPERTY_RE = re.compile( - r"\b([a-zA-Z][a-zA-Z0-9_-]*)\.([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*([^\s;()]+)" - ) + @staticmethod + def _authentication_results_segments(value: str) -> list[str]: + """Split clauses outside quoted strings and discard nested RFC comments. + + Malformed quoted strings or comments refuse the whole header. + """ + segments: list[str] = [] + current: list[str] = [] + depth = 0 + quoted = False + escaped = False + for char in value: + if escaped: + if not depth: + current.append(char) + escaped = False + elif char == "\\" and (depth or quoted): + if not depth: + current.append(char) + escaped = True + elif depth: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + elif char == '"': + quoted = not quoted + current.append(char) + elif not quoted and char == "(": + depth = 1 + current.append(" ") + elif not quoted and char == ")": + return [] + elif not quoted and char == ";": + segments.append("".join(current).strip()) + current = [] + else: + current.append(char) + if depth or quoted or escaped: + return [] + segments.append("".join(current).strip()) + return segments @classmethod def _select_trusted_authentication_results( cls, parsed_msg: Any, trusted_authserv_id: str ) -> str | None: - """Return the one Authentication-Results header this deployment trusts. + """Select one unambiguous receiver header by configured authserv-id. - Requires ``trusted_authserv_id`` and returns the header whose leading - authserv-id token matches it (case-insensitively); ``None`` when - unconfigured or no header matches. An attacker cannot forge that - match without already controlling the named server -- unlike - trusting "whichever header is on top", which a message that never - passed through a real authenticating server has no legitimate - instance of at all, letting a forged one stand in for it. + The receiving provider MUST remove forged copies bearing its own ID + (RFC 8601 section 5). The ID alone is not cryptographic provenance. + Unknown, malformed or duplicate matching headers fail closed. Args: parsed_msg: Parsed email message. - trusted_authserv_id: This deployment's own mail server's - authserv-id, or empty when not configured. + trusted_authserv_id: ID of the configured stripping receiver. Returns: - The trusted header's raw value, or ``None``. + The sole matching header, or None. """ - if not trusted_authserv_id: - return None - headers = parsed_msg.get_all("Authentication-Results") or [] wanted = trusted_authserv_id.strip().lower() - for header in headers: - authserv_id = str(header).split(";", 1)[0].strip().lower() - if authserv_id == wanted: - return str(header) - return None + if not wanted: + return None + matches: list[str] = [] + for header in parsed_msg.get_all("Authentication-Results") or []: + segments = cls._authentication_results_segments(str(header)) + if not segments: + continue + try: + identity = shlex.split(segments[0]) + except ValueError: + continue + if identity and identity[0].lower() == wanted: + if len(identity) > 2 or (len(identity) == 2 and identity[1] != "1"): + return None + matches.append(str(header)) + return matches[0] if len(matches) == 1 else None - @staticmethod + @classmethod def _parse_authentication_results_clauses( - header_value: str, + cls, header_value: str, ) -> list[dict[str, Any]]: - """Split one Authentication-Results header into its per-method clauses. + """Read method/result and properties without interpreting quoted prose. - Args: - header_value: One raw ``Authentication-Results`` header value. - - Returns: - A list of ``{"method": str, "result": str, "properties": - {(ptype, property): value}}`` dicts, one per ``;``-separated - resinfo segment after the authserv-id. A segment with no - parseable ``method=result`` is skipped rather than raising, so - one garbled clause does not lose the other, well-formed ones. + Comments have already been removed; quoted property values are decoded + by the lexer. A malformed clause is never used for authentication. """ - _, _, rest = header_value.partition(";") clauses: list[dict[str, Any]] = [] - for segment in rest.split(";"): - match = EmailChannel._AR_CLAUSE_METHOD_RE.search(segment) - if not match: + for segment in cls._authentication_results_segments(header_value)[1:]: + lexer = shlex.shlex(segment, posix=True, punctuation_chars="=") + lexer.whitespace_split = True + lexer.commenters = "" + try: + tokens = list(lexer) + except ValueError: + continue + if len(tokens) < 3 or tokens[1] != "=": + continue + if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_-]*(?:/[0-9.]+)?", tokens[0]): + continue + if not re.fullmatch(r"[a-zA-Z0-9_-]+", tokens[2]): continue properties: dict[tuple[str, str], str] = {} - for prop_match in EmailChannel._AR_PROPERTY_RE.finditer(segment): - ptype = prop_match.group(1).lower() - prop = prop_match.group(2).lower() - properties[(ptype, prop)] = prop_match.group(3) - clauses.append( - { - "method": match.group(1).lower(), - "result": match.group(2).lower(), - "properties": properties, - } - ) + valid = (len(tokens) - 3) % 3 == 0 + for index in range(3, len(tokens) - 2, 3): + key, equals, value = tokens[index:index + 3] + if equals != "=": + valid = False + break + if key.lower() == "reason": + continue + if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_-]*\.[a-zA-Z][a-zA-Z0-9_-]*", key): + valid = False + break + pair = tuple(key.lower().split(".", 1)) + if pair in properties: + valid = False + break + properties[pair] = value + if valid: + clauses.append({"method": tokens[0].lower().split("/", 1)[0], + "result": tokens[2].lower(), "properties": properties}) return clauses @staticmethod def _domains_align(candidate: str, from_domain: str) -> bool: - """Conservative same-or-subdomain check, relaxed-alignment style. + """Use strict domain alignment; relaxed alignment belongs to the receiver. - Not a full RFC 7489 organizational-domain computation (that needs a - public suffix list this module does not carry) -- a same-or-subdomain - match in either direction, which is the common case for a single - organization's mail infrastructure and never accepts an unrelated - domain. + A suffix alone cannot establish organizational ownership (public + suffixes and delegated subdomains exist). A trusted DMARC verdict can + supply relaxed alignment without a local public-suffix database. """ candidate = candidate.strip().lower().rstrip(".") from_domain = from_domain.strip().lower().rstrip(".") - if not candidate or not from_domain: - return False - return ( - candidate == from_domain - or candidate.endswith("." + from_domain) - or from_domain.endswith("." + candidate) - ) + return bool(candidate and from_domain and candidate == from_domain) @classmethod def _check_authentication_results( @@ -1045,10 +1090,18 @@ class EmailChannel(BaseChannel): if header is None: return False, False - from_domain = parseaddr(parsed_msg.get("From", ""))[1].rsplit("@", 1)[-1] + from_headers = parsed_msg.get_all("From") or [] + if len(from_headers) != 1: + return False, False + from_address = parseaddr(from_headers[0])[1] + if "@" not in from_address: + return False, False + from_domain = from_address.rsplit("@", 1)[-1] clauses = cls._parse_authentication_results_clauses(header) dmarc_pass = any( - c["method"] == "dmarc" and c["result"] == "pass" for c in clauses + c["method"] == "dmarc" and c["result"] == "pass" + and cls._domains_align(c["properties"].get(("header", "from"), ""), from_domain) + for c in clauses ) spf_pass = False diff --git a/agent/src/channels/manager.py b/agent/src/channels/manager.py index 6c694219..26dda0f1 100644 --- a/agent/src/channels/manager.py +++ b/agent/src/channels/manager.py @@ -6,9 +6,7 @@ import asyncio import hashlib import logging from collections import defaultdict -from collections.abc import Callable from contextlib import suppress -from pathlib import Path from typing import Any from src.channels.base import BaseChannel diff --git a/agent/src/channels/rich_text.py b/agent/src/channels/rich_text.py index af4f05a0..108d0ff0 100644 --- a/agent/src/channels/rich_text.py +++ b/agent/src/channels/rich_text.py @@ -2,9 +2,18 @@ from __future__ import annotations +import logging +from pathlib import Path +from tempfile import TemporaryDirectory + import nh3 from mistune import create_markdown +logger = logging.getLogger(__name__) + +# Failed native imports leave partial CFFI modules behind; probe once per process. +_WEASYPRINT_HTML = None + _MARKDOWN = create_markdown(escape=True, plugins=["table", "strikethrough", "url"]) _ALLOWED_TAGS = { @@ -17,13 +26,7 @@ _ALLOWED_ATTRIBUTES: dict[str, set[str]] = { "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; @@ -43,7 +46,14 @@ pre { background: #f3f4f6; padding: 12px; overflow-wrap: anywhere; white-space: def render_markdown_fragment(text: str) -> str: """Render Markdown to a conservative, sanitized HTML fragment.""" - return _CLEANER.clean(_MARKDOWN(text or "")).strip() + return nh3.clean( + _MARKDOWN(text or ""), + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRIBUTES, + url_schemes={"https", "http", "mailto"}, + strip_comments=True, + link_rel="noopener noreferrer", + ).strip() def render_email_html(text: str) -> str: @@ -53,3 +63,47 @@ def render_email_html(text: str) -> str: '' f"{body}" ) + + +def render_email_pdf(text: str) -> bytes: + """Render a real PDF, using the packaged renderer without native libraries. + + Args: + text: Report Markdown, treated as untrusted text. + + Returns: + PDF bytes for attachment; never a plain-text substitute. + + Raises: + Exception: The selected PDF renderer cannot produce the report. + """ + global _WEASYPRINT_HTML + if _WEASYPRINT_HTML is None: + try: + from weasyprint import HTML + + _WEASYPRINT_HTML = HTML + except (ImportError, OSError): + _WEASYPRINT_HTML = False + if _WEASYPRINT_HTML is not False: + from src.shadow_account.fonts import cjk_font_path + + css = "" + font = cjk_font_path(allow_download=False) + if font is not None: + css = ( + "@font-face { font-family: 'Report CJK'; src: url('" + + font.resolve().as_uri() + + "'); } body { font-family: 'Report CJK', sans-serif; }" + ) + document = render_email_html(text).replace("", css + "") + return _WEASYPRINT_HTML(string=document).write_pdf() + # Pip cannot supply Pango on every desktop. The existing report + # renderer embeds our packaged font and has no native dependency. + logger.info("Native PDF renderer unavailable; using packaged PDF renderer") + from src.tools.pdf_report import render_markdown_pdf + + with TemporaryDirectory(prefix="vibe-email-pdf-") as directory: + target = Path(directory) / "report.pdf" + render_markdown_pdf(text, target) + return target.read_bytes() diff --git a/agent/src/channels/runtime.py b/agent/src/channels/runtime.py index 6e8c5289..516648cd 100644 --- a/agent/src/channels/runtime.py +++ b/agent/src/channels/runtime.py @@ -205,6 +205,10 @@ class ChannelRuntime: schedule = job.get("schedule") or {} delivery = job.get("delivery") or {} action = "create" if proposal.get("operation") == "create" else "cancel" + email_format = "" + if delivery.get("channel") == "email": + label = {"html": "HTML", "pdf": "PDF"}.get(delivery.get("format"), "plain text") + email_format = f"Email format: {label}\n" reply_content = ( f"{reply_content}\n\n" f"[Scheduled research confirmation · {action}]\n" @@ -212,6 +216,7 @@ class ChannelRuntime: f"Schedule: {schedule.get('expression') or '-'} · " f"{schedule.get('timezone') or 'UTC'}\n" f"Delivery: {delivery.get('target_label') or 'in-app only'}\n" + f"{email_format}" 'Reply exactly "confirm" (确认) to commit, or "cancel" (取消) ' "to discard." ) diff --git a/agent/src/channels/slack.py b/agent/src/channels/slack.py index 26972ec7..bc574f2e 100644 --- a/agent/src/channels/slack.py +++ b/agent/src/channels/slack.py @@ -74,6 +74,7 @@ class SlackChannel(BaseChannel): name = "slack" display_name = "Slack" delivery_target_label = "Slack destination" + delivery_target_kind = "slack_destination" delivery_target_placeholder = "#channel, @user, or conversation ID" _SLACK_ID_RE = re.compile(r"^[CDGUW][A-Z0-9]{2,}$") _SLACK_CHANNEL_REF_RE = re.compile(r"^<#([A-Z0-9]+)(?:\|[^>]+)?>$") diff --git a/agent/src/scheduled_research/playbooks.py b/agent/src/scheduled_research/playbooks.py index 0f34aa39..2ee5cf1f 100644 --- a/agent/src/scheduled_research/playbooks.py +++ b/agent/src/scheduled_research/playbooks.py @@ -149,6 +149,8 @@ class ResearchPlaybook: config: Optional[Mapping[str, Any]] = None, next_run_at: Optional[int] = None, now_ms: Optional[int] = None, + delivery_target_ref: Optional[str] = None, + delivery_format: Optional[str] = None, ) -> ScheduledResearchJob: """Build a schedulable job from this playbook. @@ -170,6 +172,8 @@ class ResearchPlaybook: for config resolution. next_run_at: Explicit first-fire epoch-ms, bypassing the rule above. now_ms: Injectable clock for tests, in epoch milliseconds. + delivery_target_ref: Operator-configured destination reference. + delivery_format: Email presentation, html/pdf; None keeps plain text. Returns: A ``PENDING`` :class:`ScheduledResearchJob`. @@ -199,6 +203,14 @@ class ResearchPlaybook: job_config: Dict[str, Any] = dict(config or {}) job_config.setdefault("playbook", self.slug) + from src.channels.targets import resolve_delivery_target + + target = resolve_delivery_target(delivery_target_ref) if delivery_target_ref else None + if delivery_format not in (None, "html", "pdf"): + raise ValueError("delivery_format must be 'html', 'pdf', or null") + if delivery_format is not None and (target is None or target.channel != "email"): + raise ValueError("delivery_format is supported only for email delivery") + return ScheduledResearchJob( id=job_id or f"playbook-{self.slug}-{uuid.uuid4().hex[:8]}", prompt=self.render(variables), @@ -208,6 +220,14 @@ class ResearchPlaybook: created_at=now, config=job_config, timezone=tz, + title=self.name, + source_type="playbook", + playbook_slug=self.slug, + delivery_channel=target.channel if target else None, + delivery_target=target.target if target else None, + delivery_target_ref=target.ref if target else None, + delivery_target_label=target.label if target else None, + delivery_format=delivery_format, ) def to_dict(self, include_body: bool = False) -> Dict[str, Any]: diff --git a/agent/src/scheduled_research/service.py b/agent/src/scheduled_research/service.py index 97d7c8ab..6fc429ff 100644 --- a/agent/src/scheduled_research/service.py +++ b/agent/src/scheduled_research/service.py @@ -46,6 +46,7 @@ def public_job(job: ScheduledResearchJob) -> dict[str, Any]: "channel": job.delivery_channel, "target_ref": job.delivery_target_ref, "target_label": job.delivery_target_label, + "format": job.delivery_format, "status": job.delivery.status.value, "attempts": job.delivery.attempts, "provider_message_id": job.delivery.provider_message_id, @@ -182,6 +183,12 @@ def build_job_from_draft( elif mode != "in_app": raise ValueError("delivery.mode must be 'in_app', 'origin', or 'configured'") + delivery_format = delivery_spec.get("format") + if delivery_format not in (None, "html", "pdf"): + raise ValueError("delivery.format must be 'html', 'pdf', or null") + if delivery_format is not None and delivery_channel != "email": + raise ValueError("delivery.format is supported only for email delivery") + return ScheduledResearchJob( id=str(draft.get("id") or f"sr-{uuid.uuid4().hex[:12]}"), title=title, @@ -199,6 +206,7 @@ def build_job_from_draft( delivery_target=delivery_target, delivery_target_ref=target_ref, delivery_target_label=target_label, + delivery_format=delivery_format, ) diff --git a/agent/src/tools/scheduled_research_tool.py b/agent/src/tools/scheduled_research_tool.py index dd3629e3..14807d05 100644 --- a/agent/src/tools/scheduled_research_tool.py +++ b/agent/src/tools/scheduled_research_tool.py @@ -72,6 +72,11 @@ class ScheduledResearchTool(BaseTool): "enum": ["in_app", "origin", "configured"], }, "target_ref": {"type": "string"}, + "format": { + "type": ["string", "null"], + "enum": ["html", "pdf", None], + "description": "Email only; omit or null for plain text. Destination must be configured or from the origin session.", + }, }, "required": ["mode"], }, diff --git a/agent/tests/test_channel_config_meta.py b/agent/tests/test_channel_config_meta.py index c21a3bdb..2fa121c5 100644 --- a/agent/tests/test_channel_config_meta.py +++ b/agent/tests/test_channel_config_meta.py @@ -306,6 +306,7 @@ _EMAIL_HINT_KEYS = ( "allow_from", "verify_dkim", "verify_spf", + "trusted_authserv_id", "allowed_attachment_types", "max_attachment_size", "max_attachments_per_email", @@ -337,7 +338,7 @@ def test_email_field_hints_contract() -> None: keys = tuple(hint["key"] for hint in hints) assert keys == _EMAIL_HINT_KEYS assert "enabled" not in keys - assert len(hints) == 31 + assert len(hints) == 32 by_key = {hint["key"]: hint for hint in hints} assert {key for key, hint in by_key.items() if hint["secret"]} == { diff --git a/agent/tests/test_channels_config_api.py b/agent/tests/test_channels_config_api.py index 2f26b183..a70602d1 100644 --- a/agent/tests/test_channels_config_api.py +++ b/agent/tests/test_channels_config_api.py @@ -1352,3 +1352,13 @@ def test_email_test_normalizes_blank_nullable_fields_without_persisting(tmp_path assert response.status_code == 200 and response.json()["ok"] is False assert response.json()["code"] == "invalid_credentials" and seen == [None] assert response.json()["detail"].startswith("validation_error:") + + +def test_email_authentication_trust_anchor_is_editable_in_generic_form(tmp_path, monkeypatch): + client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) + entry = client.get("/channels/config").json()["channels"]["email"] + field = next(field for field in entry["fields"] if field["key"] == "trusted_authserv_id") + assert field["help_key"] == "settings.channels.fields.email.trusted_authserv_id" + response = client.put("/channels/config/email", json={"config": {"trusted_authserv_id": "mx.example.test"}}) + assert response.status_code == 200 + assert json.loads(path.read_text())["channels"]["email"]["trusted_authserv_id"] == "mx.example.test" diff --git a/agent/tests/test_channels_runtime.py b/agent/tests/test_channels_runtime.py index 766e89ba..1f8312b3 100644 --- a/agent/tests/test_channels_runtime.py +++ b/agent/tests/test_channels_runtime.py @@ -986,3 +986,33 @@ def test_signal_group_command_requires_per_sender_authorization() -> None: ) assert allowed is True assert allowed_chat == "group-1" + + +@pytest.mark.parametrize("fmt,label", [("html", "HTML"), ("pdf", "PDF"), (None, "plain text")]) +def test_channel_confirmation_displays_email_format(tmp_path, monkeypatch, fmt, label): + from src.scheduled_research import proposals + + monkeypatch.setattr(proposals, "latest_pending_for_session", lambda sid: { + "operation": "create", "job": { + "title": "Research", "schedule": {"expression": "60000"}, + "delivery": {"channel": "email", "target_label": "Research", "format": fmt}, + }, + }) + + async def scenario(): + from src.channels.runtime import ChannelRuntime + + bus = MessageBus() + runtime = ChannelRuntime(bus=bus, session_service=FakeSessionService(), manager=None, + session_map_path=tmp_path / "sessions.json", reply_timeout_s=1, + poll_interval_s=0.01) + await runtime.start(start_manager=False) + try: + await bus.publish_inbound(InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="schedule research")) + outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=1) + assert f"Email format: {label}" in outbound.content + assert 'Reply exactly "confirm"' in outbound.content + finally: + await runtime.stop() + + asyncio.run(scenario()) diff --git a/agent/tests/test_email_authentication_results.py b/agent/tests/test_email_authentication_results.py index c74aa123..5df1e59c 100644 --- a/agent/tests/test_email_authentication_results.py +++ b/agent/tests/test_email_authentication_results.py @@ -129,9 +129,8 @@ def test_pass_for_an_unaligned_domain_is_not_honoured() -> None: assert (spf_pass, dkim_pass) == (False, False) -def test_subdomain_of_the_from_domain_is_aligned() -> None: - """A relaxed-alignment match: mail.trusted-domain.com authenticating for - a From: address at trusted-domain.com is the same organization.""" +def test_subdomain_alignment_requires_receiver_dmarc() -> None: + """Delegated subdomains require the receiver's explicit DMARC alignment.""" raw = ( b"From: real@trusted-domain.com\r\n" b"To: bot@example.com\r\n" @@ -145,7 +144,7 @@ def test_subdomain_of_the_from_domain_is_aligned() -> None: spf_pass, dkim_pass = EmailChannel._check_authentication_results( _parse(raw), "mx.ourprovider.com" ) - assert (spf_pass, dkim_pass) == (True, True) + assert (spf_pass, dkim_pass) == (False, False) def test_dmarc_pass_substitutes_for_domain_alignment() -> None: @@ -199,3 +198,37 @@ def test_spf_and_dkim_are_independent_verdicts() -> None: _parse(raw), "mx.ourprovider.com" ) assert (spf_pass, dkim_pass) == (True, False) + + +def _verdict(header: str, *, from_address: str = "victim@example.com"): + raw = f"From: {from_address}\r\nAuthentication-Results: {header}\r\n\r\nbody".encode() + return EmailChannel._check_authentication_results(_parse(raw), "mx.example") + + +def test_comments_and_quoted_reason_are_not_authentication_clauses(): + assert _verdict('mx.example; (nested (spf=pass) smtp.mailfrom=example.com); (dkim=pass header.d=example.com)') == (False, False) + assert _verdict('mx.example; reason="spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com"') == (False, False) + assert _verdict('mx.example; spf=fail reason="spf=pass smtp.mailfrom=example.com"') == (False, False) + + +def test_quoted_domains_comments_and_authserv_version_are_valid(): + assert _verdict('"mx.example" (receiver) 1; spf/1=pass (verified) smtp.mailfrom="victim@example.com"; dkim=pass header.d="example.com"') == (True, True) + + +def test_dmarc_must_bind_the_visible_from_domain(): + assert _verdict('mx.example; spf=pass smtp.mailfrom=evil.test; dkim=pass header.d=evil.test; dmarc=pass header.from=evil.test') == (False, False) + assert _verdict('mx.example; spf=pass smtp.mailfrom=mail.example.com; dkim=pass header.d=mail.example.com; dmarc=pass header.from=example.com') == (True, True) + + +def test_duplicate_matching_headers_fail_closed(): + assert _verdict('mx.example; spf=pass smtp.mailfrom=example.com\r\nAuthentication-Results: mx.example; spf=fail smtp.mailfrom=example.com') == (False, False) + + +def test_delegated_suffix_is_not_ownership_without_dmarc(): + assert _verdict('mx.example; spf=pass smtp.mailfrom=com; dkim=pass header.d=com') == (False, False) + assert _verdict('mx.example; spf=pass smtp.mailfrom=tenant.example.com; dkim=pass header.d=tenant.example.com') == (False, False) + + +def test_malformed_comments_and_conflicting_properties_fail_closed(): + assert _verdict('mx.example; spf=pass smtp.mailfrom=example.com (unclosed') == (False, False) + assert _verdict('mx.example; spf=pass smtp.mailfrom=evil.test smtp.mailfrom=example.com') == (False, False) diff --git a/agent/tests/test_email_rich_delivery.py b/agent/tests/test_email_rich_delivery.py index 22227fda..76095122 100644 --- a/agent/tests/test_email_rich_delivery.py +++ b/agent/tests/test_email_rich_delivery.py @@ -91,6 +91,7 @@ def test_per_message_pdf_keeps_report_out_of_body(monkeypatch): return b"%PDF-1.7\n%%EOF\n" monkeypatch.setitem(sys.modules, "weasyprint", SimpleNamespace(HTML=FakeHTML)) + monkeypatch.setattr("src.channels.rich_text._WEASYPRINT_HTML", None) asyncio.run( channel.send( @@ -116,3 +117,70 @@ def test_per_message_pdf_keeps_report_out_of_body(monkeypatch): assert len(pdf_parts) == 1 assert pdf_parts[0].get_filename() == "vibe-trading-report.pdf" assert pdf_parts[0].get_payload(decode=True).startswith(b"%PDF") + + +def test_pdf_failure_never_sends_report_as_plain_text(monkeypatch): + import pytest + import src.channels.rich_text as rich_text + + channel = _channel() + sent = [] + monkeypatch.setattr(channel, "_smtp_send", sent.append) + def fail(_content): + raise RuntimeError("PDF failed") + monkeypatch.setattr(rich_text, "render_email_pdf", fail) + with pytest.raises(RuntimeError, match="PDF failed"): + asyncio.run(channel.send(OutboundMessage(channel="email", chat_id="reader@example.test", content="private report", metadata={"delivery_format": "pdf"}))) + assert not sent + + +def test_pdf_render_runs_outside_event_loop(monkeypatch): + import threading + import src.channels.rich_text as rich_text + + channel = _channel() + main_thread = threading.get_ident() + calls = [] + def render(content): + assert threading.get_ident() != main_thread + calls.append(content) + return b"%PDF-1.7\n%%EOF\n" + monkeypatch.setattr(rich_text, "render_email_pdf", render) + monkeypatch.setattr(channel, "_smtp_send", lambda _msg: None) + asyncio.run(channel.send(OutboundMessage(channel="email", chat_id="reader@example.test", content="report", metadata={"delivery_format": "pdf"}))) + assert calls == ["report"] + + +def test_packaged_fallback_generates_real_multilingual_pdf(monkeypatch): + import pypdfium2 as pdfium + from src.channels.rich_text import render_email_pdf + + monkeypatch.setitem(sys.modules, "weasyprint", None) + monkeypatch.setattr("src.channels.rich_text._WEASYPRINT_HTML", None) + data = render_email_pdf("# Daily report 每日报告\n\n中文报告 日本語 한국어\n\n| Asset | Weight |\n|---|---|\n| ABC | 12.5% |") + assert data.startswith(b"%PDF") + pdf = pdfium.PdfDocument(data) + extracted = "".join(pdf[i].get_textpage().get_text_range() for i in range(len(pdf))) + for expected in ["Daily report", "每日报告", "中文报告", "日本語", "한국어", "12.5%"]: + assert expected in extracted + + +def test_scheduled_delivery_forces_proactive_mail_but_requires_consent(monkeypatch): + import pytest + import api_server + from src.api.scheduled_routes import _send_scheduled_briefing + + channel = _channel() + channel.config.auto_reply_enabled = False + channel._last_subject_by_chat["reader@example.test"] = "old conversation" + sent = [] + monkeypatch.setattr(channel, "_smtp_send", sent.append) + monkeypatch.setattr(api_server, "_channel_manager", SimpleNamespace(get_channel=lambda _name: channel), raising=False) + receipt = asyncio.run(_send_scheduled_briefing("email", "reader@example.test", "scheduled report", "html")) + assert receipt.status == "accepted" + assert len(sent) == 1 + assert "scheduled report" in sent[0].get_body(preferencelist=("html",)).get_content() + channel.config.consent_granted = False + with pytest.raises(RuntimeError, match="consent_granted"): + asyncio.run(_send_scheduled_briefing("email", "reader@example.test", "private report")) + assert len(sent) == 1 diff --git a/agent/tests/test_playbooks_surface.py b/agent/tests/test_playbooks_surface.py index 2ba71a67..6c5874a3 100644 --- a/agent/tests/test_playbooks_surface.py +++ b/agent/tests/test_playbooks_surface.py @@ -23,7 +23,7 @@ patching the route module's singleton (REST). from __future__ import annotations from pathlib import Path -from typing import Any, List, Tuple +from typing import Any, List import pytest from fastapi.testclient import TestClient @@ -768,3 +768,50 @@ def test_catalogue_size_matches_the_loader(client: TestClient) -> None: rows = client.get("/scheduled-runs/playbooks").json() assert [row["slug"] for row in rows] == [pb.slug for pb in list_playbooks()] + + +@pytest.fixture +def configured_destinations(monkeypatch): + from types import SimpleNamespace + from src.channels import targets + + monkeypatch.setattr(targets, "load_agent_config", lambda: SimpleNamespace(channels=SimpleNamespace(delivery_targets={ + "research": SimpleNamespace(enabled=True, label="Research", channel="email", target="report@example.com"), + "team": SimpleNamespace(enabled=True, label="Team", channel="slack", target="channel-id"), + }))) + + +@pytest.mark.parametrize("fmt", ["html", "pdf"]) +def test_cli_persists_email_format(cli_store, configured_destinations, fmt): + assert _run_cli(["playbook", "create", SAMPLE, "--id", "mail-job", "--delivery-target-ref", "research", "--delivery-format", fmt, "--json"]) == 0 + job = cli_store.get("mail-job") + assert job.delivery_format == fmt + assert job.delivery_channel == "email" + assert job.delivery_target == "report@example.com" + assert job.delivery_target_ref == "research" + + +@pytest.mark.parametrize("ref", [None, "team", "unknown", "injected@example.com"]) +def test_cli_rejects_nonemail_format_without_persisting(cli_store, configured_destinations, ref): + args = ["playbook", "create", SAMPLE, "--delivery-format", "pdf"] + if ref: + args += ["--delivery-target-ref", ref] + assert _run_cli(args) == 1 + assert cli_store.load() == {} + + +@pytest.mark.parametrize("fmt", [None, "html", "pdf"]) +def test_playbook_rest_persists_email_format(client, rest_store, configured_destinations, fmt): + response = client.post(f"/scheduled-runs/playbooks/{SAMPLE}", json={"id": "mail-job", "delivery_target_ref": "research", "delivery_format": fmt}) + assert response.status_code == 201, response.text + assert response.json()["delivery_format"] == fmt + job = rest_store.get("mail-job") + assert job.delivery_format == fmt + assert job.delivery_target == "report@example.com" + + +@pytest.mark.parametrize("ref", [None, "team", "unknown"]) +def test_playbook_rest_rejects_nonemail_format(client, rest_store, configured_destinations, ref): + response = client.post(f"/scheduled-runs/playbooks/{SAMPLE}", json={"delivery_target_ref": ref, "delivery_format": "pdf"}) + assert response.status_code == 422 + assert rest_store.load() == {} diff --git a/agent/tests/test_scheduled_research_confirmation.py b/agent/tests/test_scheduled_research_confirmation.py index 83c2dfce..4491e9fc 100644 --- a/agent/tests/test_scheduled_research_confirmation.py +++ b/agent/tests/test_scheduled_research_confirmation.py @@ -235,3 +235,78 @@ def test_im_confirmation_accepts_english_and_chinese_tokens( assert len(store.load()) == 2 asyncio.run(scenario()) + + +def test_email_format_survives_human_proposal_commit(tmp_path, monkeypatch): + from src.scheduled_research import proposals, service + + monkeypatch.setenv("VIBE_TRADING_HOME", str(tmp_path)) + store = ScheduledResearchJobStore(tmp_path / "jobs.json") + monkeypatch.setattr(proposals, "default_store", lambda: store) + monkeypatch.setattr(proposals, "scheduler_status", lambda: {"executable": True}) + monkeypatch.setattr(service.time, "time", lambda: 1000) + monkeypatch.setattr(service, "resolve_delivery_target", lambda ref: DeliveryTarget(ref, "Research", "email", "configured@example.com")) + draft = _draft() + draft["delivery"] = {"mode": "configured", "target_ref": "research", "format": "pdf"} + proposal = json.loads(ScheduledResearchTool().execute(action="propose_create", draft=draft)) + assert proposal["job"]["delivery"]["format"] == "pdf" + assert "configured@example.com" not in json.dumps(proposal) + assert store.load() == {} + proposals.commit_proposal(proposal["proposal_id"]) + job = next(iter(store.load().values())) + assert job.delivery_format == "pdf" + assert job.delivery_target == "configured@example.com" + + +def test_origin_format_uses_session_destination(monkeypatch): + from types import SimpleNamespace + import api_server + + session = SimpleNamespace(config={"channel": "email", "channel_chat_id": "origin@example.com"}) + monkeypatch.setattr(api_server, "_get_session_service", lambda: SimpleNamespace(get_session=lambda sid: session)) + draft = _draft() + draft["delivery"] = {"mode": "origin", "format": "html", "target": "injected@example.com"} + job = build_job_from_draft(draft, session_id="s", now_ms=1000) + assert job.delivery_target == "origin@example.com" + assert public_job(job)["delivery"]["format"] == "html" + import pytest + session.config["channel"] = "slack" + with pytest.raises(ValueError, match="only for email"): + build_job_from_draft(draft, session_id="s", now_ms=1000) + + +def test_invalid_format_cannot_create_a_proposal(tmp_path, monkeypatch): + import pytest + from src.scheduled_research import service + + monkeypatch.setenv("VIBE_TRADING_HOME", str(tmp_path)) + monkeypatch.setattr(service.time, "time", lambda: 1000) + monkeypatch.setattr(service, "resolve_delivery_target", lambda ref: DeliveryTarget(ref, "Team", "slack", "raw")) + for delivery in [ + {"mode": "in_app", "format": "pdf"}, + {"mode": "configured", "target_ref": "team", "format": "html"}, + {"mode": "in_app", "format": "rtf"}, + ]: + draft = _draft() + draft["delivery"] = delivery + with pytest.raises(ValueError, match="format"): + ScheduledResearchTool().execute(action="propose_create", draft=draft) + assert not list(tmp_path.rglob("srp_*.json")) + + +def test_cli_confirmation_displays_format_and_legacy_relay_preserves_it(monkeypatch): + from io import StringIO + from rich.console import Console + from cli.main import _render_scheduled_proposal + from cli._legacy import _scheduled_proposal_from_tool_result + from src.scheduled_research import proposals + + for fmt, label in [("html", "HTML"), ("pdf", "PDF"), (None, "纯文本")]: + proposal = {"operation": "create", "job": {"title": "Research", "delivery": {"channel": "email", "format": fmt}}} + monkeypatch.setattr(proposals, "load_proposal", lambda pid: proposal) + recovered = _scheduled_proposal_from_tool_result({"tool": "scheduled_research", "status": "ok", "preview": json.dumps({"proposal_id": "srp_" + "a" * 32})}) + assert recovered == proposal + output = StringIO() + _render_scheduled_proposal(Console(file=output, width=200), recovered) + assert f"邮件格式: {label}" in output.getvalue() + assert "[y/N]" in output.getvalue() diff --git a/agent/tests/test_scheduled_routes.py b/agent/tests/test_scheduled_routes.py index 0b91597f..3a9311b3 100644 --- a/agent/tests/test_scheduled_routes.py +++ b/agent/tests/test_scheduled_routes.py @@ -591,3 +591,35 @@ def test_list_omits_verdict_when_never_recorded( assert response.status_code == 200 (row,) = response.json() assert row["last_verdict"] is None + + +def test_patch_refuses_edit_while_delivery_is_in_flight(client, store): + job = _seed(store, delivery=DeliveryRecord(status=DeliveryStatus.SENDING, session_id="s1", key="k1")) + response = client.patch(f"/scheduled-runs/{job.id}", json={"prompt": "new prompt"}) + assert response.status_code == 409 + assert store.get(job.id).prompt == job.prompt + + +def test_patch_email_format_preserves_target_ref_and_clears_old_receipt(client, store): + job = _seed(store, delivery_channel="email", delivery_target="reader@example.test", + delivery_target_ref="mail-reader", delivery_target_label="Reader", delivery_format="html", + delivery=DeliveryRecord(status=DeliveryStatus.SENT, session_id="s1", key="k1")) + response = client.patch(f"/scheduled-runs/{job.id}", json={"delivery_format": "pdf"}) + assert response.status_code == 200, response.text + body = response.json() + assert body["delivery_format"] == "pdf" + assert body["delivery_target_ref"] == "mail-reader" + assert body["delivery_status"] == "none" + assert store.get(job.id).delivery.session_id == "s1" # retain verdict observation + assert client.patch(f"/scheduled-runs/{job.id}", json={"prompt": "other"}).json()["delivery_format"] == "pdf" + assert client.patch(f"/scheduled-runs/{job.id}", json={"delivery_format": None}).json()["delivery_format"] is None + + +def test_patch_leaving_email_clears_implicit_format_and_refuses_explicit_pdf(client, store): + job = _seed(store, delivery_channel="email", delivery_target="reader@example.test", delivery_format="pdf") + response = client.patch(f"/scheduled-runs/{job.id}", json={"delivery_channel": "telegram", "delivery_target": "123", "delivery_format": "pdf"}) + assert response.status_code == 422 + assert store.get(job.id).delivery_channel == "email" + response = client.patch(f"/scheduled-runs/{job.id}", json={"delivery_channel": "telegram", "delivery_target": "123"}) + assert response.status_code == 200 + assert response.json()["delivery_format"] is None diff --git a/frontend/src/components/chat/ScheduledResearchProposalCard.tsx b/frontend/src/components/chat/ScheduledResearchProposalCard.tsx index ace3cdd0..0b0e4f95 100644 --- a/frontend/src/components/chat/ScheduledResearchProposalCard.tsx +++ b/frontend/src/components/chat/ScheduledResearchProposalCard.tsx @@ -90,6 +90,16 @@ export const ScheduledResearchProposalCard = memo(function ScheduledResearchProp
{i18n.t("scheduled.proposalDelivery")}
{job.delivery.target_label || i18n.t("scheduled.proposalInApp")}
+ {job.delivery.channel === "email" && ( +
+
{i18n.t("scheduled.deliveryFormatLabel")}
+
{i18n.t(job.delivery.format === "html" + ? "scheduled.deliveryFormatHtml" + : job.delivery.format === "pdf" + ? "scheduled.deliveryFormatPdf" + : "scheduled.deliveryFormatDefault")}
+
+ )} )} diff --git a/frontend/src/components/chat/__tests__/ScheduledResearchProposalCard.test.tsx b/frontend/src/components/chat/__tests__/ScheduledResearchProposalCard.test.tsx new file mode 100644 index 00000000..4a96a2cb --- /dev/null +++ b/frontend/src/components/chat/__tests__/ScheduledResearchProposalCard.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ScheduledResearchProposalCard } from "../ScheduledResearchProposalCard"; +import type { ScheduledResearchProposal } from "@/lib/api"; +import i18n from "@/i18n"; + +function proposal(format?: "html" | "pdf" | null): ScheduledResearchProposal { + return { + type: "scheduled_research.proposal", proposal_id: "proposal", operation: "create", + status: "pending", expires_at: 2000000, + job: { + id: "job", title: "Research", state: "active", source: { kind: "prompt" }, + schedule: { expression: "60000", timezone: null, next_run_at: null, end_at: null }, + delivery: { channel: "email", target_ref: "research", target_label: "Research inbox", status: "none", format }, + }, + }; +} + +describe("scheduled proposal email format confirmation", () => { + it.each([ + ["html", "scheduled.deliveryFormatHtml"], + ["pdf", "scheduled.deliveryFormatPdf"], + [undefined, "scheduled.deliveryFormatDefault"], + [null, "scheduled.deliveryFormatDefault"], + ] as const)("shows %s before confirmation", (format, key) => { + render(); + expect(screen.getByText(i18n.t(key))).toBeInTheDocument(); + expect(screen.getByText("Research inbox")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/settings/ChannelConfigPanel.tsx b/frontend/src/components/settings/ChannelConfigPanel.tsx index fa4dec44..ce3f3ce0 100644 --- a/frontend/src/components/settings/ChannelConfigPanel.tsx +++ b/frontend/src/components/settings/ChannelConfigPanel.tsx @@ -258,6 +258,7 @@ export function ChannelConfigPanel({ "settings.channels.fields.email.allow_from": { label: t("settings.channels.fields.email.allow_from.label"), help: t("settings.channels.fields.email.allow_from.help") }, "settings.channels.fields.email.verify_dkim": { label: t("settings.channels.fields.email.verify_dkim.label"), help: t("settings.channels.fields.email.verify_dkim.help") }, "settings.channels.fields.email.verify_spf": { label: t("settings.channels.fields.email.verify_spf.label"), help: t("settings.channels.fields.email.verify_spf.help") }, + "settings.channels.fields.email.trusted_authserv_id": { label: t("settings.channels.fields.email.trusted_authserv_id.label"), help: t("settings.channels.fields.email.trusted_authserv_id.help") }, "settings.channels.fields.email.allowed_attachment_types": { label: t("settings.channels.fields.email.allowed_attachment_types.label"), help: t("settings.channels.fields.email.allowed_attachment_types.help") }, "settings.channels.fields.email.max_attachment_size": { label: t("settings.channels.fields.email.max_attachment_size.label"), help: t("settings.channels.fields.email.max_attachment_size.help") }, "settings.channels.fields.email.max_attachments_per_email": { label: t("settings.channels.fields.email.max_attachments_per_email.label"), help: t("settings.channels.fields.email.max_attachments_per_email.help") }, diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index b3705351..e961555a 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -148,7 +148,7 @@ "editingHint": "أنت تعدّل هذا التشغيل المجدول. يحافظ الحفظ على هويته وسجل التنفيذ.", "edit": "تعديل", "editAria": "تعديل التشغيل المجدول: {{prompt}}", - "editRunningDisabled": "انتظر حتى ينتهي هذا التشغيل قبل تعديله.", + "editRunningDisabled": "انتظر انتهاء التشغيل أو التسليم قبل التعديل.", "deliveryInApp": "الاحتفاظ في Vibe فقط (عدم الإرسال)", "deliveryNoChannels": "لا توجد قنوات تسليم مفعّلة ومهيأة. هيّئ واحدة في الإعدادات > القنوات.", "deliveryChannelsLoadFailed": "تعذّر تحميل قنوات التسليم المهيأة.", @@ -224,7 +224,13 @@ "verdictRecorded": "اعتبارًا من {{when}}", "deliveryKnownTargetLabel": "وجهة معروفة", "deliveryKnownTargetPlaceholder": "اختر وجهة معروفة…", - "deliverySuggestionKind_private_chat": "محادثتي الخاصة" + "deliverySuggestionKind_private_chat": "محادثتي الخاصة", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "معرّف المحادثة أو المجموعة أو المستخدم", + "deliveryTargetPlaceholder_channel": "معرّف القناة", + "deliveryTargetPlaceholder_destination": "معرّف محادثة / مجموعة / مستخدم", + "deliveryTargetPlaceholder_slack_destination": "#قناة أو @مستخدم أو معرّف محادثة", + "deliveryTargetKind_slack_destination": "الوجهة" }, "reports": { "badge": "التقارير", @@ -672,6 +678,10 @@ "max_attachments_per_email": { "label": "الحد الأقصى للمرفقات لكل رسالة", "help": "عدد المرفقات المقبولة من رسالة واحدة (الافتراضي 5)." + }, + "trusted_authserv_id": { + "label": "خادم المصادقة الموثوق", + "help": "معرّف authserv-id لخادم الاستلام، مثل mx.google.com. يجب على مزود البريد إزالة ترويسات Authentication-Results الواردة المزوّرة التي تحمل هذا المعرّف. مطلوب عند تفعيل SPF أو DKIM؛ تركه فارغًا يرفض البريد الوارد." } }, "websocket": { diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 85f610f5..5408d244 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -153,7 +153,7 @@ "editingHint": "Dieser geplante Lauf wird bearbeitet. Beim Speichern bleiben Identität und Ausführungsverlauf erhalten.", "edit": "Bearbeiten", "editAria": "Geplanten Lauf bearbeiten: {{prompt}}", - "editRunningDisabled": "Warten Sie, bis dieser Lauf beendet ist, bevor Sie ihn bearbeiten.", + "editRunningDisabled": "Warte, bis der Lauf oder die Zustellung abgeschlossen ist.", "deliveryInApp": "Nur in Vibe behalten (nicht senden)", "deliveryNoChannels": "Es sind keine aktivierten Zustellungskanäle konfiguriert. Konfigurieren Sie einen unter Einstellungen → Kanäle.", "deliveryChannelsLoadFailed": "Konfigurierte Zustellungskanäle konnten nicht geladen werden.", @@ -229,7 +229,13 @@ "verdictRecorded": "Stand {{when}}", "deliveryKnownTargetLabel": "Bekanntes Ziel", "deliveryKnownTargetPlaceholder": "Bekanntes Ziel auswählen…", - "deliverySuggestionKind_private_chat": "Privater Chat" + "deliverySuggestionKind_private_chat": "Privater Chat", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "Chat-, Gruppen- oder Benutzer-ID", + "deliveryTargetPlaceholder_channel": "Kanal-ID", + "deliveryTargetPlaceholder_destination": "Chat- / Gruppen- / Benutzer-ID", + "deliveryTargetPlaceholder_slack_destination": "#Kanal, @Benutzer oder Konversations-ID", + "deliveryTargetKind_slack_destination": "Ziel" }, "reports": { "badge": "Berichte", @@ -677,6 +683,10 @@ "max_attachments_per_email": { "label": "Max. Anhänge pro E-Mail", "help": "Wie viele Anhänge aus einer E-Mail übernommen werden (Standard 5)." + }, + "trusted_authserv_id": { + "label": "Vertrauenswürdiger Authentifizierungsserver", + "help": "authserv-id des empfangenden Servers, z. B. mx.google.com. Der Mailanbieter muss gefälschte eingehende Authentication-Results-Header mit dieser ID entfernen. Bei SPF- oder DKIM-Prüfung erforderlich; leer wird eingehende Mail abgewiesen." } }, "websocket": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 85a43fb5..d6c1e1b2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -187,7 +187,7 @@ "lastError": "Last error: {{error}}", "edit": "Edit", "editAria": "Edit scheduled run: {{prompt}}", - "editRunningDisabled": "Wait for this run to finish before editing it.", + "editRunningDisabled": "Wait for this run or its delivery to finish before editing it.", "delete": "Delete", "deleteAria": "Delete scheduled run: {{prompt}}", "confirmDelete": "Confirm delete", @@ -229,7 +229,13 @@ "verdictRecorded": "as of {{when}}", "deliveryKnownTargetLabel": "Known destination", "deliveryKnownTargetPlaceholder": "Choose a known destination…", - "deliverySuggestionKind_private_chat": "Private chat" + "deliverySuggestionKind_private_chat": "Private chat", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "Chat, group, or user ID", + "deliveryTargetPlaceholder_channel": "Channel ID", + "deliveryTargetPlaceholder_destination": "Chat, group, user, address, or channel ID", + "deliveryTargetPlaceholder_slack_destination": "#channel, @user, or conversation ID", + "deliveryTargetKind_slack_destination": "Destination" }, "reports": { "badge": "Reports", @@ -677,6 +683,10 @@ "max_attachments_per_email": { "label": "Max attachments per email", "help": "How many attachments are accepted from one email (default 5)." + }, + "trusted_authserv_id": { + "label": "Trusted authentication server", + "help": "Receiver authserv-id, e.g. mx.google.com. Your mail provider must strip incoming forged Authentication-Results headers bearing this ID. Required when SPF or DKIM verification is enabled; blank rejects inbound mail." } }, "websocket": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 99c4cf5f..6392565d 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -187,7 +187,7 @@ "lastError": "Último error: {{error}}", "edit": "Editar", "editAria": "Editar ejecución programada: {{prompt}}", - "editRunningDisabled": "Espera a que termine esta ejecución antes de editarla.", + "editRunningDisabled": "Espera a que termine la ejecución o su entrega antes de editar.", "delete": "Eliminar", "deleteAria": "Eliminar ejecución programada: {{prompt}}", "confirmDelete": "Confirmar eliminación", @@ -229,7 +229,13 @@ "verdictRecorded": "a fecha de {{when}}", "deliveryKnownTargetLabel": "Destino conocido", "deliveryKnownTargetPlaceholder": "Elegir un destino conocido…", - "deliverySuggestionKind_private_chat": "Mi chat privado" + "deliverySuggestionKind_private_chat": "Mi chat privado", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "ID de chat, grupo o usuario", + "deliveryTargetPlaceholder_channel": "ID del canal", + "deliveryTargetPlaceholder_destination": "chat, grupo, usuario, dirección o ID de canal", + "deliveryTargetPlaceholder_slack_destination": "#canal, @usuario o ID de conversación", + "deliveryTargetKind_slack_destination": "Destino" }, "reports": { "badge": "Informes", @@ -677,6 +683,10 @@ "max_attachments_per_email": { "label": "Máx. adjuntos por correo", "help": "Cuántos adjuntos se aceptan de un mismo correo (predeterminado 5)." + }, + "trusted_authserv_id": { + "label": "Servidor de autenticación de confianza", + "help": "authserv-id del servidor receptor, p. ej. mx.google.com. Tu proveedor debe eliminar las cabeceras Authentication-Results falsificadas entrantes que usen este ID. Obligatorio al verificar SPF o DKIM; vacío rechaza el correo entrante." } }, "websocket": { diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index ba90d53b..82dc3380 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -153,7 +153,7 @@ "editingHint": "Anda sedang mengedit proses terjadwal ini. Penyimpanan mempertahankan identitas dan riwayat eksekusinya.", "edit": "Edit", "editAria": "Edit proses terjadwal: {{prompt}}", - "editRunningDisabled": "Tunggu hingga proses ini selesai sebelum mengeditnya.", + "editRunningDisabled": "Tunggu proses atau pengirimannya selesai sebelum mengedit.", "deliveryInApp": "Simpan hanya di Vibe (jangan kirim)", "deliveryNoChannels": "Tidak ada saluran pengiriman aktif yang dikonfigurasi. Konfigurasikan satu di Pengaturan → Saluran.", "deliveryChannelsLoadFailed": "Tidak dapat memuat saluran pengiriman yang dikonfigurasi.", @@ -229,7 +229,13 @@ "verdictRecorded": "per {{when}}", "deliveryKnownTargetLabel": "Tujuan dikenal", "deliveryKnownTargetPlaceholder": "Pilih tujuan yang dikenal…", - "deliverySuggestionKind_private_chat": "Chat pribadi saya" + "deliverySuggestionKind_private_chat": "Chat pribadi saya", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "ID obrolan, grup, atau pengguna", + "deliveryTargetPlaceholder_channel": "ID kanal", + "deliveryTargetPlaceholder_destination": "ID chat / grup / pengguna", + "deliveryTargetPlaceholder_slack_destination": "#kanal, @pengguna, atau ID percakapan", + "deliveryTargetKind_slack_destination": "Tujuan" }, "reports": { "badge": "Laporan", @@ -677,6 +683,10 @@ "max_attachments_per_email": { "label": "Maks. lampiran per email", "help": "Berapa banyak lampiran yang diterima dari satu email (default 5)." + }, + "trusted_authserv_id": { + "label": "Server autentikasi tepercaya", + "help": "authserv-id server penerima, misalnya mx.google.com. Penyedia email harus menghapus header Authentication-Results masuk palsu yang memakai ID ini. Wajib saat verifikasi SPF atau DKIM aktif; kosong menolak email masuk." } }, "websocket": { diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 3166cd4b..023b93c0 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -148,7 +148,7 @@ "editingHint": "このスケジュール実行を編集中です。保存しても識別子と実行履歴は保持されます。", "edit": "編集", "editAria": "スケジュール実行を編集: {{prompt}}", - "editRunningDisabled": "この実行が完了してから編集してください。", + "editRunningDisabled": "実行または配信が完了してから編集してください。", "deliveryInApp": "Vibe内のみに保持(送信しない)", "deliveryNoChannels": "有効な配信チャネルが設定されていません。設定 → チャネルで設定してください。", "deliveryChannelsLoadFailed": "設定済みの配信チャネルを読み込めませんでした。", @@ -224,7 +224,13 @@ "verdictRecorded": "{{when}} 時点", "deliveryKnownTargetLabel": "既知の送信先", "deliveryKnownTargetPlaceholder": "既知の送信先を選択…", - "deliverySuggestionKind_private_chat": "自分のプライベートチャット" + "deliverySuggestionKind_private_chat": "自分のプライベートチャット", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "チャット、グループ、ユーザーの ID", + "deliveryTargetPlaceholder_channel": "チャンネル ID", + "deliveryTargetPlaceholder_destination": "チャット / グループ / ユーザー ID", + "deliveryTargetPlaceholder_slack_destination": "#チャンネル、@ユーザー、会話 ID", + "deliveryTargetKind_slack_destination": "送信先" }, "reports": { "badge": "レポート", @@ -672,6 +678,10 @@ "max_attachments_per_email": { "label": "1 通あたりの添付数上限", "help": "1 通のメールから受け入れる添付ファイルの最大数(既定 5)。" + }, + "trusted_authserv_id": { + "label": "信頼する認証サーバー", + "help": "受信サーバーの authserv-id(例:mx.google.com)。メール提供者が、この ID を偽装した外部の Authentication-Results ヘッダーを削除する必要があります。SPF または DKIM 検証時は必須です。空欄では受信メールを拒否します。" } }, "websocket": { diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 508dbbb2..4748de8a 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -148,7 +148,7 @@ "editingHint": "이 예약 실행을 편집 중입니다. 저장해도 식별 정보와 실행 기록은 유지됩니다.", "edit": "편집", "editAria": "예약 실행 편집: {{prompt}}", - "editRunningDisabled": "이 실행이 끝난 후 편집하세요.", + "editRunningDisabled": "실행 또는 전송이 완료된 후 편집하세요.", "deliveryInApp": "Vibe에만 보관(전송 안 함)", "deliveryNoChannels": "활성화된 전송 채널이 구성되어 있지 않습니다. 설정 → 채널에서 하나를 구성하세요.", "deliveryChannelsLoadFailed": "구성된 전송 채널을 불러오지 못했습니다.", @@ -224,7 +224,13 @@ "verdictRecorded": "{{when}} 기준", "deliveryKnownTargetLabel": "알려진 대상", "deliveryKnownTargetPlaceholder": "알려진 대상 선택…", - "deliverySuggestionKind_private_chat": "내 비공개 채팅" + "deliverySuggestionKind_private_chat": "내 비공개 채팅", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "채팅, 그룹 또는 사용자 ID", + "deliveryTargetPlaceholder_channel": "채널 ID", + "deliveryTargetPlaceholder_destination": "채팅 / 그룹 / 사용자 ID", + "deliveryTargetPlaceholder_slack_destination": "#채널, @사용자 또는 대화 ID", + "deliveryTargetKind_slack_destination": "대상" }, "reports": { "badge": "리포트", @@ -672,6 +678,10 @@ "max_attachments_per_email": { "label": "메일당 첨부 수 상한", "help": "메일 한 통에서 허용하는 첨부 파일 수입니다(기본 5)." + }, + "trusted_authserv_id": { + "label": "신뢰할 인증 서버", + "help": "수신 서버의 authserv-id(예: mx.google.com)입니다. 메일 제공자는 이 ID를 위조한 외부 Authentication-Results 헤더를 제거해야 합니다. SPF 또는 DKIM 검증을 사용하면 필수이며, 비워 두면 수신 메일이 거부됩니다." } }, "websocket": { diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 42f57eb4..c7823ba3 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -153,7 +153,7 @@ "editingHint": "Você está editando esta execução agendada. Ao salvar, a identidade e o histórico de execuções são preservados.", "edit": "Editar", "editAria": "Editar execução agendada: {{prompt}}", - "editRunningDisabled": "Aguarde esta execução terminar antes de editá-la.", + "editRunningDisabled": "Aguarde a execução ou a entrega terminar antes de editar.", "deliveryInApp": "Manter apenas no Vibe (não enviar)", "deliveryNoChannels": "Nenhum canal de entrega habilitado está configurado. Configure um em Configurações → Canais.", "deliveryChannelsLoadFailed": "Não foi possível carregar os canais de entrega configurados.", @@ -229,7 +229,13 @@ "verdictRecorded": "a partir de {{when}}", "deliveryKnownTargetLabel": "Destino conhecido", "deliveryKnownTargetPlaceholder": "Escolher um destino conhecido…", - "deliverySuggestionKind_private_chat": "Meu chat privado" + "deliverySuggestionKind_private_chat": "Meu chat privado", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "ID do chat, grupo ou usuário", + "deliveryTargetPlaceholder_channel": "ID do canal", + "deliveryTargetPlaceholder_destination": "chat / grupo / ID de usuário", + "deliveryTargetPlaceholder_slack_destination": "#canal, @usuário ou ID da conversa", + "deliveryTargetKind_slack_destination": "Destino" }, "reports": { "badge": "Relatórios", @@ -677,6 +683,10 @@ "max_attachments_per_email": { "label": "Máx. de anexos por e-mail", "help": "Quantos anexos são aceitos em um único e-mail (padrão 5)." + }, + "trusted_authserv_id": { + "label": "Servidor de autenticação confiável", + "help": "authserv-id do servidor receptor, por exemplo mx.google.com. O provedor deve remover cabeçalhos Authentication-Results recebidos e forjados com esse ID. Obrigatório ao verificar SPF ou DKIM; vazio rejeita emails recebidos." } }, "websocket": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 0993a962..ef39949e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -148,7 +148,7 @@ "editingHint": "正在编辑此定时任务。保存后将保留其标识和执行历史。", "edit": "编辑", "editAria": "编辑定时任务:{{prompt}}", - "editRunningDisabled": "请等待此任务执行完成后再编辑。", + "editRunningDisabled": "请等待本次执行或投递完成后再编辑。", "deliveryInApp": "仅保留在 Vibe 中(不发送)", "deliveryNoChannels": "尚未配置已启用的投递渠道。请在“设置 → 渠道”中配置一个。", "deliveryChannelsLoadFailed": "无法加载已配置的投递渠道。", @@ -224,7 +224,13 @@ "verdictRecorded": "记于 {{when}}", "deliveryKnownTargetLabel": "已知目标", "deliveryKnownTargetPlaceholder": "选择已知目标…", - "deliverySuggestionKind_private_chat": "我的私聊" + "deliverySuggestionKind_private_chat": "我的私聊", + "deliveryTargetPlaceholder_email_address": "name@example.com", + "deliveryTargetPlaceholder_chat": "聊天、群组或用户 ID", + "deliveryTargetPlaceholder_channel": "频道 ID", + "deliveryTargetPlaceholder_destination": "会话 / 群 / 用户 id", + "deliveryTargetPlaceholder_slack_destination": "#频道、@用户或会话 ID", + "deliveryTargetKind_slack_destination": "目标" }, "reports": { "badge": "报告", @@ -672,6 +678,10 @@ "max_attachments_per_email": { "label": "单封邮件附件数上限", "help": "一封邮件最多接受的附件数量(默认 5)。" + }, + "trusted_authserv_id": { + "label": "受信任的认证服务器", + "help": "收件服务器的 authserv-id,例如 mx.google.com。邮件服务商必须移除外部伪造且使用此 ID 的 Authentication-Results 邮件头。启用 SPF 或 DKIM 验证时必填;留空会拒收邮件。" } }, "websocket": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cc07ea77..76c48761 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -726,6 +726,7 @@ export interface UpdateScheduledRunRequest { delivery_channel?: string | null; delivery_target?: string | null; delivery_target_ref?: string | null; + delivery_format?: "html" | "pdf" | null; } export interface ScheduledResearchProposalJob { @@ -743,6 +744,7 @@ export interface ScheduledResearchProposalJob { channel: string | null; target_ref: string | null; target_label: string | null; + format?: "html" | "pdf" | null; status: string; }; } diff --git a/frontend/src/pages/Scheduled.tsx b/frontend/src/pages/Scheduled.tsx index 675495c5..ad30caab 100644 --- a/frontend/src/pages/Scheduled.tsx +++ b/frontend/src/pages/Scheduled.tsx @@ -376,15 +376,12 @@ export function Scheduled() { const selectedChannelIsAvailable = configuredDeliveryChannels.some( (channel) => channel.name === deliveryChannel, ); - const targetLabel = - selectedChannel?.delivery_target_label || - (selectedChannel?.delivery_target_kind - ? t(`scheduled.deliveryTargetKind_${selectedChannel.delivery_target_kind}`, { - defaultValue: t("scheduled.deliveryTargetLabel"), - }) - : t("scheduled.deliveryTargetLabel")); - const targetPlaceholder = - selectedChannel?.delivery_target_placeholder || t("scheduled.deliveryTargetPlaceholder"); + const targetLabel = t(`scheduled.deliveryTargetKind_${selectedChannel?.delivery_target_kind || "destination"}`, { + defaultValue: selectedChannel?.delivery_target_label || t("scheduled.deliveryTargetLabel"), + }); + const targetPlaceholder = t(`scheduled.deliveryTargetPlaceholder_${selectedChannel?.delivery_target_kind || "destination"}`, { + defaultValue: selectedChannel?.delivery_target_placeholder || t("scheduled.deliveryTargetPlaceholder"), + }); const targetInputType = selectedChannel?.delivery_target_input_type === "email" ? "email" : "text"; const targetSuggestions = selectedChannel?.delivery_target_suggestions ?? []; @@ -764,10 +761,10 @@ export function Scheduled() {