mail.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """
  2. app/mail.py
  3. ───────────
  4. Adaptive SMTP delivery engine.
  5. Three mutually-exclusive modes are selected at runtime based on environment:
  6. Mode A (Authenticated TLS relay)
  7. All three MAIL_RELAYHOST + MAIL_RELAYHOST_USERNAME + MAIL_RELAYHOST_PASSWORD
  8. must be set. The backend connects with STARTTLS and authenticates.
  9. Mode B (Simple container/host proxy relay)
  10. Only MAIL_RELAY_HOST is set (hostname, no credentials).
  11. Backend opens an unauthenticated SMTP session on port 587 to that host.
  12. Designed for a companion boky/postfix container in the same Docker network.
  13. Direct SMTP (fallback)
  14. No relay variables present. Backend attempts delivery via localhost:25
  15. (or any MTA listening locally).
  16. """
  17. from __future__ import annotations
  18. import logging
  19. import smtplib
  20. from email.mime.multipart import MIMEMultipart
  21. from email.mime.text import MIMEText
  22. from enum import Enum
  23. from app.config import settings
  24. from app.models import STATUS_DELIVERED, STATUS_FAILED, STATUS_RELAY
  25. logger = logging.getLogger(__name__)
  26. # ─────────────────────────────────────────────────────────────────────────────
  27. # Mode detection
  28. # ─────────────────────────────────────────────────────────────────────────────
  29. class MailMode(str, Enum):
  30. MODE_A = "authenticated_relay"
  31. MODE_B = "proxy_relay"
  32. DIRECT = "direct_smtp"
  33. def _detect_mode() -> MailMode:
  34. if (
  35. settings.MAIL_RELAYHOST
  36. and settings.MAIL_RELAYHOST_USERNAME
  37. and settings.MAIL_RELAYHOST_PASSWORD
  38. ):
  39. return MailMode.MODE_A
  40. if settings.MAIL_RELAY_HOST:
  41. return MailMode.MODE_B
  42. return MailMode.DIRECT
  43. def _build_message(to_email: str, subject: str, body: str) -> MIMEMultipart:
  44. msg = MIMEMultipart("alternative")
  45. msg["Subject"] = subject
  46. msg["From"] = f"{settings.MAIL_FROM_NAME} <{settings.MAIL_FROM}>"
  47. msg["To"] = to_email
  48. msg.attach(MIMEText(body, "plain", "utf-8"))
  49. return msg
  50. # ─────────────────────────────────────────────────────────────────────────────
  51. # Public send function
  52. # ─────────────────────────────────────────────────────────────────────────────
  53. def send_email(to_email: str, subject: str, body: str) -> str:
  54. """
  55. Deliver an email to *to_email* using the configured strategy.
  56. Returns one of the STATUS_* constants on success.
  57. Raises on failure so the caller can log STATUS_FAILED.
  58. """
  59. mode = _detect_mode()
  60. msg = _build_message(to_email, subject, body)
  61. raw = msg.as_string()
  62. try:
  63. if mode == MailMode.MODE_A:
  64. # ── Authenticated TLS relay ──────────────────────────────────────
  65. relay = settings.MAIL_RELAYHOST
  66. if ":" in relay:
  67. host, port_str = relay.rsplit(":", 1)
  68. port = int(port_str)
  69. else:
  70. host, port = relay, 587
  71. with smtplib.SMTP(host, port, timeout=20) as srv:
  72. srv.ehlo()
  73. srv.starttls()
  74. srv.ehlo()
  75. srv.login(settings.MAIL_RELAYHOST_USERNAME, settings.MAIL_RELAYHOST_PASSWORD)
  76. srv.sendmail(settings.MAIL_FROM, [to_email], raw)
  77. logger.info("[MAIL MODE-A] → %s via %s:%d", to_email, host, port)
  78. return STATUS_DELIVERED
  79. elif mode == MailMode.MODE_B:
  80. # ── Unauthenticated container proxy relay ────────────────────────
  81. host = settings.MAIL_RELAY_HOST
  82. with smtplib.SMTP(host, 587, timeout=20) as srv:
  83. srv.ehlo()
  84. srv.sendmail(settings.MAIL_FROM, [to_email], raw)
  85. logger.info("[MAIL MODE-B] → %s via %s:587", to_email, host)
  86. return STATUS_RELAY
  87. else:
  88. # ── Direct localhost:25 delivery ─────────────────────────────────
  89. with smtplib.SMTP("localhost", 25, timeout=20) as srv:
  90. srv.sendmail(settings.MAIL_FROM, [to_email], raw)
  91. logger.info("[MAIL DIRECT] → %s via localhost:25", to_email)
  92. return STATUS_DELIVERED
  93. except Exception as exc:
  94. logger.error(
  95. "[MAIL ERROR] Failed to deliver to %s (mode=%s): %s",
  96. to_email, mode.value, exc,
  97. exc_info=True,
  98. )
  99. raise