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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 07:17 +0800
1"""Mailer driver configuration."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Any, ClassVar
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
18@dataclass(init=False)
19class SMTPDriverConfig(DomainModel):
20 """SMTP-specific connection configuration."""
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 )
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))
62@dataclass(init=False)
63class SendGridDriverConfig(DomainModel):
64 """SendGrid API configuration."""
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 )
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))
88@dataclass(init=False)
89class NamedMailerConfig(DomainModel):
90 """Configuration for a single named mailer backend.
92 Used in MailerConfig.backends to declare multiple mailer backends
93 that the framework registers as named DI bindings.
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
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 """
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 )
143@dataclass(init=False)
144class MailerConfig(BaseConfig):
145 """Top-level mailer configuration.
147 Loaded from the ``mailer:`` key in application.yaml, with environment
148 variable overrides via ``LEX_NOTIFICATION__MAILER__*`` prefix.
149 """
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 )
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 )
190 @classmethod
191 def from_named(cls, entry: NamedMailerConfig) -> MailerConfig:
192 """Build a single-backend MailerConfig from a NamedMailerConfig entry.
194 Used internally by MailerProvider to create per-backend configs
195 from a multi-backend declaration.
197 Args:
198 entry: The named backend entry to materialise.
200 Returns:
201 A MailerConfig configured for the single named backend.
202 """
203 return cls(backends=[entry])
206__all__ = [
207 "MailerConfig",
208 "NamedMailerConfig",
209 "SMTPDriverConfig",
210 "SendGridDriverConfig",
211]