editorial-system / email_render.py
ICSAC's picture
Public-flip preparation
470bcea
"""Author correspondence: render accept/reject/invite email templates with author metadata."""
import os
import re
from urllib.parse import quote
import config
from review import _creator_display_names
def load_template(name: str) -> str:
"""Load an email template by name."""
path = os.path.join(config.TEMPLATES_DIR, f"{name}.md")
with open(path) as f:
return f.read()
class TemplateUnfilledKeysError(RuntimeError):
"""Raised when a template still contains {{...}} placeholders after rendering.
Hard-fail by design: author-facing mail with an unfilled key (e.g. a
'Dear {{author_name}},' greeting) is worse than no mail at all. The
raise propagates through the worker, which converts it to /pain via the
standard nerve and writes a `template_unfilled_keys` audit-log entry.
Caller should never catch and swallow this.
"""
def _render(template: str, data: dict) -> str:
"""Replace {{key}} placeholders with values from data.
Hard-fails if any {{...}} remains after substitution β€” by design,
silent template breakage in author-facing mail is worse than no mail.
Missing keys are still left in place during the substitution pass
(per existing semantics), but the post-pass scan catches them and
raises before any byte is sent.
"""
def sub(match):
key = match.group(1).strip()
return str(data.get(key, match.group(0)))
rendered = re.sub(r"\{\{(\w+)\}\}", sub, template)
leftover = re.findall(r"\{\{[^}]+\}\}", rendered)
if leftover:
raise TemplateUnfilledKeysError(
f"unfilled template keys after render: {sorted(set(leftover))}"
)
return rendered
def _split_creator(entry: str) -> tuple[str, str]:
"""Best-effort (first, last) split for a Zenodo creator string.
'Doe, Jane M.' -> ('Jane', 'Doe')
'Jane M. Doe' -> ('Jane', 'Doe')
'Plato' -> ('Plato', 'Plato')
"""
entry = entry.strip()
if "," in entry:
last, after = [s.strip() for s in entry.split(",", 1)]
first = after.split()[0].rstrip(".") if after else last
return (first, last)
parts = entry.split()
if len(parts) >= 2:
return (parts[0], parts[-1])
return (entry, entry)
def _greeting(creators: list, title_pref: str) -> str:
"""Build the name used after 'Dear '.
With a title preference, use 'Title Lastname' (e.g. 'Dr. Doe').
Without one, use the author's first name. Title prefs are only available
for authors who've opted into the community directory.
"""
entry = creators[0] if creators else "Researcher"
first, last = _split_creator(entry)
if title_pref and title_pref not in ("No title (first name is fine)", "Prefer not to say", ""):
return f"{title_pref} {last}"
return first
def _share_urls(paper_title: str, share_target_url: str) -> dict:
"""Build pre-filled social-share URLs for the accept email.
Share target is the ICSAC-branded landing page on icsacinstitute.org,
not the raw Zenodo record. LinkedIn and Facebook scrape OpenGraph tags
from the target URL, and the landing page's tags are ICSAC-branded so
the preview card shows ICSAC rather than generic Zenodo.
"""
share_sentence = (
f'My paper "{paper_title}" was accepted into the ICSAC Community '
f'β€” open peer review with AI tooling for complexity science.'
)
enc_sentence = quote(share_sentence, safe="")
enc_url = quote(share_target_url, safe="")
bluesky_text = quote(f"{share_sentence} {share_target_url}", safe="")
return {
"share_x_url": f"https://twitter.com/intent/tweet?text={enc_sentence}&url={enc_url}",
"share_linkedin_url": f"https://www.linkedin.com/sharing/share-offsite/?url={enc_url}",
"share_fb_url": f"https://www.facebook.com/sharer/sharer.php?u={enc_url}",
"share_bluesky_url": f"https://bsky.app/intent/compose?text={bluesky_text}",
}
def _base_data(review_data: dict) -> dict:
creators = _creator_display_names(review_data.get("creators"))
title_pref = review_data.get("author_title_preference", "")
paper_title = review_data.get("title", "Untitled")
record_id = review_data.get("record_id", "")
zenodo_url = f"https://zenodo.org/records/{record_id}"
site_base = getattr(config, "SITE_BASE_URL", "https://icsacinstitute.org")
share_target_url = f"{site_base}/accepted/{record_id}" if record_id else zenodo_url
# icsac_submission_id is the one canonical author-facing identifier β€” same
# key the audit-log uses (sub_id field on the submission record). For the
# Zenodo-watcher path, record_id is the Zenodo record ID; for the
# icsac-submission-intake path, record_id is the ICSAC-SUB-NNNNN string.
# Empty default rather than missing key β€” empty renders cleanly while a
# missing key would leave the literal {{icsac_submission_id}} placeholder
# and now (with the post-render assert) hard-fail the send.
data = {
"paper_title": paper_title,
"author_name": ", ".join(creators) if creators else "Researcher",
"greeting": _greeting(creators, title_pref),
"icsac_submission_id": str(record_id) if record_id else "",
"zenodo_record_url": zenodo_url,
"share_target_url": share_target_url,
"zenodo_submit_url": f"https://zenodo.org/communities/{getattr(config, 'COMMUNITY_ID', 'icsac')}",
"google_form_url": getattr(config, "GOOGLE_FORM_URL", "https://icsacinstitute.org/join"),
}
data.update(_share_urls(paper_title, share_target_url))
return data
def render_accept_email(review_data: dict, google_form_url: str = "") -> str:
"""Render the accept email."""
template = load_template("accept")
data = _base_data(review_data)
if google_form_url:
data["google_form_url"] = google_form_url
return _render(template, data)
def render_revise_and_resubmit_email(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the revise-and-resubmit email β€” ICSAC's default non-accept path.
Used for engageable in-scope submissions whose issues revision could
plausibly repair. The author is invited to revise and resubmit (no limit
on rounds, no bias on re-evaluation).
"""
template = load_template("revise-and-resubmit")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see detailed review notes below."
data["specific_concerns"] = specific_concerns or "Review details available upon request."
return _render(template, data)
def render_scope_reject_email(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the scope-not-suitable rejection email.
Reserved for submissions outside ICSAC's editorial scope (pseudoscience,
non-engageable epistemics). This is NOT the standard decline path β€” for
engageable in-scope work that needs revision, use
`render_revise_and_resubmit_email` instead.
"""
template = load_template("scope-reject")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see detailed review notes below."
data["specific_concerns"] = specific_concerns or "Review details available upon request."
return _render(template, data)
def render_community_invite_email(review_data: dict, google_form_url: str = "") -> str:
"""Render the community invite (perks/signup) email sent after accept."""
template = load_template("community-invite")
data = _base_data(review_data)
if google_form_url:
data["google_form_url"] = google_form_url
return _render(template, data)
def render_accept_comment(review_data: dict, landing_url: str = "") -> str:
"""Render the markdown comment we post to the Zenodo request on accept.
The comment is delivered to the author by Zenodo's notification machinery,
so it does not need a Subject line, Dear-greeting, or signature wrapper.
Share links and rich content live on the icsacinstitute.org landing page;
the comment just points there.
"""
template = load_template("accept-comment")
data = _base_data(review_data)
record_id = review_data.get("record_id", "")
site_base = getattr(config, "SITE_BASE_URL", "https://icsacinstitute.org")
data["landing_url"] = landing_url or f"{site_base}/accepted/{record_id}"
return _render(template, data)
def render_revise_and_resubmit_comment(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the markdown comment we post to the Zenodo request on R&R.
This is ICSAC's default decline path β€” engageable in-scope work that
needs revision. Use `render_scope_reject_comment` for scope-not-suitable
submissions.
"""
template = load_template("revise-and-resubmit-comment")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see review notes for details."
data["specific_concerns"] = specific_concerns or "Review report available on request."
return _render(template, data)
def render_scope_reject_comment(review_data: dict) -> str:
"""Render the markdown comment posted to the Zenodo request on scope-reject.
Scope-not-suitable only. The scope-reject template does not carry a review
summary or concerns list β€” the verdict is "out of scope," not "revise
these points" β€” so the signature is intentionally minimal.
"""
template = load_template("scope-reject-comment")
data = _base_data(review_data)
return _render(template, data)