Coverage for src/lexigram/notification/mailer/smtp_mailer.py: 92%
77 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
1"""SMTP mailer backend."""
3from __future__ import annotations
5import asyncio
6from email.errors import MessageError
7from email.mime.multipart import MIMEMultipart
8from email.mime.text import MIMEText
9import smtplib
10import ssl
11from typing import TYPE_CHECKING
12import uuid
14from lexigram.contracts.core import HealthCheckResult, HealthStatus
15from lexigram.contracts.mailer import (
16 EmailMessage,
17 MessageDeliveryReceipt,
18)
19from lexigram.logging import get_logger
20from lexigram.notification.constants import DEFAULT_FROM_EMAIL
21from lexigram.notification.exceptions import SMTPMailerError
22from lexigram.result import Err, Ok, Result
24if TYPE_CHECKING:
25 from lexigram.contracts.mailer.errors import MailerError
27logger = get_logger(__name__)
30class SMTPMailer:
31 """Email backend that sends via SMTP.
33 Implements :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`.
34 SMTP operations are blocking; they are run in a thread pool via
35 ``run_in_executor`` to avoid blocking the event loop.
37 Args:
38 host: SMTP server hostname.
39 port: SMTP port (587 for TLS, 465 for SSL, 25 for plain).
40 username: SMTP authentication username.
41 password: SMTP authentication password.
42 use_tls: Use STARTTLS after connection (port 587).
43 use_ssl: Use SSL from the start (port 465).
44 timeout: Connection timeout in seconds.
45 from_email: Default sender address.
46 from_name: Default sender display name.
47 """
49 def __init__(
50 self,
51 host: str = "localhost",
52 port: int = 587,
53 username: str | None = None,
54 password: str | None = None,
55 use_tls: bool = True,
56 use_ssl: bool = False,
57 timeout: int = 30,
58 from_email: str | None = None,
59 from_name: str | None = None,
60 ) -> None:
61 self.host = host
62 self.port = port
63 self.username = username
64 self.password = password
65 self.use_tls = use_tls
66 self.use_ssl = use_ssl
67 self.timeout = timeout
68 self.from_email = from_email or DEFAULT_FROM_EMAIL
69 self.from_name = from_name
71 def _build_mime(self, message: EmailMessage) -> MIMEMultipart:
72 """Build MIME message from EmailMessage."""
73 mime = MIMEMultipart("alternative")
74 sender = (
75 message.from_email if message.from_email is not None else self.from_email
76 )
77 display_name = (
78 message.from_name if message.from_name is not None else self.from_name
79 )
80 mime["From"] = f"{display_name} <{sender}>" if display_name else sender
81 mime["To"] = ", ".join(message.to)
82 mime["Subject"] = message.subject
83 if message.cc:
84 mime["Cc"] = ", ".join(message.cc)
85 for key, value in message.headers.items():
86 mime[key] = value
87 if message.body:
88 mime.attach(MIMEText(message.body, "plain", "utf-8"))
89 if message.html_body:
90 mime.attach(MIMEText(message.html_body, "html", "utf-8"))
91 return mime
93 def _send_sync(self, mime: MIMEMultipart, recipients: list[str]) -> None:
94 """Synchronous SMTP send (runs in executor to not block event loop)."""
95 ctx = ssl.create_default_context() if (self.use_tls or self.use_ssl) else None
96 if self.use_ssl and ctx:
97 server: smtplib.SMTP = smtplib.SMTP_SSL(
98 self.host, self.port, context=ctx, timeout=self.timeout
99 )
100 else:
101 server = smtplib.SMTP(self.host, self.port, timeout=self.timeout)
102 with server:
103 if self.use_tls and not self.use_ssl and ctx:
104 server.starttls(context=ctx)
105 if self.username and self.password:
106 server.login(self.username, self.password)
107 server.sendmail(mime["From"], recipients, mime.as_string())
109 async def send(
110 self, message: EmailMessage
111 ) -> Result[MessageDeliveryReceipt, MailerError]:
112 """Send an email via SMTP.
114 Args:
115 message: The email message to send.
117 Returns:
118 ``Ok(MessageDeliveryReceipt)`` on success.
119 ``Err(SMTPMailerError)`` for SMTP delivery failures.
120 """
121 try:
122 mime = self._build_mime(message)
123 recipients = message.to + message.cc + message.bcc
124 loop = asyncio.get_running_loop()
125 await loop.run_in_executor(None, self._send_sync, mime, recipients)
126 except (smtplib.SMTPException, MessageError) as exc:
127 # MessageError covers HeaderParseError/HeaderWriteError raised
128 # by as_string() on CRLF-infiltrated headers (defense-in-depth:
129 # EmailMessage validation rejects these at construction).
130 logger.warning("smtp_send_failed", error=str(exc), host=self.host)
131 return Err(SMTPMailerError(str(exc)))
133 receipt = MessageDeliveryReceipt(
134 message_id=str(uuid.uuid4()),
135 backend="smtp",
136 channel="email",
137 )
138 logger.info("smtp_sent", to=message.to, subject=message.subject)
139 return Ok(receipt)
141 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
142 """Check SMTP connectivity by attempting a connection.
144 Args:
145 timeout: Max seconds to wait for the connection.
147 Returns:
148 :class:`~lexigram.contracts.core.HealthCheckResult`.
149 """
151 def _check() -> None:
152 with smtplib.SMTP(self.host, self.port, timeout=timeout):
153 pass
155 try:
156 loop = asyncio.get_running_loop()
157 await loop.run_in_executor(None, _check)
158 return HealthCheckResult(
159 component="smtp",
160 status=HealthStatus.HEALTHY,
161 details={"host": self.host, "port": self.port},
162 )
163 except OSError as exc:
164 return HealthCheckResult(
165 component="smtp",
166 status=HealthStatus.UNHEALTHY,
167 details={"host": self.host, "error": str(exc)},
168 )
171__all__ = ["SMTPMailer"]