"""SMTP delivery for ICSAC author correspondence. Sends HTML (multipart/alternative) email through Gmail SMTP with the ICSAC logo inlined as a CID attachment. From header uses info@icsacinstitute.org as a Send-As alias over a backing SMTP mailbox. Defaults to dry-run mode for safety. Pass send=True to actually deliver. """ import os import re import smtplib import ssl import time import imaplib from email.message import EmailMessage import markdown import config LOGO_CID = "icsac-logo" LOGO_PATH = os.path.join(config.BASE_DIR, "assets", "icsac-logo.png") HTML_WRAPPER = """ {body} """ def _markdown_to_plaintext(md: str) -> str: """Strip markdown syntax for the plain-text alternative.""" md = re.sub(r'^#{1,6}\s+', '', md, flags=re.MULTILINE) md = re.sub(r'\*\*([^*]+)\*\*', r'\1', md) md = re.sub(r'(?\s?', '', md, flags=re.MULTILINE) md = re.sub(r'\n{3,}', '\n\n', md) return md.strip() + "\n" def _markdown_to_html(md: str) -> str: """Render markdown body to HTML with a branded wrapper and inline logo CID.""" inner = markdown.markdown(md, extensions=["extra", "sane_lists"]) return HTML_WRAPPER.format(cid=LOGO_CID, body=inner) def extract_subject(rendered_template: str) -> str: """Pull the Subject line out of a rendered email template.""" for line in rendered_template.splitlines(): if line.lower().startswith("subject:"): return line.split(":", 1)[1].strip() return "ICSAC Community" def extract_body(rendered_template: str) -> str: """Strip the template header (title, subject, ---) before the first '---' separator.""" parts = rendered_template.split("\n---\n", 1) if len(parts) == 2: return parts[1].strip() return rendered_template.strip() def send_email(to_addr: str, subject: str, body_md: str, from_name: str = "ICSAC", send: bool = False, draft: bool = False, attachments: list[tuple[str, bytes]] | None = None, outbox_dir: str | None = None, eml_filename: str | None = None, ) -> tuple[bool, str]: """Send a multipart email (plain-text + HTML, inline logo, optional attachments). `body_md` is the markdown body (what lives below the template's --- separator). `attachments` is an optional list of (filename, raw_bytes) pairs; PDFs go out as application/pdf, anything else as application/octet-stream. EmailMessage promotes the multipart structure to multipart/mixed automatically when attachments are appended on top of the existing alternative+related layout. Four delivery modes (mutually exclusive): send=False, draft=False → DRY RUN (default; safe) send=True → SMTP send via Gmail draft=True → IMAP APPEND to Gmail Drafts (operator manually reviews and sends from Gmail UI) outbox_dir= → Write rendered MIME to /.eml. No SMTP, no IMAP. Used by Tier 2 test-pipeline runs so a real panel exercise produces a real on-disk decision artifact without touching Gmail or the author. `eml_filename` overrides the on-disk filename; default derives from the subject slug. """ modes = sum(int(bool(x)) for x in (send, draft, outbox_dir)) if modes > 1: return (False, "send, draft, and outbox_dir are mutually exclusive") smtp_host = getattr(config, "SMTP_HOST", "smtp.gmail.com") smtp_port = int(getattr(config, "SMTP_PORT", 465)) smtp_user = getattr(config, "SMTP_USER", "") smtp_pass = getattr(config, "SMTP_PASSWORD", "") from_addr = getattr(config, "FROM_EMAIL", "info@icsacinstitute.org") reply_to = getattr(config, "REPLY_TO_EMAIL", from_addr) if not (smtp_user and smtp_pass) and not outbox_dir: # outbox_dir mode is purely on-disk; no Gmail creds required. # All other modes (DRY RUN, send, draft) need the SMTP creds # configured because the From/Reply-To headers are derived from # them and IMAP login uses the same pair. return (False, "SMTP_USER or SMTP_PASSWORD not configured") if not to_addr or "@" not in to_addr: return (False, f"invalid recipient: {to_addr!r}") plain = _markdown_to_plaintext(body_md) html = _markdown_to_html(body_md) msg = EmailMessage() msg["From"] = f"{from_name} <{from_addr}>" msg["To"] = to_addr msg["Subject"] = subject msg["Reply-To"] = reply_to msg.set_content(plain) msg.add_alternative(html, subtype="html") try: with open(LOGO_PATH, "rb") as f: logo_data = f.read() msg.get_payload()[1].add_related( logo_data, maintype="image", subtype="png", cid=f"<{LOGO_CID}>" ) except FileNotFoundError: return (False, f"logo asset missing: {LOGO_PATH}") for filename, data in (attachments or []): subtype = "pdf" if filename.lower().endswith(".pdf") else "octet-stream" msg.add_attachment( data, maintype="application", subtype=subtype, filename=filename, ) if outbox_dir: # Tier-2 test-pipeline target: write the rendered MIME message # to disk and return. No SMTP, no IMAP, no Gmail interaction at # all. The .eml file is the operator's audit trail that the # Tier-2 panel ran to completion and the decision email # rendered cleanly without burning a real send to the author. try: outdir = os.path.abspath(os.path.expanduser(str(outbox_dir))) os.makedirs(outdir, exist_ok=True) if eml_filename: fname = eml_filename else: slug = re.sub(r"[^A-Za-z0-9]+", "-", subject).strip("-")[:60].lower() or "message" fname = f"{slug}.eml" if not fname.lower().endswith(".eml"): fname += ".eml" target = os.path.join(outdir, fname) with open(target, "wb") as f: f.write(msg.as_bytes()) return (True, f"wrote outbox eml: {target}") except Exception as e: return (False, f"outbox write failed: {type(e).__name__}: {e}") if draft: # IMAP APPEND to Gmail Drafts. Operator opens Gmail, reviews, sends. # Same MIME message that SMTP would deliver — when the operator opens the # draft, Gmail's From: dropdown still lets him pick the alias. imap_host = getattr(config, "IMAP_HOST", "imap.gmail.com") imap_port = int(getattr(config, "IMAP_PORT", 993)) imap_user = getattr(config, "IMAP_USER", smtp_user) imap_pass = getattr(config, "IMAP_PASSWORD", smtp_pass) drafts_folder = getattr(config, "IMAP_DRAFTS_FOLDER", "[Gmail]/Drafts") try: with imaplib.IMAP4_SSL(imap_host, imap_port) as imap: imap.login(imap_user, imap_pass) raw = msg.as_bytes() date = imaplib.Time2Internaldate(time.time()) typ, data = imap.append(drafts_folder, "\\Draft", date, raw) if typ != "OK": return (False, f"IMAP APPEND returned {typ}: {data!r}") return (True, f"draft saved to {drafts_folder} for {to_addr}") except imaplib.IMAP4.error as e: return (False, f"IMAP error (check Gmail app password + IMAP enabled): {e}") except Exception as e: return (False, f"draft save failed: {type(e).__name__}: {e}") if not send: return (True, f"DRY RUN: would send to {to_addr!r} via {smtp_host}:{smtp_port} as {smtp_user} " f"(From: {from_name} <{from_addr}>, subject: {subject!r})") try: ctx = ssl.create_default_context() with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as server: server.login(smtp_user, smtp_pass) server.send_message(msg) return (True, f"sent to {to_addr}") except smtplib.SMTPAuthenticationError as e: return (False, f"SMTP auth failed (check Gmail app password): {e}") except Exception as e: return (False, f"SMTP error: {type(e).__name__}: {e}") def send_accept_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]: return send_email( to_addr=to_addr, subject=extract_subject(rendered_template), body_md=extract_body(rendered_template), send=send, ) def send_revise_and_resubmit_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]: return send_email( to_addr=to_addr, subject=extract_subject(rendered_template), body_md=extract_body(rendered_template), send=send, ) def send_scope_reject_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]: return send_email( to_addr=to_addr, subject=extract_subject(rendered_template), body_md=extract_body(rendered_template), send=send, ) def send_invite_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]: return send_email( to_addr=to_addr, subject=extract_subject(rendered_template), body_md=extract_body(rendered_template), send=send, )