2026-07-10 01:05:58 +02:00
|
|
|
|
"""Email service using aiosmtplib for contact and rental confirmation emails.
|
|
|
|
|
|
|
|
|
|
|
|
Includes a failed-email queue: when SMTP fails and a db session is provided,
|
|
|
|
|
|
the email is persisted to the email_queue table for later retry via APScheduler.
|
|
|
|
|
|
"""
|
2026-07-09 01:36:46 +02:00
|
|
|
|
import logging
|
|
|
|
|
|
from email.mime.text import MIMEText
|
|
|
|
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
|
|
from typing import Any
|
2026-07-10 01:05:58 +02:00
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-07-09 01:36:46 +02:00
|
|
|
|
import aiosmtplib
|
|
|
|
|
|
from app.config import get_settings
|
2026-07-10 01:05:58 +02:00
|
|
|
|
from app.models.email_queue import EmailQueue
|
2026-07-09 01:36:46 +02:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmailService:
|
2026-07-10 01:05:58 +02:00
|
|
|
|
"""Send transactional emails via SMTP with failed-email queue support."""
|
2026-07-09 01:36:46 +02:00
|
|
|
|
|
|
|
|
|
|
async def send_email(
|
|
|
|
|
|
self,
|
|
|
|
|
|
to_addr: str,
|
|
|
|
|
|
subject: str,
|
|
|
|
|
|
html_body: str,
|
|
|
|
|
|
text_body: str = "",
|
|
|
|
|
|
reply_to: str | None = None,
|
2026-07-10 01:05:58 +02:00
|
|
|
|
db: AsyncSession | None = None,
|
2026-07-09 01:36:46 +02:00
|
|
|
|
) -> bool:
|
2026-07-10 01:05:58 +02:00
|
|
|
|
"""Send an email via aiosmtplib. Returns True on success.
|
|
|
|
|
|
|
|
|
|
|
|
If SMTP fails and a db session is provided, the email is saved to
|
|
|
|
|
|
the email_queue table for later retry.
|
|
|
|
|
|
"""
|
2026-07-09 01:36:46 +02:00
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
|
|
|
|
msg["From"] = settings.smtp_from
|
|
|
|
|
|
msg["To"] = to_addr
|
|
|
|
|
|
msg["Subject"] = subject
|
|
|
|
|
|
if reply_to:
|
|
|
|
|
|
msg["Reply-To"] = reply_to
|
|
|
|
|
|
msg.attach(MIMEText(text_body or html_body, "plain"))
|
|
|
|
|
|
msg.attach(MIMEText(html_body, "html"))
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await aiosmtplib.send(
|
|
|
|
|
|
msg,
|
|
|
|
|
|
hostname=settings.smtp_host,
|
|
|
|
|
|
port=settings.smtp_port,
|
|
|
|
|
|
username=settings.smtp_user,
|
|
|
|
|
|
password=settings.smtp_password,
|
|
|
|
|
|
start_tls=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error("Email send failed to %s: %s", to_addr, exc)
|
2026-07-10 01:05:58 +02:00
|
|
|
|
if db is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
queued = EmailQueue(
|
|
|
|
|
|
to_addr=to_addr,
|
|
|
|
|
|
subject=subject,
|
|
|
|
|
|
html_body=html_body,
|
|
|
|
|
|
text_body=text_body,
|
|
|
|
|
|
reply_to=reply_to,
|
|
|
|
|
|
status="pending",
|
|
|
|
|
|
attempts=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(queued)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
logger.info("Email queued for retry: %s", to_addr)
|
|
|
|
|
|
except Exception as queue_exc:
|
|
|
|
|
|
logger.error("Failed to queue email: %s", queue_exc)
|
2026-07-09 01:36:46 +02:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-07-10 01:05:58 +02:00
|
|
|
|
async def send_contact_email(
|
|
|
|
|
|
self, contact_data: dict[str, Any], db: AsyncSession | None = None
|
|
|
|
|
|
) -> bool:
|
2026-07-09 01:36:46 +02:00
|
|
|
|
"""Send contact form data to info@hms-licht-ton.de."""
|
|
|
|
|
|
html = f"""
|
|
|
|
|
|
<h2>Neue Kontaktanfrage</h2>
|
|
|
|
|
|
<p><strong>Name:</strong> {contact_data.get('name', '')}</p>
|
|
|
|
|
|
<p><strong>Email:</strong> {contact_data.get('email', '')}</p>
|
|
|
|
|
|
<p><strong>Telefon:</strong> {contact_data.get('phone', '')}</p>
|
|
|
|
|
|
<p><strong>Nachricht:</strong></p>
|
|
|
|
|
|
<p>{contact_data.get('message', '')}</p>
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = (
|
|
|
|
|
|
f"Neue Kontaktanfrage\n"
|
|
|
|
|
|
f"Name: {contact_data.get('name', '')}\n"
|
|
|
|
|
|
f"Email: {contact_data.get('email', '')}\n"
|
|
|
|
|
|
f"Telefon: {contact_data.get('phone', '')}\n"
|
|
|
|
|
|
f"Nachricht: {contact_data.get('message', '')}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
return await self.send_email(
|
|
|
|
|
|
to_addr=settings.smtp_from,
|
|
|
|
|
|
subject="Neue Kontaktanfrage ueber hms-licht-ton.de",
|
|
|
|
|
|
html_body=html,
|
|
|
|
|
|
text_body=text,
|
|
|
|
|
|
reply_to=contact_data.get("email"),
|
2026-07-10 01:05:58 +02:00
|
|
|
|
db=db,
|
2026-07-09 01:36:46 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def send_rental_confirmation(
|
|
|
|
|
|
self,
|
|
|
|
|
|
to_addr: str,
|
|
|
|
|
|
reference_number: str,
|
|
|
|
|
|
event_name: str,
|
|
|
|
|
|
items: list[dict[str, Any]],
|
2026-07-10 01:05:58 +02:00
|
|
|
|
db: AsyncSession | None = None,
|
2026-07-09 01:36:46 +02:00
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Send rental request confirmation to customer."""
|
|
|
|
|
|
items_html = "".join(
|
|
|
|
|
|
f"<li>{item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}</li>"
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
)
|
|
|
|
|
|
html = f"""
|
|
|
|
|
|
<h2>Mietanfrage bestätigt – {reference_number}</h2>
|
|
|
|
|
|
<p>Vielen Dank für Ihre Mietanfrage bei HMS Licht & Ton.</p>
|
|
|
|
|
|
<p><strong>Veranstaltung:</strong> {event_name}</p>
|
|
|
|
|
|
<p><strong>Referenznummer:</strong> {reference_number}</p>
|
|
|
|
|
|
<h3>Artikel:</h3>
|
|
|
|
|
|
<ul>{items_html}</ul>
|
|
|
|
|
|
<p>Wir melden uns in Kürze bei Ihnen.</p>
|
|
|
|
|
|
<p>Ihr HMS Licht & Ton Team</p>
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = (
|
|
|
|
|
|
f"Mietanfrage bestaetigt - {reference_number}\n"
|
|
|
|
|
|
f"Veranstaltung: {event_name}\n"
|
|
|
|
|
|
f"Referenznummer: {reference_number}\n"
|
|
|
|
|
|
f"Artikel:\n"
|
|
|
|
|
|
+ "\n".join(
|
|
|
|
|
|
f"- {item.get('equipment_name', 'Unbekannt')}: {item.get('quantity', 1)}"
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return await self.send_email(
|
|
|
|
|
|
to_addr=to_addr,
|
|
|
|
|
|
subject=f"Mietanfrage bestaetigt – {reference_number}",
|
|
|
|
|
|
html_body=html,
|
|
|
|
|
|
text_body=text,
|
2026-07-10 01:05:58 +02:00
|
|
|
|
db=db,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def retry_failed_emails(db: AsyncSession, max_attempts: int = 5) -> int:
|
|
|
|
|
|
"""Retry all pending emails in the queue. Returns count of successfully sent emails."""
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
|
select(EmailQueue).where(
|
|
|
|
|
|
EmailQueue.status == "pending",
|
|
|
|
|
|
EmailQueue.attempts < max_attempts,
|
|
|
|
|
|
)
|
2026-07-09 01:36:46 +02:00
|
|
|
|
)
|
2026-07-10 01:05:58 +02:00
|
|
|
|
pending = result.scalars().all()
|
|
|
|
|
|
sent_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
for item in pending:
|
|
|
|
|
|
item.attempts += 1
|
|
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
|
|
|
|
msg["From"] = settings.smtp_from
|
|
|
|
|
|
msg["To"] = item.to_addr
|
|
|
|
|
|
msg["Subject"] = item.subject
|
|
|
|
|
|
if item.reply_to:
|
|
|
|
|
|
msg["Reply-To"] = item.reply_to
|
|
|
|
|
|
msg.attach(MIMEText(item.text_body or item.html_body, "plain"))
|
|
|
|
|
|
msg.attach(MIMEText(item.html_body, "html"))
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await aiosmtplib.send(
|
|
|
|
|
|
msg,
|
|
|
|
|
|
hostname=settings.smtp_host,
|
|
|
|
|
|
port=settings.smtp_port,
|
|
|
|
|
|
username=settings.smtp_user,
|
|
|
|
|
|
password=settings.smtp_password,
|
|
|
|
|
|
start_tls=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
item.status = "sent"
|
|
|
|
|
|
sent_count += 1
|
|
|
|
|
|
logger.info("Retry succeeded for email to %s", item.to_addr)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error("Retry failed for email to %s: %s", item.to_addr, exc)
|
|
|
|
|
|
if item.attempts >= max_attempts:
|
|
|
|
|
|
item.status = "failed"
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return sent_count
|