arbiter-smtp 0.9.1.dev2__tar.gz → 0.9.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arbiter-smtp
3
- Version: 0.9.1.dev2
3
+ Version: 0.9.2.dev1
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-server<0.10.0,>=0.9.1.dev2
23
+ Requires-Dist: arbiter-server<0.10.0,>=0.9.2.dev1
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.1.dev2"
7
+ version = "0.9.2.dev1"
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-server>=0.9.1.dev2,<0.10.0",
32
+ "arbiter-server>=0.9.2.dev1,<0.10.0",
33
33
  "diskcache>=5.6,<6.0",
34
34
  ]
35
35
 
@@ -54,5 +54,5 @@ name = "Arbiter SMTP"
54
54
  package = "arbiter_smtp"
55
55
  package_dir = "src"
56
56
  filename = "NEWS.md"
57
- directory = "newsfragments"
57
+ directory = "news"
58
58
  issue_format = "#{issue}"
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from datetime import datetime, timezone
6
6
  from email import policy as email_policy
7
7
  from email.message import EmailMessage
@@ -17,6 +17,8 @@ from hydra.core.config_store import ConfigStore
17
17
 
18
18
  from arbiter_server.services import (
19
19
  CapabilityDescriptor,
20
+ ConfigCheckError,
21
+ ConfigCheckIssue,
20
22
  OperationDescriptor,
21
23
  ServicePluginContext,
22
24
  ServiceRuntimeContext,
@@ -30,6 +32,7 @@ from .config import (
30
32
  register_configs as register_smtp_configs,
31
33
  )
32
34
  from .idempotency import SMTPIdempotencyResult, SMTPIdempotencyStore
35
+ from .runtime_policy import _SMTPRuntimePolicyMixin
33
36
 
34
37
  SERVER_API_VERSION = "0.9"
35
38
 
@@ -67,6 +70,13 @@ class SMTPClientProtocol(Protocol):
67
70
 
68
71
 
69
72
  class SentMessageAppender(Protocol):
73
+ def check_destination(
74
+ self,
75
+ *,
76
+ account: str,
77
+ folder: str | None,
78
+ ) -> SentCopyDestination: ...
79
+
70
80
  def resolve_destination(
71
81
  self,
72
82
  *,
@@ -94,51 +104,39 @@ SEND_EMAIL_DESCRIPTION = (
94
104
  "one of text_body or html_body."
95
105
  )
96
106
 
97
- SEND_EMAIL_INPUT_SCHEMA: dict[str, object] = {
98
- "type": "object",
99
- "properties": {
100
- "account": {
101
- "type": "string",
102
- "description": "Configured SMTP account name.",
103
- },
104
- "to": {
105
- "type": "array",
106
- "items": {"type": "string"},
107
- "description": "Primary recipient email addresses.",
108
- },
109
- "subject": {
110
- "type": "string",
111
- "description": "Email subject line.",
112
- },
113
- "text_body": {
114
- "type": "string",
115
- "description": "Plain text body.",
116
- },
117
- "html_body": {
118
- "type": "string",
119
- "description": "HTML body.",
120
- },
121
- "cc": {
122
- "type": "array",
123
- "items": {"type": "string"},
124
- "description": "CC recipient email addresses.",
125
- },
126
- "bcc": {
127
- "type": "array",
128
- "items": {"type": "string"},
129
- "description": "BCC recipient email addresses.",
130
- },
131
- "idempotency_key": {
132
- "type": "string",
133
- "description": "Optional caller-supplied key for retry-safe dedupe.",
134
- },
135
- },
136
- "required": ["account", "to", "subject"],
137
- "additionalProperties": False,
138
- }
139
-
140
-
141
- class SMTPRuntime:
107
+
108
+ @dataclass(frozen=True)
109
+ class SendEmailInput:
110
+ account: str = field(
111
+ metadata={"description": "Configured SMTP account name."},
112
+ )
113
+ to: list[str] = field(
114
+ metadata={"description": "Primary recipient email addresses."},
115
+ )
116
+ subject: str = field(metadata={"description": "Email subject line."})
117
+ text_body: str | None = field(
118
+ default=None,
119
+ metadata={"description": "Plain text body."},
120
+ )
121
+ html_body: str | None = field(
122
+ default=None,
123
+ metadata={"description": "HTML body."},
124
+ )
125
+ cc: list[str] | None = field(
126
+ default=None,
127
+ metadata={"description": "CC recipient email addresses."},
128
+ )
129
+ bcc: list[str] | None = field(
130
+ default=None,
131
+ metadata={"description": "BCC recipient email addresses."},
132
+ )
133
+ idempotency_key: str | None = field(
134
+ default=None,
135
+ metadata={"description": "Optional caller-supplied key for retry-safe dedupe."},
136
+ )
137
+
138
+
139
+ class SMTPRuntime(_SMTPRuntimePolicyMixin):
142
140
  service_name = "smtp"
143
141
 
144
142
  def __init__(
@@ -179,16 +177,19 @@ class SMTPRuntime:
179
177
  "policy": account.policy,
180
178
  "enabled": True,
181
179
  "send": "allowed",
182
- "require_confirmation": self._policies[
183
- account.policy
184
- ].require_confirmation,
185
180
  }
186
181
  for account_name, account in sorted(self._accounts.items())
187
182
  }
188
183
 
189
- def test_accounts(self) -> dict[str, object]:
184
+ def test_accounts(
185
+ self,
186
+ *,
187
+ progress: Callable[[str], None] | None = None,
188
+ ) -> dict[str, object]:
190
189
  results: dict[str, object] = {}
191
190
  for account_name, smtp_config in sorted(self._accounts.items()):
191
+ if progress is not None:
192
+ progress(account_name)
192
193
  smtp_policy = self._policies[smtp_config.policy]
193
194
  stage = "connect_auth_noop_idempotency"
194
195
  strict_sent_copy = self._sent_copy_requires_readiness_check(smtp_policy)
@@ -309,8 +310,18 @@ class SMTPRuntime:
309
310
  account=account,
310
311
  smtp_config=smtp_config,
311
312
  smtp_policy=smtp_policy,
313
+ enforce_required=False,
312
314
  )
313
315
  )
316
+ sent_copy_decision = self._sent_copy_preflight_decision(
317
+ smtp_policy=smtp_policy,
318
+ result=sent_copy_result,
319
+ ).evaluate()
320
+ if not sent_copy_decision.allowed:
321
+ raise RuntimeError(
322
+ sent_copy_decision.why_not
323
+ or "send_email sent-copy preflight failed"
324
+ )
314
325
  self._consume_rate_limit(account, smtp_policy)
315
326
  smtp_client = self._smtp_client_factory(smtp_config)
316
327
  smtp_client.send(
@@ -362,6 +373,66 @@ class SMTPRuntime:
362
373
  self._raise_if_submitted_sent_copy_required(result, smtp_policy)
363
374
  return result
364
375
 
376
+ def check_operation(
377
+ self,
378
+ operation: str,
379
+ arguments: Mapping[str, object],
380
+ ) -> dict[str, object]:
381
+ operation_id = f"smtp:{operation}"
382
+ if operation != "send_email":
383
+ return {
384
+ "operation": operation_id,
385
+ "allowed": False,
386
+ "why_not": f"unknown SMTP operation: {operation}",
387
+ }
388
+ account = cast(str, arguments.get("account"))
389
+ try:
390
+ smtp_config, smtp_policy = self._resolve_context(account)
391
+ recipients_to = self._normalize_recipients(
392
+ "to", cast(list[str], arguments.get("to"))
393
+ )
394
+ recipients_cc = self._normalize_recipients(
395
+ "cc", cast(list[str] | None, arguments.get("cc")) or []
396
+ )
397
+ recipients_bcc = self._normalize_recipients(
398
+ "bcc", cast(list[str] | None, arguments.get("bcc")) or []
399
+ )
400
+ text_body = cast(str | None, arguments.get("text_body"))
401
+ html_body = cast(str | None, arguments.get("html_body"))
402
+ if not text_body and not html_body:
403
+ raise ValueError("send_email requires text_body or html_body")
404
+ normalized_subject = cast(str, arguments.get("subject")).strip()
405
+ if not normalized_subject:
406
+ raise ValueError("send_email requires a non-empty subject")
407
+
408
+ recipients = recipients_to + recipients_cc + recipients_bcc
409
+ send_decision = self._send_policy_decision(
410
+ account_name=account,
411
+ smtp_policy=smtp_policy,
412
+ recipients=recipients,
413
+ ).evaluate()
414
+ if not send_decision.allowed:
415
+ return self._decision_check_result(operation_id, send_decision)
416
+
417
+ sent_copy_result = self._check_sent_copy_destination(
418
+ account=account,
419
+ smtp_config=smtp_config,
420
+ smtp_policy=smtp_policy,
421
+ )
422
+ return self._check_send_decision(
423
+ operation_id=operation_id,
424
+ account=account,
425
+ smtp_policy=smtp_policy,
426
+ recipients=recipients,
427
+ sent_copy_result=sent_copy_result,
428
+ )
429
+ except Exception as exc:
430
+ return {
431
+ "operation": operation_id,
432
+ "allowed": False,
433
+ "why_not": str(exc),
434
+ }
435
+
365
436
  def _resolve_context(
366
437
  self,
367
438
  account_name: str,
@@ -380,6 +451,43 @@ class SMTPRuntime:
380
451
 
381
452
  return smtp_config, smtp_policy
382
453
 
454
+ def _check_sent_copy_destination(
455
+ self,
456
+ *,
457
+ account: str,
458
+ smtp_config: SMTPConfig,
459
+ smtp_policy: SMTPServicePolicyConfig,
460
+ ) -> dict[str, object] | None:
461
+ if not smtp_policy.sent_copy.enabled:
462
+ return self._sent_copy_outcome("disabled", account=account)
463
+
464
+ folder_override = self._normalize_sent_copy_folder(smtp_config.sent_copy.folder)
465
+ if self._sent_message_appender is None:
466
+ return self._sent_copy_outcome(
467
+ "skipped",
468
+ account=account,
469
+ reason="IMAP sent-copy appender is not configured",
470
+ )
471
+
472
+ try:
473
+ destination = self._sent_message_appender.check_destination(
474
+ account=account,
475
+ folder=folder_override,
476
+ )
477
+ except Exception as exc:
478
+ return self._sent_copy_outcome(
479
+ "skipped",
480
+ account=account,
481
+ reason=str(exc),
482
+ error_type=type(exc).__name__,
483
+ )
484
+
485
+ return self._sent_copy_outcome(
486
+ "resolved",
487
+ account=destination.account,
488
+ folder=destination.folder,
489
+ )
490
+
383
491
  def _resolve_sent_copy_destination(
384
492
  self,
385
493
  *,
@@ -614,65 +722,42 @@ class SMTPRuntime:
614
722
  _, _, domain = smtp_config.from_email.partition("@")
615
723
  return domain or "localhost"
616
724
 
725
+ def _policy_now(self) -> float:
726
+ return self._time_provider()
727
+
728
+ def _rate_limit_attempt_timestamps(self) -> dict[str, list[float]]:
729
+ return self._attempt_timestamps
730
+
617
731
  def _enforce_policy(
618
732
  self,
619
733
  account_name: str,
620
734
  smtp_policy: SMTPServicePolicyConfig,
621
735
  recipients: list[str],
622
736
  ) -> None:
623
- max_recipients = smtp_policy.limits.max_recipients_per_message
624
- if max_recipients is not None and len(recipients) > max_recipients:
625
- raise ValueError(
626
- f"send_email exceeds max_recipients_per_message for account: {account_name}"
627
- )
628
-
629
- recipient_policy = smtp_policy.recipient_policy
630
- for recipient in recipients:
631
- normalized_recipient = recipient.strip().lower()
632
- _, _, domain = normalized_recipient.partition("@")
633
- if self._recipient_matches_list(
634
- normalized_recipient, recipient_policy.blocked_recipients
635
- ):
636
- raise ValueError(
637
- f"send_email recipient is blocked by exact address policy: {recipient}"
638
- )
639
- if self._domain_matches_any_pattern(
640
- domain, recipient_policy.blocked_domain_patterns
641
- ):
642
- raise ValueError(
643
- f"send_email recipient is blocked by domain policy: {recipient}"
644
- )
645
-
646
- has_allowlist = bool(
647
- recipient_policy.allowed_recipients
648
- or recipient_policy.allowed_domain_patterns
649
- )
650
- if has_allowlist and not (
651
- self._recipient_matches_list(
652
- normalized_recipient, recipient_policy.allowed_recipients
653
- )
654
- or self._domain_matches_any_pattern(
655
- domain, recipient_policy.allowed_domain_patterns
656
- )
657
- ):
658
- raise ValueError(
659
- f"send_email recipient is not allowed by policy: {recipient}"
660
- )
737
+ self._send_policy_decision(
738
+ account_name=account_name,
739
+ smtp_policy=smtp_policy,
740
+ recipients=recipients,
741
+ ).evaluate().require_allowed()
661
742
 
662
743
  def _consume_rate_limit(
663
744
  self,
664
745
  account_name: str,
665
746
  smtp_policy: SMTPServicePolicyConfig,
666
747
  ) -> None:
748
+ self._rate_limit_decision(
749
+ account_name, smtp_policy
750
+ ).evaluate().require_allowed()
667
751
  max_messages = smtp_policy.limits.max_messages_per_minute
668
752
  if max_messages is None:
669
753
  return
670
754
 
671
- now = self._time_provider()
755
+ now = self._policy_now()
672
756
  window_start = now - 60.0
757
+ attempt_timestamps = self._rate_limit_attempt_timestamps()
673
758
  active_attempts = [
674
759
  timestamp
675
- for timestamp in self._attempt_timestamps.get(account_name, [])
760
+ for timestamp in attempt_timestamps.get(account_name, [])
676
761
  if timestamp > window_start
677
762
  ]
678
763
  if len(active_attempts) >= max_messages:
@@ -681,7 +766,7 @@ class SMTPRuntime:
681
766
  )
682
767
 
683
768
  active_attempts.append(now)
684
- self._attempt_timestamps[account_name] = active_attempts
769
+ attempt_timestamps[account_name] = active_attempts
685
770
 
686
771
  def _recipient_matches_list(
687
772
  self,
@@ -947,7 +1032,7 @@ verify_peer: true
947
1032
  timeout_seconds: 30
948
1033
 
949
1034
  # 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.
1035
+ # Leave null to infer the only kind=SENT folder on the matching IMAP account.
951
1036
  sent_copy:
952
1037
  folder: null
953
1038
  """
@@ -960,9 +1045,6 @@ defaults:
960
1045
  - schema@_here_
961
1046
  - _self_
962
1047
 
963
- # Require confirmation before sending through this policy.
964
- require_confirmation: true
965
-
966
1048
  # Basic send-rate limits. Use null to disable a limit.
967
1049
  limits:
968
1050
  max_messages_per_minute: 30
@@ -1043,6 +1125,48 @@ class SMTPServicePlugin:
1043
1125
  ),
1044
1126
  )
1045
1127
 
1128
+ def check_config(
1129
+ self,
1130
+ *,
1131
+ accounts: Mapping[str, object],
1132
+ policies: Mapping[str, object],
1133
+ ) -> None:
1134
+ smtp_accounts = cast(Mapping[str, SMTPConfig], accounts)
1135
+ smtp_policies = cast(Mapping[str, SMTPServicePolicyConfig], policies)
1136
+ errors: list[ConfigCheckIssue] = []
1137
+ for account_name, smtp_config in sorted(smtp_accounts.items()):
1138
+ if smtp_config.policy not in smtp_policies:
1139
+ continue
1140
+ if (
1141
+ smtp_config.sent_copy.folder is not None
1142
+ and not smtp_config.sent_copy.folder.strip()
1143
+ ):
1144
+ errors.append(
1145
+ ConfigCheckIssue(
1146
+ message="SMTP sent_copy.folder must be non-empty",
1147
+ account=account_name,
1148
+ policy=smtp_config.policy,
1149
+ )
1150
+ )
1151
+ for policy_name, smtp_policy in sorted(smtp_policies.items()):
1152
+ if smtp_policy.idempotency.expiration_days <= 0:
1153
+ errors.append(
1154
+ ConfigCheckIssue(
1155
+ message="SMTP idempotency expiration_days must be positive",
1156
+ policy=policy_name,
1157
+ )
1158
+ )
1159
+ cache_dir = smtp_policy.idempotency.cache_dir
1160
+ if cache_dir is not None and not cache_dir.strip():
1161
+ errors.append(
1162
+ ConfigCheckIssue(
1163
+ message="SMTP idempotency cache_dir must be non-empty",
1164
+ policy=policy_name,
1165
+ )
1166
+ )
1167
+ if errors:
1168
+ raise ConfigCheckError(errors)
1169
+
1046
1170
  def describe_capability(
1047
1171
  self,
1048
1172
  context: ServicePluginContext,
@@ -1060,7 +1184,7 @@ class SMTPServicePlugin:
1060
1184
  OperationDescriptor(
1061
1185
  name="send_email",
1062
1186
  description=SEND_EMAIL_DESCRIPTION,
1063
- input_schema=SEND_EMAIL_INPUT_SCHEMA,
1187
+ input_schema=SendEmailInput,
1064
1188
  ),
1065
1189
  )
1066
1190
 
@@ -1092,6 +1216,15 @@ class SMTPServicePlugin:
1092
1216
  "idempotency_replayed": result.idempotency_replayed,
1093
1217
  }
1094
1218
 
1219
+ def check_operation(
1220
+ self,
1221
+ operation: str,
1222
+ arguments: Mapping[str, object],
1223
+ context: ServicePluginContext,
1224
+ ) -> dict[str, object]:
1225
+ runtime = context.runtimes.require(self.name, SMTPRuntime)
1226
+ return runtime.check_operation(operation, arguments)
1227
+
1095
1228
 
1096
1229
  def plugin() -> SMTPServicePlugin:
1097
1230
  return SMTPServicePlugin()
@@ -53,9 +53,14 @@ class SMTPSubmissionClient:
53
53
  code, message = response
54
54
  if code < 200 or code >= 400:
55
55
  raise smtplib.SMTPResponseException(
56
- code, f"SMTP {action} failed: {message!r}"
56
+ code, f"SMTP {action} failed: {self._format_response_message(message)}"
57
57
  )
58
58
 
59
+ def _format_response_message(self, message: bytes | str) -> str:
60
+ if isinstance(message, bytes):
61
+ return message.decode("utf-8", errors="replace")
62
+ return message
63
+
59
64
  def _build_ssl_context(self) -> ssl.SSLContext:
60
65
  if self._config.verify_peer:
61
66
  return ssl.create_default_context()
@@ -71,7 +71,6 @@ class SMTPConfig(Policy):
71
71
 
72
72
  @dataclass
73
73
  class SMTPServicePolicyConfig(Policy):
74
- require_confirmation: bool = False
75
74
  limits: SMTPLimitsConfig = field(default_factory=SMTPLimitsConfig)
76
75
  idempotency: SMTPIdempotencyConfig = field(default_factory=SMTPIdempotencyConfig)
77
76
  recipient_policy: SMTPRecipientPolicyConfig = field(
@@ -98,7 +97,6 @@ SMTP_ACCOUNT_EXAMPLE = SMTPConfig(
98
97
  )
99
98
 
100
99
  SMTP_POLICY_EXAMPLE = SMTPServicePolicyConfig(
101
- require_confirmation=True,
102
100
  limits=SMTPLimitsConfig(
103
101
  max_messages_per_minute=30,
104
102
  max_recipients_per_message=10,
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from arbiter_server.decisions import (
4
+ AllowDecision,
5
+ AndDecision,
6
+ Decision,
7
+ DecisionResult,
8
+ DenyDecision,
9
+ )
10
+
11
+ from .config import SMTPRecipientPolicyConfig, SMTPServicePolicyConfig
12
+
13
+
14
+ class _SMTPRuntimePolicyMixin:
15
+ def _policy_now(self) -> float:
16
+ raise NotImplementedError
17
+
18
+ def _rate_limit_attempt_timestamps(self) -> dict[str, list[float]]:
19
+ raise NotImplementedError
20
+
21
+ def _recipient_matches_list(
22
+ self,
23
+ recipient: str,
24
+ configured_recipients: list[str],
25
+ ) -> bool:
26
+ raise NotImplementedError
27
+
28
+ def _domain_matches_any_pattern(self, domain: str, patterns: list[str]) -> bool:
29
+ raise NotImplementedError
30
+
31
+ def _decision_check_result(
32
+ self,
33
+ operation_id: str,
34
+ decision: DecisionResult,
35
+ ) -> dict[str, object]:
36
+ result: dict[str, object] = {
37
+ "operation": operation_id,
38
+ "allowed": decision.allowed,
39
+ "evidence": decision.evidence,
40
+ }
41
+ if not decision.allowed:
42
+ result["why_not"] = decision.why_not or "operation is not allowed"
43
+ if decision.failed_gate is not None:
44
+ result["failed_gate"] = decision.failed_gate
45
+ return result
46
+
47
+ def _send_policy_decision(
48
+ self,
49
+ *,
50
+ account_name: str,
51
+ smtp_policy: SMTPServicePolicyConfig,
52
+ recipients: list[str],
53
+ ) -> Decision:
54
+ return AndDecision(
55
+ self._max_recipients_decision(
56
+ account_name=account_name,
57
+ smtp_policy=smtp_policy,
58
+ recipients=recipients,
59
+ ),
60
+ *(
61
+ self._recipient_policy_decision(
62
+ recipient=recipient,
63
+ recipient_policy=smtp_policy.recipient_policy,
64
+ )
65
+ for recipient in recipients
66
+ ),
67
+ )
68
+
69
+ def _max_recipients_decision(
70
+ self,
71
+ *,
72
+ account_name: str,
73
+ smtp_policy: SMTPServicePolicyConfig,
74
+ recipients: list[str],
75
+ ) -> Decision:
76
+ max_recipients = smtp_policy.limits.max_recipients_per_message
77
+ evidence = {
78
+ "account": account_name,
79
+ "recipient_count": len(recipients),
80
+ "max_recipients_per_message": max_recipients,
81
+ }
82
+ if max_recipients is None or len(recipients) <= max_recipients:
83
+ return AllowDecision(evidence)
84
+ return DenyDecision(
85
+ f"send_email exceeds max_recipients_per_message for account: {account_name}",
86
+ evidence,
87
+ failed_gate="max_recipients_per_message",
88
+ )
89
+
90
+ def _recipient_policy_decision(
91
+ self,
92
+ *,
93
+ recipient: str,
94
+ recipient_policy: SMTPRecipientPolicyConfig,
95
+ ) -> Decision:
96
+ normalized_recipient = recipient.strip().lower()
97
+ _, _, domain = normalized_recipient.partition("@")
98
+ evidence = {"recipient": recipient, "domain": domain}
99
+ if self._recipient_matches_list(
100
+ normalized_recipient, recipient_policy.blocked_recipients
101
+ ):
102
+ return DenyDecision(
103
+ f"send_email recipient is blocked by exact address policy: {recipient}",
104
+ evidence,
105
+ failed_gate="blocked_recipient",
106
+ )
107
+ if self._domain_matches_any_pattern(
108
+ domain, recipient_policy.blocked_domain_patterns
109
+ ):
110
+ return DenyDecision(
111
+ f"send_email recipient is blocked by domain policy: {recipient}",
112
+ evidence,
113
+ failed_gate="blocked_domain",
114
+ )
115
+
116
+ has_allowlist = bool(
117
+ recipient_policy.allowed_recipients
118
+ or recipient_policy.allowed_domain_patterns
119
+ )
120
+ if has_allowlist and not (
121
+ self._recipient_matches_list(
122
+ normalized_recipient, recipient_policy.allowed_recipients
123
+ )
124
+ or self._domain_matches_any_pattern(
125
+ domain, recipient_policy.allowed_domain_patterns
126
+ )
127
+ ):
128
+ return DenyDecision(
129
+ f"send_email recipient is not allowed by policy: {recipient}",
130
+ evidence,
131
+ failed_gate="allowed_recipients",
132
+ )
133
+ return AllowDecision(evidence)
134
+
135
+ def _rate_limit_decision(
136
+ self,
137
+ account_name: str,
138
+ smtp_policy: SMTPServicePolicyConfig,
139
+ ) -> Decision:
140
+ max_messages = smtp_policy.limits.max_messages_per_minute
141
+ evidence: dict[str, object] = {
142
+ "account": account_name,
143
+ "max_messages_per_minute": max_messages,
144
+ }
145
+ if max_messages is None:
146
+ return AllowDecision(evidence)
147
+
148
+ now = self._policy_now()
149
+ window_start = now - 60.0
150
+ attempt_timestamps = self._rate_limit_attempt_timestamps()
151
+ active_attempts = [
152
+ timestamp
153
+ for timestamp in attempt_timestamps.get(account_name, [])
154
+ if timestamp > window_start
155
+ ]
156
+ evidence["active_attempt_count"] = len(active_attempts)
157
+ if len(active_attempts) < max_messages:
158
+ return AllowDecision(evidence)
159
+ return DenyDecision(
160
+ f"send_email exceeds max_messages_per_minute for account: {account_name}",
161
+ evidence,
162
+ failed_gate="max_messages_per_minute",
163
+ )
164
+
165
+ def _sent_copy_preflight_decision(
166
+ self,
167
+ *,
168
+ smtp_policy: SMTPServicePolicyConfig,
169
+ result: dict[str, object] | None,
170
+ ) -> Decision:
171
+ if smtp_policy.sent_copy.on_failure.value != "fail":
172
+ return AllowDecision({"sent_copy": result or {"status": "not_required"}})
173
+ if result is None:
174
+ return AllowDecision({"sent_copy": {"status": "resolved"}})
175
+ if result.get("status") in {"saved", "disabled", "resolved"}:
176
+ return AllowDecision({"sent_copy": result})
177
+ reason = result.get("reason")
178
+ return DenyDecision(
179
+ f"send_email sent-copy preflight failed: {reason}",
180
+ {"sent_copy": result},
181
+ failed_gate="sent_copy",
182
+ )
183
+
184
+ def _check_send_decision(
185
+ self,
186
+ *,
187
+ operation_id: str,
188
+ account: str,
189
+ smtp_policy: SMTPServicePolicyConfig,
190
+ recipients: list[str],
191
+ sent_copy_result: dict[str, object] | None,
192
+ ) -> dict[str, object]:
193
+ decision = AndDecision(
194
+ self._send_policy_decision(
195
+ account_name=account,
196
+ smtp_policy=smtp_policy,
197
+ recipients=recipients,
198
+ ),
199
+ self._sent_copy_preflight_decision(
200
+ smtp_policy=smtp_policy,
201
+ result=sent_copy_result,
202
+ ),
203
+ self._rate_limit_decision(account, smtp_policy),
204
+ ).evaluate()
205
+ return self._decision_check_result(operation_id, decision)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arbiter-smtp
3
- Version: 0.9.1.dev2
3
+ Version: 0.9.2.dev1
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-server<0.10.0,>=0.9.1.dev2
23
+ Requires-Dist: arbiter-server<0.10.0,>=0.9.2.dev1
24
24
  Requires-Dist: diskcache<6.0,>=5.6
25
25
 
26
26
  SMTP service plugin for Arbiter.
@@ -4,6 +4,7 @@ src/arbiter_smtp/client.py
4
4
  src/arbiter_smtp/config.py
5
5
  src/arbiter_smtp/idempotency.py
6
6
  src/arbiter_smtp/py.typed
7
+ src/arbiter_smtp/runtime_policy.py
7
8
  src/arbiter_smtp.egg-info/PKG-INFO
8
9
  src/arbiter_smtp.egg-info/SOURCES.txt
9
10
  src/arbiter_smtp.egg-info/dependency_links.txt
@@ -0,0 +1,2 @@
1
+ arbiter-server<0.10.0,>=0.9.2.dev1
2
+ diskcache<6.0,>=5.6
@@ -1,2 +0,0 @@
1
- arbiter-server<0.10.0,>=0.9.1.dev2
2
- diskcache<6.0,>=5.6