| """ |
| 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") |
|
|
| |
| |
| |
| |
| |
| |
| RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") |
| RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>") |
|
|
| |
| 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] |
| |
| 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", |
| |
| |
| "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) |
|
|