""" Email brief sender — builds a clean HTML brief and sends it. Two delivery methods (auto-selected): 1. Brevo HTTP API (works everywhere, incl. hosts that block SMTP like Hugging Face Spaces). Set BREVO_API_KEY. Free 300 emails/day. BREVO_API_KEY=xkeysib-... SMTP_FROM=Crypto Narrative (verified sender in Brevo) 2. SMTP (Gmail App Password) — for local runs / hosts that allow SMTP. SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM Brevo is preferred when BREVO_API_KEY is set. """ import os import re import ssl import json import smtplib import logging import requests from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from datetime import datetime, timezone logger = logging.getLogger(__name__) BREVO_URL = "https://api.brevo.com/v3/smtp/email" # Public site base for unsubscribe links in email headers (deliverability). PUBLIC_BASE_URL = os.getenv( "PUBLIC_BASE_URL", "https://sentimentsanalyzer.areebalikhan.com").rstrip("/") def _html_to_text(html: str) -> str: """Crude HTML->text so every email carries a plain-text part (spam filters penalise HTML-only mail).""" t = re.sub(r"<(script|style)[^>]*>.*?", "", html, flags=re.S | re.I) t = re.sub(r"", "\n", t, flags=re.I) t = re.sub(r"", "\n", t, flags=re.I) t = re.sub(r"<[^>]+>", "", t) t = re.sub(r"[ \t]+", " ", t) t = re.sub(r"\n\s*\n+", "\n\n", t) return t.strip() or "View this email in an HTML-capable client." def _deliverability_headers() -> dict: """List-Unsubscribe (+ one-click) — required by Gmail/Yahoo bulk-sender rules and a strong signal that keeps mail out of spam.""" _, email = _parse_sender() return { "List-Unsubscribe": f"<{PUBLIC_BASE_URL}/unsubscribe>, " f"", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", } def brevo_configured() -> bool: return bool(os.getenv("BREVO_API_KEY")) def smtp_configured() -> bool: return bool(os.getenv("SMTP_HOST") and os.getenv("SMTP_USER") and os.getenv("SMTP_PASS")) def email_configured() -> bool: return brevo_configured() or smtp_configured() def _parse_sender() -> tuple[str, str]: """Return (name, email) from SMTP_FROM or SMTP_USER.""" raw = os.getenv("SMTP_FROM") or os.getenv("SMTP_USER") or os.getenv("BREVO_SENDER", "") m = re.match(r"\s*(.*?)\s*<([^>]+)>\s*$", raw) if m: return (m.group(1) or "Crypto Narrative", m.group(2)) return ("Crypto Narrative", raw) def _color(net: float) -> str: if net >= 0.35: return "#1a9e63" if net >= 0.12: return "#2ECC71" if net > -0.12: return "#C99A1E" if net > -0.35: return "#d98050" return "#E05252" def _index_color(index: float) -> str: """0-39 bearish · 40-60 neutral · 61-100 bullish.""" if index >= 61: return "#2ECC71" if index >= 40: return "#C99A1E" return "#E05252" def _esc(t: str) -> str: return (str(t or "").replace("&", "&").replace("<", "<").replace(">", ">")) def build_brief_html(data: dict) -> str: """Render a detailed client-ready HTML brief from the pipeline result dict.""" s = data.get("sentiment_24h", {}) or {} o = s.get("overall", {}) or {} fg = data.get("fear_greed", {}) or {} gm = data.get("global_market", {}) or {} narratives = data.get("narratives", [])[:6] per_narr = s.get("per_narrative", []) spikes = data.get("spike_alerts", []) or [] trending = data.get("trending_coins", [])[:6] news = data.get("news_feed", []) or [] prices = sorted(data.get("coin_prices", []), key=lambda c: (c.get("change_24h") or 0), reverse=True) gainers = prices[:3] losers = prices[-3:][::-1] idx = o.get("index", 50) label = o.get("label", "NEUTRAL") color = _index_color(idx) dpts = s.get("trend_delta_index", 0) arrow = "▲" if dpts > 0 else "▼" if dpts < 0 else "►" when = data.get("fetch_time", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")) macro = s.get("macro", {}) or {} macro_label = macro.get("label", "NEUTRAL") macro_col = _index_color(macro.get("index", 50)) combined_label = s.get("combined_label", label) combined_col = _index_color(s.get("combined_index", 50)) macro_html = "" if macro.get("count"): macro_html = f"""
US MACRO BACKDROP: {macro_label}  |  COMBINED OUTLOOK: {combined_label}
{macro.get('count',0)} macro stories · {macro.get('bullish_pct',0):.0f}% bull / {macro.get('bearish_pct',0):.0f}% bear
""" # ---- Market snapshot ---- mcap = gm.get("total_market_cap_usd") vol = gm.get("total_volume_usd") mcap_chg = gm.get("market_cap_change_24h") def _big(v): if not v: return "--" if v >= 1e12: return f"${v/1e12:.2f}T" if v >= 1e9: return f"${v/1e9:.0f}B" return f"${v/1e6:.0f}M" mcap_col = "#2ECC71" if (mcap_chg or 0) >= 0 else "#E05252" market_html = f"""
MARKET CAP
{_big(mcap)}
{('+' if (mcap_chg or 0)>=0 else '')}{(mcap_chg or 0):.1f}%
24H VOLUME
{_big(vol)}
BTC DOM
{gm.get('btc_dominance','--')}%
FEAR & GREED
{fg.get('value','--')}
{fg.get('label','--')}
""" # ---- What's surging (spike alerts) ---- spike_html = "" if spikes: rows = "" for a in spikes[:4]: kind = a.get("kind", "").replace("_", " ").title() rows += f"""
{kind} {_esc(a.get('narrative',''))} — {_esc(a.get('message',''))}
""" spike_html = f"""
⚡ WHAT'S SURGING NOW
{rows}
""" # ---- Top narratives (with bull/bear split) ---- def narr_rows(): out = "" pn = {p["name"]: p for p in per_narr} for i, n in enumerate(narratives, 1): name = n.get("name", "") avg = n.get("avg_sentiment", 0) tag = "BULLISH" if avg >= 0.15 else "BEARISH" if avg <= -0.15 else "NEUTRAL" tcol = _color(avg) p = pn.get(name, {}) split = f"{p.get('bullish_pct',0):.0f}% / {p.get('bearish_pct',0):.0f}%" if p else "—" out += f""" {i} {_esc(name)} {split} {n.get('score',0):.2f} {tag} """ return out # ---- Top headlines (the meat — what actually happened) ---- def headline_rows(): out = "" # prefer the most recent, mix of bull/bear, skip pure-neutral filler picked = [n for n in news if n.get("sentiment") in ("bullish", "bearish")][:7] if len(picked) < 7: picked += [n for n in news if n not in picked][: 7 - len(picked)] for n in picked[:7]: sent = n.get("sentiment", "neutral") scol = "#2ECC71" if sent == "bullish" else "#E05252" if sent == "bearish" else "#999" stag = "BULL" if sent == "bullish" else "BEAR" if sent == "bearish" else "NEUT" src = (n.get("source", "") or "").replace("GoogleNews: ", "").replace("Reddit/", "") title = _esc(n.get("title", ""))[:110] url = _esc(n.get("url", "")) title_html = f'{title}' if url else title out += f"""
{stag} {title_html}
{_esc(src)}
""" return out def mover_chips(coins): chips = "" for c in coins: chg = c.get("change_24h") or 0 col = "#2ECC71" if chg >= 0 else "#E05252" sign = "+" if chg >= 0 else "" chips += f'{c.get("symbol","")} {sign}{chg:.1f}%' return chips trend_chips = "".join( f'#{i+1} {_esc(c.get("name",""))} ({_esc(c.get("symbol",""))})' for i, c in enumerate(trending) ) return f"""
■ CRYPTO NARRATIVE BRIEF
{when}
24-HOUR MARKET SENTIMENT
{idx}/100
{label}
{arrow} {('+' if dpts>=0 else '')}{dpts} pts vs 24h ago · {o.get('count',0)} stories analysed
{o.get('bullish_pct',0)}% Bullish  ·  {o.get('neutral_pct',0)}% Neutral  ·  {o.get('bearish_pct',0)}% Bearish
{macro_html}
{market_html}
{spike_html}
TOP NARRATIVES (bull% / bear%)
{narr_rows()}
TOP HEADLINES
{headline_rows()}
24H MOVERS
Gainers
{mover_chips(gainers)}
Losers
{mover_chips(losers)}
TRENDING
{trend_chips}
CRYPTO NARRATIVE BRIEF
Market analysis, not financial advice.
""" # Recipients are BCC'd in one call; providers cap how many per message, so we # send in batches. 90 keeps us safely under Brevo's ~99-per-message limit. def send_brief(recipients: list[str], subject: str, html: str) -> tuple[bool, str]: """Send the HTML brief to each recipient INDIVIDUALLY (one personal email per person). No BCC and no copy to the sender — so you don't receive your own test/brief sends, recipients never see each other, and deliverability improves (BCC-to-many looks like spam). Prefers Brevo HTTP, falls back to SMTP.""" if not recipients: return False, "No recipients" if not (brevo_configured() or smtp_configured()): return False, "Email not configured (set BREVO_API_KEY, or SMTP_* in .env)" sent, failures = 0, [] for r in recipients: ok, detail = send_transactional(r, subject, html) if ok: sent += 1 else: failures.append(detail) if sent == 0: return False, f"All sends failed: {'; '.join(failures)[:200]}" if failures: return True, f"Sent to {sent}/{len(recipients)} ({len(failures)} failed)" return True, f"Sent to {sent} recipient(s)" def send_transactional(to_email: str, subject: str, html: str) -> tuple[bool, str]: """Send a single direct email (e.g. a signup confirmation) to one address. Uses Brevo HTTP if a key is set, else falls back to SMTP.""" name, email = _parse_sender() if not email: return False, "No sender email (set SMTP_FROM or SMTP_USER)" if os.getenv("BREVO_API_KEY"): payload = { "sender": {"name": name, "email": email}, "to": [{"email": to_email}], "replyTo": {"email": email, "name": name}, "subject": subject, "htmlContent": html, "textContent": _html_to_text(html), "headers": _deliverability_headers(), } try: resp = requests.post( BREVO_URL, headers={ "api-key": os.getenv("BREVO_API_KEY", ""), "content-type": "application/json", "accept": "application/json", }, data=json.dumps(payload), timeout=20, ) if resp.status_code in (200, 201): return True, "sent" return False, f"Brevo error {resp.status_code}: {resp.text[:200]}" except Exception as exc: return False, str(exc) # SMTP fallback return _send_smtp([to_email], subject, html) def _send_brevo(recipients: list[str], subject: str, html: str) -> tuple[bool, str]: """Send via Brevo's HTTP API — works on hosts that block SMTP (e.g. HF Spaces).""" name, email = _parse_sender() if not email: return False, "No sender email (set SMTP_FROM or SMTP_USER)" payload = { "sender": {"name": name, "email": email}, "to": [{"email": email}], # send to self "bcc": [{"email": r} for r in recipients], # clients hidden from each other "replyTo": {"email": email, "name": name}, "subject": subject, "htmlContent": html, "textContent": _html_to_text(html), "headers": _deliverability_headers(), } try: resp = requests.post( BREVO_URL, headers={ "api-key": os.getenv("BREVO_API_KEY", ""), "content-type": "application/json", "accept": "application/json", }, data=json.dumps(payload), timeout=20, ) if resp.status_code in (200, 201): return True, f"Sent to {len(recipients)} recipient(s) via Brevo" return False, f"Brevo error {resp.status_code}: {resp.text[:200]}" except Exception as exc: logger.warning(f"[Mailer] Brevo send failed: {exc}") return False, str(exc) def _send_smtp(recipients: list[str], subject: str, html: str) -> tuple[bool, str]: """Send via SMTP (Gmail App Password). For local / SMTP-allowed hosts.""" host = os.getenv("SMTP_HOST") port = int(os.getenv("SMTP_PORT", "587")) user = os.getenv("SMTP_USER") pw = os.getenv("SMTP_PASS") sender = os.getenv("SMTP_FROM", user) msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = sender msg["To"] = sender # recipients go via BCC for privacy msg.attach(MIMEText("Your email client does not support HTML. Open the web terminal for the brief.", "plain")) msg.attach(MIMEText(html, "html")) try: ctx = ssl.create_default_context() with smtplib.SMTP(host, port, timeout=20) as server: server.starttls(context=ctx) server.login(user, pw) server.sendmail(sender, [sender] + recipients, msg.as_string()) return True, f"Sent to {len(recipients)} recipient(s)" except Exception as exc: logger.warning(f"[Mailer] send failed: {exc}") return False, str(exc)