"""Reply rendering — turn a template + a report into copy-ready text. Substitution rules, and why they are what they are: * **Only known tokens are replaced.** ``str.format`` would raise on any stray ``{`` in an operator-authored template (and researcher mails are full of JSON, curly-braced payloads and code). A regex over an explicit token vocabulary can only ever under-substitute, never explode. * **A token with no value renders as an empty string AND is reported.** The caller gets ``missing`` back so the console can warn "this reward letter has no amount in it" *before* the operator pastes it into Gmail. Silently rendering ``USD `` is how a researcher gets told they won nothing. * Rendering never mutates the report. Logging the reply (which does mutate) is a separate, explicit step in ``service.log_reply``. """ from __future__ import annotations import re from dataclasses import dataclass, field from decimal import Decimal from typing import Any from app.domains.bounty.catalog import DEFAULT_SIGNATURE #: Every token the renderer understands. A template may reference any #: subset; anything outside this set is left verbatim in the output. KNOWN_TOKENS: frozenset[str] = frozenset({ "researcher_name", "researcher_email", "report_code", "report_title", "affected_system", "cve_id", "duplicate_reference", "amount_usd", "severity_label", "signer_name", "signer_title", "team_name", "company_name", "entity_block", "security_policy_url", "reported_date", }) _TOKEN_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}") #: Tokens whose absence is *fine* — a greeting falls back to "submitter", #: a policy URL to the shipped default — so they never surface as a #: warning. Everything else missing is worth a warning. _SOFT_TOKENS: frozenset[str] = frozenset({ "researcher_name", "security_policy_url", "company_name", "team_name", "signer_title", "report_title", "affected_system", "reported_date", "researcher_email", }) @dataclass(slots=True) class RenderedReply: subject: str body: str #: Tokens the template referenced that had no value. Advisory for the #: UI; a reward letter missing ``amount_usd`` should be blocked by the #: caller, not by the renderer. missing: list[str] = field(default_factory=list) #: Tokens actually substituted, for the audit trail. used: list[str] = field(default_factory=list) def format_usd(amount: Decimal | float | int | None) -> str: """Money as it should read in a letter: ``100``, ``1,500``, ``12.50``. Trailing ``.00`` is dropped because "USD 100.00" in a hand-written reply reads like a generated invoice, and every real template the security team has sent says "USD 100". """ if amount is None: return "" dec = Decimal(str(amount)) quantised = dec.quantize(Decimal("0.01")) if quantised == quantised.to_integral_value(): return f"{int(quantised):,}" return f"{quantised:,.2f}" def _duplicate_reference(report: Any, original_code: str | None) -> str: """A parenthetical the researcher can act on, or "" if we recorded nothing. Returned WITH its leading space and brackets so the sentence reads correctly either way: "the issue you reported (internal issue #1681), we had already…" and, with nothing recorded, "the issue you reported, we had already…". Building the punctuation into the template instead would leave a stray " ()" on every letter where the reference is unknown. The free-text note wins over the Helix code: it is what an operator wrote to be read OUTSIDE, whereas ``BB-2026-041`` is an identifier the researcher cannot look up and which names another submitter's report. """ note = (getattr(report, "duplicate_note", None) or "").strip() if note: return f" ({note})" return f" ({original_code})" if original_code else "" def build_context( *, report: Any, researcher: Any | None, severity_label: str | None, signature: dict[str, Any] | None, amount_usd: Decimal | None = None, duplicate_of_code: str | None = None, ) -> dict[str, str]: """Assemble the substitution context. ``amount_usd`` overrides the report's stored reward, so the console can preview "what would this letter say if I offered $300" before committing the figure. ``duplicate_of_code`` is passed in rather than read off a relationship: rendering runs on reports loaded without eager loads, and a lazy hop here would either raise under asyncio or silently resolve to nothing. """ sig = {**DEFAULT_SIGNATURE, **(signature or {})} amount = amount_usd if amount_usd is not None else getattr( report, "reward_amount_usd", None, ) name = "" if researcher is not None: name = ( (researcher.display_name or "").strip() or (researcher.handle or "").strip() ) reported = getattr(report, "reported_at", None) ctx = { # "submitter" mirrors the wording of the shipped templates — a # letter that opens "Dear ," is worse than a generic greeting. "researcher_name": name or "submitter", "researcher_email": ( (researcher.email or "") if researcher is not None else "" ), "report_code": getattr(report, "code", "") or "", "report_title": getattr(report, "title", "") or "", "affected_system": getattr(report, "affected_system", None) or "", "cve_id": getattr(report, "cve_id", None) or "", # What the duplicate verdict points at, phrased for the letter. # Prefers the free-text reference (an internal issue or an # earlier mail — the common case) over the Helix code, which # means nothing to an external researcher. "duplicate_reference": _duplicate_reference(report, duplicate_of_code), "amount_usd": format_usd(amount), "severity_label": severity_label or "", "reported_date": reported.date().isoformat() if reported else "", "signer_name": str(sig.get("signer_name") or "").strip(), "signer_title": str(sig.get("signer_title") or "").strip(), "team_name": str(sig.get("team_name") or "").strip(), "company_name": str(sig.get("company_name") or "").strip(), "entity_block": str(sig.get("entity_block") or "").strip(), "security_policy_url": str(sig.get("security_policy_url") or "").strip(), } return ctx def render( *, subject: str, body: str, context: dict[str, str], ) -> RenderedReply: """Substitute known tokens in ``subject`` and ``body``.""" missing: set[str] = set() used: set[str] = set() def _sub(match: re.Match[str]) -> str: token = match.group(1) if token not in KNOWN_TOKENS: # Not ours — hand the literal back untouched so a JSON blob # or a future token survives round-tripping. return match.group(0) value = (context.get(token) or "").strip() if not value: if token not in _SOFT_TOKENS: missing.add(token) return "" used.add(token) return value rendered_subject = _TOKEN_RE.sub(_sub, subject or "") # Substitute line by line so a line that existed ONLY to carry a # token can be dropped when that token is empty. An unset signer name # otherwise leaves "Best regards,\n\nSecurity Team" — a stray blank # line in the middle of a signature block, in a letter going to an # external researcher. A line that had prose on it is always kept, # even if its tokens rendered empty. kept: list[str] = [] for source_line in (body or "").split("\n"): rendered_line = _TOKEN_RE.sub(_sub, source_line) if ( not rendered_line.strip() and source_line.strip() and not _TOKEN_RE.sub("", source_line).strip() ): continue kept.append(rendered_line) # Collapse any run of blank lines left by consecutive drops back to a # single paragraph break. rendered_body = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip() rendered_subject = " ".join(rendered_subject.split()) return RenderedReply( subject=rendered_subject, body=rendered_body, missing=sorted(missing), used=sorted(used), ) def declared_tokens(subject: str, body: str) -> list[str]: """Known tokens a template actually references — used to keep the stored ``variables`` hint honest when an operator edits a body.""" found = { m.group(1) for m in _TOKEN_RE.finditer(f"{subject or ''}\n{body or ''}") if m.group(1) in KNOWN_TOKENS } return sorted(found)