Feat: Alle ausgehenden Mails landen im IMAP-Gesendet-Ordner (SMTP + Brevo)

This commit is contained in:
rene 2026-05-15 12:32:52 +02:00
parent aea5f04bc1
commit 865407d428

View file

@ -5,11 +5,14 @@ Unterstützt zwei Backends (wird automatisch gewählt):
2. SMTP wenn SMTP_HOST gesetzt (Fallback) 2. SMTP wenn SMTP_HOST gesetzt (Fallback)
""" """
import imaplib
import os import os
import base64 import base64
import smtplib import smtplib
import asyncio import asyncio
import logging import logging
import ssl
from datetime import datetime
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
@ -28,10 +31,70 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "") SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "") 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>") SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro <noreply@banyaro.app>")
APP_URL = os.getenv("APP_URL", "https://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
return msg
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Brevo REST-API # Brevo REST-API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -92,12 +155,14 @@ def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: l
msg["From"] = SMTP_FROM msg["From"] = SMTP_FROM
msg["To"] = to msg["To"] = to
msg_bytes = msg.as_bytes()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
s.ehlo() s.ehlo()
s.starttls() s.starttls()
if SMTP_USER: if SMTP_USER:
s.login(SMTP_USER, SMTP_PASS) s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string()) s.sendmail(SMTP_FROM, [to], msg_bytes)
_imap_save_sent(msg_bytes)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -108,6 +173,11 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme
try: try:
await _send_brevo(to, subject, html, plain, attachments) await _send_brevo(to, subject, html, plain, attachments)
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") 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 return
except Exception as e: except Exception as e:
logger.error(f"Brevo-Fehler: {e}") logger.error(f"Brevo-Fehler: {e}")