fix(channels): finish trusted email delivery and scheduling workflows
Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
This commit is contained in:
1 parent
e29c8fb9b4
commit
d6b3db261d
36 files changed
+763
-143
No files matched your search
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
+129
-76
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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]+)(?:\|[^>]+)?>$")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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"]} == {
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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") },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user