test(T05): contact router, rental router, email service, rate limiting tests + retry queue

This commit is contained in:
Implementation Engineer
2026-07-10 01:05:58 +02:00
parent db5080df48
commit e220ff7db9
17 changed files with 780 additions and 89 deletions
+80 -4
View File
@@ -1,17 +1,24 @@
"""Email service using aiosmtplib for contact and rental confirmation emails."""
"""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.
"""
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
import aiosmtplib
from app.config import get_settings
from app.models.email_queue import EmailQueue
logger = logging.getLogger(__name__)
settings = get_settings()
class EmailService:
"""Send transactional emails via SMTP."""
"""Send transactional emails via SMTP with failed-email queue support."""
async def send_email(
self,
@@ -20,8 +27,13 @@ class EmailService:
html_body: str,
text_body: str = "",
reply_to: str | None = None,
db: AsyncSession | None = None,
) -> bool:
"""Send an email via aiosmtplib. Returns True on success."""
"""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.
"""
msg = MIMEMultipart("alternative")
msg["From"] = settings.smtp_from
msg["To"] = to_addr
@@ -43,9 +55,27 @@ class EmailService:
return True
except Exception as exc:
logger.error("Email send failed to %s: %s", to_addr, exc)
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)
return False
async def send_contact_email(self, contact_data: dict[str, Any]) -> bool:
async def send_contact_email(
self, contact_data: dict[str, Any], db: AsyncSession | None = None
) -> bool:
"""Send contact form data to info@hms-licht-ton.de."""
html = f"""
<h2>Neue Kontaktanfrage</h2>
@@ -68,6 +98,7 @@ class EmailService:
html_body=html,
text_body=text,
reply_to=contact_data.get("email"),
db=db,
)
async def send_rental_confirmation(
@@ -76,6 +107,7 @@ class EmailService:
reference_number: str,
event_name: str,
items: list[dict[str, Any]],
db: AsyncSession | None = None,
) -> bool:
"""Send rental request confirmation to customer."""
items_html = "".join(
@@ -107,4 +139,48 @@ class EmailService:
subject=f"Mietanfrage bestaetigt {reference_number}",
html_body=html,
text_body=text,
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,
)
)
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