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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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,2 @@
1
+ [agent_arbiter.services]
2
+ smtp = agent_arbiter_smtp:plugin
@@ -0,0 +1,2 @@
1
+ arbiter-core<0.10.0,>=0.9.0.dev1
2
+ diskcache<6.0,>=5.6
@@ -0,0 +1 @@
1
+ agent_arbiter_smtp