Coverage for extensions/smtp.py: 97.89%
95 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
1import logging
2import smtplib
3import ssl
4from collections.abc import Iterable
5from dataclasses import dataclass
6from email.message import EmailMessage
7from email.mime.multipart import MIMEMultipart
8from email.mime.text import MIMEText
10from fastapi import FastAPI
12from core.config import settings
13from utils.custom_exception import SMTPNotConfiguredException
15logger = logging.getLogger("smtp")
18@dataclass(frozen=True)
19class SMTPSettings:
20 enabled: bool
21 host: str
22 port: int
23 username: str | None
24 password: str | None
25 from_email: str | None
26 from_name: str
27 encryption: str
30class SMTPMailer:
31 def __init__(self, cfg: SMTPSettings):
32 self._cfg = cfg
34 @property
35 def enabled(self) -> bool:
36 return self._cfg.enabled
38 def _validate(self) -> None:
39 if not self._cfg.enabled:
40 raise SMTPNotConfiguredException("SMTP is disabled")
41 missing = []
42 if not self._cfg.host:
43 missing.append("SMTP_HOST")
44 if not self._cfg.port:
45 missing.append("SMTP_PORT")
46 if not self._cfg.username:
47 missing.append("SMTP_USERNAME/SMTP_USER")
48 if not self._cfg.password:
49 missing.append("SMTP_PASSWORD")
50 if not self._cfg.from_email:
51 missing.append("SMTP_FROM_EMAIL/SMTP_FROM")
52 if missing:
53 raise SMTPNotConfiguredException(f"Missing SMTP settings: {', '.join(missing)}")
55 enc = (self._cfg.encryption or "").strip().lower()
56 if enc not in {"tls", "ssl", "none"}:
57 raise SMTPNotConfiguredException("SMTP_ENCRYPTION must be tls, ssl, or none")
59 def _open(self, timeout: int = 30) -> smtplib.SMTP:
60 """
61 Create a new SMTP connection per send.
62 This avoids keeping long-lived connections in the web process.
63 """
64 self._validate()
66 enc = self._cfg.encryption.strip().lower()
67 context = ssl.create_default_context()
69 if enc == "ssl":
70 client: smtplib.SMTP = smtplib.SMTP_SSL(
71 self._cfg.host, self._cfg.port, timeout=timeout, context=context
72 )
73 else:
74 client = smtplib.SMTP(self._cfg.host, self._cfg.port, timeout=timeout)
76 try:
77 client.ehlo()
78 if enc == "tls":
79 client.starttls(context=context)
80 client.ehlo()
81 if self._cfg.username and self._cfg.password:
82 client.login(self._cfg.username, self._cfg.password)
83 return client
84 except Exception as e:
85 logger.error(
86 "SMTP connection failed: host=%s port=%s encryption=%s error=%s",
87 self._cfg.host,
88 self._cfg.port,
89 enc,
90 e,
91 exc_info=True,
92 )
93 try:
94 client.quit()
95 except Exception:
96 pass
97 raise
99 def send_text(
100 self,
101 *,
102 to_emails: Iterable[str],
103 subject: str,
104 body: str,
105 html_body: str | None = None,
106 from_email: str | None = None,
107 from_name: str | None = None,
108 timeout: int = 30,
109 ) -> None:
110 self._validate()
112 sender_email = from_email or self._cfg.from_email
113 sender_name = from_name or self._cfg.from_name
115 # If HTML body is provided, create multipart message
116 if html_body:
117 msg = MIMEMultipart("alternative")
118 msg["Subject"] = subject
119 msg["From"] = f"{sender_name} <{sender_email}>"
120 msg["To"] = ", ".join(list(to_emails))
122 # Add plain text and HTML parts
123 part1 = MIMEText(body, "plain", "utf-8")
124 part2 = MIMEText(html_body, "html", "utf-8")
125 msg.attach(part1)
126 msg.attach(part2)
127 else:
128 # Plain text only
129 msg = EmailMessage()
130 msg["Subject"] = subject
131 msg["From"] = f"{sender_name} <{sender_email}>"
132 msg["To"] = ", ".join(list(to_emails))
133 msg.set_content(body)
135 with self._open(timeout=timeout) as client:
136 client.send_message(msg)
139def build_smtp_settings() -> SMTPSettings:
140 return SMTPSettings(
141 enabled=bool(settings.SMTP_ENABLE),
142 host=(settings.SMTP_HOST or "").strip(),
143 port=int(settings.SMTP_PORT),
144 username=(settings.SMTP_USERNAME or None),
145 password=(settings.SMTP_PASSWORD or None),
146 from_email=(settings.SMTP_FROM_EMAIL or None),
147 from_name=(settings.SMTP_FROM_NAME or "Docker Fullstack Template"),
148 encryption=(settings.SMTP_ENCRYPTION or "tls"),
149 )
152# Global singleton instance
153_SMTP_MAILER: SMTPMailer | None = None
156def get_mailer() -> SMTPMailer:
157 """
158 Get SMTP mailer singleton instance.
159 Lazy initialization on first access.
160 """
161 global _SMTP_MAILER
162 if _SMTP_MAILER is None:
163 cfg = build_smtp_settings()
164 _SMTP_MAILER = SMTPMailer(cfg)
166 if cfg.enabled:
167 logger.info(
168 "SMTP enabled: host=%s port=%s encryption=%s from=%s",
169 cfg.host,
170 cfg.port,
171 (cfg.encryption or "").lower(),
172 cfg.from_email,
173 )
174 else:
175 logger.info("SMTP disabled")
177 return _SMTP_MAILER
180def add_smtp(app: FastAPI) -> None:
181 """
182 Initialize SMTP mailer and register to app.state.
183 """
184 mailer = get_mailer()
185 app.state.smtp = mailer