arbiter-smtp 0.9.0.dev1__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/PKG-INFO +26 -0
- arbiter_smtp-0.9.0.dev1/pyproject.toml +58 -0
- arbiter_smtp-0.9.0.dev1/setup.cfg +4 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/__init__.py +676 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/client.py +55 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/config.py +113 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/idempotency.py +101 -0
- arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/py.typed +1 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/PKG-INFO +26 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/SOURCES.txt +12 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/dependency_links.txt +1 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/entry_points.txt +2 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/requires.txt +2 -0
- arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arbiter-smtp
|
|
3
|
+
Version: 0.9.0.dev1
|
|
4
|
+
Summary: SMTP service plugin for Arbiter
|
|
5
|
+
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
|
+
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/omry/arbiter
|
|
9
|
+
Project-URL: Repository, https://github.com/omry/arbiter
|
|
10
|
+
Keywords: agent,mcp,smtp,email
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Requires-Python: <3.15,>=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: arbiter-core<0.10.0,>=0.9.0.dev1
|
|
24
|
+
Requires-Dist: diskcache<6.0,>=5.6
|
|
25
|
+
|
|
26
|
+
SMTP service plugin for Arbiter.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arbiter-smtp"
|
|
7
|
+
version = "0.9.0.dev1"
|
|
8
|
+
description = "SMTP service plugin for Arbiter"
|
|
9
|
+
readme = { text = "SMTP service plugin for Arbiter.", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.10,<3.15"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Omry Yadan", email = "omry@yadan.net" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Omry Yadan", email = "omry@yadan.net" },
|
|
17
|
+
]
|
|
18
|
+
keywords = ["agent", "mcp", "smtp", "email"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Communications :: Email",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"arbiter-core>=0.9.0.dev1,<0.10.0",
|
|
33
|
+
"diskcache>=5.6,<6.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/omry/arbiter"
|
|
38
|
+
Repository = "https://github.com/omry/arbiter"
|
|
39
|
+
|
|
40
|
+
[project.entry-points."agent_arbiter.services"]
|
|
41
|
+
smtp = "agent_arbiter_smtp:plugin"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
package-dir = {"" = "src"}
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
"agent_arbiter_smtp" = ["py.typed"]
|
|
51
|
+
|
|
52
|
+
[tool.towncrier]
|
|
53
|
+
name = "Arbiter SMTP"
|
|
54
|
+
package = "agent_arbiter_smtp"
|
|
55
|
+
package_dir = "src"
|
|
56
|
+
filename = "NEWS.md"
|
|
57
|
+
directory = "newsfragments"
|
|
58
|
+
issue_format = "#{issue}"
|
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from email.message import EmailMessage
|
|
6
|
+
from email.utils import formataddr, make_msgid
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import smtplib
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from typing import Callable, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from hydra.core.config_store import ConfigStore
|
|
14
|
+
|
|
15
|
+
from agent_arbiter.services import (
|
|
16
|
+
CapabilityDescriptor,
|
|
17
|
+
OperationDescriptor,
|
|
18
|
+
ServicePluginContext,
|
|
19
|
+
ServiceRuntimeContext,
|
|
20
|
+
)
|
|
21
|
+
from agent_arbiter.version import distribution_version
|
|
22
|
+
|
|
23
|
+
from .config import (
|
|
24
|
+
SMTPConfig,
|
|
25
|
+
SMTPServicePolicyConfig,
|
|
26
|
+
register_configs as register_smtp_configs,
|
|
27
|
+
)
|
|
28
|
+
from .idempotency import SMTPIdempotencyResult, SMTPIdempotencyStore
|
|
29
|
+
|
|
30
|
+
CORE_API_VERSION = "0.9"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SendEmailResult:
|
|
35
|
+
tool: str
|
|
36
|
+
message_id: str
|
|
37
|
+
recipient_count: int
|
|
38
|
+
idempotency_replayed: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SMTPClientProtocol(Protocol):
|
|
42
|
+
def send(
|
|
43
|
+
self,
|
|
44
|
+
message: EmailMessage,
|
|
45
|
+
sender: str,
|
|
46
|
+
recipients: list[str],
|
|
47
|
+
) -> None: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SMTPClientFactory = Callable[[SMTPConfig], SMTPClientProtocol]
|
|
51
|
+
TimeProvider = Callable[[], float]
|
|
52
|
+
SMTPIdempotencyStoreFactory = Callable[[str], SMTPIdempotencyStore]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SEND_EMAIL_DESCRIPTION = (
|
|
56
|
+
"Send a single email message through the configured SMTP submission server "
|
|
57
|
+
"for the selected account. Use at least one recipient in to and at least "
|
|
58
|
+
"one of text_body or html_body."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
SEND_EMAIL_INPUT_SCHEMA: dict[str, object] = {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"account": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"description": "Configured SMTP account name.",
|
|
67
|
+
},
|
|
68
|
+
"to": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {"type": "string"},
|
|
71
|
+
"description": "Primary recipient email addresses.",
|
|
72
|
+
},
|
|
73
|
+
"subject": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Email subject line.",
|
|
76
|
+
},
|
|
77
|
+
"text_body": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Plain text body.",
|
|
80
|
+
},
|
|
81
|
+
"html_body": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "HTML body.",
|
|
84
|
+
},
|
|
85
|
+
"cc": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": {"type": "string"},
|
|
88
|
+
"description": "CC recipient email addresses.",
|
|
89
|
+
},
|
|
90
|
+
"bcc": {
|
|
91
|
+
"type": "array",
|
|
92
|
+
"items": {"type": "string"},
|
|
93
|
+
"description": "BCC recipient email addresses.",
|
|
94
|
+
},
|
|
95
|
+
"idempotency_key": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Optional caller-supplied key for retry-safe dedupe.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
"required": ["account", "to", "subject"],
|
|
101
|
+
"additionalProperties": False,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SMTPRuntime:
|
|
106
|
+
service_name = "smtp"
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
accounts: Mapping[str, object],
|
|
111
|
+
policies: Mapping[str, object],
|
|
112
|
+
smtp_client_factory: SMTPClientFactory,
|
|
113
|
+
time_provider: TimeProvider = monotonic,
|
|
114
|
+
idempotency_store_factory: SMTPIdempotencyStoreFactory = SMTPIdempotencyStore,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._accounts = cast(Mapping[str, SMTPConfig], accounts)
|
|
117
|
+
self._policies = cast(
|
|
118
|
+
Mapping[str, SMTPServicePolicyConfig],
|
|
119
|
+
policies,
|
|
120
|
+
)
|
|
121
|
+
self._smtp_client_factory = smtp_client_factory
|
|
122
|
+
self._time_provider = time_provider
|
|
123
|
+
self._idempotency_store_factory = idempotency_store_factory
|
|
124
|
+
self._idempotency_stores: dict[str, SMTPIdempotencyStore] = {}
|
|
125
|
+
self._attempt_timestamps: dict[str, list[float]] = {}
|
|
126
|
+
self._validate_config()
|
|
127
|
+
|
|
128
|
+
def account_summaries(self) -> dict[str, object]:
|
|
129
|
+
return {
|
|
130
|
+
account_name: {
|
|
131
|
+
"description": account.description,
|
|
132
|
+
"policy": account.policy,
|
|
133
|
+
"enabled": True,
|
|
134
|
+
"send": "allowed",
|
|
135
|
+
"require_confirmation": self._policies[
|
|
136
|
+
account.policy
|
|
137
|
+
].require_confirmation,
|
|
138
|
+
}
|
|
139
|
+
for account_name, account in sorted(self._accounts.items())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def send_email(
|
|
143
|
+
self,
|
|
144
|
+
account: str,
|
|
145
|
+
to: list[str],
|
|
146
|
+
subject: str,
|
|
147
|
+
text_body: str | None = None,
|
|
148
|
+
html_body: str | None = None,
|
|
149
|
+
cc: list[str] | None = None,
|
|
150
|
+
bcc: list[str] | None = None,
|
|
151
|
+
idempotency_key: str | None = None,
|
|
152
|
+
) -> SendEmailResult:
|
|
153
|
+
smtp_config, smtp_policy = self._resolve_context(account)
|
|
154
|
+
recipients_to = self._normalize_recipients("to", to)
|
|
155
|
+
recipients_cc = self._normalize_recipients("cc", cc or [])
|
|
156
|
+
recipients_bcc = self._normalize_recipients("bcc", bcc or [])
|
|
157
|
+
|
|
158
|
+
if not text_body and not html_body:
|
|
159
|
+
raise ValueError("send_email requires text_body or html_body")
|
|
160
|
+
|
|
161
|
+
normalized_subject = subject.strip()
|
|
162
|
+
if not normalized_subject:
|
|
163
|
+
raise ValueError("send_email requires a non-empty subject")
|
|
164
|
+
|
|
165
|
+
sender = formataddr((smtp_config.from_name, smtp_config.from_email))
|
|
166
|
+
message = EmailMessage()
|
|
167
|
+
message["From"] = sender
|
|
168
|
+
message["To"] = ", ".join(recipients_to)
|
|
169
|
+
if recipients_cc:
|
|
170
|
+
message["Cc"] = ", ".join(recipients_cc)
|
|
171
|
+
message["Subject"] = normalized_subject
|
|
172
|
+
message["Message-ID"] = make_msgid(domain=self._sender_domain(smtp_config))
|
|
173
|
+
|
|
174
|
+
if text_body:
|
|
175
|
+
message.set_content(text_body)
|
|
176
|
+
if html_body:
|
|
177
|
+
message.add_alternative(html_body, subtype="html")
|
|
178
|
+
else:
|
|
179
|
+
message.set_content(html_body or "", subtype="html")
|
|
180
|
+
|
|
181
|
+
envelope_recipients = recipients_to + recipients_cc + recipients_bcc
|
|
182
|
+
self._enforce_policy(account, smtp_policy, envelope_recipients)
|
|
183
|
+
|
|
184
|
+
normalized_idempotency_key = self._normalize_idempotency_key(idempotency_key)
|
|
185
|
+
payload_hash: str | None = None
|
|
186
|
+
cache_key: str | None = None
|
|
187
|
+
if normalized_idempotency_key is not None:
|
|
188
|
+
payload_hash = self._idempotency_payload_hash(
|
|
189
|
+
account=account,
|
|
190
|
+
policy=smtp_config.policy,
|
|
191
|
+
sender=smtp_config.from_email,
|
|
192
|
+
sender_name=smtp_config.from_name,
|
|
193
|
+
to=recipients_to,
|
|
194
|
+
cc=recipients_cc,
|
|
195
|
+
bcc=recipients_bcc,
|
|
196
|
+
subject=normalized_subject,
|
|
197
|
+
text_body=text_body,
|
|
198
|
+
html_body=html_body,
|
|
199
|
+
)
|
|
200
|
+
cache_key = self._idempotency_cache_key(
|
|
201
|
+
account=account,
|
|
202
|
+
idempotency_key=normalized_idempotency_key,
|
|
203
|
+
)
|
|
204
|
+
replayed_result = self._reserve_or_replay_idempotency(
|
|
205
|
+
smtp_policy,
|
|
206
|
+
cache_key=cache_key,
|
|
207
|
+
payload_hash=payload_hash,
|
|
208
|
+
)
|
|
209
|
+
if replayed_result is not None:
|
|
210
|
+
return replayed_result
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
self._consume_rate_limit(account, smtp_policy)
|
|
214
|
+
smtp_client = self._smtp_client_factory(smtp_config)
|
|
215
|
+
smtp_client.send(
|
|
216
|
+
message,
|
|
217
|
+
sender=smtp_config.from_email,
|
|
218
|
+
recipients=envelope_recipients,
|
|
219
|
+
)
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
if cache_key is not None and self._should_clear_idempotency_reservation(
|
|
222
|
+
exc,
|
|
223
|
+
envelope_recipients=envelope_recipients,
|
|
224
|
+
):
|
|
225
|
+
self._idempotency_store(smtp_policy).delete(cache_key)
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
result = SendEmailResult(
|
|
229
|
+
tool="send_email",
|
|
230
|
+
message_id=str(message["Message-ID"]),
|
|
231
|
+
recipient_count=len(envelope_recipients),
|
|
232
|
+
)
|
|
233
|
+
if cache_key is not None and payload_hash is not None:
|
|
234
|
+
self._idempotency_store(smtp_policy).store_success(
|
|
235
|
+
cache_key,
|
|
236
|
+
payload_hash=payload_hash,
|
|
237
|
+
result=SMTPIdempotencyResult(
|
|
238
|
+
message_id=result.message_id,
|
|
239
|
+
recipient_count=result.recipient_count,
|
|
240
|
+
),
|
|
241
|
+
expire_seconds=self._idempotency_expire_seconds(smtp_policy),
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
def _resolve_context(
|
|
246
|
+
self,
|
|
247
|
+
account_name: str,
|
|
248
|
+
) -> tuple[SMTPConfig, SMTPServicePolicyConfig]:
|
|
249
|
+
smtp_config = self._accounts.get(account_name)
|
|
250
|
+
if smtp_config is None:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"send_email requires an SMTP-enabled account: {account_name}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
smtp_policy = self._policies.get(smtp_config.policy)
|
|
256
|
+
if smtp_policy is None:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"send_email account references an unknown SMTP policy: {account_name}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return smtp_config, smtp_policy
|
|
262
|
+
|
|
263
|
+
def _validate_config(self) -> None:
|
|
264
|
+
for account_name, smtp_config in sorted(self._accounts.items()):
|
|
265
|
+
if smtp_config.policy not in self._policies:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"SMTP account references an unknown policy: "
|
|
268
|
+
f"{account_name} -> {smtp_config.policy}"
|
|
269
|
+
)
|
|
270
|
+
for policy_name, smtp_policy in sorted(self._policies.items()):
|
|
271
|
+
if smtp_policy.idempotency.expiration_days <= 0:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"SMTP idempotency expiration_days must be positive: "
|
|
274
|
+
f"{policy_name}"
|
|
275
|
+
)
|
|
276
|
+
if not smtp_policy.idempotency.cache_dir.strip():
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"SMTP idempotency cache_dir must be non-empty: {policy_name}"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _normalize_recipients(
|
|
282
|
+
self,
|
|
283
|
+
field_name: str,
|
|
284
|
+
recipients: list[str],
|
|
285
|
+
) -> list[str]:
|
|
286
|
+
normalized = [
|
|
287
|
+
recipient.strip() for recipient in recipients if recipient.strip()
|
|
288
|
+
]
|
|
289
|
+
if field_name == "to" and not normalized:
|
|
290
|
+
raise ValueError("send_email requires at least one recipient in to")
|
|
291
|
+
|
|
292
|
+
for recipient in normalized:
|
|
293
|
+
if "@" not in recipient:
|
|
294
|
+
raise ValueError(f"send_email received an invalid {field_name} address")
|
|
295
|
+
|
|
296
|
+
return normalized
|
|
297
|
+
|
|
298
|
+
def _sender_domain(self, smtp_config: SMTPConfig) -> str:
|
|
299
|
+
_, _, domain = smtp_config.from_email.partition("@")
|
|
300
|
+
return domain or "localhost"
|
|
301
|
+
|
|
302
|
+
def _enforce_policy(
|
|
303
|
+
self,
|
|
304
|
+
account_name: str,
|
|
305
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
306
|
+
recipients: list[str],
|
|
307
|
+
) -> None:
|
|
308
|
+
max_recipients = smtp_policy.limits.max_recipients_per_message
|
|
309
|
+
if max_recipients is not None and len(recipients) > max_recipients:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"send_email exceeds max_recipients_per_message for account: {account_name}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
recipient_policy = smtp_policy.recipient_policy
|
|
315
|
+
for recipient in recipients:
|
|
316
|
+
normalized_recipient = recipient.strip().lower()
|
|
317
|
+
_, _, domain = normalized_recipient.partition("@")
|
|
318
|
+
if self._recipient_matches_list(
|
|
319
|
+
normalized_recipient, recipient_policy.blocked_recipients
|
|
320
|
+
):
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"send_email recipient is blocked by exact address policy: {recipient}"
|
|
323
|
+
)
|
|
324
|
+
if self._domain_matches_any_pattern(
|
|
325
|
+
domain, recipient_policy.blocked_domain_patterns
|
|
326
|
+
):
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"send_email recipient is blocked by domain policy: {recipient}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
has_allowlist = bool(
|
|
332
|
+
recipient_policy.allowed_recipients
|
|
333
|
+
or recipient_policy.allowed_domain_patterns
|
|
334
|
+
)
|
|
335
|
+
if has_allowlist and not (
|
|
336
|
+
self._recipient_matches_list(
|
|
337
|
+
normalized_recipient, recipient_policy.allowed_recipients
|
|
338
|
+
)
|
|
339
|
+
or self._domain_matches_any_pattern(
|
|
340
|
+
domain, recipient_policy.allowed_domain_patterns
|
|
341
|
+
)
|
|
342
|
+
):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"send_email recipient is not allowed by policy: {recipient}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def _consume_rate_limit(
|
|
348
|
+
self,
|
|
349
|
+
account_name: str,
|
|
350
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
351
|
+
) -> None:
|
|
352
|
+
max_messages = smtp_policy.limits.max_messages_per_minute
|
|
353
|
+
if max_messages is None:
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
now = self._time_provider()
|
|
357
|
+
window_start = now - 60.0
|
|
358
|
+
active_attempts = [
|
|
359
|
+
timestamp
|
|
360
|
+
for timestamp in self._attempt_timestamps.get(account_name, [])
|
|
361
|
+
if timestamp > window_start
|
|
362
|
+
]
|
|
363
|
+
if len(active_attempts) >= max_messages:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"send_email exceeds max_messages_per_minute for account: {account_name}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
active_attempts.append(now)
|
|
369
|
+
self._attempt_timestamps[account_name] = active_attempts
|
|
370
|
+
|
|
371
|
+
def _recipient_matches_list(
|
|
372
|
+
self,
|
|
373
|
+
recipient: str,
|
|
374
|
+
configured_recipients: list[str],
|
|
375
|
+
) -> bool:
|
|
376
|
+
normalized = recipient.lower()
|
|
377
|
+
return any(
|
|
378
|
+
normalized == value.strip().lower() for value in configured_recipients
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _domain_matches_any_pattern(self, domain: str, patterns: list[str]) -> bool:
|
|
382
|
+
normalized_domain = domain.lower()
|
|
383
|
+
for pattern in patterns:
|
|
384
|
+
normalized_pattern = pattern.strip().lower()
|
|
385
|
+
if normalized_pattern.startswith("*."):
|
|
386
|
+
suffix = normalized_pattern[2:]
|
|
387
|
+
if normalized_domain.endswith(f".{suffix}"):
|
|
388
|
+
return True
|
|
389
|
+
continue
|
|
390
|
+
if normalized_domain == normalized_pattern:
|
|
391
|
+
return True
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
def _normalize_idempotency_key(self, idempotency_key: str | None) -> str | None:
|
|
395
|
+
if idempotency_key is None:
|
|
396
|
+
return None
|
|
397
|
+
normalized = idempotency_key.strip()
|
|
398
|
+
if not normalized:
|
|
399
|
+
raise ValueError("send_email idempotency_key must be non-empty")
|
|
400
|
+
return normalized
|
|
401
|
+
|
|
402
|
+
def _idempotency_payload_hash(
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
account: str,
|
|
406
|
+
policy: str,
|
|
407
|
+
sender: str,
|
|
408
|
+
sender_name: str,
|
|
409
|
+
to: list[str],
|
|
410
|
+
cc: list[str],
|
|
411
|
+
bcc: list[str],
|
|
412
|
+
subject: str,
|
|
413
|
+
text_body: str | None,
|
|
414
|
+
html_body: str | None,
|
|
415
|
+
) -> str:
|
|
416
|
+
payload = {
|
|
417
|
+
"account": account,
|
|
418
|
+
"policy": policy,
|
|
419
|
+
"sender": sender,
|
|
420
|
+
"sender_name": sender_name,
|
|
421
|
+
"to": to,
|
|
422
|
+
"cc": cc,
|
|
423
|
+
"bcc": bcc,
|
|
424
|
+
"subject": subject,
|
|
425
|
+
"text_body": text_body,
|
|
426
|
+
"html_body": html_body,
|
|
427
|
+
}
|
|
428
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode(
|
|
429
|
+
"utf-8"
|
|
430
|
+
)
|
|
431
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
432
|
+
|
|
433
|
+
def _idempotency_cache_key(self, *, account: str, idempotency_key: str) -> str:
|
|
434
|
+
return f"smtp:{account}:{idempotency_key}"
|
|
435
|
+
|
|
436
|
+
def _reserve_or_replay_idempotency(
|
|
437
|
+
self,
|
|
438
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
439
|
+
*,
|
|
440
|
+
cache_key: str,
|
|
441
|
+
payload_hash: str,
|
|
442
|
+
) -> SendEmailResult | None:
|
|
443
|
+
store = self._idempotency_store(smtp_policy)
|
|
444
|
+
expire_seconds = self._idempotency_expire_seconds(smtp_policy)
|
|
445
|
+
if store.add_pending(
|
|
446
|
+
cache_key,
|
|
447
|
+
payload_hash=payload_hash,
|
|
448
|
+
expire_seconds=expire_seconds,
|
|
449
|
+
):
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
record = store.get(cache_key)
|
|
453
|
+
if record is None:
|
|
454
|
+
if store.add_pending(
|
|
455
|
+
cache_key,
|
|
456
|
+
payload_hash=payload_hash,
|
|
457
|
+
expire_seconds=expire_seconds,
|
|
458
|
+
):
|
|
459
|
+
return None
|
|
460
|
+
record = store.get(cache_key)
|
|
461
|
+
if record is None:
|
|
462
|
+
raise ValueError("send_email idempotency cache reservation failed")
|
|
463
|
+
if record.payload_hash != payload_hash:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
"send_email idempotency_key was reused with a different payload"
|
|
466
|
+
)
|
|
467
|
+
if record.result is None:
|
|
468
|
+
raise ValueError("send_email idempotency_key is already in progress")
|
|
469
|
+
return SendEmailResult(
|
|
470
|
+
tool="send_email",
|
|
471
|
+
message_id=record.result.message_id,
|
|
472
|
+
recipient_count=record.result.recipient_count,
|
|
473
|
+
idempotency_replayed=True,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _idempotency_store(
|
|
477
|
+
self,
|
|
478
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
479
|
+
) -> SMTPIdempotencyStore:
|
|
480
|
+
cache_dir = smtp_policy.idempotency.cache_dir
|
|
481
|
+
store = self._idempotency_stores.get(cache_dir)
|
|
482
|
+
if store is None:
|
|
483
|
+
store = self._idempotency_store_factory(cache_dir)
|
|
484
|
+
self._idempotency_stores[cache_dir] = store
|
|
485
|
+
return store
|
|
486
|
+
|
|
487
|
+
def _idempotency_expire_seconds(
|
|
488
|
+
self,
|
|
489
|
+
smtp_policy: SMTPServicePolicyConfig,
|
|
490
|
+
) -> int:
|
|
491
|
+
return smtp_policy.idempotency.expiration_days * 24 * 60 * 60
|
|
492
|
+
|
|
493
|
+
def _should_clear_idempotency_reservation(
|
|
494
|
+
self,
|
|
495
|
+
exc: Exception,
|
|
496
|
+
*,
|
|
497
|
+
envelope_recipients: list[str],
|
|
498
|
+
) -> bool:
|
|
499
|
+
if isinstance(exc, smtplib.SMTPRecipientsRefused):
|
|
500
|
+
refused_recipients = {
|
|
501
|
+
recipient.strip().lower() for recipient in exc.recipients
|
|
502
|
+
}
|
|
503
|
+
attempted_recipients = {
|
|
504
|
+
recipient.strip().lower() for recipient in envelope_recipients
|
|
505
|
+
}
|
|
506
|
+
return attempted_recipients <= refused_recipients
|
|
507
|
+
if isinstance(exc, smtplib.SMTPServerDisconnected):
|
|
508
|
+
return False
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _smtp_account_bootstrap_template(
|
|
513
|
+
*,
|
|
514
|
+
name: str,
|
|
515
|
+
policy_name: str,
|
|
516
|
+
env_suffix: str,
|
|
517
|
+
) -> str:
|
|
518
|
+
return f"""# @package arbiter.account.smtp.{name}
|
|
519
|
+
defaults:
|
|
520
|
+
# Extend the plugin-owned structured schema, then override values below.
|
|
521
|
+
- schema@_here_
|
|
522
|
+
- _self_
|
|
523
|
+
|
|
524
|
+
# Human-facing summary shown by account listing tools.
|
|
525
|
+
description: SMTP account for (${{.from_email}})
|
|
526
|
+
|
|
527
|
+
# Matching policy generated alongside this account.
|
|
528
|
+
policy: {policy_name}
|
|
529
|
+
|
|
530
|
+
# SMTP submission endpoint.
|
|
531
|
+
host: smtp.example.com
|
|
532
|
+
port: 587
|
|
533
|
+
|
|
534
|
+
# Set to false for unauthenticated local relays.
|
|
535
|
+
authenticate: true
|
|
536
|
+
|
|
537
|
+
# Credentials are read from the Arbiter process environment.
|
|
538
|
+
username: ${{oc.env:SMTP_{env_suffix}_USERNAME}}
|
|
539
|
+
password: ${{oc.env:SMTP_{env_suffix}_PASSWORD}}
|
|
540
|
+
|
|
541
|
+
# Sender identity used in message headers.
|
|
542
|
+
from_email: agent@example.com
|
|
543
|
+
from_name: Arbiter
|
|
544
|
+
|
|
545
|
+
# TLS mode: starttls, implicit, or none.
|
|
546
|
+
tls: starttls
|
|
547
|
+
verify_peer: true
|
|
548
|
+
timeout_seconds: 30
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _smtp_policy_bootstrap_template(*, name: str) -> str:
|
|
553
|
+
return f"""# @package arbiter.policy.smtp.{name}
|
|
554
|
+
defaults:
|
|
555
|
+
# Extend the plugin-owned structured schema, then override values below.
|
|
556
|
+
- schema@_here_
|
|
557
|
+
- _self_
|
|
558
|
+
|
|
559
|
+
# Require confirmation before sending through this policy.
|
|
560
|
+
require_confirmation: true
|
|
561
|
+
|
|
562
|
+
# Basic send-rate limits. Use null to disable a limit.
|
|
563
|
+
limits:
|
|
564
|
+
max_messages_per_minute: 30
|
|
565
|
+
max_recipients_per_message: 10
|
|
566
|
+
|
|
567
|
+
# Dedupe window for repeated send attempts.
|
|
568
|
+
idempotency:
|
|
569
|
+
expiration_days: 7
|
|
570
|
+
cache_dir: .arbiter/smtp-idempotency
|
|
571
|
+
|
|
572
|
+
# Empty lists do not restrict recipients. Add entries to enforce allow/block rules.
|
|
573
|
+
recipient_policy:
|
|
574
|
+
allowed_recipients: []
|
|
575
|
+
blocked_recipients: []
|
|
576
|
+
allowed_domain_patterns: []
|
|
577
|
+
blocked_domain_patterns: []
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class SMTPServicePlugin:
|
|
582
|
+
name = "smtp"
|
|
583
|
+
version = distribution_version("arbiter-smtp", package_file=__file__)
|
|
584
|
+
core_api_version = CORE_API_VERSION
|
|
585
|
+
|
|
586
|
+
def register_configs(self, config_store: ConfigStore) -> None:
|
|
587
|
+
register_smtp_configs(config_store)
|
|
588
|
+
|
|
589
|
+
def bootstrap_config(self, *, kind: str, name: str) -> object | None:
|
|
590
|
+
if kind == "account":
|
|
591
|
+
env_suffix = name.upper().replace("-", "_")
|
|
592
|
+
if not env_suffix.endswith("_ACCOUNT"):
|
|
593
|
+
env_suffix = f"{env_suffix}_ACCOUNT"
|
|
594
|
+
return _smtp_account_bootstrap_template(
|
|
595
|
+
name=name,
|
|
596
|
+
policy_name=f"{name}_policy",
|
|
597
|
+
env_suffix=env_suffix,
|
|
598
|
+
)
|
|
599
|
+
if kind == "policy":
|
|
600
|
+
return _smtp_policy_bootstrap_template(name=name)
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
def build_runtime(
|
|
604
|
+
self,
|
|
605
|
+
accounts: Mapping[str, object],
|
|
606
|
+
policies: Mapping[str, object],
|
|
607
|
+
context: ServiceRuntimeContext,
|
|
608
|
+
) -> object:
|
|
609
|
+
from .client import SMTPSubmissionClient
|
|
610
|
+
|
|
611
|
+
smtp_client_factory = cast(
|
|
612
|
+
SMTPClientFactory,
|
|
613
|
+
context.dependencies.get("smtp_client_factory", SMTPSubmissionClient),
|
|
614
|
+
)
|
|
615
|
+
time_provider = cast(
|
|
616
|
+
TimeProvider,
|
|
617
|
+
context.dependencies.get("time_provider", monotonic),
|
|
618
|
+
)
|
|
619
|
+
return SMTPRuntime(
|
|
620
|
+
accounts=accounts,
|
|
621
|
+
policies=policies,
|
|
622
|
+
smtp_client_factory=smtp_client_factory,
|
|
623
|
+
time_provider=time_provider,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def describe_capability(
|
|
627
|
+
self,
|
|
628
|
+
context: ServicePluginContext,
|
|
629
|
+
) -> CapabilityDescriptor:
|
|
630
|
+
return CapabilityDescriptor(
|
|
631
|
+
name=self.name,
|
|
632
|
+
description="Send email through configured SMTP accounts.",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def describe_operations(
|
|
636
|
+
self,
|
|
637
|
+
context: ServicePluginContext,
|
|
638
|
+
) -> tuple[OperationDescriptor, ...]:
|
|
639
|
+
return (
|
|
640
|
+
OperationDescriptor(
|
|
641
|
+
name="send_email",
|
|
642
|
+
description=SEND_EMAIL_DESCRIPTION,
|
|
643
|
+
input_schema=SEND_EMAIL_INPUT_SCHEMA,
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def invoke_operation(
|
|
648
|
+
self,
|
|
649
|
+
operation: str,
|
|
650
|
+
arguments: Mapping[str, object],
|
|
651
|
+
context: ServicePluginContext,
|
|
652
|
+
) -> object:
|
|
653
|
+
if operation != "send_email":
|
|
654
|
+
raise ValueError(f"unknown SMTP operation: {operation}")
|
|
655
|
+
|
|
656
|
+
runtime = context.runtimes.require(self.name, SMTPRuntime)
|
|
657
|
+
result = runtime.send_email(
|
|
658
|
+
account=cast(str, arguments.get("account")),
|
|
659
|
+
to=cast(list[str], arguments.get("to")),
|
|
660
|
+
subject=cast(str, arguments.get("subject")),
|
|
661
|
+
text_body=cast(str | None, arguments.get("text_body")),
|
|
662
|
+
html_body=cast(str | None, arguments.get("html_body")),
|
|
663
|
+
cc=cast(list[str] | None, arguments.get("cc")),
|
|
664
|
+
bcc=cast(list[str] | None, arguments.get("bcc")),
|
|
665
|
+
idempotency_key=cast(str | None, arguments.get("idempotency_key")),
|
|
666
|
+
)
|
|
667
|
+
return {
|
|
668
|
+
"ok": True,
|
|
669
|
+
"message_id": result.message_id,
|
|
670
|
+
"recipient_count": result.recipient_count,
|
|
671
|
+
"idempotency_replayed": result.idempotency_replayed,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def plugin() -> SMTPServicePlugin:
|
|
676
|
+
return SMTPServicePlugin()
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from agent_arbiter.config import Policy
|
|
7
|
+
from hydra.core.config_store import ConfigStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MailTlsMode(str, Enum):
|
|
11
|
+
none = "none"
|
|
12
|
+
starttls = "starttls"
|
|
13
|
+
implicit = "implicit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SMTPLimitsConfig:
|
|
18
|
+
max_messages_per_minute: int | None = None
|
|
19
|
+
max_recipients_per_message: int | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SMTPIdempotencyConfig:
|
|
24
|
+
expiration_days: int = 7
|
|
25
|
+
cache_dir: str = ".arbiter/smtp-idempotency"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SMTPRecipientPolicyConfig:
|
|
30
|
+
allowed_recipients: list[str] = field(default_factory=list)
|
|
31
|
+
blocked_recipients: list[str] = field(default_factory=list)
|
|
32
|
+
allowed_domain_patterns: list[str] = field(default_factory=list)
|
|
33
|
+
blocked_domain_patterns: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SMTPConfig(Policy):
|
|
38
|
+
policy: str = "bot"
|
|
39
|
+
description: str = ""
|
|
40
|
+
host: str = "localhost"
|
|
41
|
+
port: int = 587
|
|
42
|
+
authenticate: bool = False
|
|
43
|
+
username: str = ""
|
|
44
|
+
password: str = ""
|
|
45
|
+
from_email: str = "agent@example.com"
|
|
46
|
+
from_name: str = "Arbiter"
|
|
47
|
+
tls: MailTlsMode = MailTlsMode.starttls
|
|
48
|
+
verify_peer: bool = True
|
|
49
|
+
timeout_seconds: float = 30.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SMTPServicePolicyConfig(Policy):
|
|
54
|
+
require_confirmation: bool = False
|
|
55
|
+
limits: SMTPLimitsConfig = field(default_factory=SMTPLimitsConfig)
|
|
56
|
+
idempotency: SMTPIdempotencyConfig = field(default_factory=SMTPIdempotencyConfig)
|
|
57
|
+
recipient_policy: SMTPRecipientPolicyConfig = field(
|
|
58
|
+
default_factory=SMTPRecipientPolicyConfig
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SMTP_ACCOUNT_EXAMPLE = SMTPConfig(
|
|
63
|
+
policy="bot_policy",
|
|
64
|
+
description="SMTP account for (${.from_email})",
|
|
65
|
+
host="smtp.example.com",
|
|
66
|
+
port=587,
|
|
67
|
+
authenticate=True,
|
|
68
|
+
username="${oc.env:SMTP_BOT_ACCOUNT_USERNAME}",
|
|
69
|
+
password="${oc.env:SMTP_BOT_ACCOUNT_PASSWORD}",
|
|
70
|
+
from_email="agent@example.com",
|
|
71
|
+
from_name="Arbiter",
|
|
72
|
+
tls=MailTlsMode.starttls,
|
|
73
|
+
verify_peer=True,
|
|
74
|
+
timeout_seconds=30.0,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
SMTP_POLICY_EXAMPLE = SMTPServicePolicyConfig(
|
|
78
|
+
require_confirmation=True,
|
|
79
|
+
limits=SMTPLimitsConfig(
|
|
80
|
+
max_messages_per_minute=30,
|
|
81
|
+
max_recipients_per_message=10,
|
|
82
|
+
),
|
|
83
|
+
recipient_policy=SMTPRecipientPolicyConfig(
|
|
84
|
+
allowed_domain_patterns=[],
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register_configs(config_store: ConfigStore) -> None:
|
|
90
|
+
config_store.store(
|
|
91
|
+
group="arbiter/account/smtp",
|
|
92
|
+
name="schema",
|
|
93
|
+
node=SMTPConfig,
|
|
94
|
+
provider="arbiter-smtp",
|
|
95
|
+
)
|
|
96
|
+
config_store.store(
|
|
97
|
+
group="arbiter/account/smtp",
|
|
98
|
+
name="example",
|
|
99
|
+
node=SMTP_ACCOUNT_EXAMPLE,
|
|
100
|
+
provider="arbiter-smtp",
|
|
101
|
+
)
|
|
102
|
+
config_store.store(
|
|
103
|
+
group="arbiter/policy/smtp",
|
|
104
|
+
name="schema",
|
|
105
|
+
node=SMTPServicePolicyConfig,
|
|
106
|
+
provider="arbiter-smtp",
|
|
107
|
+
)
|
|
108
|
+
config_store.store(
|
|
109
|
+
group="arbiter/policy/smtp",
|
|
110
|
+
name="example",
|
|
111
|
+
node=SMTP_POLICY_EXAMPLE,
|
|
112
|
+
provider="arbiter-smtp",
|
|
113
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from diskcache import Cache # type: ignore[import-untyped]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SMTPIdempotencyResult:
|
|
12
|
+
message_id: str
|
|
13
|
+
recipient_count: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SMTPIdempotencyRecord:
|
|
18
|
+
payload_hash: str
|
|
19
|
+
result: SMTPIdempotencyResult | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SMTPIdempotencyStore:
|
|
23
|
+
def __init__(self, cache_dir: str) -> None:
|
|
24
|
+
self._cache = Cache(str(Path(cache_dir)))
|
|
25
|
+
|
|
26
|
+
def get(self, key: str) -> SMTPIdempotencyRecord | None:
|
|
27
|
+
raw_record = self._cache.get(key)
|
|
28
|
+
if raw_record is None:
|
|
29
|
+
return None
|
|
30
|
+
if not isinstance(raw_record, str):
|
|
31
|
+
raise ValueError("SMTP idempotency cache contains an invalid record")
|
|
32
|
+
return _decode_record(raw_record)
|
|
33
|
+
|
|
34
|
+
def add_pending(
|
|
35
|
+
self,
|
|
36
|
+
key: str,
|
|
37
|
+
*,
|
|
38
|
+
payload_hash: str,
|
|
39
|
+
expire_seconds: int,
|
|
40
|
+
) -> bool:
|
|
41
|
+
record = SMTPIdempotencyRecord(payload_hash=payload_hash)
|
|
42
|
+
return bool(self._cache.add(key, _encode_record(record), expire=expire_seconds))
|
|
43
|
+
|
|
44
|
+
def store_success(
|
|
45
|
+
self,
|
|
46
|
+
key: str,
|
|
47
|
+
*,
|
|
48
|
+
payload_hash: str,
|
|
49
|
+
result: SMTPIdempotencyResult,
|
|
50
|
+
expire_seconds: int,
|
|
51
|
+
) -> None:
|
|
52
|
+
record = SMTPIdempotencyRecord(payload_hash=payload_hash, result=result)
|
|
53
|
+
self._cache.set(key, _encode_record(record), expire=expire_seconds)
|
|
54
|
+
|
|
55
|
+
def delete(self, key: str) -> None:
|
|
56
|
+
self._cache.delete(key)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _encode_record(record: SMTPIdempotencyRecord) -> str:
|
|
60
|
+
return json.dumps(
|
|
61
|
+
{
|
|
62
|
+
"payload_hash": record.payload_hash,
|
|
63
|
+
"result": (
|
|
64
|
+
None
|
|
65
|
+
if record.result is None
|
|
66
|
+
else {
|
|
67
|
+
"message_id": record.result.message_id,
|
|
68
|
+
"recipient_count": record.result.recipient_count,
|
|
69
|
+
}
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
sort_keys=True,
|
|
73
|
+
separators=(",", ":"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _decode_record(raw_record: str) -> SMTPIdempotencyRecord:
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw_record)
|
|
80
|
+
payload_hash = data["payload_hash"]
|
|
81
|
+
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
82
|
+
raise ValueError("SMTP idempotency cache contains an invalid record") from exc
|
|
83
|
+
if not isinstance(payload_hash, str):
|
|
84
|
+
raise ValueError("SMTP idempotency cache contains an invalid payload hash")
|
|
85
|
+
|
|
86
|
+
raw_result = data.get("result")
|
|
87
|
+
if raw_result is None:
|
|
88
|
+
return SMTPIdempotencyRecord(payload_hash=payload_hash)
|
|
89
|
+
if not isinstance(raw_result, dict):
|
|
90
|
+
raise ValueError("SMTP idempotency cache contains an invalid result")
|
|
91
|
+
message_id = raw_result.get("message_id")
|
|
92
|
+
recipient_count = raw_result.get("recipient_count")
|
|
93
|
+
if not isinstance(message_id, str) or not isinstance(recipient_count, int):
|
|
94
|
+
raise ValueError("SMTP idempotency cache contains an invalid result")
|
|
95
|
+
return SMTPIdempotencyRecord(
|
|
96
|
+
payload_hash=payload_hash,
|
|
97
|
+
result=SMTPIdempotencyResult(
|
|
98
|
+
message_id=message_id,
|
|
99
|
+
recipient_count=recipient_count,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arbiter-smtp
|
|
3
|
+
Version: 0.9.0.dev1
|
|
4
|
+
Summary: SMTP service plugin for Arbiter
|
|
5
|
+
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
|
+
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/omry/arbiter
|
|
9
|
+
Project-URL: Repository, https://github.com/omry/arbiter
|
|
10
|
+
Keywords: agent,mcp,smtp,email
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Requires-Python: <3.15,>=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: arbiter-core<0.10.0,>=0.9.0.dev1
|
|
24
|
+
Requires-Dist: diskcache<6.0,>=5.6
|
|
25
|
+
|
|
26
|
+
SMTP service plugin for Arbiter.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/agent_arbiter_smtp/__init__.py
|
|
3
|
+
src/agent_arbiter_smtp/client.py
|
|
4
|
+
src/agent_arbiter_smtp/config.py
|
|
5
|
+
src/agent_arbiter_smtp/idempotency.py
|
|
6
|
+
src/agent_arbiter_smtp/py.typed
|
|
7
|
+
src/arbiter_smtp.egg-info/PKG-INFO
|
|
8
|
+
src/arbiter_smtp.egg-info/SOURCES.txt
|
|
9
|
+
src/arbiter_smtp.egg-info/dependency_links.txt
|
|
10
|
+
src/arbiter_smtp.egg-info/entry_points.txt
|
|
11
|
+
src/arbiter_smtp.egg-info/requires.txt
|
|
12
|
+
src/arbiter_smtp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_arbiter_smtp
|