feat(email): protect scheduled PDF reports with password

Signed-off-by: zeus229 <69399886+zeus229@users.noreply.github.com>
This commit is contained in:
zeus229 committed 2026-10-06 04:47:18 +00:00
1 parent 7f6908b7e1
commit 79f2ceb1a5
34 files changed
+622 -25

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
CLI playbooks and agent proposals. Confirmation surfaces show the format. Format edits
persist, active sends cannot be overwritten, and desktop PDF delivery uses
the packaged renderer when native libraries are unavailable, with embedded
CJK/Arabic fonts and tested multiline/table pagination.
CJK/Arabic fonts and tested multiline/table pagination. Email PDF delivery
also supports optional AES-256 password protection using an operator-managed
Email channel secret; scheduled jobs store only the boolean choice.
- **Structured backtest summaries and paged artifact reads** (#1646, #1647,
resolves #1644 and #1645). Complete scalar metrics, structured metrics and
validation accompany endpoint-preserving equity previews and OHLCV paths.
+5
View File
@@ -1546,6 +1546,11 @@ that surface action calls the commit endpoint. Create drafts carry `title`, `sou
Scheduled delivery is channel-agnostic. Configure reusable opaque target refs
under `channels.deliveryTargets`; the agent tool and its confirmation surfaces
expose the ref, label, and channel but never the provider's raw chat/user id.
For Email PDF reports, an operator can set the private `pdf_password` field in
Settings → IM Channels → Email. The Scheduled page then offers optional PDF
protection only for PDF delivery. The password stays in Email channel config;
jobs persist only the protection boolean, and requesting protection without a
configured password is rejected.
The existing direct REST/admin fields remain available for backward
compatibility:
+1
View File
@@ -26,6 +26,7 @@ openpyxl>=3.1.0
python-docx>=1.1.0
python-pptx>=0.6.23
pypdfium2>=4.0.0
pypdf>=6.0.0
Pillow>=12.2.0
# ML Strategy
+3
View File
@@ -226,6 +226,9 @@ def _channel_entry(name: str, section: dict[str, Any], status_map: dict[str, Any
"fields": channel_field_hints(name),
"values": values,
"secrets": secrets,
"pdf_password_configured": bool(
name == "email" and secrets.get("pdf_password", {}).get("set")
),
}
+69 -3
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from starlette.responses import Response
from src.config.accessor import get_env_config
from src.scheduled_research.models import contains_pdf_password
if TYPE_CHECKING:
from src.scheduled_research.models import ScheduledResearchJob
@@ -116,7 +117,11 @@ def _read_scheduled_briefing(session_id: str) -> Optional[tuple[str, str]]:
async def _send_scheduled_briefing(
channel: str, target: Optional[str], text: str, delivery_format: Optional[str] = None
channel: str,
target: Optional[str],
text: str,
delivery_format: Optional[str] = None,
protect_pdf: bool = False,
):
"""Deliver one briefing through the configured IM channel.
@@ -125,6 +130,7 @@ async def _send_scheduled_briefing(
target: Address within that channel, or ``None`` for its default.
text: The briefing to deliver.
delivery_format: Optional presentation hint for channels that support it.
protect_pdf: Whether the generated PDF must be password protected.
Raises:
RuntimeError: If the channel runtime is unavailable or has no such
@@ -145,6 +151,8 @@ async def _send_scheduled_briefing(
metadata = {"force_send": True}
if delivery_format:
metadata["delivery_format"] = delivery_format
if protect_pdf:
metadata["protect_pdf"] = True
return await adapter.send_with_receipt(
OutboundMessage(channel=channel, chat_id=target, content=text, metadata=metadata)
)
@@ -250,6 +258,7 @@ class CreateScheduledRunRequest(BaseModel):
delivery_format: Optional[Literal["html", "pdf"]] = Field(
None, description="Email report presentation: HTML body or PDF attachment"
)
protect_pdf: bool = Field(False, description="Password-protect the generated PDF")
end_at: Optional[int] = Field(
None, description="Epoch-ms boundary after which no further run is dispatched"
)
@@ -288,6 +297,7 @@ class UpdateScheduledRunRequest(BaseModel):
delivery_format: Optional[Literal["html", "pdf"]] = Field(
None, description="Email presentation; null restores plain text"
)
protect_pdf: Optional[bool] = Field(None, description="Replace the PDF protection choice")
end_at: Optional[int] = Field(
None, description="Replacement epoch-ms end boundary; null removes it"
)
@@ -354,6 +364,7 @@ class CreateRunFromPlaybookRequest(BaseModel):
end_at: Optional[int] = None
delivery_target_ref: Optional[str] = None
delivery_format: Optional[Literal["html", "pdf"]] = None
protect_pdf: bool = False
class ScheduledRunResponse(BaseModel):
@@ -380,6 +391,7 @@ class ScheduledRunResponse(BaseModel):
delivery_target_ref: Optional[str] = None
delivery_target_label: Optional[str] = None
delivery_format: Optional[str] = None
protect_pdf: bool = False
delivery_status: str = "none"
delivery_error: Optional[str] = None
delivery_updated_at: Optional[int] = None
@@ -415,6 +427,23 @@ def _job_to_response(job: ScheduledResearchJob) -> "ScheduledRunResponse":
)
def _email_pdf_password_configured() -> bool:
"""Read only whether the private Email PDF password is configured."""
host = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
manager = getattr(host, "_channel_manager", None) if host else None
adapter = manager.get_channel("email") if manager is not None else None
if adapter is not None:
return bool(getattr(getattr(adapter, "config", None), "pdf_password", ""))
try:
from src.channels.config import load_channels_config
section = load_channels_config().get("email", {})
return bool(section.get("pdf_password")) if isinstance(section, dict) else False
except Exception:
# A configuration read/parse failure must not authorize protected jobs.
return False
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
@@ -483,6 +512,8 @@ def register_scheduled_routes(
"'_' or '-'"
),
)
if contains_pdf_password(request.config):
raise HTTPException(status_code=422, detail="config must not include pdf_password")
try:
validate_schedule(request.schedule)
@@ -540,6 +571,15 @@ def register_scheduled_routes(
status_code=422,
detail="delivery_format is supported only for email delivery",
)
if request.protect_pdf and request.delivery_format != "pdf":
raise HTTPException(status_code=422, detail="protect_pdf requires PDF email delivery")
if request.protect_pdf and delivery_channel != "email":
raise HTTPException(status_code=422, detail="protect_pdf requires Email delivery")
if request.protect_pdf and not _email_pdf_password_configured():
raise HTTPException(
status_code=422,
detail="PDF protection requested but no PDF password is configured",
)
job = ScheduledResearchJob(
id=request.id or str(uuid.uuid4()),
@@ -557,6 +597,7 @@ def register_scheduled_routes(
delivery_target_ref=request.delivery_target_ref,
delivery_target_label=delivery_target_label,
delivery_format=request.delivery_format,
protect_pdf=request.protect_pdf,
)
_get_scheduled_research_store().upsert(job)
return _job_to_response(job)
@@ -671,6 +712,8 @@ def register_scheduled_routes(
timezone = job.timezone if "timezone" not in fields else request.timezone
config = job.config if "config" not in fields else (request.config or {})
if contains_pdf_password(config):
raise HTTPException(status_code=422, detail="config must not include pdf_password")
end_at = job.end_at if "end_at" not in fields else request.end_at
try:
@@ -710,6 +753,7 @@ def register_scheduled_routes(
"delivery_channel",
"delivery_target",
"delivery_target_ref",
"protect_pdf",
}
delivery_requested = bool(delivery_fields & fields)
delivery_channel = job.delivery_channel
@@ -753,16 +797,30 @@ def register_scheduled_routes(
)
delivery_format = job.delivery_format
protect_pdf = job.protect_pdf
if "delivery_format" in fields:
delivery_format = request.delivery_format
elif delivery_channel != "email":
if "protect_pdf" in fields:
protect_pdf = request.protect_pdf is True
if delivery_channel != "email" and "delivery_format" not in fields:
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"
)
if delivery_channel != "email":
protect_pdf = False
if protect_pdf and delivery_format != "pdf":
raise HTTPException(status_code=422, detail="protect_pdf requires PDF email delivery")
if protect_pdf and delivery_channel != "email":
raise HTTPException(status_code=422, detail="protect_pdf requires Email delivery")
if protect_pdf and not _email_pdf_password_configured():
raise HTTPException(
status_code=422,
detail="PDF protection requested but no PDF password is configured",
)
delivery_changed = delivery_format != job.delivery_format or delivery_requested and (
delivery_changed = delivery_format != job.delivery_format or protect_pdf != job.protect_pdf or delivery_requested and (
delivery_channel != job.delivery_channel
or delivery_target != job.delivery_target
or delivery_target_ref != job.delivery_target_ref
@@ -778,6 +836,7 @@ def register_scheduled_routes(
job.next_run_at = next_run_at
if delivery_changed:
job.delivery_format = delivery_format
job.protect_pdf = protect_pdf
job.delivery_channel = delivery_channel
job.delivery_target = delivery_target
job.delivery_target_ref = delivery_target_ref
@@ -913,6 +972,7 @@ def register_scheduled_routes(
next_run_at=request.next_run_at,
delivery_target_ref=request.delivery_target_ref,
delivery_format=request.delivery_format,
protect_pdf=request.protect_pdf,
**kwargs,
)
except ValueError as exc:
@@ -920,6 +980,12 @@ def register_scheduled_routes(
# schedule / timezone / cron-window failures, all ValueError.
raise HTTPException(status_code=422, detail=str(exc)) from exc
if job.protect_pdf and not _email_pdf_password_configured():
raise HTTPException(
status_code=422,
detail="PDF protection requested but no PDF password is configured",
)
now_ms = int(time.time() * 1000)
if request.end_at is not None:
if request.end_at <= now_ms or job.next_run_at > request.end_at:
+6 -3
View File
@@ -167,6 +167,7 @@ def _email_hints() -> list[FieldHint]:
("smtp_port", "text", False, False),
("smtp_username", "text", False, True),
("smtp_password", "password", True, True),
("pdf_password", "password", True, False),
("smtp_use_tls", "bool", False, False),
("smtp_use_ssl", "bool", False, False),
("verify_tls", "bool", False, False),
@@ -332,13 +333,14 @@ def is_secret_key(name: str, key: str) -> bool:
return bool(SECRET_KEY_RE.search(key))
def _mask(value: Any) -> dict[str, Any]:
def _mask(value: Any, *, reveal_suffix: bool = True) -> dict[str, Any]:
"""Return the ``{set, masked}`` descriptor for one secret value."""
is_set = bool(value)
if not is_set:
return {"set": False, "masked": ""}
text = str(value)
return {"set": True, "masked": "****" if len(text) <= 8 else "****" + text[-4:]}
masked = "****" + text[-4:] if reveal_suffix and len(text) > 8 else "****"
return {"set": True, "masked": masked}
def _strip_url_userinfo(value: Any) -> Any:
@@ -383,7 +385,8 @@ def split_values_secrets(
secrets: dict[str, dict[str, Any]] = {}
for key, value in section.items():
if is_secret_key(name, key):
secrets[key] = _mask(value)
# The PDF password is deliberately represented by presence only.
secrets[key] = _mask(value, reveal_suffix=key != "pdf_password")
else:
values[key] = _strip_url_userinfo(value)
return values, secrets
+31
View File
@@ -3,6 +3,7 @@
import asyncio
import html
import imaplib
from io import BytesIO
import logging
import mimetypes
import re
@@ -55,6 +56,8 @@ class EmailConfig(BaseModel):
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
# Operator-managed secret used only for explicitly protected PDF reports.
pdf_password: str = Field(default="", repr=False)
smtp_use_tls: bool = True
smtp_use_ssl: bool = False
# Certificate + hostname verification on every TLS path (implicit SSL and
@@ -343,6 +346,12 @@ class EmailChannel(BaseChannel):
delivery_format = (msg.metadata or {}).get("delivery_format")
if delivery_format not in {"html", "pdf"}:
delivery_format = None
metadata = msg.metadata or {}
protect_pdf = metadata.get("protect_pdf") is True
if protect_pdf and delivery_format != "pdf":
raise ValueError("PDF protection requires PDF delivery")
if protect_pdf and not self.config.pdf_password:
raise RuntimeError("PDF protection requested but no PDF password is configured")
email_msg = EmailMessage()
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
@@ -360,6 +369,8 @@ class EmailChannel(BaseChannel):
from src.channels.rich_text import render_email_pdf
generated_pdf = await asyncio.to_thread(render_email_pdf, content)
if protect_pdf:
generated_pdf = await asyncio.to_thread(self._encrypt_pdf, generated_pdf)
except Exception:
self.logger.exception("Failed to render required PDF attachment")
raise
@@ -394,6 +405,26 @@ class EmailChannel(BaseChannel):
self.logger.exception("Error sending to %s", to_addr)
raise
def _encrypt_pdf(self, pdf_data: bytes) -> bytes:
"""Encrypt a generated PDF with the private channel password."""
password = self.config.pdf_password
if not password:
raise RuntimeError("PDF protection requested but no PDF password is configured")
try:
from pypdf import PdfReader, PdfWriter
reader = PdfReader(BytesIO(pdf_data))
writer = PdfWriter()
writer.append_pages_from_reader(reader)
writer.encrypt(user_password=password, algorithm="AES-256")
output = BytesIO()
writer.write(output)
return output.getvalue()
except Exception:
# Keep dependency/parser errors from reflecting secret input.
raise RuntimeError("PDF protection failed") from None
def _validate_config(self) -> bool:
missing = []
if not self.config.imap_host:
+9 -1
View File
@@ -647,7 +647,15 @@ class ScheduledResearchExecutor:
self._store.upsert(current, validate=False)
try:
if job.delivery_format is None:
if job.protect_pdf:
receipt = await self._channel_sender(
job.delivery_channel,
job.delivery_target,
text,
job.delivery_format,
True,
)
elif job.delivery_format is None:
receipt = await self._channel_sender(
job.delivery_channel, job.delivery_target, text
)
+24
View File
@@ -325,6 +325,18 @@ def _verdict_record_or_none(data: Any, job_id: str) -> Optional[VerdictRecord]:
return None
def contains_pdf_password(value: Any) -> bool:
"""Return whether arbitrary job config contains the Email PDF secret key."""
if isinstance(value, dict):
return any(
str(key).casefold() == "pdf_password" or contains_pdf_password(item)
for key, item in value.items()
)
if isinstance(value, (list, tuple)):
return any(contains_pdf_password(item) for item in value)
return False
@dataclass
class ScheduledResearchJob:
"""Mutable persisted state for a scheduled research / backtest job.
@@ -381,6 +393,7 @@ class ScheduledResearchJob:
delivery_target_ref: Optional[str] = None
delivery_target_label: Optional[str] = None
delivery_format: Optional[str] = None
protect_pdf: bool = False
delivery: DeliveryRecord = field(default_factory=DeliveryRecord)
last_verdict: Optional[VerdictRecord] = None
@@ -391,6 +404,8 @@ class ScheduledResearchJob:
A dict containing all job fields, with ``status`` as its string
value.
"""
if contains_pdf_password(self.config):
raise ValueError("scheduled research config must not contain pdf_password")
return {
"id": self.id,
"prompt": self.prompt,
@@ -413,6 +428,7 @@ class ScheduledResearchJob:
"delivery_target_ref": self.delivery_target_ref,
"delivery_target_label": self.delivery_target_label,
"delivery_format": self.delivery_format,
"protect_pdf": self.protect_pdf,
"delivery": self.delivery.to_dict(),
"last_verdict": self.last_verdict.to_dict() if self.last_verdict else None,
}
@@ -486,6 +502,8 @@ class ScheduledResearchJob:
raise TypeError("'end_at' must be an integer (epoch ms) or null")
raw_config = data.get("config")
config: Dict[str, Any] = raw_config if isinstance(raw_config, dict) else {}
if contains_pdf_password(config):
raise ValueError("scheduled research config must not contain pdf_password")
delivery_channel = data.get("delivery_channel")
delivery_target = data.get("delivery_target")
delivery_target_ref = data.get("delivery_target_ref")
@@ -493,6 +511,11 @@ class ScheduledResearchJob:
delivery_format = data.get("delivery_format")
if delivery_format not in {None, "html", "pdf"}:
raise ValueError("'delivery_format' must be 'html', 'pdf', or null")
protect_pdf = data.get("protect_pdf", False)
if not isinstance(protect_pdf, bool):
raise TypeError("'protect_pdf' must be a boolean")
if protect_pdf and delivery_format != "pdf":
raise ValueError("'protect_pdf' requires delivery_format='pdf'")
for name, value in (
("delivery_channel", delivery_channel),
("delivery_target", delivery_target),
@@ -523,6 +546,7 @@ class ScheduledResearchJob:
delivery_target_ref=delivery_target_ref,
delivery_target_label=delivery_target_label,
delivery_format=delivery_format,
protect_pdf=protect_pdf,
delivery=DeliveryRecord.from_dict(data.get("delivery")),
last_verdict=_verdict_record_or_none(data.get("last_verdict"), job_id),
)
@@ -151,6 +151,7 @@ class ResearchPlaybook:
now_ms: Optional[int] = None,
delivery_target_ref: Optional[str] = None,
delivery_format: Optional[str] = None,
protect_pdf: bool = False,
) -> ScheduledResearchJob:
"""Build a schedulable job from this playbook.
@@ -174,6 +175,7 @@ class ResearchPlaybook:
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.
protect_pdf: Encrypt generated PDF output using the Email channel secret.
Returns:
A ``PENDING`` :class:`ScheduledResearchJob`.
@@ -210,6 +212,8 @@ class ResearchPlaybook:
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")
if protect_pdf and (delivery_format != "pdf" or target is None or target.channel != "email"):
raise ValueError("protect_pdf requires PDF email delivery")
return ScheduledResearchJob(
id=job_id or f"playbook-{self.slug}-{uuid.uuid4().hex[:8]}",
@@ -228,6 +232,7 @@ class ResearchPlaybook:
delivery_target_ref=target.ref if target else None,
delivery_target_label=target.label if target else None,
delivery_format=delivery_format,
protect_pdf=protect_pdf,
)
def to_dict(self, include_body: bool = False) -> Dict[str, Any]:
+6
View File
@@ -184,10 +184,15 @@ def build_job_from_draft(
raise ValueError("delivery.mode must be 'in_app', 'origin', or 'configured'")
delivery_format = delivery_spec.get("format")
protect_pdf = delivery_spec.get("protect_pdf", False)
if not isinstance(protect_pdf, bool):
raise ValueError("delivery.protect_pdf must be a boolean")
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")
if protect_pdf and delivery_format != "pdf":
raise ValueError("delivery.protect_pdf requires PDF email delivery")
return ScheduledResearchJob(
id=str(draft.get("id") or f"sr-{uuid.uuid4().hex[:12]}"),
@@ -207,6 +212,7 @@ def build_job_from_draft(
delivery_target_ref=target_ref,
delivery_target_label=target_label,
delivery_format=delivery_format,
protect_pdf=protect_pdf,
)
+12 -1
View File
@@ -290,6 +290,7 @@ _EMAIL_HINT_KEYS = (
"smtp_port",
"smtp_username",
"smtp_password",
"pdf_password",
"smtp_use_tls",
"smtp_use_ssl",
"verify_tls",
@@ -338,15 +339,17 @@ 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) == 32
assert len(hints) == 33
by_key = {hint["key"]: hint for hint in hints}
assert {key for key, hint in by_key.items() if hint["secret"]} == {
"imap_password",
"smtp_password",
"pdf_password",
}
assert by_key["imap_password"]["type"] == "password"
assert by_key["smtp_password"]["type"] == "password"
assert by_key["pdf_password"]["type"] == "password"
# required mirrors EmailChannel._validate_config: the channel refuses to
# start without all six credential fields.
assert {key for key, hint in by_key.items() if hint["required"]} == {
@@ -361,6 +364,14 @@ def test_email_field_hints_contract() -> None:
assert hint["help_key"] == f"settings.channels.fields.email.{hint['key']}"
def test_email_pdf_password_config_metadata_exposes_presence_only() -> None:
value = "private-pdf-password-1234"
values, secrets = split_values_secrets("email", {"pdf_password": value})
assert values == {}
assert secrets["pdf_password"] == {"set": True, "masked": "****"}
assert value not in repr((values, secrets))
def test_websocket_field_hints_contract() -> None:
"""WebSocket hints: declaration order, secrets, help_key prefix."""
hints = channel_field_hints("websocket")
+26
View File
@@ -1362,3 +1362,29 @@ def test_email_authentication_trust_anchor_is_editable_in_generic_form(tmp_path,
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"
def test_email_pdf_password_config_exposes_presence_only(tmp_path, monkeypatch):
password = "private-pdf-password-123456"
client, path = _client(
tmp_path, monkeypatch,
channels={"email": _email_section(pdf_password=password)},
)
response = client.get("/channels/config")
assert response.status_code == 200
entry = response.json()["channels"]["email"]
assert entry["pdf_password_configured"] is True
assert entry["secrets"]["pdf_password"] == {"set": True, "masked": "****"}
assert "pdf_password" not in entry["values"]
assert password not in response.text
update = client.put(
"/channels/config/email", json={"config": {"pdf_password": "replacement-password"}}
)
assert update.status_code == 200, update.text
assert update.json()["channel"]["pdf_password_configured"] is True
assert "replacement-password" not in update.text
assert json.loads(path.read_text())["channels"]["email"]["pdf_password"] == "replacement-password"
client, _path = _client(tmp_path, monkeypatch, channels={"email": _email_section()})
entry = client.get("/channels/config").json()["channels"]["email"]
assert entry["pdf_password_configured"] is False
+72 -3
View File
@@ -1,28 +1,43 @@
from __future__ import annotations
import asyncio
from io import BytesIO
import sys
from types import SimpleNamespace
import pytest
from pypdf import PdfReader
from src.channels.bus.events import OutboundMessage
from src.channels.bus.queue import MessageBus
from src.channels.email import EmailChannel
from src.channels.rich_text import render_email_html
def _channel() -> EmailChannel:
def _channel(*, pdf_password: str = "") -> EmailChannel:
return EmailChannel(
{
"consent_granted": True,
"smtp_host": "smtp.example.test",
"smtp_username": "bot@example.test",
"smtp_password": "secret",
"pdf_password": pdf_password,
"from_address": "bot@example.test",
},
MessageBus(),
)
def _sample_pdf(text: str) -> bytes:
from reportlab.pdfgen.canvas import Canvas
buffer = BytesIO()
canvas = Canvas(buffer)
canvas.drawString(72, 720, text)
canvas.save()
return buffer.getvalue()
def test_rich_email_renderer_preserves_markdown_and_sanitizes_html():
rendered = render_email_html(
"# Portfolio\n\n**Return:** 1.2%\n\n| Asset | Weight |\n|---|---:|\n| ABC | 10% |\n"
@@ -88,7 +103,7 @@ def test_per_message_pdf_keeps_report_out_of_body(monkeypatch):
def write_pdf(self) -> bytes:
assert "Portfolio value: 123456" in self.string
return b"%PDF-1.7\n%%EOF\n"
return _sample_pdf("Portfolio value: 123456")
monkeypatch.setitem(sys.modules, "weasyprint", SimpleNamespace(HTML=FakeHTML))
monkeypatch.setattr("src.channels.rich_text._WEASYPRINT_HTML", None)
@@ -116,7 +131,61 @@ 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")
pdf_data = pdf_parts[0].get_payload(decode=True)
assert pdf_data.startswith(b"%PDF")
assert not PdfReader(BytesIO(pdf_data)).is_encrypted
def test_pdf_delivery_encrypts_with_configured_password(monkeypatch):
monkeypatch.setattr("src.channels.rich_text.render_email_pdf", lambda _text: _sample_pdf("Protected report content"))
channel = _channel(pdf_password="correct horse battery staple")
sent = []
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
asyncio.run(channel.send(OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="Protected report content",
metadata={"delivery_format": "pdf", "protect_pdf": True},
)))
pdf_part = next(part for part in sent[0].iter_attachments() if part.get_content_type() == "application/pdf")
encrypted = pdf_part.get_payload(decode=True)
reader = PdfReader(BytesIO(encrypted))
assert reader.is_encrypted
assert reader.decrypt("wrong password") == 0
assert reader.decrypt("correct horse battery staple") != 0
assert "Protected report content" in "".join(page.extract_text() or "" for page in reader.pages)
def test_protected_pdf_without_password_fails_closed(monkeypatch):
channel = _channel()
sent = []
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
with pytest.raises(RuntimeError, match="no PDF password is configured"):
asyncio.run(channel.send(OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="Secret report",
metadata={"delivery_format": "pdf", "protect_pdf": True},
)))
assert sent == []
def test_pdf_password_is_not_in_email_config_repr():
password = "private-pdf-password-1234"
assert password not in repr(_channel(pdf_password=password).config)
def test_pdf_protection_rejects_non_pdf_delivery():
channel = _channel(pdf_password="private-password")
with pytest.raises(ValueError, match="requires PDF delivery"):
asyncio.run(channel.send(OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="report",
metadata={"delivery_format": "html", "protect_pdf": True},
)))
def test_pdf_failure_never_sends_report_as_plain_text(monkeypatch):
@@ -73,6 +73,30 @@ def test_a_job_without_a_channel_never_arms_the_outbox(tmp_path: Path) -> None:
assert sender.sent == []
def test_protected_pdf_flag_reaches_channel_sender_without_a_password(tmp_path: Path) -> None:
store = _store(tmp_path)
job = _job(
delivery_channel="email",
delivery_target="reader@example.test",
delivery_format="pdf",
protect_pdf=True,
)
store.upsert(job)
calls = []
async def sender(*args):
calls.append(args)
executor = _executor(
store,
reader=lambda _session: ("completed", "briefing"),
sender=sender,
)
asyncio.run(executor.tick(now_ms=1_000))
assert calls == [("email", "reader@example.test", "briefing", "pdf", True)]
def test_a_firing_arms_the_outbox_and_the_sweep_delivers_it(tmp_path: Path) -> None:
store = _store(tmp_path)
store.upsert(_job(delivery_channel="telegram", delivery_target="chat-9"))
+106
View File
@@ -623,3 +623,109 @@ def test_patch_leaving_email_clears_implicit_format_and_refuses_explicit_pdf(cli
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
def test_protected_pdf_create_validates_format_and_private_password(client, store, monkeypatch):
body = {
"id": "protected-pdf",
"prompt": "daily report",
"schedule": "60000",
"delivery_channel": "email",
"delivery_target": "reader@example.test",
"delivery_format": "pdf",
"protect_pdf": True,
}
invalid = {**body, "delivery_format": "html"}
assert client.post("/scheduled-runs", json=invalid).status_code == 422
assert store.get("protected-pdf") is None
unsafe_config = {**body, "protect_pdf": False, "config": {"nested": [{"pdf_password": "do-not-save"}]}}
rejected_config = client.post("/scheduled-runs", json=unsafe_config)
assert rejected_config.status_code == 422
assert "do-not-save" not in rejected_config.text
assert store.get("protected-pdf") is None
monkeypatch.setattr(scheduled_routes, "_email_pdf_password_configured", lambda: False)
response = client.post("/scheduled-runs", json=body)
assert response.status_code == 422
assert "password is configured" in response.json()["detail"]
assert store.get("protected-pdf") is None
monkeypatch.setattr(scheduled_routes, "_email_pdf_password_configured", lambda: True)
response = client.post("/scheduled-runs", json=body)
assert response.status_code == 201, response.text
assert response.json()["protect_pdf"] is True
assert "pdf_password" not in response.text
def test_protected_pdf_update_persists_only_boolean_and_requires_secret(client, store, monkeypatch):
job = _seed(
store,
id="protectable",
delivery_channel="email",
delivery_target="reader@example.test",
delivery_format="pdf",
)
monkeypatch.setattr(scheduled_routes, "_email_pdf_password_configured", lambda: False)
response = client.patch(f"/scheduled-runs/{job.id}", json={"protect_pdf": True})
assert response.status_code == 422
assert store.get(job.id).protect_pdf is False
rejected_config = client.patch(
f"/scheduled-runs/{job.id}",
json={"config": {"nested": [{"pdf_password": "do-not-save"}]}},
)
assert rejected_config.status_code == 422
assert "do-not-save" not in rejected_config.text
monkeypatch.setattr(scheduled_routes, "_email_pdf_password_configured", lambda: True)
response = client.patch(f"/scheduled-runs/{job.id}", json={"protect_pdf": True})
assert response.status_code == 200, response.text
assert response.json()["protect_pdf"] is True
persisted = store.path.read_text()
assert '"protect_pdf": true' in persisted
assert "pdf_password" not in persisted
assert client.patch(f"/scheduled-runs/{job.id}", json={"delivery_format": "html"}).status_code == 422
def test_protect_pdf_round_trip_contains_boolean_only():
from src.scheduled_research.models import ScheduledResearchJob
job = ScheduledResearchJob(
id="safe-job",
prompt="report",
schedule="60000",
next_run_at=1_700_000_000_000,
created_at=1_700_000_000_000,
delivery_channel="email",
delivery_target="reader@example.test",
delivery_format="pdf",
protect_pdf=True,
)
encoded = job.to_dict()
assert encoded["protect_pdf"] is True
assert "pdf_password" not in repr(encoded)
assert ScheduledResearchJob.from_dict(encoded).protect_pdf is True
unsafe = {**encoded, "config": {"nested": [{"pdf_password": "do-not-save"}]}}
with pytest.raises(ValueError, match="must not contain pdf_password"):
ScheduledResearchJob.from_dict(unsafe)
def test_scheduled_email_dispatch_passes_only_protection_flag(monkeypatch):
class Adapter:
async def send_with_receipt(self, message):
self.message = message
return None
adapter = Adapter()
manager = type("Manager", (), {"get_channel": lambda self, name: adapter if name == "email" else None})()
monkeypatch.setattr(api_server, "_channel_manager", manager)
import asyncio
asyncio.run(scheduled_routes._send_scheduled_briefing(
"email", "reader@example.test", "report", "pdf", True
))
assert adapter.message.metadata == {
"force_send": True,
"delivery_format": "pdf",
"protect_pdf": True,
}
assert "pdf_password" not in repr(adapter.message.metadata)
@@ -2893,6 +2893,10 @@ pyparsing==3.3.2 \
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
# via matplotlib
pypdf==6.19.0 \
--hash=sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14 \
--hash=sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155
# via -r agent/requirements.txt
pypdfium2==5.12.1 \
--hash=sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4 \
--hash=sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf \
@@ -100,6 +100,14 @@ export const ScheduledResearchProposalCard = memo(function ScheduledResearchProp
: "scheduled.deliveryFormatDefault")}</dd>
</div>
)}
{job.delivery.channel === "email" && job.delivery.format === "pdf" && (
<div>
<dt className="text-muted-foreground">{i18n.t("scheduled.proposalPdfProtection")}</dt>
<dd>{i18n.t(job.delivery.protect_pdf === true
? "scheduled.proposalPdfProtectionOn"
: "scheduled.proposalPdfProtectionOff")}</dd>
</div>
)}
</dl>
)}
@@ -4,14 +4,14 @@ import { ScheduledResearchProposalCard } from "../ScheduledResearchProposalCard"
import type { ScheduledResearchProposal } from "@/lib/api";
import i18n from "@/i18n";
function proposal(format?: "html" | "pdf" | null): ScheduledResearchProposal {
function proposal(format?: "html" | "pdf" | null, protectPdf = false): 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 },
delivery: { channel: "email", target_ref: "research", target_label: "Research inbox", status: "none", format, protect_pdf: protectPdf },
},
};
}
@@ -27,4 +27,15 @@ describe("scheduled proposal email format confirmation", () => {
expect(screen.getByText(i18n.t(key))).toBeInTheDocument();
expect(screen.getByText("Research inbox")).toBeInTheDocument();
});
it("shows PDF protection before confirming a protected proposal", () => {
render(<ScheduledResearchProposalCard proposal={proposal("pdf", true)} />);
expect(screen.getByText(i18n.t("scheduled.proposalPdfProtection"))).toBeInTheDocument();
expect(screen.getByText(i18n.t("scheduled.proposalPdfProtectionOn"))).toBeInTheDocument();
});
it("does not show PDF protection for HTML delivery", () => {
render(<ScheduledResearchProposalCard proposal={proposal("html", true)} />);
expect(screen.queryByText(i18n.t("scheduled.proposalPdfProtection"))).not.toBeInTheDocument();
});
});
@@ -242,6 +242,7 @@ export function ChannelConfigPanel({
"settings.channels.fields.email.smtp_port": { label: t("settings.channels.fields.email.smtp_port.label"), help: t("settings.channels.fields.email.smtp_port.help") },
"settings.channels.fields.email.smtp_username": { label: t("settings.channels.fields.email.smtp_username.label"), help: t("settings.channels.fields.email.smtp_username.help") },
"settings.channels.fields.email.smtp_password": { label: t("settings.channels.fields.email.smtp_password.label"), help: t("settings.channels.fields.email.smtp_password.help") },
"settings.channels.fields.email.pdf_password": { label: t("settings.channels.fields.email.pdf_password.label"), help: t("settings.channels.fields.email.pdf_password.help") },
"settings.channels.fields.email.smtp_use_tls": { label: t("settings.channels.fields.email.smtp_use_tls.label"), help: t("settings.channels.fields.email.smtp_use_tls.help") },
"settings.channels.fields.email.smtp_use_ssl": { label: t("settings.channels.fields.email.smtp_use_ssl.label"), help: t("settings.channels.fields.email.smtp_use_ssl.help") },
"settings.channels.fields.email.verify_tls": { label: t("settings.channels.fields.email.verify_tls.label"), help: t("settings.channels.fields.email.verify_tls.help") },
+10 -1
View File
@@ -206,6 +206,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "حماية PDF بكلمة مرور",
"proposalPdfProtectionOn": "مفعّلة",
"proposalPdfProtectionOff": "غير مفعّلة",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -230,7 +233,9 @@
"deliveryTargetPlaceholder_channel": "معرّف القناة",
"deliveryTargetPlaceholder_destination": "معرّف محادثة / مجموعة / مستخدم",
"deliveryTargetPlaceholder_slack_destination": "#قناة أو @مستخدم أو معرّف محادثة",
"deliveryTargetKind_slack_destination": "الوجهة"
"deliveryTargetKind_slack_destination": "الوجهة",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "التقارير",
@@ -682,6 +687,10 @@
"trusted_authserv_id": {
"label": "خادم المصادقة الموثوق",
"help": "معرّف authserv-id لخادم الاستلام، مثل mx.google.com. يجب على مزود البريد إزالة ترويسات Authentication-Results الواردة المزوّرة التي تحمل هذا المعرّف. مطلوب عند تفعيل SPF أو DKIM؛ تركه فارغًا يرفض البريد الوارد."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -211,6 +211,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "PDF-Passwortschutz",
"proposalPdfProtectionOn": "Ein",
"proposalPdfProtectionOff": "Aus",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -235,7 +238,9 @@
"deliveryTargetPlaceholder_channel": "Kanal-ID",
"deliveryTargetPlaceholder_destination": "Chat- / Gruppen- / Benutzer-ID",
"deliveryTargetPlaceholder_slack_destination": "#Kanal, @Benutzer oder Konversations-ID",
"deliveryTargetKind_slack_destination": "Ziel"
"deliveryTargetKind_slack_destination": "Ziel",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "Berichte",
@@ -687,6 +692,10 @@
"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."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -213,6 +213,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "PDF password protection",
"proposalPdfProtectionOn": "On",
"proposalPdfProtectionOff": "Off",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -235,7 +238,9 @@
"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"
"deliveryTargetKind_slack_destination": "Destination",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "Reports",
@@ -687,6 +692,10 @@
"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."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -213,6 +213,9 @@
"deliveryFormatDefault": "Predeterminado del canal (texto plano)",
"deliveryFormatHtml": "Email HTML",
"deliveryFormatPdf": "PDF adjunto",
"proposalPdfProtection": "Protección del PDF con contraseña",
"proposalPdfProtectionOn": "Activada",
"proposalPdfProtectionOff": "Desactivada",
"deliveryFormatDefaultHint": "Mantiene el comportamiento de texto plano existente.",
"deliveryFormatHtmlHint": "Muestra el informe completo como HTML sanitizado en el cuerpo del email.",
"deliveryFormatPdfHint": "Adjunta el informe completo como PDF y deja solo un aviso breve en el cuerpo.",
@@ -235,7 +238,9 @@
"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"
"deliveryTargetKind_slack_destination": "Destino",
"deliveryProtectPdf": "Proteger el PDF con contraseña",
"deliveryProtectPdfUnavailable": "No hay una contraseña PDF configurada. Configurala en Configuración > Canales > Email para habilitar la protección."
},
"reports": {
"badge": "Informes",
@@ -687,6 +692,10 @@
"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."
},
"pdf_password": {
"label": "Contraseña de informes PDF",
"help": "Contraseña opcional para los informes PDF programados que soliciten protección. Se guarda en el servidor y nunca se incluye en los trabajos programados ni se devuelve al navegador."
}
},
"websocket": {
+10 -1
View File
@@ -211,6 +211,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "Perlindungan PDF dengan kata sandi",
"proposalPdfProtectionOn": "Aktif",
"proposalPdfProtectionOff": "Nonaktif",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -235,7 +238,9 @@
"deliveryTargetPlaceholder_channel": "ID kanal",
"deliveryTargetPlaceholder_destination": "ID chat / grup / pengguna",
"deliveryTargetPlaceholder_slack_destination": "#kanal, @pengguna, atau ID percakapan",
"deliveryTargetKind_slack_destination": "Tujuan"
"deliveryTargetKind_slack_destination": "Tujuan",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "Laporan",
@@ -687,6 +692,10 @@
"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."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -206,6 +206,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "PDFのパスワード保護",
"proposalPdfProtectionOn": "有効",
"proposalPdfProtectionOff": "無効",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -230,7 +233,9 @@
"deliveryTargetPlaceholder_channel": "チャンネル ID",
"deliveryTargetPlaceholder_destination": "チャット / グループ / ユーザー ID",
"deliveryTargetPlaceholder_slack_destination": "#チャンネル、@ユーザー、会話 ID",
"deliveryTargetKind_slack_destination": "送信先"
"deliveryTargetKind_slack_destination": "送信先",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "レポート",
@@ -682,6 +687,10 @@
"trusted_authserv_id": {
"label": "信頼する認証サーバー",
"help": "受信サーバーの authserv-id(例:mx.google.com)。メール提供者が、この ID を偽装した外部の Authentication-Results ヘッダーを削除する必要があります。SPF または DKIM 検証時は必須です。空欄では受信メールを拒否します。"
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -206,6 +206,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "PDF 비밀번호 보호",
"proposalPdfProtectionOn": "켜짐",
"proposalPdfProtectionOff": "꺼짐",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -230,7 +233,9 @@
"deliveryTargetPlaceholder_channel": "채널 ID",
"deliveryTargetPlaceholder_destination": "채팅 / 그룹 / 사용자 ID",
"deliveryTargetPlaceholder_slack_destination": "#채널, @사용자 또는 대화 ID",
"deliveryTargetKind_slack_destination": "대상"
"deliveryTargetKind_slack_destination": "대상",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "리포트",
@@ -682,6 +687,10 @@
"trusted_authserv_id": {
"label": "신뢰할 인증 서버",
"help": "수신 서버의 authserv-id(예: mx.google.com)입니다. 메일 제공자는 이 ID를 위조한 외부 Authentication-Results 헤더를 제거해야 합니다. SPF 또는 DKIM 검증을 사용하면 필수이며, 비워 두면 수신 메일이 거부됩니다."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -211,6 +211,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "Proteção do PDF por senha",
"proposalPdfProtectionOn": "Ativada",
"proposalPdfProtectionOff": "Desativada",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -235,7 +238,9 @@
"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"
"deliveryTargetKind_slack_destination": "Destino",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "Relatórios",
@@ -687,6 +692,10 @@
"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."
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+10 -1
View File
@@ -206,6 +206,9 @@
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"proposalPdfProtection": "PDF 密码保护",
"proposalPdfProtectionOn": "开启",
"proposalPdfProtectionOff": "关闭",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
@@ -230,7 +233,9 @@
"deliveryTargetPlaceholder_channel": "频道 ID",
"deliveryTargetPlaceholder_destination": "会话 / 群 / 用户 id",
"deliveryTargetPlaceholder_slack_destination": "#频道、@用户或会话 ID",
"deliveryTargetKind_slack_destination": "目标"
"deliveryTargetKind_slack_destination": "目标",
"deliveryProtectPdf": "Protect PDF with password",
"deliveryProtectPdfUnavailable": "No PDF password is configured. Set one in Settings > Channels > Email to enable protection."
},
"reports": {
"badge": "报告",
@@ -682,6 +687,10 @@
"trusted_authserv_id": {
"label": "受信任的认证服务器",
"help": "收件服务器的 authserv-id,例如 mx.google.com。邮件服务商必须移除外部伪造且使用此 ID 的 Authentication-Results 邮件头。启用 SPF 或 DKIM 验证时必填;留空会拒收邮件。"
},
"pdf_password": {
"label": "PDF report password",
"help": "Optional password for scheduled PDF reports that explicitly request protection. Stored server-side and never included in scheduled jobs or returned to the browser."
}
},
"websocket": {
+5
View File
@@ -693,6 +693,7 @@ export interface ScheduledRun {
delivery_channel: string | null;
delivery_target: string | null;
delivery_format: "html" | "pdf" | null;
protect_pdf: boolean;
delivery_target_ref: string | null;
delivery_target_label: string | null;
delivery_status: string;
@@ -716,6 +717,7 @@ export interface CreateScheduledRunRequest {
delivery_channel?: string | null;
delivery_target?: string | null;
delivery_format?: "html" | "pdf" | null;
protect_pdf?: boolean;
delivery_target_ref?: string | null;
}
@@ -730,6 +732,7 @@ export interface UpdateScheduledRunRequest {
delivery_target?: string | null;
delivery_target_ref?: string | null;
delivery_format?: "html" | "pdf" | null;
protect_pdf?: boolean;
}
export interface ScheduledResearchProposalJob {
@@ -748,6 +751,7 @@ export interface ScheduledResearchProposalJob {
target_ref: string | null;
target_label: string | null;
format?: "html" | "pdf" | null;
protect_pdf?: boolean;
status: string;
};
}
@@ -945,6 +949,7 @@ export interface ChannelConfigEntry {
fields: ChannelFieldHint[];
values: Record<string, unknown>;
secrets: Record<string, ChannelSecretStatus>;
pdf_password_configured?: boolean;
}
export interface ChannelsConfigResponse {
+44 -2
View File
@@ -113,6 +113,8 @@ export function Scheduled() {
const [deliveryTarget, setDeliveryTarget] = useState("");
const [deliveryTargetRef, setDeliveryTargetRef] = useState<string | null>(null);
const [deliveryFormat, setDeliveryFormat] = useState<EmailDeliveryFormat>("");
const [protectPdf, setProtectPdf] = useState(false);
const [emailPdfPasswordConfigured, setEmailPdfPasswordConfigured] = useState(false);
const [saving, setSaving] = useState(false);
const [composerError, setComposerError] = useState<string | null>(null);
@@ -171,6 +173,22 @@ export function Scheduled() {
};
}, []);
useEffect(() => {
let cancelled = false;
void api.getChannelsConfig()
.then((config) => {
if (!cancelled) {
setEmailPdfPasswordConfigured(config.channels.email?.pdf_password_configured === true);
}
})
.catch(() => {
if (!cancelled) setEmailPdfPasswordConfigured(false);
});
return () => {
cancelled = true;
};
}, []);
// Auto-disarm an armed delete after a few seconds (blur is unreliable on
// Safari/iOS, where buttons do not take focus on click).
useEffect(() => {
@@ -201,6 +219,7 @@ export function Scheduled() {
setDeliveryTarget("");
setDeliveryTargetRef(null);
setDeliveryFormat("");
setProtectPdf(false);
setComposerError(null);
}
@@ -214,6 +233,7 @@ export function Scheduled() {
setDeliveryTarget(run.delivery_target ?? "");
setDeliveryTargetRef(run.delivery_target_ref);
setDeliveryFormat(run.delivery_format === "html" || run.delivery_format === "pdf" ? run.delivery_format : "");
setProtectPdf(run.protect_pdf === true);
setComposerError(null);
if (cadence.kind === "daily") {
@@ -260,6 +280,7 @@ export function Scheduled() {
delivery_channel: channel || null,
delivery_target: channel ? target : null,
delivery_format: channel === "email" && deliveryFormat ? deliveryFormat : null,
protect_pdf: channel === "email" && deliveryFormat === "pdf" && protectPdf,
delivery_target_ref: channel ? deliveryTargetRef : null,
};
if (editingId) {
@@ -522,7 +543,10 @@ export function Scheduled() {
setDeliveryTargetRef(null);
}
setDeliveryChannel(nextChannel);
if (nextChannel !== "email") setDeliveryFormat("");
if (nextChannel !== "email") {
setDeliveryFormat("");
setProtectPdf(false);
}
}}
className={fieldClass}
>
@@ -606,7 +630,11 @@ export function Scheduled() {
<select
id="scheduled-delivery-format"
value={deliveryFormat}
onChange={(e) => setDeliveryFormat(e.target.value as EmailDeliveryFormat)}
onChange={(e) => {
const nextFormat = e.target.value as EmailDeliveryFormat;
setDeliveryFormat(nextFormat);
if (nextFormat !== "pdf") setProtectPdf(false);
}}
className={fieldClass}
>
<option value="">{t("scheduled.deliveryFormatDefault")}</option>
@@ -620,6 +648,20 @@ export function Scheduled() {
? t("scheduled.deliveryFormatPdfHint")
: t("scheduled.deliveryFormatDefaultHint")}
</p>
{deliveryFormat === "pdf" && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={protectPdf}
disabled={!emailPdfPasswordConfigured && !protectPdf}
onChange={(e) => setProtectPdf(e.target.checked)}
/>
<span>{t("scheduled.deliveryProtectPdf")}</span>
</label>
)}
{deliveryFormat === "pdf" && !emailPdfPasswordConfigured && !protectPdf && (
<p className={hintClass}>{t("scheduled.deliveryProtectPdfUnavailable")}</p>
)}
</div>
)}
</div>
@@ -14,6 +14,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
updateScheduledRun: vi.fn(),
deleteScheduledRun: vi.fn(),
getChannelStatus: vi.fn(),
getChannelsConfig: vi.fn(),
},
};
});
@@ -24,6 +25,7 @@ const mocked = api as unknown as {
updateScheduledRun: ReturnType<typeof vi.fn>;
deleteScheduledRun: ReturnType<typeof vi.fn>;
getChannelStatus: ReturnType<typeof vi.fn>;
getChannelsConfig: ReturnType<typeof vi.fn>;
};
function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
@@ -62,6 +64,9 @@ function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
beforeEach(() => {
vi.clearAllMocks();
mocked.listScheduledRuns.mockResolvedValue([]);
mocked.getChannelsConfig.mockResolvedValue({
channels: { email: { pdf_password_configured: true } },
});
mocked.getChannelStatus.mockResolvedValue({
running: true,
inbound_queue: 0,
@@ -152,6 +157,51 @@ describe("Scheduled page", () => {
);
});
it("offers PDF protection only for PDF email delivery and sends the boolean choice", async () => {
mocked.createScheduledRun.mockResolvedValue(run({
delivery_channel: "email",
delivery_format: "pdf",
protect_pdf: true,
}));
render(<Scheduled />);
await screen.findByText(/No scheduled runs yet/);
fireEvent.change(screen.getByLabelText("Research prompt"), {
target: { value: "send a protected report" },
});
fireEvent.change(screen.getByLabelText("Delivery channel"), { target: { value: "email" } });
fireEvent.change(screen.getByLabelText("Recipient email address"), {
target: { value: "reader@example.test" },
});
expect(screen.queryByLabelText("Protect PDF with password")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Email report format"), { target: { value: "pdf" } });
const protect = await screen.findByLabelText("Protect PDF with password");
fireEvent.click(protect);
fireEvent.submit(screen.getByRole("button", { name: /Schedule run/ }));
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalledWith(
expect.objectContaining({ delivery_format: "pdf", protect_pdf: true }),
));
});
it("keeps PDF protection unavailable until the Email secret is configured", async () => {
mocked.getChannelsConfig.mockResolvedValue({
channels: { email: { pdf_password_configured: false } },
});
render(<Scheduled />);
await screen.findByText(/No scheduled runs yet/);
fireEvent.change(screen.getByLabelText("Research prompt"), {
target: { value: "send a report" },
});
fireEvent.change(screen.getByLabelText("Delivery channel"), { target: { value: "email" } });
fireEvent.change(screen.getByLabelText("Recipient email address"), {
target: { value: "reader@example.test" },
});
fireEvent.change(screen.getByLabelText("Email report format"), { target: { value: "pdf" } });
expect(await screen.findByLabelText("Protect PDF with password")).toBeDisabled();
expect(screen.getByText(/No PDF password is configured/)).toBeInTheDocument();
});
it("edits prompt, wall-clock schedule, and delivery without recreating the job", async () => {
mocked.listScheduledRuns.mockResolvedValue([
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"python-docx>=1.1.0",
"python-pptx>=0.6.23",
"pypdfium2>=4.0.0",
"pypdf>=6.0.0",
"Pillow>=12.2.0",
"numpy>=1.24.0",
"scipy>=1.10.0",
+4
View File
@@ -2946,6 +2946,10 @@ pyparsing==3.3.2 \
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
# via matplotlib
pypdf==6.19.0 \
--hash=sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14 \
--hash=sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155
# via -r agent/requirements.txt
pypdfium2==5.13.0 \
--hash=sha256:07f58e91b8c45ca144a1ff3008faf3c73ef8a5e9fb32988831788363288228cd \
--hash=sha256:2abedfb5c70992b19c780ed58d7f7b929e8ce8ee52c9140158f44317c90ec6c7 \