Coverage for src/lexigram/notification/di/mailer_provider.py: 73%

74 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 02:32 +0800

1"""MailerProvider — DI provider for email delivery backends.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

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) 

17 

18if TYPE_CHECKING: 

19 from lexigram.contracts.core.di import ( 

20 ContainerRegistrarProtocol, 

21 ContainerResolverProtocol, 

22 ) 

23 

24logger = get_logger(__name__) 

25 

26 

27class MailerProvider(Provider): 

28 """Register SMTP and SendGrid mailer backends into the DI container. 

29 

30 Reads :class:`~lexigram.notification.config.MailerConfig`, creates the 

31 appropriate mailer backends, and registers them as 

32 :class:`~lexigram.contracts.mailer.protocols.MailerProtocol`. 

33 

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. 

37 

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 """ 

43 

44 name = "mailer" 

45 priority = ProviderPriority.INFRASTRUCTURE 

46 

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]] = [] 

51 

52 @classmethod 

53 def from_config(cls, config: MailerConfig, **context: Any) -> MailerProvider: 

54 """Factory method for DI container setup. 

55 

56 Args: 

57 config: Mailer configuration. 

58 **context: Ignored extra context. 

59 

60 Returns: 

61 A new :class:`MailerProvider` instance. 

62 """ 

63 return cls(config) 

64 

65 def _create_mailer(self, entry: NamedMailerConfig) -> Any: 

66 """Instantiate the correct mailer implementation for a config entry. 

67 

68 Args: 

69 entry: Named mailer configuration entry. 

70 

71 Returns: 

72 A mailer instance conforming to :class:`MailerProtocol`. 

73 

74 Raises: 

75 ValueError: When the driver name is not recognised. 

76 """ 

77 from lexigram.notification.mailer.smtp_mailer import SMTPMailer 

78 

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 ) 

96 

97 if entry.driver == "sendgrid": 

98 from lexigram.notification.mailer.sendgrid_mailer import SendGridMailer 

99 

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 ) 

115 

116 if entry.driver == "console": 

117 from lexigram.notification.mailer.console_mailer import ConsoleMailer 

118 

119 return ConsoleMailer() 

120 

121 raise ValueError(f"Unsupported mailer driver: {entry.driver!r}") 

122 

123 async def register(self, container: ContainerRegistrarProtocol) -> None: 

124 """Bind all mailer backends into the container. 

125 

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. 

130 

131 Args: 

132 container: DI registrar received from the framework. 

133 """ 

134 container.singleton(MailerConfig, self._config) 

135 

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 ) 

152 

153 if not self._config.backends and self._config.console_fallback: 

154 from lexigram.notification.mailer.console_mailer import ConsoleMailer 

155 

156 console = ConsoleMailer() 

157 self._mailers.append(("console", console)) 

158 container.singleton( 

159 MailerProtocol, 

160 factory=lambda _resolver, m=console: m, 

161 ) 

162 

163 logger.info( 

164 "mailer_registered", 

165 backends=[n for n, _ in self._mailers], 

166 ) 

167 

168 async def boot(self, container: ContainerResolverProtocol) -> None: 

169 """No-op boot; mailers are stateless and require no startup. 

170 

171 Args: 

172 container: DI resolver (unused). 

173 """ 

174 

175 async def shutdown(self) -> None: 

176 """Clear registered mailer references.""" 

177 self._mailers.clear() 

178 

179 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

180 """Aggregate health across all registered mailer backends. 

181 

182 Args: 

183 timeout: Per-backend health-check timeout in seconds. 

184 

185 Returns: 

186 :class:`~lexigram.contracts.core.HealthCheckResult`. 

187 """ 

188 import asyncio 

189 

190 if not self._mailers: 

191 return HealthCheckResult( 

192 component="mailer", 

193 status=HealthStatus.HEALTHY, 

194 details={"backends": []}, 

195 ) 

196 

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 

220 

221 return HealthCheckResult( 

222 component="mailer", 

223 status=worst, 

224 details=details, 

225 ) 

226 

227 

228__all__ = ["MailerProvider"]