267 lines
9.5 KiB
Python
267 lines
9.5 KiB
Python
"""
|
|
BAN YARO — E-Mail-Versand
|
|
Unterstützt zwei Backends (wird automatisch gewählt):
|
|
1. Brevo REST-API — wenn BREVO_API_KEY gesetzt (bevorzugt)
|
|
2. SMTP — wenn SMTP_HOST gesetzt (Fallback)
|
|
"""
|
|
|
|
import imaplib
|
|
import os
|
|
import base64
|
|
import smtplib
|
|
import asyncio
|
|
import logging
|
|
import ssl
|
|
from datetime import datetime
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.application import MIMEApplication
|
|
from email.utils import formatdate
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Brevo REST-API
|
|
BREVO_API_KEY = os.getenv("BREVO_API_KEY", "")
|
|
BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
|
|
|
|
# SMTP Fallback
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "")
|
|
|
|
# IMAP für Gesendet-Ordner
|
|
IMAP_HOST = os.getenv("IMAP_HOST", SMTP_HOST)
|
|
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
|
|
_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
|
|
|
|
SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro <noreply@banyaro.app>")
|
|
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# IMAP: Mail in Gesendet-Ordner speichern
|
|
# ------------------------------------------------------------------
|
|
def _imap_save_sent(msg_bytes: bytes):
|
|
if not IMAP_HOST or not SMTP_USER or not SMTP_PASS:
|
|
return
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=ctx) as imap:
|
|
imap.login(SMTP_USER, SMTP_PASS)
|
|
_, raw_folders = imap.list()
|
|
available = [f.decode(errors="replace") for f in (raw_folders or [])]
|
|
folder = None
|
|
for line in available:
|
|
name = line.rsplit('"." ', 1)[-1].strip().strip('"')
|
|
for candidate in _SENT_CANDIDATES:
|
|
if candidate.lower() in name.lower():
|
|
folder = name
|
|
break
|
|
if folder:
|
|
break
|
|
if not folder:
|
|
folder = "INBOX.Sent"
|
|
imap.append(
|
|
folder,
|
|
r"\Seen",
|
|
imaplib.Time2Internaldate(datetime.now().timestamp()),
|
|
msg_bytes,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"IMAP Gesendet-Speicherung fehlgeschlagen: {e}")
|
|
|
|
|
|
def _build_mime_copy(to: str, subject: str, html: str, plain: str, attachments: list | None) -> MIMEMultipart:
|
|
"""Baut eine MIME-Nachricht für die Gesendet-Ablage (Brevo-Pfad)."""
|
|
if attachments:
|
|
msg = MIMEMultipart("mixed")
|
|
alt = MIMEMultipart("alternative")
|
|
alt.attach(MIMEText(plain, "plain", "utf-8"))
|
|
alt.attach(MIMEText(html, "html", "utf-8"))
|
|
msg.attach(alt)
|
|
for a in attachments:
|
|
part = MIMEApplication(a["content"], Name=a["filename"])
|
|
part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
|
|
msg.attach(part)
|
|
else:
|
|
msg = MIMEMultipart("alternative")
|
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
|
msg["Subject"] = subject
|
|
msg["From"] = SMTP_FROM
|
|
msg["To"] = to
|
|
msg["Date"] = formatdate(localtime=False)
|
|
return msg
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Brevo REST-API
|
|
# ------------------------------------------------------------------
|
|
async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
|
|
from_raw = SMTP_FROM
|
|
if "<" in from_raw:
|
|
from_name = from_raw[:from_raw.index("<")].strip()
|
|
from_email = from_raw[from_raw.index("<")+1:from_raw.index(">")].strip()
|
|
else:
|
|
from_name = "Ban Yaro"
|
|
from_email = from_raw.strip()
|
|
|
|
payload = {
|
|
"sender": {"name": from_name, "email": from_email},
|
|
"to": [{"email": to}],
|
|
"subject": subject,
|
|
"htmlContent": html,
|
|
"textContent": plain,
|
|
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"},
|
|
}
|
|
if attachments:
|
|
payload["attachment"] = [
|
|
{
|
|
"name": a["filename"],
|
|
"content": base64.b64encode(a["content"]).decode("ascii"),
|
|
}
|
|
for a in attachments
|
|
]
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
BREVO_API_URL,
|
|
json=payload,
|
|
headers={"api-key": BREVO_API_KEY, "Content-Type": "application/json"},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# SMTP Fallback
|
|
# ------------------------------------------------------------------
|
|
def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
|
|
if attachments:
|
|
msg = MIMEMultipart("mixed")
|
|
alt = MIMEMultipart("alternative")
|
|
alt.attach(MIMEText(plain, "plain", "utf-8"))
|
|
alt.attach(MIMEText(html, "html", "utf-8"))
|
|
msg.attach(alt)
|
|
for a in attachments:
|
|
part = MIMEApplication(a["content"], Name=a["filename"])
|
|
part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
|
|
msg.attach(part)
|
|
else:
|
|
msg = MIMEMultipart("alternative")
|
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
|
|
|
msg["Subject"] = subject
|
|
msg["From"] = SMTP_FROM
|
|
msg["To"] = to
|
|
msg["Date"] = formatdate(localtime=False)
|
|
|
|
msg_bytes = msg.as_bytes()
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
|
|
s.ehlo()
|
|
s.starttls()
|
|
if SMTP_USER:
|
|
s.login(SMTP_USER, SMTP_PASS)
|
|
s.sendmail(SMTP_FROM, [to], msg_bytes)
|
|
_imap_save_sent(msg_bytes)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Öffentliche Funktion
|
|
# ------------------------------------------------------------------
|
|
async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None):
|
|
if BREVO_API_KEY:
|
|
try:
|
|
await _send_brevo(to, subject, html, plain, attachments)
|
|
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}")
|
|
# MIME-Kopie für Gesendet-Ordner konstruieren
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(None, lambda: _imap_save_sent(
|
|
_build_mime_copy(to, subject, html, plain, attachments).as_bytes()
|
|
))
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"Brevo-Fehler: {e}")
|
|
raise
|
|
|
|
if SMTP_HOST:
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
await loop.run_in_executor(
|
|
None, _send_smtp_sync, to, subject, html, plain, attachments
|
|
)
|
|
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"SMTP-Fehler: {e}")
|
|
raise
|
|
|
|
logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}")
|
|
|
|
|
|
def email_html(
|
|
body_html: str,
|
|
cta_url: str = None,
|
|
cta_label: str = None,
|
|
footer_text: str = None,
|
|
) -> str:
|
|
"""Shared branded HTML email template (matches Status-Report design)."""
|
|
cta_block = ""
|
|
if cta_url and cta_label:
|
|
cta_block = f"""
|
|
<p style="margin:24px 0 0">
|
|
<a href="{cta_url}"
|
|
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
|
|
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
|
|
{cta_label}
|
|
</a>
|
|
</p>"""
|
|
|
|
footer = footer_text or "Ban Yaro · banyaro.app"
|
|
|
|
return f"""\
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
</head>
|
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
|
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
|
|
|
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
|
|
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro</div>
|
|
</div>
|
|
|
|
<div style="padding:28px;color:#333;font-size:15px;line-height:1.6">
|
|
{body_html}{cta_block}
|
|
</div>
|
|
|
|
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
|
{footer}
|
|
</div>
|
|
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
async def send_verify_email(to: str, name: str, token: str):
|
|
url = f"{APP_URL}/api/auth/verify/{token}"
|
|
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
|
|
|
|
body = f"""
|
|
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
|
<p style="margin:0 0 16px">
|
|
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
|
</p>
|
|
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden gültig.</p>
|
|
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
|
</p>"""
|
|
|
|
html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
|
|
plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n"
|
|
|
|
await send_email(to, subject, html, plain)
|