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.
Files changed (18) hide show
  1. {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/PKG-INFO +2 -2
  2. {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/pyproject.toml +6 -6
  3. {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/__init__.py +431 -10
  4. arbiter_smtp-0.9.1.dev2/src/arbiter_smtp/client.py +66 -0
  5. {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/config.py +25 -2
  6. {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/idempotency.py +31 -0
  7. {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/PKG-INFO +2 -2
  8. {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/src/arbiter_smtp.egg-info/SOURCES.txt +5 -5
  9. arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/entry_points.txt +2 -0
  10. arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/requires.txt +2 -0
  11. arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/top_level.txt +1 -0
  12. arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp/client.py +0 -55
  13. arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/entry_points.txt +0 -2
  14. arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/requires.txt +0 -2
  15. arbiter_smtp-0.9.0.dev1/src/arbiter_smtp.egg-info/top_level.txt +0 -1
  16. {arbiter_smtp-0.9.0.dev1 → arbiter_smtp-0.9.1.dev2}/setup.cfg +0 -0
  17. {arbiter_smtp-0.9.0.dev1/src/agent_arbiter_smtp → arbiter_smtp-0.9.1.dev2/src/arbiter_smtp}/py.typed +0 -0
  18. {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.0.dev1
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-core<0.10.0,>=0.9.0.dev1
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.0.dev1"
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-core>=0.9.0.dev1,<0.10.0",
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."agent_arbiter.services"]
41
- smtp = "agent_arbiter_smtp:plugin"
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
- "agent_arbiter_smtp" = ["py.typed"]
50
+ "arbiter_smtp" = ["py.typed"]
51
51
 
52
52
  [tool.towncrier]
53
53
  name = "Arbiter SMTP"
54
- package = "agent_arbiter_smtp"
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 agent_arbiter.services import (
18
+ from arbiter_server.services import (
16
19
  CapabilityDescriptor,
17
20
  OperationDescriptor,
18
21
  ServicePluginContext,
19
22
  ServiceRuntimeContext,
20
23
  )
21
- from agent_arbiter.version import distribution_version
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
- CORE_API_VERSION = "0.9"
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
- message: EmailMessage,
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
- message,
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
- if not smtp_policy.idempotency.cache_dir.strip():
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.idempotency.cache_dir
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
- cache_dir: .arbiter/smtp-idempotency
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
- core_api_version = CORE_API_VERSION
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 agent_arbiter.config import Policy
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 = ".arbiter/smtp-idempotency"
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.0.dev1
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-core<0.10.0,>=0.9.0.dev1
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/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
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,2 @@
1
+ [arbiter.services]
2
+ smtp = arbiter_smtp:plugin
@@ -0,0 +1,2 @@
1
+ arbiter-server<0.10.0,>=0.9.1.dev2
2
+ diskcache<6.0,>=5.6
@@ -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,2 +0,0 @@
1
- [agent_arbiter.services]
2
- smtp = agent_arbiter_smtp:plugin
@@ -1,2 +0,0 @@
1
- arbiter-core<0.10.0,>=0.9.0.dev1
2
- diskcache<6.0,>=5.6
@@ -1 +0,0 @@
1
- agent_arbiter_smtp