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
Binary file not shown.
+18 -3
View File
@@ -6,11 +6,11 @@ from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import get_settings
from app.database import engine, init_db
from app.database import engine, init_db, async_session
from app.cache import cache
from app.routers import equipment, health, admin, rental_requests, contact
from app.services.sync_service import SyncService
from app.database import async_session
from app.services.email_service import EmailService
logger = logging.getLogger(__name__)
settings = get_settings()
@@ -27,6 +27,14 @@ async def run_equipment_sync() -> None:
logger.info("Scheduled sync complete: %s", result)
async def run_email_retry() -> None:
"""Scheduled job: retry failed emails every 15 minutes."""
logger.info("Starting scheduled email retry")
async with async_session() as db:
sent = await EmailService.retry_failed_emails(db)
logger.info("Email retry complete: %d emails sent", sent)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: init DB, start scheduler, connect cache."""
@@ -44,8 +52,15 @@ async def lifespan(app: FastAPI):
id="equipment_sync",
replace_existing=True,
)
scheduler.add_job(
run_email_retry,
trigger="cron",
minute="*/15",
id="email_retry",
replace_existing=True,
)
scheduler.start()
logger.info("APScheduler started with equipment_sync job (every 6h)")
logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)")
yield
+2 -1
View File
@@ -4,5 +4,6 @@ from app.models.rental_request import RentalRequest, RentalRequestItem
from app.models.contact import Contact
from app.models.admin_user import AdminUser
from app.models.sync_log import SyncLog
from app.models.email_queue import EmailQueue
__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog"]
__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog", "EmailQueue"]
+18
View File
@@ -0,0 +1,18 @@
"""Email queue model for failed email retry."""
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func
from app.database import Base
class EmailQueue(Base):
__tablename__ = "email_queue"
id = Column(Integer, primary_key=True, autoincrement=True)
to_addr = Column(String(255), nullable=False)
subject = Column(String(255), nullable=False)
html_body = Column(Text, nullable=False)
text_body = Column(Text)
reply_to = Column(String(255))
status = Column(String(32), default="pending", nullable=False)
attempts = Column(Integer, default=0, nullable=False)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
+5 -4
View File
@@ -35,14 +35,15 @@ async def create_contact(
email_service = EmailService()
try:
await email_service.send_contact_email({
sent = await email_service.send_contact_email({
"name": payload.name,
"email": str(payload.email),
"phone": payload.phone,
"message": payload.message,
})
contact.email_sent = True
await db.commit()
}, db=db)
if sent:
contact.email_sent = True
await db.commit()
except Exception:
pass
+1
View File
@@ -133,6 +133,7 @@ async def create_rental_request(
reference_number=ref_number,
event_name=payload.event_name,
items=items_data,
db=db,
)
except Exception:
# Email failure should not affect response
+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