Spaces:
Runtime error
Runtime error
| """ | |
| 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 <onboarding@resend.dev>") | |
| # ── 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"<strong>\1</strong>", s) | |
| s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s) | |
| s = re.sub(r"`(.+?)`", r"<code>\1</code>", s) | |
| s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", | |
| r'<a href="\2">\1</a>', 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"<th style='text-align:left;padding:6px 10px;" | |
| f"border-bottom:2px solid #ddd'>{_inline_md(c)}</th>" for c in header) | |
| trs = "" | |
| for r in rows: | |
| tds = "".join(f"<td style='padding:6px 10px;border-bottom:1px solid #eee'>" | |
| f"{_inline_md(c)}</td>" for c in r) | |
| trs += f"<tr>{tds}</tr>" | |
| out.append(f"<table style='border-collapse:collapse;margin:10px 0;" | |
| f"font-size:13px'><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>") | |
| 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"<h{lvl} style='font-size:{size}px;margin:14px 0 6px;" | |
| f"color:#0265DC'>{_inline_md(m.group(2))}</h{lvl}>") | |
| 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"<li>{_inline_md(item_text)}</li>") | |
| i += 1 | |
| out.append(f"<ul style='margin:6px 0 6px 18px'>{''.join(items)}</ul>") | |
| continue | |
| if line.strip() in ("---", "***", "___"): | |
| out.append("<hr style='border:none;border-top:1px solid #e3e6ea;margin:12px 0'>") | |
| i += 1 | |
| continue | |
| if line.strip() == "": | |
| out.append("<div style='height:6px'></div>") | |
| i += 1 | |
| continue | |
| out.append(f"<p style='margin:4px 0'>{_inline_md(line)}</p>") | |
| i += 1 | |
| body = "".join(out) | |
| return ( | |
| '<html><body style="margin:0;padding:18px;background:#f6f7f9;">' | |
| '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;' | |
| 'font-size:14px;line-height:1.55;color:#1a1a1a;background:#ffffff;' | |
| 'padding:22px 26px;border-radius:12px;border:1px solid #e3e6ea;' | |
| 'max-width:780px;margin:0 auto;">' | |
| f'{body}' | |
| '<p style="font-size:11px;color:#8a8a8a;margin:16px 0 0;border-top:' | |
| '1px solid #eee;padding-top:10px;">Sent by Chan Compass · educational ' | |
| 'tool, not investment advice.</p>' | |
| '</div></body></html>' | |
| ) | |
| 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) | |