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 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
+ ]
@@ -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()
@@ -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)