Chan-Compass / emailer.py
ranranrunforit's picture
Upload 25 files
16520a2 verified
Raw
History Blame Contribute Delete
12.3 kB
"""
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)