Merge PR #1709: feat(email): add optional password protection for PDF reports
This commit is contained in:
commit
5baa3d9d0b
34 files changed
+622
-25
No files matched your search
+3
-1
@@ -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.
|
||||
|
||||
@@ -1556,6 +1556,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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in new issue
Block a user