| """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 |
| |
| |
| |
| |
| |
| |
| |
| 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) |
|
|