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.
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/PKG-INFO +2 -2
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/pyproject.toml +3 -3
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp/__init__.py +229 -96
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp/client.py +6 -1
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp/config.py +0 -2
- arbiter_smtp-0.9.2.dev1/src/arbiter_smtp/runtime_policy.py +205 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/PKG-INFO +2 -2
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/SOURCES.txt +1 -0
- arbiter_smtp-0.9.2.dev1/src/arbiter_smtp.egg-info/requires.txt +2 -0
- arbiter_smtp-0.9.1.dev2/src/arbiter_smtp.egg-info/requires.txt +0 -2
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/setup.cfg +0 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp/idempotency.py +0 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp/py.typed +0 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/dependency_links.txt +0 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/entry_points.txt +0 -0
- {arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arbiter-smtp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{arbiter_smtp-0.9.1.dev2 → arbiter_smtp-0.9.2.dev1}/src/arbiter_smtp.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|