Coverage for src/lexigram/notification/di/mailer_provider.py: 0%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 07:17 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 07:17 +0800
1"""MailerProvider — DI provider for email delivery backends."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.contracts.core import HealthCheckResult, HealthStatus, ProviderPriority
8from lexigram.contracts.mailer.protocols import MailerProtocol
9from lexigram.di.provider import Provider
10from lexigram.logging import get_logger
11from lexigram.notification.config import (
12 MailerConfig,
13 NamedMailerConfig,
14 SendGridDriverConfig,
15 SMTPDriverConfig,
16)
18if TYPE_CHECKING:
19 from lexigram.contracts.core.di import (
20 ContainerRegistrarProtocol,
21 ContainerResolverProtocol,
22 )
24logger = get_logger(__name__)
27class MailerProvider(Provider):
28 """Register SMTP and SendGrid mailer backends into the DI container.
30 Reads :class:`~lexigram.notification.config.MailerConfig`, creates the
31 appropriate mailer backends, and registers them as
32 :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`.
34 Configuration is explicit-only: ``MailerConfig`` is not bound to a
35 ``LexigramConfig`` section, so this provider declares no
36 ``config_key``/``config_model`` attributes.
38 Supports multi-backend (``MailerConfig.backends``) mode. Each entry is
39 registered under its name via ``container.singleton(name=entry.name)``.
40 The primary backend (``primary=True`` or the first entry) also receives
41 the unnamed binding for constructor injection without ``Named``.
42 """
44 name = "mailer"
45 priority = ProviderPriority.INFRASTRUCTURE
47 def __init__(self, config: MailerConfig | None = None) -> None:
48 super().__init__()
49 self._config = config or MailerConfig()
50 self._mailers: list[tuple[str, Any]] = []
52 @classmethod
53 def from_config(cls, config: MailerConfig, **context: Any) -> MailerProvider:
54 """Factory method for DI container setup.
56 Args:
57 config: Mailer configuration.
58 **context: Ignored extra context.
60 Returns:
61 A new :class:`MailerProvider` instance.
62 """
63 return cls(config)
65 def _create_mailer(self, entry: NamedMailerConfig) -> Any:
66 """Instantiate the correct mailer implementation for a config entry.
68 Args:
69 entry: Named mailer configuration entry.
71 Returns:
72 A mailer instance conforming to :class:`MailerProtocol`.
74 Raises:
75 ValueError: When the driver name is not recognised.
76 """
77 from lexigram.notification.mailer.smtp_mailer import SMTPMailer
79 if entry.driver == "smtp":
80 cfg = entry.smtp or SMTPDriverConfig()
81 password: Any | None = None
82 if cfg.password:
83 password = getattr(
84 cfg.password, "get_secret_value", lambda: cfg.password or ""
85 )()
86 return SMTPMailer(
87 host=cfg.host,
88 port=cfg.port,
89 username=cfg.username,
90 password=password,
91 use_tls=cfg.use_tls,
92 use_ssl=cfg.use_ssl,
93 timeout=cfg.timeout,
94 from_email=entry.from_email,
95 )
97 if entry.driver == "sendgrid":
98 from lexigram.notification.mailer.sendgrid_mailer import SendGridMailer
100 cfg_sg = entry.sendgrid or SendGridDriverConfig()
101 api_key: Any = ""
102 if cfg_sg.api_key:
103 api_key = (
104 getattr(
105 cfg_sg.api_key, "get_secret_value", lambda: cfg_sg.api_key or ""
106 )()
107 or ""
108 )
109 return SendGridMailer(
110 api_key=api_key,
111 timeout=cfg_sg.timeout,
112 sandbox_mode=cfg_sg.sandbox_mode,
113 from_email=entry.from_email,
114 )
116 if entry.driver == "console":
117 from lexigram.notification.mailer.console_mailer import ConsoleMailer
119 return ConsoleMailer()
121 raise ValueError(f"Unsupported mailer driver: {entry.driver!r}")
123 async def register(self, container: ContainerRegistrarProtocol) -> None:
124 """Bind all mailer backends into the container.
126 When no backends are configured and ``console_fallback`` is enabled,
127 a :class:`~lexigram.notification.mailer.console_mailer.ConsoleMailer`
128 is bound as the default ``MailerProtocol`` so outgoing emails are
129 logged to the console instead of being silently dropped.
131 Args:
132 container: DI registrar received from the framework.
133 """
134 container.singleton(MailerConfig, self._config)
136 for entry in self._config.backends:
137 mailer = self._create_mailer(entry)
138 self._mailers.append((entry.name, mailer))
139 container.singleton(
140 MailerProtocol,
141 factory=lambda _resolver, m=mailer: m,
142 name=entry.name,
143 )
144 is_primary = entry.primary or (
145 not any(e.primary for e in self._config.backends)
146 and self._config.backends[0] is entry
147 )
148 if is_primary:
149 container.singleton(
150 MailerProtocol, factory=lambda _resolver, m=mailer: m
151 )
153 if not self._config.backends and self._config.console_fallback:
154 from lexigram.notification.mailer.console_mailer import ConsoleMailer
156 console = ConsoleMailer()
157 self._mailers.append(("console", console))
158 container.singleton(
159 MailerProtocol,
160 factory=lambda _resolver, m=console: m,
161 )
163 logger.info(
164 "mailer_registered",
165 backends=[n for n, _ in self._mailers],
166 )
168 async def boot(self, container: ContainerResolverProtocol) -> None:
169 """No-op boot; mailers are stateless and require no startup.
171 Args:
172 container: DI resolver (unused).
173 """
175 async def shutdown(self) -> None:
176 """Clear registered mailer references."""
177 self._mailers.clear()
179 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
180 """Aggregate health across all registered mailer backends.
182 Args:
183 timeout: Per-backend health-check timeout in seconds.
185 Returns:
186 :class:`~lexigram.contracts.core.HealthCheckResult`.
187 """
188 import asyncio
190 if not self._mailers:
191 return HealthCheckResult(
192 component="mailer",
193 status=HealthStatus.HEALTHY,
194 details={"backends": []},
195 )
197 results = await asyncio.gather(
198 *[
199 svc.health_check(timeout=timeout)
200 for _, svc in self._mailers
201 if hasattr(svc, "health_check")
202 ],
203 return_exceptions=True,
204 )
205 worst = HealthStatus.HEALTHY
206 details: dict[str, Any] = {}
207 for (name, _), result in zip(self._mailers, results, strict=False):
208 if isinstance(result, Exception):
209 worst = HealthStatus.UNHEALTHY
210 details[name] = {"status": "error", "error": str(result)}
211 elif isinstance(result, HealthCheckResult):
212 details[name] = {"status": result.status.value}
213 if result.status == HealthStatus.UNHEALTHY:
214 worst = HealthStatus.UNHEALTHY
215 elif (
216 result.status == HealthStatus.DEGRADED
217 and worst == HealthStatus.HEALTHY
218 ):
219 worst = HealthStatus.DEGRADED
221 return HealthCheckResult(
222 component="mailer",
223 status=worst,
224 details=details,
225 )
228__all__ = ["MailerProvider"]