import logging from abc import ABC, abstractmethod from email.message import EmailMessage import aiosmtplib from app.core.config import settings logger = logging.getLogger(__name__) class EmailSender(ABC): @abstractmethod async def send(self, to: str, subject: str, html_body: str) -> None: ... class LoggingEmailSender(EmailSender): """Dev/test fallback: logs instead of sending. Wire a real provider (SES, Postmark, SMTP) behind this interface before going to prod.""" async def send(self, to: str, subject: str, html_body: str) -> None: logger.info(f"[EMAIL to={to}] {subject}\n{html_body}") class SmtpEmailSender(EmailSender): """Sends via SMTP. Works with any provider that exposes SMTP credentials (SES, Postmark, Sendgrid, a plain mailbox, etc).""" async def send(self, to: str, subject: str, html_body: str) -> None: message = EmailMessage() message["From"] = f"{settings.EMAIL_FROM_NAME} <{settings.EMAIL_FROM_ADDRESS}>" message["To"] = to message["Subject"] = subject message.set_content("Ce message nécessite un client compatible HTML.") message.add_alternative(html_body, subtype="html") try: await aiosmtplib.send( message, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT, username=settings.SMTP_USERNAME or None, password=settings.SMTP_PASSWORD or None, start_tls=settings.SMTP_USE_TLS, timeout=10, ) except (aiosmtplib.SMTPException, OSError) as exc: # Don't let a transient SMTP outage 500 the caller's whole # request (e.g. invite-member already committed the DB write) -- # log loudly so it's visible in monitoring, and let the caller # decide whether to surface a degraded-but-successful response. logger.error("Failed to send email to %s: %s", to, exc) raise EmailDeliveryError(str(exc)) from exc class EmailDeliveryError(Exception): pass def get_email_sender() -> EmailSender: if settings.ENVIRONMENT == "PYTEST" or not settings.SMTP_HOST: return LoggingEmailSender() return SmtpEmailSender()