"""Author-facing email notifications for ICSAC paper submissions. Wraps the top-level email_send primitive with submission-specific templates. Templates live in intake/templates/ as Markdown with light {{var}} interpolation; the first line is `Subject: ...`, the rest is the body. """ from __future__ import annotations import os import re import sys from pathlib import Path from typing import Optional # Parent-package imports (editorial-system modules live at repo root, one # level above this intake/ subpackage). _REPO_ROOT = Path(__file__).resolve().parent.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) import email_send # noqa: E402 TEMPLATES_DIR = Path(__file__).parent / "templates" # Print-optimized CSS for the panel-report and RQC PDFs. Letter trim, # generous margins, serif body, sans-headings. Tables get borders so # per-dimension score tables render cleanly. No vendor/model styling # concerns since the PDFs render the SCRUBBED markdown. _PDF_CSS = """ @page { size: Letter; margin: 0.85in 0.9in; } body { font-family: Georgia, "Times New Roman", serif; font-size: 10.5pt; line-height: 1.5; color: #1f1f1f; } h1, h2, h3, h4 { font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; color: #111; font-weight: 600; page-break-after: avoid; } h1 { font-size: 1.5em; border-bottom: 2px solid #888; padding-bottom: 0.3em; margin-top: 0; } h2 { font-size: 1.18em; margin-top: 1.4em; } h3 { font-size: 1.04em; margin-top: 1.0em; } h4 { font-size: 0.98em; margin-top: 0.8em; } p { margin: 0.55em 0; } ul, ol { padding-left: 1.4em; margin: 0.55em 0; } li { margin: 0.18em 0; } blockquote { border-left: 3px solid #c8c8c8; margin: 0.9em 0; padding: 0.25em 0.9em; color: #555; background: #f7f7f7; font-style: italic; } code { font-family: "SFMono-Regular", Menlo, Consolas, monospace; background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 0.9em; } pre { background: #f4f4f4; border: 1px solid #e4e4e4; border-radius: 3px; padding: 0.55em 0.7em; overflow-x: auto; font-size: 0.88em; line-height: 1.4; } pre code { background: transparent; padding: 0; border-radius: 0; } table { border-collapse: collapse; margin: 0.7em 0; width: 100%; font-size: 0.95em; } th, td { border: 1px solid #ccc; padding: 0.35em 0.55em; text-align: left; vertical-align: top; } th { background: #f0f0f0; font-weight: 600; } hr { border: none; border-top: 1px solid #ddd; margin: 1.4em 0; } details > summary { cursor: pointer; font-weight: 600; margin: 0.6em 0; } """ def _md_to_pdf_bytes(md_text: str, *, doc_title: str | None = None) -> bytes: """Render markdown to a print-quality PDF via WeasyPrint. Used for the panel-report and RQC attachments on decision emails. Input is the SCRUBBED markdown (vendor/model identifiers and the RQC injection_indicators dim already stripped upstream); we don't re-validate here — caller owns the redaction gate. """ import markdown as _md_lib import weasyprint inner = _md_lib.markdown(md_text, extensions=["extra", "sane_lists", "tables"]) title_html = f"{doc_title}" if doc_title else "" full = ( f"" f"{title_html}" f"{inner}" ) return weasyprint.HTML(string=full).write_pdf() class TemplateUnfilledKeysError(RuntimeError): """Raised when a template still contains {{...}} after rendering. Hard-fail by design — no silent author-facing breakage. Mirrors editorial-system/email_render.TemplateUnfilledKeysError. """ def _render(template_name: str, vars: dict) -> tuple[str, str]: """Load template, substitute {{vars}}, return (subject, body_md). Missing keys leave the literal {{key}} in place; a post-render scan catches any survivors and raises so the worker fails loud rather than sending mail with an unfilled placeholder. """ raw = (TEMPLATES_DIR / template_name).read_text() def sub(match: re.Match) -> str: key = match.group(1).strip() return str(vars[key]) if key in vars else match.group(0) rendered = re.sub(r"\{\{(\w+)\}\}", sub, raw) leftover = re.findall(r"\{\{[^}]+\}\}", rendered) if leftover: raise TemplateUnfilledKeysError( f"unfilled keys in {template_name}: {sorted(set(leftover))}" ) lines = rendered.splitlines() subject = "" body_start = 0 for i, line in enumerate(lines): if line.lower().startswith("subject:"): subject = line.split(":", 1)[1].strip() body_start = i + 1 break body_md = "\n".join(lines[body_start:]).lstrip("\n") return subject, body_md def send_received(to: str, sub_id: str, title: str, author_name: str) -> tuple[bool, str]: subject, body = _render("submission_received.md", { "icsac_submission_id": sub_id, "title": title, "author_name": author_name, }) return email_send.send_email(to_addr=to, subject=subject, body_md=body, send=True) def send_intake_failure(*, to: str, sub_id: str, author_name: str, failure_reason: str, remediation: str) -> tuple[bool, str]: """Notify author when intake fails before review can begin. Used when a deferred DOI fetch fails (Zenodo unreachable, record has no PDF, PDF lacks text layer). Does NOT include a panel report because no panel ran. Failure reason should be one short sentence; remediation should tell the author exactly what to try next.""" subject, body = _render("submission_intake_failure.md", { "icsac_submission_id": sub_id, "author_name": author_name, "failure_reason": failure_reason, "remediation": remediation, }) return email_send.send_email(to_addr=to, subject=subject, body_md=body, send=True) def send_published(*, to: str, sub_id: str, title: str, author_name: str, deposit_doi: str, deposit_url: str, publications_url: str) -> tuple[bool, str]: """Send the post-publish notification email for a PDF-route accept. Fired by the publish_watcher in the editorial-system repo when a curator publishes the previously-staged Zenodo draft and the DOI becomes permanent. No PDF attachments — the panel report + RQC already shipped with the original (pending) accept email; this is the short follow-up that closes the loop with the now-real DOI + publications permalink. """ subject, body = _render("submission_published.md", { "icsac_submission_id": sub_id, "title": title, "author_name": author_name, "deposit_doi": deposit_doi, "deposit_url": deposit_url, "publications_url": publications_url, }) return email_send.send_email(to_addr=to, subject=subject, body_md=body, send=True) def send_decision(*, to: str, sub_id: str, title: str, author_name: str, verdict: str, source: str, panel_report_md: str, rqc_md: str = "", source_ref: str = "", deposit_doi: str | None = None, deposit_url: str | None = None, publications_url: str | None = None, tier: int = 1, compaction_manifest: dict | None = None, ) -> tuple[bool, str]: """Send the decision email with two PDF attachments (panel report + RQC). verdict ∈ {accept, revise, scope_reject}; source ∈ {doi, upload}. Per-source template variants live in templates/submission__.md. The body references the attachments rather than inlining the panel report, keeping the email under ~400 words; the PDFs carry the detail. Both `panel_report_md` and `rqc_md` must already be SCRUBBED (the worker's _scrubbed_report_pair is the upstream gate). If either is empty the PDF is skipped — author still gets the email body, just without that artifact. `source_ref` carries the author-supplied DOI on the DOI route; the deposit_* fields carry the ICSAC-minted DOI/URL on the upload route once the deposit step ships. `publications_url` is the canonical ICSAC indexing permalink (https://icsacinstitute.org/publications/) populated by the caller after the registry write succeeds — required for accept emails on both routes (the author-facing receipt of editorial endorsement). """ if source not in ("doi", "upload"): raise ValueError(f"unknown source: {source!r}") # accept+upload routes to the post-deposit copy (references deposit_doi # + deposit_url) when the deposit step has succeeded; falls back to the # _pending variant — the previous interim copy — when deposit_doi is # empty (deposit failed or hasn't run yet for some reason). if (verdict, source) == ("accept", "upload") and not deposit_doi: template: Optional[str] = "submission_accept_upload_pending.md" else: template = { ("accept", "doi"): "submission_accept_doi.md", ("accept", "upload"): "submission_accept_upload.md", ("revise", "doi"): "submission_revise_doi.md", ("revise", "upload"): "submission_revise_upload.md", ("scope_reject", "doi"): "submission_scope_reject_doi.md", ("scope_reject", "upload"): "submission_scope_reject_upload.md", }.get((verdict, source)) if not template: raise ValueError(f"unknown (verdict, source): {verdict!r}, {source!r}") import review_compaction if compaction_manifest is None: compaction_manifest = {"_failure": "no manifest provided to send_decision"} failure = compaction_manifest.get("_failure") if failure: disclosure = ( "ICSAC normally runs every submission through an automated " "blind-review preprocessor that strips author identifiers, " "affiliations, contact information, acknowledgments, funding " "statements, and the references list before the panel reads " "the manuscript. For your submission this preprocessing did " f"not run ({failure}); the panel reviewed your manuscript " "as submitted. If you believe the unredacted form influenced " "the decision in a way you want to contest, contact " "help@icsacinstitute.org and reference your submission ID." ) else: manifest_lines = review_compaction.render_manifest(compaction_manifest) disclosure = ( "Before our AI panel reviewed your manuscript, the editorial " "system automatically removed author names, affiliations, " "contact information, ORCID iDs, acknowledgments, funding " "statements, and the references list (inline citation markers " "were preserved). This is a standard double-blind preprocessing " "step intended to reduce author-identity bias, lower token " "consumption, and add a privacy layer between authors and the " "models in the panel. Citation verification was performed " "separately against your full reference list upstream of the " "panel.\n\n" "If you believe this preprocessing affected the decision in a " "way you want to contest, contact help@icsacinstitute.org and " "reference your submission ID.\n\n" "The following content was removed from your manuscript " "before review:\n\n" f"{manifest_lines}" ) subject, body = _render(template, { "icsac_submission_id": sub_id, "title": title, "author_name": author_name, "source_ref": source_ref or "", "deposit_doi": deposit_doi or "", "deposit_url": deposit_url or "", "publications_url": publications_url or "", "compaction_disclosure": disclosure, }) attachments: list[tuple[str, bytes]] = [] if panel_report_md.strip(): attachments.append(( f"icsac-review-{sub_id}.pdf", _md_to_pdf_bytes(panel_report_md, doc_title=f"ICSAC Review — {sub_id}"), )) if rqc_md.strip(): attachments.append(( f"icsac-rqc-{sub_id}.pdf", _md_to_pdf_bytes(rqc_md, doc_title=f"ICSAC Review Quality Control — {sub_id}"), )) # Decision emails (accept/revise/scope_reject) are HIGH-STAKES author-facing # correspondence with the panel report + RQC PDFs attached. Per the # 2026-05-07 policy change, these go to Gmail Drafts for curator review # rather than sending autonomously. submission_received and # submission_intake_failure stay auto-send (low-stakes acknowledgements). # # Tier routing (test mode only — tier=1 is the production default): # tier 1: existing behavior (Gmail Drafts, no subject prefix) # tier 2: write to ~/icsac-submissions/test/_outbox/.eml, # NOT IMAP. Real panel ran but no Gmail interaction. # tier 3: Gmail Drafts WITH `[T3 TEST] ` subject prefix so the # curator can never accidentally send a test draft to a # real author from the Drafts pane. if tier == 2: from pathlib import Path as _Path outbox = _Path.home() / "icsac-submissions" / "test" / "_outbox" return email_send.send_email( to_addr=to, subject=subject, body_md=body, outbox_dir=str(outbox), eml_filename=f"{sub_id}.eml", attachments=attachments, ) if tier == 3: return email_send.send_email( to_addr=to, subject=f"[T3 TEST] {subject}", body_md=body, draft=True, attachments=attachments, ) return email_send.send_email(to_addr=to, subject=subject, body_md=body, draft=True, attachments=attachments)