- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""
|
|
Email Service - Listmonk + Gmail SMTP integration for rugmunch.io
|
|
Uses listmonk API for newsletters + transactional emails.
|
|
SMTP relay: Gmail (cryptorugmuncher@gmail.com)
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/v1/email", tags=["email"])
|
|
|
|
LISTMONK_URL = "http://127.0.0.1:9001/api"
|
|
LISTMONK_USER = "admin"
|
|
LISTMONK_PASS = "rugmunch2024"
|
|
|
|
_token_cache = {"token": None, "expires": 0}
|
|
|
|
|
|
async def _get_token() -> str:
|
|
"""Get listmonk API token, cached."""
|
|
import time
|
|
|
|
if _token_cache["token"] and time.time() < _token_cache["expires"]:
|
|
return _token_cache["token"]
|
|
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.post(
|
|
f"{LISTMONK_URL}/auth/login",
|
|
json={"username": LISTMONK_USER, "password": LISTMONK_PASS},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json().get("data", {})
|
|
token = data.get("token", "")
|
|
_token_cache["token"] = token
|
|
_token_cache["expires"] = time.time() + 3600
|
|
return token
|
|
raise HTTPException(status_code=500, detail="Listmonk auth failed")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# SUBSCRIBERS
|
|
# ═══════════════════════════════════════════════════════════
|
|
@router.get("/subscribers")
|
|
async def list_subscribers():
|
|
token = await _get_token()
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"{LISTMONK_URL}/subscribers", headers={"Authorization": f"Bearer {token}"})
|
|
return r.json()
|
|
|
|
|
|
@router.post("/subscribers")
|
|
async def add_subscriber(data: dict):
|
|
email = data.get("email", "")
|
|
name = data.get("name", "")
|
|
if not email:
|
|
raise HTTPException(status_code=400, detail="email required")
|
|
token = await _get_token()
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.post(
|
|
f"{LISTMONK_URL}/subscribers",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"email": email, "name": name, "status": "enabled"},
|
|
)
|
|
return r.json()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# NEWSLETTER CAMPAIGNS
|
|
# ═══════════════════════════════════════════════════════════
|
|
@router.post("/campaign")
|
|
async def create_campaign(data: dict):
|
|
subject = data.get("subject", "RMI Intelligence Update")
|
|
body = data.get("body", "")
|
|
list_ids = data.get("list_ids", [1])
|
|
if not body:
|
|
raise HTTPException(status_code=400, detail="body required")
|
|
token = await _get_token()
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.post(
|
|
f"{LISTMONK_URL}/campaigns",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"name": subject,
|
|
"subject": subject,
|
|
"lists": list_ids,
|
|
"content_type": "html",
|
|
"body": body,
|
|
"send_at": None,
|
|
},
|
|
)
|
|
return r.json()
|
|
|
|
|
|
@router.get("/campaigns")
|
|
async def list_campaigns():
|
|
token = await _get_token()
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"{LISTMONK_URL}/campaigns", headers={"Authorization": f"Bearer {token}"})
|
|
return r.json()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# TRANSACTIONAL - Direct Gmail SMTP
|
|
# ═══════════════════════════════════════════════════════════
|
|
@router.post("/send")
|
|
async def send_transactional(data: dict):
|
|
"""Send a single transactional email via Gmail SMTP."""
|
|
to = data.get("to", "")
|
|
subject = data.get("subject", "RMI Alert")
|
|
body = data.get("body", "")
|
|
if not to or not body:
|
|
raise HTTPException(status_code=400, detail="to and body required")
|
|
|
|
try:
|
|
import smtplib
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
msg = MIMEMultipart()
|
|
msg["From"] = "Rug Munch Intelligence <cryptorugmuncher@gmail.com>"
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.attach(MIMEText(body, "html"))
|
|
|
|
with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as server:
|
|
server.starttls()
|
|
# Gmail App Password needed - stored in env
|
|
app_password = os.getenv("GMAIL_APP_PASSWORD", "")
|
|
server.login("cryptorugmuncher@gmail.com", app_password)
|
|
server.send_message(msg)
|
|
|
|
return {
|
|
"status": "sent",
|
|
"to": to,
|
|
"subject": subject,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"status": "failed",
|
|
"error": str(e),
|
|
"hint": "Gmail App Password required. Set GMAIL_APP_PASSWORD env var",
|
|
}
|
|
|
|
|
|
@router.get("/status")
|
|
async def email_status():
|
|
return {
|
|
"provider": "listmonk + gmail",
|
|
"smtp": "smtp.gmail.com:587",
|
|
"sender": "cryptorugmuncher@gmail.com",
|
|
"domain": "rugmunch.io",
|
|
"listmonk_url": "http://localhost:9001",
|
|
"newsletter_enabled": True,
|
|
"transactional_enabled": True,
|
|
}
|