arbiter-smtp 0.9.0.dev1__tar.gz → 0.9.1.dev2__tar.gz
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.
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/PKG-INFO +2 -2
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/pyproject.toml +6 -6
- {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/__init__.py +431 -10
- arbiter_smtp-0.9.1.dev2/src/arbiter_smtp/client.py +66 -0
- {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/config.py +25 -2
- {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/idempotency.py +31 -0
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/PKG-INFO +2 -2
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/SOURCES.txt +5 -5
- arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/entry_points.txt +2 -0
- arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/requires.txt +2 -0
- arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/top_level.txt +1 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/client.py +0 -55
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/entry_points.txt +0 -2
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/requires.txt +0 -2
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/top_level.txt +0 -1
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/setup.cfg +0 -0
- {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/py.typed +0 -0
- {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/dependency_links.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arbiter-smtp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1.dev2
|
|
4
4
|
Summary: SMTP service plugin for Arbiter
|
|
5
5
|
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
6
|
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
20
20
|
Classifier: Topic :: Communications :: Email
|
|
21
21
|
Requires-Python: <3.15,>=3.10
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
|
-
Requires-Dist: arbiter-
|
|
23
|
+
Requires-Dist: arbiter-server<0.10.0,>=0.9.1.dev2
|
|
24
24
|
Requires-Dist: diskcache<6.0,>=5.6
|
|
25
25
|
|
|
26
26
|
SMTP service plugin for Arbiter.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arbiter-smtp"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.1.dev2"
|
|
8
8
|
description = "SMTP service plugin for Arbiter"
|
|
9
9
|
readme = { text = "SMTP service plugin for Arbiter.", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.10,<3.15"
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
"Topic :: Communications :: Email",
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
|
-
"arbiter-
|
|
32
|
+
"arbiter-server>=0.9.1.dev2,<0.10.0",
|
|
33
33
|
"diskcache>=5.6,<6.0",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -37,8 +37,8 @@ dependencies = [
|
|
|
37
37
|
Homepage = "https://github.com/omry/arbiter"
|
|
38
38
|
Repository = "https://github.com/omry/arbiter"
|
|
39
39
|
|
|
40
|
-
[project.entry-points."
|
|
41
|
-
smtp = "
|
|
40
|
+
[project.entry-points."arbiter.services"]
|
|
41
|
+
smtp = "arbiter_smtp:plugin"
|
|
42
42
|
|
|
43
43
|
[tool.setuptools]
|
|
44
44
|
package-dir = {"" = "src"}
|
|
@@ -47,11 +47,11 @@ package-dir = {"" = "src"}
|
|
|
47
47
|
where = ["src"]
|
|
48
48
|
|
|
49
49
|
[tool.setuptools.package-data]
|
|
50
|
-
"
|
|
50
|
+
"arbiter_smtp" = ["py.typed"]
|
|
51
51
|
|
|
52
52
|
[tool.towncrier]
|
|
53
53
|
name = "Arbiter SMTP"
|
|
54
|
-
package = "
|
|
54
|
+
package = "arbiter_smtp"
|
|
55
55
|
package_dir = "src"
|
|
56
56
|
filename = "NEWS.md"
|
|
57
57
|
directory = "newsfragments"
|
|
@@ -2,23 +2,27 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import Mapping
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from email import policy as email_policy
|
|
5
7
|
from email.message import EmailMessage
|
|
6
|
-
from email.utils import formataddr, make_msgid
|
|
8
|
+
from email.utils import format_datetime, formataddr, make_msgid
|
|
7
9
|
import hashlib
|
|
8
10
|
import json
|
|
11
|
+
import secrets
|
|
9
12
|
import smtplib
|
|
10
13
|
from time import monotonic
|
|
11
14
|
from typing import Callable, Protocol, cast
|
|
12
15
|
|
|
13
16
|
from hydra.core.config_store import ConfigStore
|
|
14
17
|
|
|
15
|
-
from
|
|
18
|
+
from arbiter_server.services import (
|
|
16
19
|
CapabilityDescriptor,
|
|
17
20
|
OperationDescriptor,
|
|
18
21
|
ServicePluginContext,
|
|
19
22
|
ServiceRuntimeContext,
|
|
20
23
|
)
|
|
21
|
-
from
|
|
24
|
+
from arbiter_server.storage import PluginStorage
|
|
25
|
+
from arbiter_server.version import distribution_version
|
|
22
26
|
|
|
23
27
|
from .config import (
|
|
24
28
|
SMTPConfig,
|
|
@@ -27,7 +31,7 @@ from .config import (
|
|
|
27
31
|
)
|
|
28
32
|
from .idempotency import SMTPIdempotencyResult, SMTPIdempotencyStore
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
SERVER_API_VERSION = "0.9"
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
@dataclass(frozen=True)
|
|
@@ -35,18 +39,50 @@ class SendEmailResult:
|
|
|
35
39
|
tool: str
|
|
36
40
|
message_id: str
|
|
37
41
|
recipient_count: int
|
|
42
|
+
sent_copy: dict[str, object] | None = None
|
|
38
43
|
idempotency_replayed: bool = False
|
|
39
44
|
|
|
40
45
|
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SentCopyDestination:
|
|
48
|
+
account: str
|
|
49
|
+
folder: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SMTPSentCopyError(RuntimeError):
|
|
53
|
+
def __init__(self, message: str, *, result: SendEmailResult) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.result = result
|
|
56
|
+
|
|
57
|
+
|
|
41
58
|
class SMTPClientProtocol(Protocol):
|
|
59
|
+
def test_connection(self) -> None: ...
|
|
60
|
+
|
|
42
61
|
def send(
|
|
43
62
|
self,
|
|
44
|
-
|
|
63
|
+
message_bytes: bytes,
|
|
45
64
|
sender: str,
|
|
46
65
|
recipients: list[str],
|
|
47
66
|
) -> None: ...
|
|
48
67
|
|
|
49
68
|
|
|
69
|
+
class SentMessageAppender(Protocol):
|
|
70
|
+
def resolve_destination(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
account: str,
|
|
74
|
+
folder: str | None,
|
|
75
|
+
) -> SentCopyDestination: ...
|
|
76
|
+
|
|
77
|
+
def append_sent_message(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
account: str,
|
|
81
|
+
folder: str,
|
|
82
|
+
message_bytes: bytes,
|
|
83
|
+
) -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
50
86
|
SMTPClientFactory = Callable[[SMTPConfig], SMTPClientProtocol]
|
|
51
87
|
TimeProvider = Callable[[], float]
|
|
52
88
|
SMTPIdempotencyStoreFactory = Callable[[str], SMTPIdempotencyStore]
|
|
@@ -112,6 +148,8 @@ class SMTPRuntime:
|
|
|
112
148
|
smtp_client_factory: SMTPClientFactory,
|
|
113
149
|
time_provider: TimeProvider = monotonic,
|
|
114
150
|
idempotency_store_factory: SMTPIdempotencyStoreFactory = SMTPIdempotencyStore,
|
|
151
|
+
plugin_storage: PluginStorage | None = None,
|
|
152
|
+
sent_message_appender: SentMessageAppender | None = None,
|
|
115
153
|
) -> None:
|
|
116
154
|
self._accounts = cast(Mapping[str, SMTPConfig], accounts)
|
|
117
155
|
self._policies = cast(
|
|
@@ -121,14 +159,23 @@ class SMTPRuntime:
|
|
|
121
159
|
self._smtp_client_factory = smtp_client_factory
|
|
122
160
|
self._time_provider = time_provider
|
|
123
161
|
self._idempotency_store_factory = idempotency_store_factory
|
|
162
|
+
self._plugin_storage = plugin_storage
|
|
163
|
+
self._sent_message_appender = sent_message_appender
|
|
124
164
|
self._idempotency_stores: dict[str, SMTPIdempotencyStore] = {}
|
|
125
165
|
self._attempt_timestamps: dict[str, list[float]] = {}
|
|
126
166
|
self._validate_config()
|
|
127
167
|
|
|
168
|
+
def configure_sent_message_appender(
|
|
169
|
+
self,
|
|
170
|
+
sent_message_appender: SentMessageAppender,
|
|
171
|
+
) -> None:
|
|
172
|
+
self._sent_message_appender = sent_message_appender
|
|
173
|
+
|
|
128
174
|
def account_summaries(self) -> dict[str, object]:
|
|
129
175
|
return {
|
|
130
176
|
account_name: {
|
|
131
177
|
"description": account.description,
|
|
178
|
+
"guidance": account.guidance,
|
|
132
179
|
"policy": account.policy,
|
|
133
180
|
"enabled": True,
|
|
134
181
|
"send": "allowed",
|
|
@@ -139,6 +186,47 @@ class SMTPRuntime:
|
|
|
139
186
|
for account_name, account in sorted(self._accounts.items())
|
|
140
187
|
}
|
|
141
188
|
|
|
189
|
+
def test_accounts(self) -> dict[str, object]:
|
|
190
|
+
results: dict[str, object] = {}
|
|
191
|
+
for account_name, smtp_config in sorted(self._accounts.items()):
|
|
192
|
+
smtp_policy = self._policies[smtp_config.policy]
|
|
193
|
+
stage = "connect_auth_noop_idempotency"
|
|
194
|
+
strict_sent_copy = self._sent_copy_requires_readiness_check(smtp_policy)
|
|
195
|
+
try:
|
|
196
|
+
self._smtp_client_factory(smtp_config).test_connection()
|
|
197
|
+
self._test_idempotency_storage(smtp_policy)
|
|
198
|
+
if strict_sent_copy:
|
|
199
|
+
stage = "connect_auth_noop_idempotency_sent_copy"
|
|
200
|
+
self._test_sent_copy_destination(
|
|
201
|
+
account=account_name,
|
|
202
|
+
smtp_config=smtp_config,
|
|
203
|
+
smtp_policy=smtp_policy,
|
|
204
|
+
)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
results[account_name] = {
|
|
207
|
+
"status": "failed",
|
|
208
|
+
"stage": stage,
|
|
209
|
+
"error_type": type(exc).__name__,
|
|
210
|
+
"message": str(exc),
|
|
211
|
+
}
|
|
212
|
+
continue
|
|
213
|
+
checks = ["connect", "ehlo", "noop"]
|
|
214
|
+
if smtp_config.tls.value != "none":
|
|
215
|
+
checks.append("tls")
|
|
216
|
+
if smtp_config.authenticate:
|
|
217
|
+
checks.append("authenticate")
|
|
218
|
+
checks.append("idempotency_storage")
|
|
219
|
+
if strict_sent_copy:
|
|
220
|
+
checks.append("sent_copy_destination")
|
|
221
|
+
results[account_name] = {
|
|
222
|
+
"status": "ok",
|
|
223
|
+
"stage": stage,
|
|
224
|
+
"checks": checks,
|
|
225
|
+
"delivery": "skipped",
|
|
226
|
+
"reason": "read-only SMTP account test does not send mail",
|
|
227
|
+
}
|
|
228
|
+
return results
|
|
229
|
+
|
|
142
230
|
def send_email(
|
|
143
231
|
self,
|
|
144
232
|
account: str,
|
|
@@ -169,6 +257,7 @@ class SMTPRuntime:
|
|
|
169
257
|
if recipients_cc:
|
|
170
258
|
message["Cc"] = ", ".join(recipients_cc)
|
|
171
259
|
message["Subject"] = normalized_subject
|
|
260
|
+
message["Date"] = format_datetime(datetime.now(timezone.utc))
|
|
172
261
|
message["Message-ID"] = make_msgid(domain=self._sender_domain(smtp_config))
|
|
173
262
|
|
|
174
263
|
if text_body:
|
|
@@ -177,6 +266,7 @@ class SMTPRuntime:
|
|
|
177
266
|
message.add_alternative(html_body, subtype="html")
|
|
178
267
|
else:
|
|
179
268
|
message.set_content(html_body or "", subtype="html")
|
|
269
|
+
message_bytes = message.as_bytes(policy=email_policy.SMTP)
|
|
180
270
|
|
|
181
271
|
envelope_recipients = recipients_to + recipients_cc + recipients_bcc
|
|
182
272
|
self._enforce_policy(account, smtp_policy, envelope_recipients)
|
|
@@ -203,17 +293,28 @@ class SMTPRuntime:
|
|
|
203
293
|
)
|
|
204
294
|
replayed_result = self._reserve_or_replay_idempotency(
|
|
205
295
|
smtp_policy,
|
|
296
|
+
account=account,
|
|
297
|
+
smtp_config=smtp_config,
|
|
206
298
|
cache_key=cache_key,
|
|
207
299
|
payload_hash=payload_hash,
|
|
208
300
|
)
|
|
209
301
|
if replayed_result is not None:
|
|
210
302
|
return replayed_result
|
|
211
303
|
|
|
304
|
+
sent_copy_destination: SentCopyDestination | None = None
|
|
305
|
+
sent_copy_result: dict[str, object] | None = None
|
|
212
306
|
try:
|
|
307
|
+
sent_copy_destination, sent_copy_result = (
|
|
308
|
+
self._resolve_sent_copy_destination(
|
|
309
|
+
account=account,
|
|
310
|
+
smtp_config=smtp_config,
|
|
311
|
+
smtp_policy=smtp_policy,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
213
314
|
self._consume_rate_limit(account, smtp_policy)
|
|
214
315
|
smtp_client = self._smtp_client_factory(smtp_config)
|
|
215
316
|
smtp_client.send(
|
|
216
|
-
|
|
317
|
+
message_bytes,
|
|
217
318
|
sender=smtp_config.from_email,
|
|
218
319
|
recipients=envelope_recipients,
|
|
219
320
|
)
|
|
@@ -225,10 +326,23 @@ class SMTPRuntime:
|
|
|
225
326
|
self._idempotency_store(smtp_policy).delete(cache_key)
|
|
226
327
|
raise
|
|
227
328
|
|
|
329
|
+
if sent_copy_result is None and sent_copy_destination is not None:
|
|
330
|
+
sent_copy_result = self._append_sent_copy(
|
|
331
|
+
destination=sent_copy_destination,
|
|
332
|
+
message_bytes=message_bytes,
|
|
333
|
+
)
|
|
334
|
+
if sent_copy_result is None:
|
|
335
|
+
sent_copy_result = self._sent_copy_outcome(
|
|
336
|
+
"skipped",
|
|
337
|
+
account=account,
|
|
338
|
+
reason="sent copy destination was not resolved",
|
|
339
|
+
)
|
|
340
|
+
|
|
228
341
|
result = SendEmailResult(
|
|
229
342
|
tool="send_email",
|
|
230
343
|
message_id=str(message["Message-ID"]),
|
|
231
344
|
recipient_count=len(envelope_recipients),
|
|
345
|
+
sent_copy=sent_copy_result,
|
|
232
346
|
)
|
|
233
347
|
if cache_key is not None and payload_hash is not None:
|
|
234
348
|
self._idempotency_store(smtp_policy).store_success(
|
|
@@ -237,9 +351,15 @@ class SMTPRuntime:
|
|
|
237
351
|
result=SMTPIdempotencyResult(
|
|
238
352
|
message_id=result.message_id,
|
|
239
353
|
recipient_count=result.recipient_count,
|
|
354
|
+
sent_copy=result.sent_copy,
|
|
355
|
+
sent_copy_message_bytes=self._sent_copy_retry_message_bytes(
|
|
356
|
+
sent_copy_result,
|
|
357
|
+
message_bytes,
|
|
358
|
+
),
|
|
240
359
|
),
|
|
241
360
|
expire_seconds=self._idempotency_expire_seconds(smtp_policy),
|
|
242
361
|
)
|
|
362
|
+
self._raise_if_submitted_sent_copy_required(result, smtp_policy)
|
|
243
363
|
return result
|
|
244
364
|
|
|
245
365
|
def _resolve_context(
|
|
@@ -260,6 +380,191 @@ class SMTPRuntime:
|
|
|
260
380
|
|
|
261
381
|
return smtp_config, smtp_policy
|
|
262
382
|
|
|
383
|
+
def _resolve_sent_copy_destination(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
account: str,
|
|
387
|
+
smtp_config: SMTPConfig,
|
|
388
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
389
|
+
enforce_required: bool = True,
|
|
390
|
+
) -> tuple[SentCopyDestination | None, dict[str, object] | None]:
|
|
391
|
+
if not smtp_policy.sent_copy.enabled:
|
|
392
|
+
return None, self._sent_copy_outcome("disabled", account=account)
|
|
393
|
+
|
|
394
|
+
folder_override = self._normalize_sent_copy_folder(smtp_config.sent_copy.folder)
|
|
395
|
+
if self._sent_message_appender is None:
|
|
396
|
+
result = self._sent_copy_outcome(
|
|
397
|
+
"skipped",
|
|
398
|
+
account=account,
|
|
399
|
+
reason="IMAP sent-copy appender is not configured",
|
|
400
|
+
)
|
|
401
|
+
if enforce_required:
|
|
402
|
+
self._raise_if_sent_copy_required(result, smtp_policy)
|
|
403
|
+
return None, result
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
destination = self._sent_message_appender.resolve_destination(
|
|
407
|
+
account=account,
|
|
408
|
+
folder=folder_override,
|
|
409
|
+
)
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
result = self._sent_copy_outcome(
|
|
412
|
+
"skipped",
|
|
413
|
+
account=account,
|
|
414
|
+
reason=str(exc),
|
|
415
|
+
error_type=type(exc).__name__,
|
|
416
|
+
)
|
|
417
|
+
if enforce_required:
|
|
418
|
+
self._raise_if_sent_copy_required(result, smtp_policy)
|
|
419
|
+
return None, result
|
|
420
|
+
|
|
421
|
+
return destination, None
|
|
422
|
+
|
|
423
|
+
def _append_sent_copy(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
destination: SentCopyDestination,
|
|
427
|
+
message_bytes: bytes,
|
|
428
|
+
) -> dict[str, object]:
|
|
429
|
+
if self._sent_message_appender is None:
|
|
430
|
+
return self._sent_copy_outcome(
|
|
431
|
+
"skipped",
|
|
432
|
+
account=destination.account,
|
|
433
|
+
folder=destination.folder,
|
|
434
|
+
reason="IMAP sent-copy appender is not configured",
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
self._sent_message_appender.append_sent_message(
|
|
438
|
+
account=destination.account,
|
|
439
|
+
folder=destination.folder,
|
|
440
|
+
message_bytes=message_bytes,
|
|
441
|
+
)
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
return self._sent_copy_outcome(
|
|
444
|
+
"failed",
|
|
445
|
+
account=destination.account,
|
|
446
|
+
folder=destination.folder,
|
|
447
|
+
reason=str(exc),
|
|
448
|
+
error_type=type(exc).__name__,
|
|
449
|
+
)
|
|
450
|
+
return self._sent_copy_outcome(
|
|
451
|
+
"saved",
|
|
452
|
+
account=destination.account,
|
|
453
|
+
folder=destination.folder,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _raise_if_sent_copy_required(
|
|
457
|
+
self,
|
|
458
|
+
result: dict[str, object],
|
|
459
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
460
|
+
) -> None:
|
|
461
|
+
if smtp_policy.sent_copy.on_failure.value != "fail":
|
|
462
|
+
return
|
|
463
|
+
raise RuntimeError(f"send_email sent-copy preflight failed: {result['reason']}")
|
|
464
|
+
|
|
465
|
+
def _normalize_sent_copy_folder(self, folder: str | None) -> str | None:
|
|
466
|
+
if folder is None:
|
|
467
|
+
return None
|
|
468
|
+
normalized = folder.strip()
|
|
469
|
+
if not normalized:
|
|
470
|
+
raise ValueError("SMTP sent_copy.folder must be non-empty when configured")
|
|
471
|
+
return normalized
|
|
472
|
+
|
|
473
|
+
def _sent_copy_outcome(
|
|
474
|
+
self,
|
|
475
|
+
status: str,
|
|
476
|
+
*,
|
|
477
|
+
account: str | None = None,
|
|
478
|
+
folder: str | None = None,
|
|
479
|
+
reason: str | None = None,
|
|
480
|
+
error_type: str | None = None,
|
|
481
|
+
) -> dict[str, object]:
|
|
482
|
+
result: dict[str, object] = {"status": status}
|
|
483
|
+
if account is not None:
|
|
484
|
+
result["account"] = account
|
|
485
|
+
if folder is not None:
|
|
486
|
+
result["folder"] = folder
|
|
487
|
+
if reason is not None:
|
|
488
|
+
result["reason"] = reason
|
|
489
|
+
if error_type is not None:
|
|
490
|
+
result["error_type"] = error_type
|
|
491
|
+
return result
|
|
492
|
+
|
|
493
|
+
def _sent_copy_retry_message_bytes(
|
|
494
|
+
self,
|
|
495
|
+
sent_copy_result: dict[str, object],
|
|
496
|
+
message_bytes: bytes,
|
|
497
|
+
) -> bytes | None:
|
|
498
|
+
if sent_copy_result.get("status") in {"failed", "skipped"}:
|
|
499
|
+
return message_bytes
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
def _sent_copy_needs_idempotent_retry(
|
|
503
|
+
self,
|
|
504
|
+
result: SMTPIdempotencyResult,
|
|
505
|
+
) -> bool:
|
|
506
|
+
if result.sent_copy_message_bytes is None:
|
|
507
|
+
return False
|
|
508
|
+
if result.sent_copy is None:
|
|
509
|
+
return True
|
|
510
|
+
return result.sent_copy.get("status") in {"failed", "skipped"}
|
|
511
|
+
|
|
512
|
+
def _retry_sent_copy_from_idempotency(
|
|
513
|
+
self,
|
|
514
|
+
*,
|
|
515
|
+
account: str,
|
|
516
|
+
smtp_config: SMTPConfig,
|
|
517
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
518
|
+
result: SMTPIdempotencyResult,
|
|
519
|
+
) -> SendEmailResult:
|
|
520
|
+
sent_copy_result = result.sent_copy
|
|
521
|
+
if result.sent_copy_message_bytes is not None:
|
|
522
|
+
destination, resolved_result = self._resolve_sent_copy_destination(
|
|
523
|
+
account=account,
|
|
524
|
+
smtp_config=smtp_config,
|
|
525
|
+
smtp_policy=smtp_policy,
|
|
526
|
+
enforce_required=False,
|
|
527
|
+
)
|
|
528
|
+
sent_copy_result = resolved_result
|
|
529
|
+
if destination is not None:
|
|
530
|
+
sent_copy_result = self._append_sent_copy(
|
|
531
|
+
destination=destination,
|
|
532
|
+
message_bytes=result.sent_copy_message_bytes,
|
|
533
|
+
)
|
|
534
|
+
if sent_copy_result is None:
|
|
535
|
+
sent_copy_result = self._sent_copy_outcome(
|
|
536
|
+
"skipped",
|
|
537
|
+
account=account,
|
|
538
|
+
reason="sent copy destination was not resolved",
|
|
539
|
+
)
|
|
540
|
+
replayed_result = SendEmailResult(
|
|
541
|
+
tool="send_email",
|
|
542
|
+
message_id=result.message_id,
|
|
543
|
+
recipient_count=result.recipient_count,
|
|
544
|
+
sent_copy=sent_copy_result,
|
|
545
|
+
idempotency_replayed=True,
|
|
546
|
+
)
|
|
547
|
+
self._raise_if_submitted_sent_copy_required(replayed_result, smtp_policy)
|
|
548
|
+
return replayed_result
|
|
549
|
+
|
|
550
|
+
def _raise_if_submitted_sent_copy_required(
|
|
551
|
+
self,
|
|
552
|
+
result: SendEmailResult,
|
|
553
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
554
|
+
) -> None:
|
|
555
|
+
if smtp_policy.sent_copy.on_failure.value != "fail":
|
|
556
|
+
return
|
|
557
|
+
sent_copy_result = result.sent_copy or {}
|
|
558
|
+
if sent_copy_result.get("status") in {"saved", "disabled"}:
|
|
559
|
+
return
|
|
560
|
+
reason = sent_copy_result.get("reason")
|
|
561
|
+
suffix = f": {reason}" if reason else ""
|
|
562
|
+
raise SMTPSentCopyError(
|
|
563
|
+
"send_email submitted the SMTP message but failed to save "
|
|
564
|
+
f"a sent copy{suffix}",
|
|
565
|
+
result=result,
|
|
566
|
+
)
|
|
567
|
+
|
|
263
568
|
def _validate_config(self) -> None:
|
|
264
569
|
for account_name, smtp_config in sorted(self._accounts.items()):
|
|
265
570
|
if smtp_config.policy not in self._policies:
|
|
@@ -267,16 +572,26 @@ class SMTPRuntime:
|
|
|
267
572
|
"SMTP account references an unknown policy: "
|
|
268
573
|
f"{account_name} -> {smtp_config.policy}"
|
|
269
574
|
)
|
|
575
|
+
if (
|
|
576
|
+
smtp_config.sent_copy.folder is not None
|
|
577
|
+
and not smtp_config.sent_copy.folder.strip()
|
|
578
|
+
):
|
|
579
|
+
raise ValueError(
|
|
580
|
+
f"SMTP sent_copy.folder must be non-empty: {account_name}"
|
|
581
|
+
)
|
|
270
582
|
for policy_name, smtp_policy in sorted(self._policies.items()):
|
|
271
583
|
if smtp_policy.idempotency.expiration_days <= 0:
|
|
272
584
|
raise ValueError(
|
|
273
585
|
"SMTP idempotency expiration_days must be positive: "
|
|
274
586
|
f"{policy_name}"
|
|
275
587
|
)
|
|
276
|
-
|
|
588
|
+
cache_dir = smtp_policy.idempotency.cache_dir
|
|
589
|
+
if cache_dir is not None and not cache_dir.strip():
|
|
277
590
|
raise ValueError(
|
|
278
591
|
f"SMTP idempotency cache_dir must be non-empty: {policy_name}"
|
|
279
592
|
)
|
|
593
|
+
if cache_dir is not None and self._plugin_storage is not None:
|
|
594
|
+
self._plugin_storage.path(cache_dir.strip())
|
|
280
595
|
|
|
281
596
|
def _normalize_recipients(
|
|
282
597
|
self,
|
|
@@ -437,6 +752,8 @@ class SMTPRuntime:
|
|
|
437
752
|
self,
|
|
438
753
|
smtp_policy: SMTPServicePolicyConfig,
|
|
439
754
|
*,
|
|
755
|
+
account: str,
|
|
756
|
+
smtp_config: SMTPConfig,
|
|
440
757
|
cache_key: str,
|
|
441
758
|
payload_hash: str,
|
|
442
759
|
) -> SendEmailResult | None:
|
|
@@ -466,10 +783,33 @@ class SMTPRuntime:
|
|
|
466
783
|
)
|
|
467
784
|
if record.result is None:
|
|
468
785
|
raise ValueError("send_email idempotency_key is already in progress")
|
|
786
|
+
if self._sent_copy_needs_idempotent_retry(record.result):
|
|
787
|
+
replayed_result = self._retry_sent_copy_from_idempotency(
|
|
788
|
+
account=account,
|
|
789
|
+
smtp_config=smtp_config,
|
|
790
|
+
smtp_policy=smtp_policy,
|
|
791
|
+
result=record.result,
|
|
792
|
+
)
|
|
793
|
+
store.store_success(
|
|
794
|
+
cache_key,
|
|
795
|
+
payload_hash=payload_hash,
|
|
796
|
+
result=SMTPIdempotencyResult(
|
|
797
|
+
message_id=replayed_result.message_id,
|
|
798
|
+
recipient_count=replayed_result.recipient_count,
|
|
799
|
+
sent_copy=replayed_result.sent_copy,
|
|
800
|
+
sent_copy_message_bytes=self._sent_copy_retry_message_bytes(
|
|
801
|
+
replayed_result.sent_copy or {},
|
|
802
|
+
record.result.sent_copy_message_bytes or b"",
|
|
803
|
+
),
|
|
804
|
+
),
|
|
805
|
+
expire_seconds=expire_seconds,
|
|
806
|
+
)
|
|
807
|
+
return replayed_result
|
|
469
808
|
return SendEmailResult(
|
|
470
809
|
tool="send_email",
|
|
471
810
|
message_id=record.result.message_id,
|
|
472
811
|
recipient_count=record.result.recipient_count,
|
|
812
|
+
sent_copy=record.result.sent_copy,
|
|
473
813
|
idempotency_replayed=True,
|
|
474
814
|
)
|
|
475
815
|
|
|
@@ -477,13 +817,69 @@ class SMTPRuntime:
|
|
|
477
817
|
self,
|
|
478
818
|
smtp_policy: SMTPServicePolicyConfig,
|
|
479
819
|
) -> SMTPIdempotencyStore:
|
|
480
|
-
cache_dir = smtp_policy
|
|
820
|
+
cache_dir = self._idempotency_cache_dir(smtp_policy)
|
|
481
821
|
store = self._idempotency_stores.get(cache_dir)
|
|
482
822
|
if store is None:
|
|
483
823
|
store = self._idempotency_store_factory(cache_dir)
|
|
484
824
|
self._idempotency_stores[cache_dir] = store
|
|
485
825
|
return store
|
|
486
826
|
|
|
827
|
+
def _test_idempotency_storage(
|
|
828
|
+
self,
|
|
829
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
830
|
+
) -> None:
|
|
831
|
+
store = self._idempotency_store(smtp_policy)
|
|
832
|
+
key = f"__arbiter_readiness__:{secrets.token_urlsafe(16)}"
|
|
833
|
+
payload_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
834
|
+
try:
|
|
835
|
+
added = store.add_pending(
|
|
836
|
+
key,
|
|
837
|
+
payload_hash=payload_hash,
|
|
838
|
+
expire_seconds=60,
|
|
839
|
+
)
|
|
840
|
+
if not added:
|
|
841
|
+
raise ValueError("SMTP idempotency cache readiness key collided")
|
|
842
|
+
record = store.get(key)
|
|
843
|
+
if record is None or record.payload_hash != payload_hash:
|
|
844
|
+
raise ValueError("SMTP idempotency cache readiness read failed")
|
|
845
|
+
finally:
|
|
846
|
+
store.delete(key)
|
|
847
|
+
|
|
848
|
+
def _sent_copy_requires_readiness_check(
|
|
849
|
+
self,
|
|
850
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
851
|
+
) -> bool:
|
|
852
|
+
return (
|
|
853
|
+
smtp_policy.sent_copy.enabled
|
|
854
|
+
and smtp_policy.sent_copy.on_failure.value == "fail"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _test_sent_copy_destination(
|
|
858
|
+
self,
|
|
859
|
+
*,
|
|
860
|
+
account: str,
|
|
861
|
+
smtp_config: SMTPConfig,
|
|
862
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
863
|
+
) -> None:
|
|
864
|
+
self._resolve_sent_copy_destination(
|
|
865
|
+
account=account,
|
|
866
|
+
smtp_config=smtp_config,
|
|
867
|
+
smtp_policy=smtp_policy,
|
|
868
|
+
enforce_required=True,
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
def _idempotency_cache_dir(self, smtp_policy: SMTPServicePolicyConfig) -> str:
|
|
872
|
+
configured_cache_dir = smtp_policy.idempotency.cache_dir
|
|
873
|
+
if configured_cache_dir is not None:
|
|
874
|
+
if self._plugin_storage is not None:
|
|
875
|
+
return str(self._plugin_storage.path(configured_cache_dir.strip()))
|
|
876
|
+
return configured_cache_dir.strip()
|
|
877
|
+
if self._plugin_storage is None:
|
|
878
|
+
raise ValueError(
|
|
879
|
+
"SMTP idempotency cache_dir is required when plugin storage is unavailable"
|
|
880
|
+
)
|
|
881
|
+
return str(self._plugin_storage.path("idempotency"))
|
|
882
|
+
|
|
487
883
|
def _idempotency_expire_seconds(
|
|
488
884
|
self,
|
|
489
885
|
smtp_policy: SMTPServicePolicyConfig,
|
|
@@ -524,6 +920,9 @@ defaults:
|
|
|
524
920
|
# Human-facing summary shown by account listing tools.
|
|
525
921
|
description: SMTP account for (${{.from_email}})
|
|
526
922
|
|
|
923
|
+
# Operator guidance shown to agents during discovery.
|
|
924
|
+
guidance: ""
|
|
925
|
+
|
|
527
926
|
# Matching policy generated alongside this account.
|
|
528
927
|
policy: {policy_name}
|
|
529
928
|
|
|
@@ -546,6 +945,11 @@ from_name: Arbiter
|
|
|
546
945
|
tls: starttls
|
|
547
946
|
verify_peer: true
|
|
548
947
|
timeout_seconds: 30
|
|
948
|
+
|
|
949
|
+
# Optional override for the IMAP sent-copy folder used after successful sends.
|
|
950
|
+
# Leave null to infer the only kind=sent folder on the matching IMAP account.
|
|
951
|
+
sent_copy:
|
|
952
|
+
folder: null
|
|
549
953
|
"""
|
|
550
954
|
|
|
551
955
|
|
|
@@ -567,7 +971,9 @@ limits:
|
|
|
567
971
|
# Dedupe window for repeated send attempts.
|
|
568
972
|
idempotency:
|
|
569
973
|
expiration_days: 7
|
|
570
|
-
|
|
974
|
+
# Optional plugin-relative subdirectory. Defaults to the SMTP plugin's
|
|
975
|
+
# private writable space under idempotency/.
|
|
976
|
+
cache_dir: null
|
|
571
977
|
|
|
572
978
|
# Empty lists do not restrict recipients. Add entries to enforce allow/block rules.
|
|
573
979
|
recipient_policy:
|
|
@@ -575,13 +981,20 @@ recipient_policy:
|
|
|
575
981
|
blocked_recipients: []
|
|
576
982
|
allowed_domain_patterns: []
|
|
577
983
|
blocked_domain_patterns: []
|
|
984
|
+
|
|
985
|
+
# Save submitted messages to the matching IMAP account's Sent folder when one
|
|
986
|
+
# can be resolved. on_failure=warn keeps SMTP success even if IMAP append fails;
|
|
987
|
+
# on_failure=fail treats missing sent-copy audit as an operation failure.
|
|
988
|
+
sent_copy:
|
|
989
|
+
enabled: true
|
|
990
|
+
on_failure: warn
|
|
578
991
|
"""
|
|
579
992
|
|
|
580
993
|
|
|
581
994
|
class SMTPServicePlugin:
|
|
582
995
|
name = "smtp"
|
|
583
996
|
version = distribution_version("arbiter-smtp", package_file=__file__)
|
|
584
|
-
|
|
997
|
+
server_api_version = SERVER_API_VERSION
|
|
585
998
|
|
|
586
999
|
def register_configs(self, config_store: ConfigStore) -> None:
|
|
587
1000
|
register_smtp_configs(config_store)
|
|
@@ -621,6 +1034,13 @@ class SMTPServicePlugin:
|
|
|
621
1034
|
policies=policies,
|
|
622
1035
|
smtp_client_factory=smtp_client_factory,
|
|
623
1036
|
time_provider=time_provider,
|
|
1037
|
+
plugin_storage=cast(
|
|
1038
|
+
PluginStorage | None, context.dependencies.get("plugin_storage")
|
|
1039
|
+
),
|
|
1040
|
+
sent_message_appender=cast(
|
|
1041
|
+
SentMessageAppender | None,
|
|
1042
|
+
context.dependencies.get("sent_message_appender"),
|
|
1043
|
+
),
|
|
624
1044
|
)
|
|
625
1045
|
|
|
626
1046
|
def describe_capability(
|
|
@@ -668,6 +1088,7 @@ class SMTPServicePlugin:
|
|
|
668
1088
|
"ok": True,
|
|
669
1089
|
"message_id": result.message_id,
|
|
670
1090
|
"recipient_count": result.recipient_count,
|
|
1091
|
+
"sent_copy": result.sent_copy,
|
|
671
1092
|
"idempotency_replayed": result.idempotency_replayed,
|
|
672
1093
|
}
|
|
673
1094
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import smtplib
|
|
4
|
+
import ssl
|
|
5
|
+
|
|
6
|
+
from .config import MailTlsMode, SMTPConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SMTPSubmissionClient:
|
|
10
|
+
def __init__(self, config: SMTPConfig) -> None:
|
|
11
|
+
self._config = config
|
|
12
|
+
|
|
13
|
+
def test_connection(self) -> None:
|
|
14
|
+
with self._connect() as server:
|
|
15
|
+
self._prepare_session(server)
|
|
16
|
+
self._expect_ok(server.noop(), "NOOP")
|
|
17
|
+
|
|
18
|
+
def send(self, message_bytes: bytes, sender: str, recipients: list[str]) -> None:
|
|
19
|
+
with self._connect() as server:
|
|
20
|
+
self._prepare_session(server)
|
|
21
|
+
refused_recipients = server.sendmail(sender, recipients, message_bytes)
|
|
22
|
+
if refused_recipients:
|
|
23
|
+
raise smtplib.SMTPRecipientsRefused(refused_recipients)
|
|
24
|
+
|
|
25
|
+
def _connect(self) -> smtplib.SMTP | smtplib.SMTP_SSL:
|
|
26
|
+
ssl_context = self._build_ssl_context()
|
|
27
|
+
if self._config.tls == MailTlsMode.implicit:
|
|
28
|
+
return smtplib.SMTP_SSL(
|
|
29
|
+
self._config.host,
|
|
30
|
+
self._config.port,
|
|
31
|
+
timeout=self._config.timeout_seconds,
|
|
32
|
+
context=ssl_context,
|
|
33
|
+
)
|
|
34
|
+
return smtplib.SMTP(
|
|
35
|
+
self._config.host,
|
|
36
|
+
self._config.port,
|
|
37
|
+
timeout=self._config.timeout_seconds,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _prepare_session(self, server: smtplib.SMTP | smtplib.SMTP_SSL) -> None:
|
|
41
|
+
ssl_context = self._build_ssl_context()
|
|
42
|
+
self._expect_ok(server.ehlo(), "EHLO")
|
|
43
|
+
if self._config.tls == MailTlsMode.starttls:
|
|
44
|
+
self._expect_ok(server.starttls(context=ssl_context), "STARTTLS")
|
|
45
|
+
self._expect_ok(server.ehlo(), "EHLO")
|
|
46
|
+
|
|
47
|
+
if self._config.authenticate:
|
|
48
|
+
self._expect_ok(
|
|
49
|
+
server.login(self._config.username, self._config.password), "login"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _expect_ok(self, response: tuple[int, bytes], action: str) -> None:
|
|
53
|
+
code, message = response
|
|
54
|
+
if code < 200 or code >= 400:
|
|
55
|
+
raise smtplib.SMTPResponseException(
|
|
56
|
+
code, f"SMTP {action} failed: {message!r}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _build_ssl_context(self) -> ssl.SSLContext:
|
|
60
|
+
if self._config.verify_peer:
|
|
61
|
+
return ssl.create_default_context()
|
|
62
|
+
|
|
63
|
+
context = ssl.create_default_context()
|
|
64
|
+
context.check_hostname = False
|
|
65
|
+
context.verify_mode = ssl.CERT_NONE
|
|
66
|
+
return context
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from enum import Enum
|
|
5
5
|
|
|
6
|
-
from
|
|
6
|
+
from arbiter_server.config import Policy
|
|
7
7
|
from hydra.core.config_store import ConfigStore
|
|
8
8
|
|
|
9
9
|
|
|
@@ -13,6 +13,11 @@ class MailTlsMode(str, Enum):
|
|
|
13
13
|
implicit = "implicit"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
class SMTPSentCopyFailureMode(str, Enum):
|
|
17
|
+
warn = "warn"
|
|
18
|
+
fail = "fail"
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
@dataclass
|
|
17
22
|
class SMTPLimitsConfig:
|
|
18
23
|
max_messages_per_minute: int | None = None
|
|
@@ -22,7 +27,7 @@ class SMTPLimitsConfig:
|
|
|
22
27
|
@dataclass
|
|
23
28
|
class SMTPIdempotencyConfig:
|
|
24
29
|
expiration_days: int = 7
|
|
25
|
-
cache_dir: str =
|
|
30
|
+
cache_dir: str | None = None
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
@dataclass
|
|
@@ -33,10 +38,22 @@ class SMTPRecipientPolicyConfig:
|
|
|
33
38
|
blocked_domain_patterns: list[str] = field(default_factory=list)
|
|
34
39
|
|
|
35
40
|
|
|
41
|
+
@dataclass
|
|
42
|
+
class SMTPSentCopyAccountConfig:
|
|
43
|
+
folder: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SMTPSentCopyPolicyConfig:
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
on_failure: SMTPSentCopyFailureMode = SMTPSentCopyFailureMode.warn
|
|
50
|
+
|
|
51
|
+
|
|
36
52
|
@dataclass
|
|
37
53
|
class SMTPConfig(Policy):
|
|
38
54
|
policy: str = "bot"
|
|
39
55
|
description: str = ""
|
|
56
|
+
guidance: str = ""
|
|
40
57
|
host: str = "localhost"
|
|
41
58
|
port: int = 587
|
|
42
59
|
authenticate: bool = False
|
|
@@ -47,6 +64,9 @@ class SMTPConfig(Policy):
|
|
|
47
64
|
tls: MailTlsMode = MailTlsMode.starttls
|
|
48
65
|
verify_peer: bool = True
|
|
49
66
|
timeout_seconds: float = 30.0
|
|
67
|
+
sent_copy: SMTPSentCopyAccountConfig = field(
|
|
68
|
+
default_factory=SMTPSentCopyAccountConfig
|
|
69
|
+
)
|
|
50
70
|
|
|
51
71
|
|
|
52
72
|
@dataclass
|
|
@@ -57,6 +77,9 @@ class SMTPServicePolicyConfig(Policy):
|
|
|
57
77
|
recipient_policy: SMTPRecipientPolicyConfig = field(
|
|
58
78
|
default_factory=SMTPRecipientPolicyConfig
|
|
59
79
|
)
|
|
80
|
+
sent_copy: SMTPSentCopyPolicyConfig = field(
|
|
81
|
+
default_factory=SMTPSentCopyPolicyConfig
|
|
82
|
+
)
|
|
60
83
|
|
|
61
84
|
|
|
62
85
|
SMTP_ACCOUNT_EXAMPLE = SMTPConfig(
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
3
5
|
from dataclasses import dataclass
|
|
4
6
|
import json
|
|
5
7
|
from pathlib import Path
|
|
@@ -11,6 +13,8 @@ from diskcache import Cache # type: ignore[import-untyped]
|
|
|
11
13
|
class SMTPIdempotencyResult:
|
|
12
14
|
message_id: str
|
|
13
15
|
recipient_count: int
|
|
16
|
+
sent_copy: dict[str, object] | None = None
|
|
17
|
+
sent_copy_message_bytes: bytes | None = None
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
@dataclass(frozen=True)
|
|
@@ -66,6 +70,14 @@ def _encode_record(record: SMTPIdempotencyRecord) -> str:
|
|
|
66
70
|
else {
|
|
67
71
|
"message_id": record.result.message_id,
|
|
68
72
|
"recipient_count": record.result.recipient_count,
|
|
73
|
+
"sent_copy": record.result.sent_copy,
|
|
74
|
+
"sent_copy_message": (
|
|
75
|
+
None
|
|
76
|
+
if record.result.sent_copy_message_bytes is None
|
|
77
|
+
else base64.b64encode(
|
|
78
|
+
record.result.sent_copy_message_bytes
|
|
79
|
+
).decode("ascii")
|
|
80
|
+
),
|
|
69
81
|
}
|
|
70
82
|
),
|
|
71
83
|
},
|
|
@@ -90,12 +102,31 @@ def _decode_record(raw_record: str) -> SMTPIdempotencyRecord:
|
|
|
90
102
|
raise ValueError("SMTP idempotency cache contains an invalid result")
|
|
91
103
|
message_id = raw_result.get("message_id")
|
|
92
104
|
recipient_count = raw_result.get("recipient_count")
|
|
105
|
+
sent_copy = raw_result.get("sent_copy")
|
|
106
|
+
sent_copy_message = raw_result.get("sent_copy_message")
|
|
93
107
|
if not isinstance(message_id, str) or not isinstance(recipient_count, int):
|
|
94
108
|
raise ValueError("SMTP idempotency cache contains an invalid result")
|
|
109
|
+
if sent_copy is not None and not isinstance(sent_copy, dict):
|
|
110
|
+
raise ValueError("SMTP idempotency cache contains an invalid sent_copy result")
|
|
111
|
+
if sent_copy_message is not None and not isinstance(sent_copy_message, str):
|
|
112
|
+
raise ValueError("SMTP idempotency cache contains an invalid sent_copy message")
|
|
113
|
+
sent_copy_message_bytes: bytes | None = None
|
|
114
|
+
if sent_copy_message is not None:
|
|
115
|
+
try:
|
|
116
|
+
sent_copy_message_bytes = base64.b64decode(
|
|
117
|
+
sent_copy_message,
|
|
118
|
+
validate=True,
|
|
119
|
+
)
|
|
120
|
+
except (binascii.Error, ValueError) as exc:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"SMTP idempotency cache contains an invalid sent_copy message"
|
|
123
|
+
) from exc
|
|
95
124
|
return SMTPIdempotencyRecord(
|
|
96
125
|
payload_hash=payload_hash,
|
|
97
126
|
result=SMTPIdempotencyResult(
|
|
98
127
|
message_id=message_id,
|
|
99
128
|
recipient_count=recipient_count,
|
|
129
|
+
sent_copy=sent_copy,
|
|
130
|
+
sent_copy_message_bytes=sent_copy_message_bytes,
|
|
100
131
|
),
|
|
101
132
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arbiter-smtp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1.dev2
|
|
4
4
|
Summary: SMTP service plugin for Arbiter
|
|
5
5
|
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
6
|
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
20
20
|
Classifier: Topic :: Communications :: Email
|
|
21
21
|
Requires-Python: <3.15,>=3.10
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
|
-
Requires-Dist: arbiter-
|
|
23
|
+
Requires-Dist: arbiter-server<0.10.0,>=0.9.1.dev2
|
|
24
24
|
Requires-Dist: diskcache<6.0,>=5.6
|
|
25
25
|
|
|
26
26
|
SMTP service plugin for Arbiter.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
|
-
src/
|
|
3
|
-
src/
|
|
4
|
-
src/
|
|
5
|
-
src/
|
|
6
|
-
src/
|
|
2
|
+
src/arbiter_smtp/__init__.py
|
|
3
|
+
src/arbiter_smtp/client.py
|
|
4
|
+
src/arbiter_smtp/config.py
|
|
5
|
+
src/arbiter_smtp/idempotency.py
|
|
6
|
+
src/arbiter_smtp/py.typed
|
|
7
7
|
src/arbiter_smtp.egg-info/PKG-INFO
|
|
8
8
|
src/arbiter_smtp.egg-info/SOURCES.txt
|
|
9
9
|
src/arbiter_smtp.egg-info/dependency_links.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
arbiter_smtp
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from email.message import EmailMessage
|
|
4
|
-
import smtplib
|
|
5
|
-
import ssl
|
|
6
|
-
|
|
7
|
-
from .config import MailTlsMode, SMTPConfig
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SMTPSubmissionClient:
|
|
11
|
-
def __init__(self, config: SMTPConfig) -> None:
|
|
12
|
-
self._config = config
|
|
13
|
-
|
|
14
|
-
def send(self, message: EmailMessage, sender: str, recipients: list[str]) -> None:
|
|
15
|
-
ssl_context = self._build_ssl_context()
|
|
16
|
-
smtp_client: smtplib.SMTP | smtplib.SMTP_SSL
|
|
17
|
-
if self._config.tls == MailTlsMode.implicit:
|
|
18
|
-
smtp_client = smtplib.SMTP_SSL(
|
|
19
|
-
self._config.host,
|
|
20
|
-
self._config.port,
|
|
21
|
-
timeout=self._config.timeout_seconds,
|
|
22
|
-
context=ssl_context,
|
|
23
|
-
)
|
|
24
|
-
else:
|
|
25
|
-
smtp_client = smtplib.SMTP(
|
|
26
|
-
self._config.host,
|
|
27
|
-
self._config.port,
|
|
28
|
-
timeout=self._config.timeout_seconds,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
with smtp_client as server:
|
|
32
|
-
server.ehlo()
|
|
33
|
-
if self._config.tls == MailTlsMode.starttls:
|
|
34
|
-
server.starttls(context=ssl_context)
|
|
35
|
-
server.ehlo()
|
|
36
|
-
|
|
37
|
-
if self._config.authenticate:
|
|
38
|
-
server.login(self._config.username, self._config.password)
|
|
39
|
-
|
|
40
|
-
refused_recipients = server.send_message(
|
|
41
|
-
message,
|
|
42
|
-
from_addr=sender,
|
|
43
|
-
to_addrs=recipients,
|
|
44
|
-
)
|
|
45
|
-
if refused_recipients:
|
|
46
|
-
raise smtplib.SMTPRecipientsRefused(refused_recipients)
|
|
47
|
-
|
|
48
|
-
def _build_ssl_context(self) -> ssl.SSLContext:
|
|
49
|
-
if self._config.verify_peer:
|
|
50
|
-
return ssl.create_default_context()
|
|
51
|
-
|
|
52
|
-
context = ssl.create_default_context()
|
|
53
|
-
context.check_hostname = False
|
|
54
|
-
context.verify_mode = ssl.CERT_NONE
|
|
55
|
-
return context
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
agent_arbiter_smtp
|
|
File without changes
|
{arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/py.typed
RENAMED
|
File without changes
|
{arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|