fix(channels): finish trusted email delivery and scheduling workflows

Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
This commit is contained in:
Haozhe Wu committed 2026-10-04 03:14:25 +08:00
1 parent e29c8fb9b4
commit d6b3db261d
36 files changed
+764 -144

No files matched your search

+3 -1
View File
@@ -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.
+13 -1
View File
@@ -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]")
+4 -1
View File
@@ -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:
+25 -17
View File
@@ -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)
+1
View File
@@ -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),
+130 -77
View File
@@ -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
-2
View File
@@ -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
+62 -8
View File
@@ -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:
'<!doctype html><html><head><meta charset="utf-8">'
f"<style>{_EMAIL_CSS}</style></head><body>{body}</body></html>"
)
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("</style>", css + "</style>")
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()
+5
View File
@@ -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."
)
+1
View File
@@ -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]+)(?:\|[^>]+)?>$")
+20
View File
@@ -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]:
+8
View File
@@ -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,
)
@@ -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"],
},
+2 -1
View File
@@ -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"]} == {
+10
View File
@@ -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"
+30
View File
@@ -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())
@@ -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)
+68
View File
@@ -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
+48 -1
View File
@@ -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() == {}
@@ -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()
+32
View File
@@ -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
@@ -90,6 +90,16 @@ export const ScheduledResearchProposalCard = memo(function ScheduledResearchProp
<dt className="text-muted-foreground">{i18n.t("scheduled.proposalDelivery")}</dt>
<dd>{job.delivery.target_label || i18n.t("scheduled.proposalInApp")}</dd>
</div>
{job.delivery.channel === "email" && (
<div>
<dt className="text-muted-foreground">{i18n.t("scheduled.deliveryFormatLabel")}</dt>
<dd>{i18n.t(job.delivery.format === "html"
? "scheduled.deliveryFormatHtml"
: job.delivery.format === "pdf"
? "scheduled.deliveryFormatPdf"
: "scheduled.deliveryFormatDefault")}</dd>
</div>
)}
</dl>
)}
@@ -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(<ScheduledResearchProposalCard proposal={proposal(format)} />);
expect(screen.getByText(i18n.t(key))).toBeInTheDocument();
expect(screen.getByText("Research inbox")).toBeInTheDocument();
});
});
@@ -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") },
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+12 -2
View File
@@ -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": {
+2
View File
@@ -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;
};
}
+8 -11
View File
@@ -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() {
<button
type="button"
onClick={() => beginEdit(run)}
disabled={run.status === "running"}
disabled={(run.status === "running" || run.delivery_status === "sending")}
aria-label={t("scheduled.editAria", { prompt: run.prompt })}
title={
run.status === "running"
(run.status === "running" || run.delivery_status === "sending")
? t("scheduled.editRunningDisabled")
: undefined
}
@@ -220,7 +220,7 @@ describe("Scheduled page", () => {
await within(channel).findByRole("option", { name: "Telegram" });
fireEvent.change(channel, { target: { value: "telegram" } });
const target = await screen.findByLabelText("Telegram chat");
const target = await screen.findByLabelText("Chat");
expect(target).toHaveValue("");
expect(target).toHaveAttribute("placeholder", "Chat, group, or user ID");
@@ -330,7 +330,7 @@ describe("briefing delivery", () => {
fireEvent.change(channel, {
target: { value: "telegram" },
});
fireEvent.change(await screen.findByLabelText(/telegram chat/i), {
fireEvent.change(await screen.findByLabelText(/^chat$/i), {
target: { value: " chat-9 " },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
@@ -456,3 +456,27 @@ describe("Scheduled page verdict cell", () => {
expect(await screen.findByText(/No calls/)).toBeInTheDocument();
});
});
describe("email format editing", () => {
it("loads, changes and resets the stored format", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ delivery_channel: "email", delivery_target: "reader@example.test", delivery_format: "pdf" })]);
mocked.updateScheduledRun.mockResolvedValue(run());
render(<Scheduled />);
fireEvent.click(await screen.findByRole("button", { name: /Edit scheduled run/ }));
expect(screen.getByLabelText("Email report format")).toHaveValue("pdf");
fireEvent.change(screen.getByLabelText("Email report format"), { target: { value: "html" } });
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(mocked.updateScheduledRun).toHaveBeenCalledWith("auckland-scan", expect.objectContaining({ delivery_format: "html" })));
const channel = screen.getByLabelText("Delivery channel");
await waitFor(() => expect(channel).toHaveValue(""));
fireEvent.change(channel, { target: { value: "email" } });
expect(screen.getByLabelText("Email report format")).toHaveValue("");
});
it("disables editing while a briefing is sending", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ delivery_status: "sending" })]);
render(<Scheduled />);
expect(await screen.findByRole("button", { name: /Edit scheduled run/ })).toBeDisabled();
});
});