""" emailer.py — send a tab's AI result to any address (English UI + content). Adapted from the user's send_predict_email.py: same multi-domain SMTP auto-detection, plain+HTML multipart, but English subjects/labels and a generic `send_result()` the Gradio tabs call. Sender credentials live here (a Gmail App Password). To change the sender, edit EMAIL_SENDER / EMAIL_PASSWORD. You can also override them with the environment variables CHAN_EMAIL_SENDER / CHAN_EMAIL_PASSWORD on the Space (recommended: store the App Password as a Space *secret* rather than in code). """ from __future__ import annotations import os import json import html import logging import smtplib import urllib.request import urllib.error import re from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header from email.utils import formataddr logger = logging.getLogger("chan_emailer") # ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ── # HF Spaces block outbound SMTP ports (465/587) → SMTP fails with # "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces # allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day. # Until you verify your own domain, Resend only lets you send FROM # "onboarding@resend.dev" — that's the default sender below. RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass ") # ── Transport 2 (fallback, works off-HF): classic SMTP ── EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "") EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "") SMTP_CONFIGS = { "gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True}, "qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True}, "163.com": {"server": "smtp.163.com", "port": 465, "ssl": True}, "126.com": {"server": "smtp.126.com", "port": 465, "ssl": True}, "outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False}, "hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False}, "foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True}, "sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True}, "aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True}, } _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") def _sender_address(sender: str) -> str: return formataddr((str(Header("Chan Compass", "utf-8")), sender)) def _md_to_plain(text: str) -> str: """Markdown -> readable plain text (for the text/plain MIME part and the Resend `text` field). Strips #/**/| noise so text-only clients look clean.""" lines, out = text.split("\n"), [] i, n = 0, len(lines) while i < n: line = lines[i] if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]): header = [c.strip() for c in line.strip().strip("|").split("|")] i += 2 while i < n and "|" in lines[i]: cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] if len(header) == 2 and len(cells) == 2: out.append(f" {cells[0]}: {cells[1]}") else: out.append(" " + " | ".join(cells)) i += 1 out.append("") continue m = re.match(r"^(#{1,4})\s+(.*)$", line) if m: title = m.group(2).strip() out.append("") out.append(title.upper() if len(m.group(1)) <= 2 else title) out.append("-" * min(len(title), 60)) i += 1 continue if line.strip() in ("---", "***", "___"): out.append("-" * 40) i += 1 continue s = re.sub(r"\*\*(.+?)\*\*", r"\1", line) s = re.sub(r"`(.+?)`", r"\1", s) s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1 (\2)", s) s = re.sub(r"^_(.+)_$", r"\1", s.strip()) out.append(s) i += 1 txt = "\n".join(out) return re.sub(r"\n{3,}", "\n\n", txt).strip() def _inline_md(s: str) -> str: s = html.escape(s) s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) s = re.sub(r"(?\1", s) s = re.sub(r"`(.+?)`", r"\1", s) s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r'\1', s) return s def _md_to_html(text: str) -> str: """Minimal but correct Markdown → HTML (headings, tables, lists, bold, code, links). The reports are Markdown, so emailing them as preformatted text showed raw `#`/`|` symbols — this renders them properly.""" lines = text.split("\n") out, i, n = [], 0, len(lines) while i < n: line = lines[i] # table block: a header row of pipes followed by a |---| separator if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]): header = [c.strip() for c in line.strip().strip("|").split("|")] i += 2 rows = [] while i < n and "|" in lines[i]: rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 th = "".join(f"{_inline_md(c)}" for c in header) trs = "" for r in rows: tds = "".join(f"" f"{_inline_md(c)}" for c in r) trs += f"{tds}" out.append(f"{th}{trs}
") continue m = re.match(r"^(#{1,4})\s+(.*)$", line) if m: lvl = len(m.group(1)) size = {1: 20, 2: 16, 3: 14, 4: 13}[lvl] out.append(f"{_inline_md(m.group(2))}") i += 1 continue if re.match(r"^\s*[-*]\s+", line): items = [] while i < n and re.match(r"^\s*[-*]\s+", lines[i]): item_text = re.sub(r"^\s*[-*]\s+", "", lines[i]) items.append(f"
  • {_inline_md(item_text)}
  • ") i += 1 out.append(f"") continue if line.strip() in ("---", "***", "___"): out.append("
    ") i += 1 continue if line.strip() == "": out.append("
    ") i += 1 continue out.append(f"

    {_inline_md(line)}

    ") i += 1 body = "".join(out) return ( '' '
    ' f'{body}' '

    Sent by Chan Compass · educational ' 'tool, not investment advice.

    ' '
    ' ) def _parse_recipients(raw: str) -> list: parts = re.split(r"[,;\s]+", (raw or "").strip()) return [p for p in parts if _EMAIL_RE.match(p)] def _close(server): if server is not None: try: server.quit() except Exception: try: server.close() except Exception: pass def _send_via_resend(recipients: list, subject: str, content: str) -> str: """HTTPS POST to Resend — works on HF (port 443). Returns '' on success.""" payload = json.dumps({ "from": RESEND_FROM, "to": recipients, "subject": subject, "text": _md_to_plain(content), "html": _md_to_html(content), }).encode("utf-8") req = urllib.request.Request( "https://api.resend.com/emails", data=payload, method="POST", headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json", # Resend/Cloudflare reject requests with no User-Agent # (403, error code 1010) before they reach the API. "User-Agent": "chan-compass/1.0 (+https://huggingface.co/spaces)"}) try: with urllib.request.urlopen(req, timeout=30) as resp: body = resp.read().decode("utf-8", "ignore") if '"id"' in body: return "" return f"Resend API responded without an id: {body[:200]}" except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", "ignore")[:200] return f"Resend HTTP {e.code}: {detail}" except Exception as e: return f"Resend request failed: {e}" def _send_via_smtp(recipients: list, subject: str, content: str) -> str: """Classic SMTP — works locally / off-HF. Returns '' on success.""" if not EMAIL_SENDER or not EMAIL_PASSWORD: return "SMTP sender not configured." msg = MIMEMultipart("alternative") msg["Subject"] = Header(subject, "utf-8") msg["From"] = _sender_address(EMAIL_SENDER) msg["To"] = ", ".join(recipients) msg.attach(MIMEText(_md_to_plain(content), "plain", "utf-8")) msg.attach(MIMEText(_md_to_html(content), "html", "utf-8")) domain = EMAIL_SENDER.split("@")[-1].lower() sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True}) server = None try: if sc["ssl"]: server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20) else: server = smtplib.SMTP(sc["server"], sc["port"], timeout=20) server.starttls() server.login(EMAIL_SENDER, EMAIL_PASSWORD) server.send_message(msg) return "" except smtplib.SMTPAuthenticationError: return "SMTP authentication failed (check sender / App Password)." except OSError as e: return f"SMTP network error: {e}" except Exception as e: return f"SMTP failed: {e}" finally: _close(server) def send_result(content: str, recipients_raw: str, subject_tag: str) -> str: """Send `content` to the address(es). Prefers the Resend HTTPS API (works on HF Spaces); falls back to SMTP. English status string for the UI.""" if not content or not content.strip(): return "⚠️ Nothing to send yet — generate a result first." if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")): return "⚠️ Wait for the AI result to finish, then send." recipients = _parse_recipients(recipients_raw) if not recipients: return "⚠️ Enter a valid email address (e.g. name@example.com)." subject = (f"Chan Compass · {subject_tag} · " f"{datetime.now().strftime('%Y-%m-%d %H:%M')}") errors = [] if RESEND_API_KEY: err = _send_via_resend(recipients, subject, content) if not err: return f"✅ Sent to {', '.join(recipients)} (via Resend API)." errors.append(err) smtp_err = _send_via_smtp(recipients, subject, content) if not smtp_err: return f"✅ Sent to {', '.join(recipients)} (via SMTP)." errors.append(smtp_err) if not RESEND_API_KEY: return ("❌ SMTP is blocked on Hugging Face Spaces (outbound mail ports " "are closed). Fix: create a free key at resend.com and add it as " "a Space secret named **RESEND_API_KEY** (then it sends over " f"HTTPS). Detail: {smtp_err}") return "❌ Send failed. " + " | ".join(errors)