msmtp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msmtp/__init__.py +119 -0
- msmtp/circuit_breaker.py +276 -0
- msmtp/connection_pool.py +302 -0
- msmtp/exceptions.py +93 -0
- msmtp/rate_limiter.py +252 -0
- msmtp/retry_queue.py +333 -0
- msmtp/sender.py +486 -0
- msmtp/types.py +181 -0
- msmtp/validation.py +154 -0
- msmtp-1.0.0.dist-info/METADATA +392 -0
- msmtp-1.0.0.dist-info/RECORD +14 -0
- msmtp-1.0.0.dist-info/WHEEL +5 -0
- msmtp-1.0.0.dist-info/licenses/LICENSE +21 -0
- msmtp-1.0.0.dist-info/top_level.txt +1 -0
msmtp/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Mercury SMTP - Production-grade async SMTP sender.
|
|
2
|
+
|
|
3
|
+
A high-performance async SMTP library with connection pooling, circuit breakers,
|
|
4
|
+
rate limiting, and automatic retry for transient failures.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> import asyncio
|
|
8
|
+
>>> from msmtp import AsyncSMTPSender, SMTPServerConfig
|
|
9
|
+
>>>
|
|
10
|
+
>>> server = SMTPServerConfig(
|
|
11
|
+
... host="smtp.gmail.com",
|
|
12
|
+
... port=587,
|
|
13
|
+
... username="user@gmail.com",
|
|
14
|
+
... password="app-password",
|
|
15
|
+
... use_tls=True,
|
|
16
|
+
... )
|
|
17
|
+
>>>
|
|
18
|
+
>>> async def send():
|
|
19
|
+
... async with AsyncSMTPSender([server]) as sender:
|
|
20
|
+
... result = await sender.send(
|
|
21
|
+
... from_addr="sender@example.com",
|
|
22
|
+
... to_addrs=["recipient@example.com"],
|
|
23
|
+
... subject="Hello",
|
|
24
|
+
... body_text="Hello World",
|
|
25
|
+
... )
|
|
26
|
+
... print(f"Sent: {result.success}")
|
|
27
|
+
>>>
|
|
28
|
+
>>> asyncio.run(send())
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .circuit_breaker import (
|
|
32
|
+
CircuitBreaker,
|
|
33
|
+
CircuitBreakerConfig,
|
|
34
|
+
CircuitBreakerStats,
|
|
35
|
+
CircuitState,
|
|
36
|
+
)
|
|
37
|
+
from .connection_pool import (
|
|
38
|
+
AsyncConnectionPool,
|
|
39
|
+
ConnectionPoolException,
|
|
40
|
+
SMTPConnectionPool,
|
|
41
|
+
SMTPServerRuntime,
|
|
42
|
+
)
|
|
43
|
+
from .exceptions import (
|
|
44
|
+
MercurySMTPError,
|
|
45
|
+
SMTPAuthenticationError,
|
|
46
|
+
SMTPConnectionError,
|
|
47
|
+
SMTPRateLimitError,
|
|
48
|
+
SMTPSendError,
|
|
49
|
+
)
|
|
50
|
+
from .rate_limiter import (
|
|
51
|
+
RateLimiter,
|
|
52
|
+
RateLimiterConfig,
|
|
53
|
+
TokenBucket,
|
|
54
|
+
)
|
|
55
|
+
from .retry_queue import (
|
|
56
|
+
RetryConfig,
|
|
57
|
+
RetryItem,
|
|
58
|
+
RetryQueue,
|
|
59
|
+
RetryStatus,
|
|
60
|
+
)
|
|
61
|
+
from .sender import (
|
|
62
|
+
AsyncSMTPSender,
|
|
63
|
+
LoadBalancingStrategy,
|
|
64
|
+
)
|
|
65
|
+
from .types import (
|
|
66
|
+
BulkSendResult,
|
|
67
|
+
EmailResult,
|
|
68
|
+
SMTPServerConfig,
|
|
69
|
+
)
|
|
70
|
+
from .types import (
|
|
71
|
+
SMTPServerConfig as ServerConfig, # Alias for backwards compat
|
|
72
|
+
)
|
|
73
|
+
from .validation import (
|
|
74
|
+
sanitize_header_value,
|
|
75
|
+
sanitize_subject,
|
|
76
|
+
validate_email_address,
|
|
77
|
+
validate_email_list,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__version__ = "1.0.0"
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
# Core sender
|
|
84
|
+
"AsyncSMTPSender",
|
|
85
|
+
"EmailResult",
|
|
86
|
+
"BulkSendResult",
|
|
87
|
+
"LoadBalancingStrategy",
|
|
88
|
+
# Configuration
|
|
89
|
+
"SMTPServerConfig",
|
|
90
|
+
"ServerConfig",
|
|
91
|
+
"CircuitBreakerConfig",
|
|
92
|
+
"RateLimiterConfig",
|
|
93
|
+
"RetryConfig",
|
|
94
|
+
# Components
|
|
95
|
+
"SMTPConnectionPool",
|
|
96
|
+
"AsyncConnectionPool",
|
|
97
|
+
"CircuitBreaker",
|
|
98
|
+
"CircuitBreakerStats",
|
|
99
|
+
"CircuitState",
|
|
100
|
+
"RateLimiter",
|
|
101
|
+
"TokenBucket",
|
|
102
|
+
"RetryQueue",
|
|
103
|
+
"RetryStatus",
|
|
104
|
+
"RetryItem",
|
|
105
|
+
# Runtime
|
|
106
|
+
"SMTPServerRuntime",
|
|
107
|
+
# Exceptions
|
|
108
|
+
"MercurySMTPError",
|
|
109
|
+
"SMTPAuthenticationError",
|
|
110
|
+
"SMTPConnectionError",
|
|
111
|
+
"SMTPRateLimitError",
|
|
112
|
+
"SMTPSendError",
|
|
113
|
+
"ConnectionPoolException",
|
|
114
|
+
# Validation
|
|
115
|
+
"validate_email_address",
|
|
116
|
+
"validate_email_list",
|
|
117
|
+
"sanitize_subject",
|
|
118
|
+
"sanitize_header_value",
|
|
119
|
+
]
|
msmtp/circuit_breaker.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Circuit breaker pattern for SMTP servers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CircuitState(Enum):
|
|
13
|
+
"""Circuit breaker states."""
|
|
14
|
+
|
|
15
|
+
CLOSED = "closed" # Normal operation
|
|
16
|
+
OPEN = "open" # Too many failures, stop trying
|
|
17
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CircuitBreakerConfig:
|
|
22
|
+
"""Configuration for circuit breaker."""
|
|
23
|
+
|
|
24
|
+
failure_threshold: int = 5 # Open circuit after N failures
|
|
25
|
+
success_threshold: int = 2 # Close circuit after N successes in half-open
|
|
26
|
+
timeout_seconds: int = 60 # Time to wait before trying half-open
|
|
27
|
+
monitor_window_seconds: int = 300 # Rolling window for failure counting
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CircuitBreakerStats:
|
|
32
|
+
"""Statistics for circuit breaker."""
|
|
33
|
+
|
|
34
|
+
state: CircuitState
|
|
35
|
+
failure_count: int = 0
|
|
36
|
+
success_count: int = 0
|
|
37
|
+
last_failure_time: datetime | None = None
|
|
38
|
+
last_success_time: datetime | None = None
|
|
39
|
+
opened_at: datetime | None = None
|
|
40
|
+
total_opens: int = 0
|
|
41
|
+
total_trips: int = 0 # Total state changes
|
|
42
|
+
# Most-recent failure messages (kept small — last 5). The root cause
|
|
43
|
+
# of a circuit-open used to be invisible: the breaker would log "5
|
|
44
|
+
# failures in 300s" and operators had to dig through the per-recipient
|
|
45
|
+
# log to find that all 5 were the same iCloud 5.7.0 reject. Keeping
|
|
46
|
+
# the last few errors on the stats lets us include them in the
|
|
47
|
+
# OPENING log line AND in the "No SMTP servers available" cascade
|
|
48
|
+
# error so the cause is visible without log archaeology.
|
|
49
|
+
last_error_messages: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"state": self.state.value,
|
|
54
|
+
"failure_count": self.failure_count,
|
|
55
|
+
"success_count": self.success_count,
|
|
56
|
+
"last_failure_time": self.last_failure_time.isoformat()
|
|
57
|
+
if self.last_failure_time
|
|
58
|
+
else None,
|
|
59
|
+
"last_success_time": self.last_success_time.isoformat()
|
|
60
|
+
if self.last_success_time
|
|
61
|
+
else None,
|
|
62
|
+
"opened_at": self.opened_at.isoformat() if self.opened_at else None,
|
|
63
|
+
"total_opens": self.total_opens,
|
|
64
|
+
"total_trips": self.total_trips,
|
|
65
|
+
"last_error_messages": list(self.last_error_messages),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CircuitBreaker:
|
|
70
|
+
"""
|
|
71
|
+
Circuit breaker for SMTP servers.
|
|
72
|
+
|
|
73
|
+
Prevents repeated attempts to failing servers by temporarily
|
|
74
|
+
disabling them after consecutive failures.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, server_name: str, config: CircuitBreakerConfig | None = None):
|
|
78
|
+
"""
|
|
79
|
+
Initialize circuit breaker.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
server_name: Name of SMTP server being protected
|
|
83
|
+
config: Circuit breaker configuration
|
|
84
|
+
"""
|
|
85
|
+
self.server_name = server_name
|
|
86
|
+
self.config = config or CircuitBreakerConfig()
|
|
87
|
+
self._stats = CircuitBreakerStats(state=CircuitState.CLOSED)
|
|
88
|
+
|
|
89
|
+
# Rolling window for failure tracking
|
|
90
|
+
self._recent_failures: list[datetime] = []
|
|
91
|
+
|
|
92
|
+
def is_available(self) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
Check if circuit allows operations.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if circuit is closed or half-open
|
|
98
|
+
"""
|
|
99
|
+
current_state = self._get_current_state()
|
|
100
|
+
|
|
101
|
+
if current_state == CircuitState.OPEN:
|
|
102
|
+
logger.warning(
|
|
103
|
+
"circuit_breaker_open",
|
|
104
|
+
extra={
|
|
105
|
+
"server": self.server_name,
|
|
106
|
+
"state": current_state.value,
|
|
107
|
+
"failure_count": self._stats.failure_count,
|
|
108
|
+
"last_errors": self._stats.last_error_messages[:3],
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def _get_current_state(self) -> CircuitState:
|
|
116
|
+
"""
|
|
117
|
+
Get current circuit state, handling automatic transitions.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Current circuit state
|
|
121
|
+
"""
|
|
122
|
+
# If closed, stay closed
|
|
123
|
+
if self._stats.state == CircuitState.CLOSED:
|
|
124
|
+
return CircuitState.CLOSED
|
|
125
|
+
|
|
126
|
+
# If open, check if timeout elapsed
|
|
127
|
+
if self._stats.state == CircuitState.OPEN:
|
|
128
|
+
if self._stats.opened_at:
|
|
129
|
+
elapsed = (datetime.now(timezone.utc) - self._stats.opened_at).total_seconds()
|
|
130
|
+
if elapsed >= self.config.timeout_seconds:
|
|
131
|
+
# Transition to half-open
|
|
132
|
+
logger.info(
|
|
133
|
+
f"🔄 Circuit breaker transitioning to HALF-OPEN for {self.server_name} "
|
|
134
|
+
f"(timeout elapsed: {elapsed:.1f}s)"
|
|
135
|
+
)
|
|
136
|
+
self._stats.state = CircuitState.HALF_OPEN
|
|
137
|
+
self._stats.success_count = 0
|
|
138
|
+
self._stats.total_trips += 1
|
|
139
|
+
return CircuitState.HALF_OPEN
|
|
140
|
+
return CircuitState.OPEN
|
|
141
|
+
|
|
142
|
+
# If half-open, stay half-open
|
|
143
|
+
return CircuitState.HALF_OPEN
|
|
144
|
+
|
|
145
|
+
def record_success(self) -> None:
|
|
146
|
+
"""Record successful operation."""
|
|
147
|
+
current_state = self._get_current_state()
|
|
148
|
+
now = datetime.now(timezone.utc)
|
|
149
|
+
|
|
150
|
+
self._stats.last_success_time = now
|
|
151
|
+
|
|
152
|
+
if current_state == CircuitState.HALF_OPEN:
|
|
153
|
+
self._stats.success_count += 1
|
|
154
|
+
|
|
155
|
+
# Close circuit if enough successes
|
|
156
|
+
if self._stats.success_count >= self.config.success_threshold:
|
|
157
|
+
logger.info(
|
|
158
|
+
"circuit_breaker_closed",
|
|
159
|
+
extra={
|
|
160
|
+
"server": self.server_name,
|
|
161
|
+
"success_count": self._stats.success_count,
|
|
162
|
+
"success_threshold": self.config.success_threshold,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
self._stats.state = CircuitState.CLOSED
|
|
166
|
+
self._stats.failure_count = 0
|
|
167
|
+
self._stats.success_count = 0
|
|
168
|
+
self._recent_failures.clear()
|
|
169
|
+
self._stats.total_trips += 1
|
|
170
|
+
|
|
171
|
+
elif current_state == CircuitState.CLOSED:
|
|
172
|
+
# Reset failure counter on success
|
|
173
|
+
if self._stats.failure_count > 0:
|
|
174
|
+
self._stats.failure_count = 0
|
|
175
|
+
self._recent_failures.clear()
|
|
176
|
+
|
|
177
|
+
def record_failure(self, error: Exception) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Record failed operation.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
error: Exception that occurred
|
|
183
|
+
"""
|
|
184
|
+
current_state = self._get_current_state()
|
|
185
|
+
now = datetime.now(timezone.utc)
|
|
186
|
+
|
|
187
|
+
self._stats.last_failure_time = now
|
|
188
|
+
self._stats.failure_count += 1
|
|
189
|
+
self._recent_failures.append(now)
|
|
190
|
+
|
|
191
|
+
# Capture the error text on the stats (cap at 5 entries, FIFO).
|
|
192
|
+
# The actual diagnostic value is the unique-message set — repeated
|
|
193
|
+
# identical 5.7.0 rejects from iCloud occupy all 5 slots and tell
|
|
194
|
+
# the operator nothing they don't already know from one. So we
|
|
195
|
+
# dedupe by message text before appending.
|
|
196
|
+
msg = f"{type(error).__name__}: {str(error)[:200]}"
|
|
197
|
+
if msg not in self._stats.last_error_messages:
|
|
198
|
+
self._stats.last_error_messages.append(msg)
|
|
199
|
+
if len(self._stats.last_error_messages) > 5:
|
|
200
|
+
self._stats.last_error_messages.pop(0)
|
|
201
|
+
|
|
202
|
+
# Clean old failures outside monitoring window
|
|
203
|
+
cutoff = now - timedelta(seconds=self.config.monitor_window_seconds)
|
|
204
|
+
self._recent_failures = [f for f in self._recent_failures if f > cutoff]
|
|
205
|
+
|
|
206
|
+
# Check if we should open the circuit
|
|
207
|
+
if current_state == CircuitState.CLOSED:
|
|
208
|
+
if len(self._recent_failures) >= self.config.failure_threshold:
|
|
209
|
+
# Loud-log the root cause(s) on the same line as the OPEN
|
|
210
|
+
# event. The previous log just said "5 failures in 300s"
|
|
211
|
+
# with no hint of what kind of failures — operators had
|
|
212
|
+
# to grep failed-emails.txt to find that all 5 were the
|
|
213
|
+
# same iCloud 5.7.0 reject. Now the cause is visible
|
|
214
|
+
# inline.
|
|
215
|
+
causes = " | ".join(self._stats.last_error_messages) or "(no error captured)"
|
|
216
|
+
logger.error(
|
|
217
|
+
"⚠️ Circuit breaker OPENING for %s "
|
|
218
|
+
"(%d failures in %ds). Recent unique errors: %s",
|
|
219
|
+
self.server_name,
|
|
220
|
+
len(self._recent_failures),
|
|
221
|
+
self.config.monitor_window_seconds,
|
|
222
|
+
causes,
|
|
223
|
+
)
|
|
224
|
+
self._stats.state = CircuitState.OPEN
|
|
225
|
+
self._stats.opened_at = now
|
|
226
|
+
self._stats.total_opens += 1
|
|
227
|
+
self._stats.total_trips += 1
|
|
228
|
+
|
|
229
|
+
elif current_state == CircuitState.HALF_OPEN:
|
|
230
|
+
# Any failure in half-open immediately opens circuit
|
|
231
|
+
logger.warning(
|
|
232
|
+
"⚠️ Circuit breaker RE-OPENING for %s (failure during half-open state): %s",
|
|
233
|
+
self.server_name,
|
|
234
|
+
msg,
|
|
235
|
+
)
|
|
236
|
+
self._stats.state = CircuitState.OPEN
|
|
237
|
+
self._stats.opened_at = now
|
|
238
|
+
self._stats.success_count = 0
|
|
239
|
+
self._stats.total_opens += 1
|
|
240
|
+
self._stats.total_trips += 1
|
|
241
|
+
|
|
242
|
+
def force_open(self) -> None:
|
|
243
|
+
"""Manually open circuit (for maintenance, etc.)."""
|
|
244
|
+
logger.warning(f"🔒 Manually opening circuit for {self.server_name}")
|
|
245
|
+
self._stats.state = CircuitState.OPEN
|
|
246
|
+
self._stats.opened_at = datetime.now(timezone.utc)
|
|
247
|
+
self._stats.total_opens += 1
|
|
248
|
+
|
|
249
|
+
def force_close(self) -> None:
|
|
250
|
+
"""Manually close circuit (override)."""
|
|
251
|
+
logger.info(f"🔓 Manually closing circuit for {self.server_name}")
|
|
252
|
+
self._stats.state = CircuitState.CLOSED
|
|
253
|
+
self._stats.failure_count = 0
|
|
254
|
+
self._stats.success_count = 0
|
|
255
|
+
self._recent_failures.clear()
|
|
256
|
+
|
|
257
|
+
def get_stats(self) -> dict[str, Any]:
|
|
258
|
+
"""Get circuit breaker statistics."""
|
|
259
|
+
current_state = self._get_current_state()
|
|
260
|
+
|
|
261
|
+
stats = self._stats.to_dict()
|
|
262
|
+
stats["state"] = current_state.value # Get current state
|
|
263
|
+
stats["recent_failures"] = len(self._recent_failures)
|
|
264
|
+
stats["is_available"] = current_state != CircuitState.OPEN
|
|
265
|
+
|
|
266
|
+
if self._stats.opened_at and current_state == CircuitState.OPEN:
|
|
267
|
+
elapsed = (datetime.now(timezone.utc) - self._stats.opened_at).total_seconds()
|
|
268
|
+
stats["seconds_until_half_open"] = max(0, self.config.timeout_seconds - elapsed)
|
|
269
|
+
|
|
270
|
+
return stats
|
|
271
|
+
|
|
272
|
+
def reset(self) -> None:
|
|
273
|
+
"""Reset circuit breaker to initial state."""
|
|
274
|
+
logger.info(f"🔄 Resetting circuit breaker for {self.server_name}")
|
|
275
|
+
self._stats = CircuitBreakerStats(state=CircuitState.CLOSED)
|
|
276
|
+
self._recent_failures.clear()
|
msmtp/connection_pool.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""SMTP connection pooling with circuit breaker and load balancing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import aiosmtplib
|
|
11
|
+
|
|
12
|
+
from .circuit_breaker import CircuitBreaker, CircuitBreakerConfig
|
|
13
|
+
from .exceptions import SMTPAuthenticationError, SMTPConnectionError
|
|
14
|
+
from .types import SMTPServerConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConnectionPoolException(Exception):
|
|
20
|
+
"""Errors related to connection pool operations."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _create_circuit_breaker(
|
|
26
|
+
server_name: str = "default",
|
|
27
|
+
*,
|
|
28
|
+
failure_threshold: int = 5,
|
|
29
|
+
success_threshold: int = 3,
|
|
30
|
+
timeout_seconds: int = 60,
|
|
31
|
+
monitor_window_seconds: int = 300,
|
|
32
|
+
) -> CircuitBreaker:
|
|
33
|
+
"""Factory function to create a circuit breaker."""
|
|
34
|
+
return CircuitBreaker(
|
|
35
|
+
server_name=server_name,
|
|
36
|
+
config=CircuitBreakerConfig(
|
|
37
|
+
failure_threshold=failure_threshold,
|
|
38
|
+
success_threshold=success_threshold,
|
|
39
|
+
timeout_seconds=timeout_seconds,
|
|
40
|
+
monitor_window_seconds=monitor_window_seconds,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SMTPServerRuntime:
|
|
47
|
+
"""Per-process mutable runtime state for an SMTP server."""
|
|
48
|
+
|
|
49
|
+
circuit_breaker: CircuitBreaker
|
|
50
|
+
current_minute_count: int = 0
|
|
51
|
+
current_hour_count: int = 0
|
|
52
|
+
total_sent: int = 0
|
|
53
|
+
total_failures: int = 0
|
|
54
|
+
consecutive_failures: int = 0
|
|
55
|
+
last_minute_reset: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
56
|
+
last_hour_reset: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
57
|
+
handshake_latencies: list[float] = field(default_factory=list)
|
|
58
|
+
send_latencies: list[float] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def avg_handshake_latency(self) -> float | None:
|
|
62
|
+
"""Get average connection handshake latency in seconds."""
|
|
63
|
+
if not self.handshake_latencies:
|
|
64
|
+
return None
|
|
65
|
+
return sum(self.handshake_latencies) / len(self.handshake_latencies)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def avg_send_latency(self) -> float | None:
|
|
69
|
+
"""Get average mail sending latency in seconds."""
|
|
70
|
+
if not self.send_latencies:
|
|
71
|
+
return None
|
|
72
|
+
return sum(self.send_latencies) / len(self.send_latencies)
|
|
73
|
+
|
|
74
|
+
def record_handshake(self, seconds: float) -> None:
|
|
75
|
+
"""Record a connection handshake latency measurement."""
|
|
76
|
+
self.handshake_latencies.append(seconds)
|
|
77
|
+
if len(self.handshake_latencies) > 50:
|
|
78
|
+
self.handshake_latencies.pop(0)
|
|
79
|
+
|
|
80
|
+
def record_send(self, seconds: float) -> None:
|
|
81
|
+
"""Record a mail sending latency measurement."""
|
|
82
|
+
self.send_latencies.append(seconds)
|
|
83
|
+
if len(self.send_latencies) > 50:
|
|
84
|
+
self.send_latencies.pop(0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SMTPConnectionPool:
|
|
88
|
+
"""
|
|
89
|
+
SMTP connection pool with health checks and circuit breaker.
|
|
90
|
+
|
|
91
|
+
Maintains a pool of persistent SMTP connections to reduce handshake overhead.
|
|
92
|
+
Includes health checks and automatic connection recycling.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
server: SMTPServerConfig,
|
|
98
|
+
max_connections: int = 10,
|
|
99
|
+
health_check_interval: int = 60,
|
|
100
|
+
max_idle_time: int = 300,
|
|
101
|
+
):
|
|
102
|
+
"""
|
|
103
|
+
Initialize connection pool.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
server: SMTP server configuration
|
|
107
|
+
max_connections: Maximum pooled connections
|
|
108
|
+
health_check_interval: Seconds between health checks
|
|
109
|
+
max_idle_time: Recycle connection after this many idle seconds
|
|
110
|
+
"""
|
|
111
|
+
self.server = server
|
|
112
|
+
self.max_connections = max_connections
|
|
113
|
+
self.health_check_interval = health_check_interval
|
|
114
|
+
self.max_idle_time = max_idle_time
|
|
115
|
+
|
|
116
|
+
self._pool: list[aiosmtplib.SMTP] = []
|
|
117
|
+
self._in_use: set[aiosmtplib.SMTP] = set()
|
|
118
|
+
self._lock = asyncio.Lock()
|
|
119
|
+
self._last_health_check = time.monotonic()
|
|
120
|
+
|
|
121
|
+
# Runtime state
|
|
122
|
+
self.runtime = SMTPServerRuntime(
|
|
123
|
+
circuit_breaker=_create_circuit_breaker(server.name or server.host)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def acquire(self) -> aiosmtplib.SMTP:
|
|
127
|
+
"""
|
|
128
|
+
Acquire a connection from the pool.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
SMTP connection
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ConnectionPoolException: If no connections available
|
|
135
|
+
"""
|
|
136
|
+
async with self._lock:
|
|
137
|
+
# Try to get from pool
|
|
138
|
+
while self._pool:
|
|
139
|
+
conn = self._pool.pop()
|
|
140
|
+
if await self._is_healthy(conn):
|
|
141
|
+
self._in_use.add(conn)
|
|
142
|
+
return conn
|
|
143
|
+
# Unhealthy, close it
|
|
144
|
+
try:
|
|
145
|
+
await conn.quit()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# Create new connection if under limit
|
|
150
|
+
if len(self._in_use) < self.max_connections:
|
|
151
|
+
conn = await self._create_connection()
|
|
152
|
+
self._in_use.add(conn)
|
|
153
|
+
return conn
|
|
154
|
+
|
|
155
|
+
raise ConnectionPoolException("No connections available")
|
|
156
|
+
|
|
157
|
+
async def release(self, conn: aiosmtplib.SMTP) -> None:
|
|
158
|
+
"""Release a connection back to the pool."""
|
|
159
|
+
async with self._lock:
|
|
160
|
+
if conn in self._in_use:
|
|
161
|
+
self._in_use.remove(conn)
|
|
162
|
+
if await self._is_healthy(conn):
|
|
163
|
+
self._pool.append(conn)
|
|
164
|
+
else:
|
|
165
|
+
try:
|
|
166
|
+
await conn.quit()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
async def _create_connection(self) -> aiosmtplib.SMTP:
|
|
171
|
+
"""Create a new SMTP connection with SSL verification."""
|
|
172
|
+
start = time.perf_counter()
|
|
173
|
+
|
|
174
|
+
# Prepare SSL context
|
|
175
|
+
tls_context = self.server.ssl_context
|
|
176
|
+
if tls_context is None and (self.server.use_tls or self.server.use_ssl):
|
|
177
|
+
tls_context = ssl.create_default_context()
|
|
178
|
+
|
|
179
|
+
# Configure SSL verification
|
|
180
|
+
if not self.server.verify_ssl:
|
|
181
|
+
tls_context.check_hostname = False
|
|
182
|
+
tls_context.verify_mode = ssl.CERT_NONE
|
|
183
|
+
logger.warning(
|
|
184
|
+
"ssl_verification_disabled",
|
|
185
|
+
extra={
|
|
186
|
+
"server": self.server.name,
|
|
187
|
+
"host": self.server.host,
|
|
188
|
+
"port": self.server.port,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
smtp = aiosmtplib.SMTP(
|
|
194
|
+
hostname=self.server.host,
|
|
195
|
+
port=self.server.port,
|
|
196
|
+
timeout=self.server.timeout,
|
|
197
|
+
use_tls=self.server.use_ssl,
|
|
198
|
+
tls_context=tls_context,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
await smtp.connect()
|
|
202
|
+
|
|
203
|
+
if self.server.use_tls and not self.server.use_ssl:
|
|
204
|
+
await smtp.starttls(tls_context=tls_context)
|
|
205
|
+
|
|
206
|
+
if self.server.username:
|
|
207
|
+
password = self.server.get_password()
|
|
208
|
+
await smtp.login(self.server.username, password)
|
|
209
|
+
|
|
210
|
+
# Record handshake latency
|
|
211
|
+
latency = time.perf_counter() - start
|
|
212
|
+
self.runtime.record_handshake(latency)
|
|
213
|
+
|
|
214
|
+
logger.info(
|
|
215
|
+
"smtp_connection_created",
|
|
216
|
+
extra={
|
|
217
|
+
"server": self.server.name,
|
|
218
|
+
"host": self.server.host,
|
|
219
|
+
"port": self.server.port,
|
|
220
|
+
"handshake_ms": latency * 1000,
|
|
221
|
+
"use_tls": self.server.use_tls,
|
|
222
|
+
"use_ssl": self.server.use_ssl,
|
|
223
|
+
"verify_ssl": self.server.verify_ssl,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return smtp
|
|
228
|
+
|
|
229
|
+
except aiosmtplib.SMTPAuthenticationError as e:
|
|
230
|
+
logger.error(
|
|
231
|
+
"smtp_auth_failed",
|
|
232
|
+
extra={
|
|
233
|
+
"server": self.server.name,
|
|
234
|
+
"host": self.server.host,
|
|
235
|
+
"username": self.server.username,
|
|
236
|
+
"error": str(e),
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
raise SMTPAuthenticationError(f"Authentication failed: {e}") from e
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(
|
|
242
|
+
"smtp_connection_failed",
|
|
243
|
+
extra={
|
|
244
|
+
"server": self.server.name,
|
|
245
|
+
"host": self.server.host,
|
|
246
|
+
"port": self.server.port,
|
|
247
|
+
"error_type": type(e).__name__,
|
|
248
|
+
"error": str(e)[:200],
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
raise SMTPConnectionError(f"Connection failed: {e}") from e
|
|
252
|
+
|
|
253
|
+
async def _is_healthy(self, conn: aiosmtplib.SMTP) -> bool:
|
|
254
|
+
"""Check if connection is healthy."""
|
|
255
|
+
try:
|
|
256
|
+
# Try NOOP command
|
|
257
|
+
await asyncio.wait_for(conn.noop(), timeout=5.0)
|
|
258
|
+
return True
|
|
259
|
+
except Exception:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
async def close_all(self) -> None:
|
|
263
|
+
"""Close all connections in the pool."""
|
|
264
|
+
logger.info(
|
|
265
|
+
"closing_connection_pool",
|
|
266
|
+
extra={
|
|
267
|
+
"server": self.server.name,
|
|
268
|
+
"pooled_connections": len(self._pool),
|
|
269
|
+
"in_use_connections": len(self._in_use),
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
async with self._lock:
|
|
274
|
+
for conn in self._pool:
|
|
275
|
+
try:
|
|
276
|
+
await conn.quit()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.debug("connection_close_error", extra={"error": str(e)})
|
|
279
|
+
self._pool.clear()
|
|
280
|
+
|
|
281
|
+
for conn in self._in_use:
|
|
282
|
+
try:
|
|
283
|
+
await conn.quit()
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.debug("connection_close_error", extra={"error": str(e)})
|
|
286
|
+
self._in_use.clear()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class AsyncConnectionPool:
|
|
290
|
+
"""Async context manager wrapper for connection pool."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, pool: SMTPConnectionPool):
|
|
293
|
+
self.pool = pool
|
|
294
|
+
self._conn: aiosmtplib.SMTP | None = None
|
|
295
|
+
|
|
296
|
+
async def __aenter__(self) -> aiosmtplib.SMTP:
|
|
297
|
+
self._conn = await self.pool.acquire()
|
|
298
|
+
return self._conn
|
|
299
|
+
|
|
300
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb): # type: ignore[no-untyped-def]
|
|
301
|
+
if self._conn:
|
|
302
|
+
await self.pool.release(self._conn)
|