Coverage for src/lexigram/notification/config/mailer.py: 87%

53 statements  

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

1"""Mailer driver configuration.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any, ClassVar 

7 

8from lexigram.config.base import BaseConfig 

9from lexigram.domain import DomainModel 

10from lexigram.notification.constants import ( 

11 DEFAULT_SENDGRID_TIMEOUT, 

12 DEFAULT_SMTP_PORT, 

13 DEFAULT_SMTP_TIMEOUT, 

14) 

15from lexigram.validation import ConfigDict, Field, SecretStr, field_validator 

16 

17 

18@dataclass(init=False) 

19class SMTPDriverConfig(DomainModel): 

20 """SMTP-specific connection configuration.""" 

21 

22 host: str = Field( 

23 default="localhost", 

24 description="SMTP server hostname", 

25 ) 

26 port: int = Field( 

27 default=DEFAULT_SMTP_PORT, 

28 ge=1, 

29 le=65535, 

30 description="SMTP port", 

31 ) 

32 username: str | None = Field( 

33 default=None, 

34 description="SMTP auth username", 

35 ) 

36 password: SecretStr | None = Field( 

37 default=None, 

38 description="SMTP auth password", 

39 ) 

40 use_tls: bool = Field( 

41 default=True, 

42 description="Use STARTTLS (port 587)", 

43 ) 

44 use_ssl: bool = Field( 

45 default=False, 

46 description="Use SSL from connect (port 465)", 

47 ) 

48 timeout: int = Field( 

49 default=DEFAULT_SMTP_TIMEOUT, 

50 ge=1, 

51 description="Connection timeout (s)", 

52 ) 

53 

54 @field_validator("password") 

55 @classmethod 

56 def _coerce_password(cls, value: Any) -> Any: 

57 if value is None or isinstance(value, SecretStr): 

58 return value 

59 return SecretStr(str(value)) 

60 

61 

62@dataclass(init=False) 

63class SendGridDriverConfig(DomainModel): 

64 """SendGrid API configuration.""" 

65 

66 api_key: SecretStr | None = Field( 

67 default=None, 

68 description="SendGrid API key", 

69 ) 

70 timeout: int = Field( 

71 default=DEFAULT_SENDGRID_TIMEOUT, 

72 ge=1, 

73 description="HTTP timeout (s)", 

74 ) 

75 sandbox_mode: bool = Field( 

76 default=False, 

77 description="Sandbox mode — emails not sent", 

78 ) 

79 

80 @field_validator("api_key") 

81 @classmethod 

82 def _coerce_api_key(cls, value: Any) -> Any: 

83 if value is None or isinstance(value, SecretStr): 

84 return value 

85 return SecretStr(str(value)) 

86 

87 

88@dataclass(init=False) 

89class NamedMailerConfig(DomainModel): 

90 """Configuration for a single named mailer backend. 

91 

92 Used in MailerConfig.backends to declare multiple mailer backends 

93 that the framework registers as named DI bindings. 

94 

95 Example: 

96 backends: 

97 - name: transactional 

98 driver: sendgrid 

99 primary: true 

100 sendgrid: 

101 api_key: sg_... 

102 - name: internal 

103 driver: smtp 

104 smtp: 

105 host: smtp.example.com 

106 port: 587 

107 

108 Args: 

109 name: Unique backend identifier. Used as the Named() DI key. 

110 primary: Whether this is the primary backend. Primary backends 

111 also receive the unnamed MailerProtocol binding. 

112 driver: Mailer driver. One of 'smtp' or 'sendgrid'. 

113 from_email: Default sender email address. 

114 from_name: Default sender display name. 

115 smtp: SMTP-specific connection config. 

116 sendgrid: SendGrid-specific config. 

117 """ 

118 

119 name: str = Field(..., description="Unique backend name used as the Named() DI key") 

120 primary: bool = Field( 

121 default=False, 

122 description="Also register under unnamed MailerProtocol binding", 

123 ) 

124 driver: str = Field(default="smtp", description="Mailer driver name") 

125 from_email: str | None = Field( 

126 default=None, 

127 description="Default sender email address", 

128 ) 

129 from_name: str | None = Field( 

130 default=None, 

131 description="Default sender display name", 

132 ) 

133 smtp: SMTPDriverConfig | None = Field( 

134 default=None, 

135 description="SMTP driver config", 

136 ) 

137 sendgrid: SendGridDriverConfig | None = Field( 

138 default=None, 

139 description="SendGrid driver config", 

140 ) 

141 

142 

143@dataclass(init=False) 

144class MailerConfig(BaseConfig): 

145 """Top-level mailer configuration. 

146 

147 Loaded from the ``mailer:`` key in application.yaml, with environment 

148 variable overrides via ``LEX_NOTIFICATION__MAILER__*`` prefix. 

149 """ 

150 

151 model_config: ClassVar[ConfigDict] = ConfigDict( # type: ignore[typeddict-unknown-key] 

152 env_prefix="LEX_NOTIFICATION__MAILER__", 

153 env_nested_delimiter="__", 

154 extra="ignore", 

155 ) 

156 

157 backends: list[NamedMailerConfig] = Field( 

158 default_factory=list, 

159 description=( 

160 "Named mailer backends for multi-backend support. " 

161 "When non-empty, the provider registers each backend under " 

162 "Annotated[MailerProtocol, Named(entry.name)]. " 

163 "The first entry (or the one with primary=True) also receives " 

164 "the unnamed MailerProtocol binding for backward compatibility." 

165 ), 

166 ) 

167 console_fallback: bool = Field( 

168 default=True, 

169 description=( 

170 "When no backends are configured, bind a ConsoleMailer as the " 

171 "default MailerProtocol so emails are logged to the application " 

172 "console instead of being silently dropped. Set to False to " 

173 "render email sending unavailable (MailerProtocol unbindable)." 

174 ), 

175 ) 

176 retry_max_attempts: int = Field( 

177 default=0, 

178 ge=0, 

179 description=( 

180 "When > 0, wrap the default MailerProtocol in RetryingMailer with " 

181 "this many attempts, persisting delivery state so transient SMTP " 

182 "failures are retried instead of dropped." 

183 ), 

184 ) 

185 retry_base_delay: float = Field( 

186 default=60.0, 

187 description="Base delay in seconds for the exponential backoff.", 

188 ) 

189 

190 @classmethod 

191 def from_named(cls, entry: NamedMailerConfig) -> MailerConfig: 

192 """Build a single-backend MailerConfig from a NamedMailerConfig entry. 

193 

194 Used internally by MailerProvider to create per-backend configs 

195 from a multi-backend declaration. 

196 

197 Args: 

198 entry: The named backend entry to materialise. 

199 

200 Returns: 

201 A MailerConfig configured for the single named backend. 

202 """ 

203 return cls(backends=[entry]) 

204 

205 

206__all__ = [ 

207 "MailerConfig", 

208 "NamedMailerConfig", 

209 "SMTPDriverConfig", 

210 "SendGridDriverConfig", 

211]