Spaces:
Runtime error
Runtime error
Upload 18 files
Browse files- emailer.py +84 -27
emailer.py
CHANGED
|
@@ -13,9 +13,12 @@ store the App Password as a Space *secret* rather than in code).
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
|
|
|
| 16 |
import html
|
| 17 |
import logging
|
| 18 |
import smtplib
|
|
|
|
|
|
|
| 19 |
import re
|
| 20 |
from datetime import datetime
|
| 21 |
from email.mime.text import MIMEText
|
|
@@ -25,9 +28,18 @@ from email.utils import formataddr
|
|
| 25 |
|
| 26 |
logger = logging.getLogger("chan_emailer")
|
| 27 |
|
| 28 |
-
# ──
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
SMTP_CONFIGS = {
|
| 33 |
"gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
|
|
@@ -82,47 +94,92 @@ def _close(server):
|
|
| 82 |
pass
|
| 83 |
|
| 84 |
|
| 85 |
-
def
|
| 86 |
-
"""
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 100 |
msg = MIMEMultipart("alternative")
|
| 101 |
msg["Subject"] = Header(subject, "utf-8")
|
| 102 |
msg["From"] = _sender_address(EMAIL_SENDER)
|
| 103 |
msg["To"] = ", ".join(recipients)
|
| 104 |
msg.attach(MIMEText(content, "plain", "utf-8"))
|
| 105 |
msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
|
| 106 |
-
|
| 107 |
domain = EMAIL_SENDER.split("@")[-1].lower()
|
| 108 |
sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
|
| 109 |
server = None
|
| 110 |
try:
|
| 111 |
if sc["ssl"]:
|
| 112 |
-
server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=
|
| 113 |
else:
|
| 114 |
-
server = smtplib.SMTP(sc["server"], sc["port"], timeout=
|
| 115 |
server.starttls()
|
| 116 |
server.login(EMAIL_SENDER, EMAIL_PASSWORD)
|
| 117 |
server.send_message(msg)
|
| 118 |
-
|
| 119 |
-
return f"✅ Sent to {', '.join(recipients)}."
|
| 120 |
except smtplib.SMTPAuthenticationError:
|
| 121 |
-
return
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
return f"❌ Could not connect to the mail server: {e}"
|
| 125 |
except Exception as e:
|
| 126 |
-
return f"
|
| 127 |
finally:
|
| 128 |
_close(server)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
| 16 |
+
import json
|
| 17 |
import html
|
| 18 |
import logging
|
| 19 |
import smtplib
|
| 20 |
+
import urllib.request
|
| 21 |
+
import urllib.error
|
| 22 |
import re
|
| 23 |
from datetime import datetime
|
| 24 |
from email.mime.text import MIMEText
|
|
|
|
| 28 |
|
| 29 |
logger = logging.getLogger("chan_emailer")
|
| 30 |
|
| 31 |
+
# ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ──
|
| 32 |
+
# HF Spaces block outbound SMTP ports (465/587) → SMTP fails with
|
| 33 |
+
# "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces
|
| 34 |
+
# allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day.
|
| 35 |
+
# Until you verify your own domain, Resend only lets you send FROM
|
| 36 |
+
# "onboarding@resend.dev" — that's the default sender below.
|
| 37 |
+
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
|
| 38 |
+
RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>")
|
| 39 |
+
|
| 40 |
+
# ── Transport 2 (fallback, works off-HF): classic SMTP ──
|
| 41 |
+
EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "cz78illinoisedu@gmail.com")
|
| 42 |
+
EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "tknl qpmb gzye zlva")
|
| 43 |
|
| 44 |
SMTP_CONFIGS = {
|
| 45 |
"gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
|
|
|
|
| 94 |
pass
|
| 95 |
|
| 96 |
|
| 97 |
+
def _send_via_resend(recipients: list, subject: str, content: str) -> str:
|
| 98 |
+
"""HTTPS POST to Resend — works on HF (port 443). Returns '' on success."""
|
| 99 |
+
payload = json.dumps({
|
| 100 |
+
"from": RESEND_FROM,
|
| 101 |
+
"to": recipients,
|
| 102 |
+
"subject": subject,
|
| 103 |
+
"text": content,
|
| 104 |
+
"html": _md_to_html(content),
|
| 105 |
+
}).encode("utf-8")
|
| 106 |
+
req = urllib.request.Request(
|
| 107 |
+
"https://api.resend.com/emails", data=payload, method="POST",
|
| 108 |
+
headers={"Authorization": f"Bearer {RESEND_API_KEY}",
|
| 109 |
+
"Content-Type": "application/json"})
|
| 110 |
+
try:
|
| 111 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 112 |
+
body = resp.read().decode("utf-8", "ignore")
|
| 113 |
+
if '"id"' in body:
|
| 114 |
+
return ""
|
| 115 |
+
return f"Resend API responded without an id: {body[:200]}"
|
| 116 |
+
except urllib.error.HTTPError as e:
|
| 117 |
+
detail = e.read().decode("utf-8", "ignore")[:200]
|
| 118 |
+
return f"Resend HTTP {e.code}: {detail}"
|
| 119 |
+
except Exception as e:
|
| 120 |
+
return f"Resend request failed: {e}"
|
| 121 |
|
| 122 |
+
|
| 123 |
+
def _send_via_smtp(recipients: list, subject: str, content: str) -> str:
|
| 124 |
+
"""Classic SMTP — works locally / off-HF. Returns '' on success."""
|
| 125 |
+
if not EMAIL_SENDER or not EMAIL_PASSWORD:
|
| 126 |
+
return "SMTP sender not configured."
|
| 127 |
msg = MIMEMultipart("alternative")
|
| 128 |
msg["Subject"] = Header(subject, "utf-8")
|
| 129 |
msg["From"] = _sender_address(EMAIL_SENDER)
|
| 130 |
msg["To"] = ", ".join(recipients)
|
| 131 |
msg.attach(MIMEText(content, "plain", "utf-8"))
|
| 132 |
msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
|
|
|
|
| 133 |
domain = EMAIL_SENDER.split("@")[-1].lower()
|
| 134 |
sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
|
| 135 |
server = None
|
| 136 |
try:
|
| 137 |
if sc["ssl"]:
|
| 138 |
+
server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20)
|
| 139 |
else:
|
| 140 |
+
server = smtplib.SMTP(sc["server"], sc["port"], timeout=20)
|
| 141 |
server.starttls()
|
| 142 |
server.login(EMAIL_SENDER, EMAIL_PASSWORD)
|
| 143 |
server.send_message(msg)
|
| 144 |
+
return ""
|
|
|
|
| 145 |
except smtplib.SMTPAuthenticationError:
|
| 146 |
+
return "SMTP authentication failed (check sender / App Password)."
|
| 147 |
+
except OSError as e:
|
| 148 |
+
return f"SMTP network error: {e}"
|
|
|
|
| 149 |
except Exception as e:
|
| 150 |
+
return f"SMTP failed: {e}"
|
| 151 |
finally:
|
| 152 |
_close(server)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def send_result(content: str, recipients_raw: str, subject_tag: str) -> str:
|
| 156 |
+
"""Send `content` to the address(es). Prefers the Resend HTTPS API (works on
|
| 157 |
+
HF Spaces); falls back to SMTP. English status string for the UI."""
|
| 158 |
+
if not content or not content.strip():
|
| 159 |
+
return "⚠️ Nothing to send yet — generate a result first."
|
| 160 |
+
if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")):
|
| 161 |
+
return "⚠️ Wait for the AI result to finish, then send."
|
| 162 |
+
recipients = _parse_recipients(recipients_raw)
|
| 163 |
+
if not recipients:
|
| 164 |
+
return "⚠️ Enter a valid email address (e.g. name@example.com)."
|
| 165 |
+
|
| 166 |
+
subject = (f"Chan Compass · {subject_tag} · "
|
| 167 |
+
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
| 168 |
+
|
| 169 |
+
errors = []
|
| 170 |
+
if RESEND_API_KEY:
|
| 171 |
+
err = _send_via_resend(recipients, subject, content)
|
| 172 |
+
if not err:
|
| 173 |
+
return f"✅ Sent to {', '.join(recipients)} (via Resend API)."
|
| 174 |
+
errors.append(err)
|
| 175 |
+
smtp_err = _send_via_smtp(recipients, subject, content)
|
| 176 |
+
if not smtp_err:
|
| 177 |
+
return f"✅ Sent to {', '.join(recipients)} (via SMTP)."
|
| 178 |
+
errors.append(smtp_err)
|
| 179 |
+
|
| 180 |
+
if not RESEND_API_KEY:
|
| 181 |
+
return ("❌ SMTP is blocked on Hugging Face Spaces (outbound mail ports "
|
| 182 |
+
"are closed). Fix: create a free key at resend.com and add it as "
|
| 183 |
+
"a Space secret named **RESEND_API_KEY** (then it sends over "
|
| 184 |
+
f"HTTPS). Detail: {smtp_err}")
|
| 185 |
+
return "❌ Send failed. " + " | ".join(errors)
|