arbiter-imap 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.
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/PKG-INFO +2 -2
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/pyproject.toml +6 -6
- {arbiter_imap-0.9.0.dev1/src/agent_arbiter_imap → arbiter_imap-0.9.1.dev2/src/arbiter_imap}/__init__.py +533 -14
- {arbiter_imap-0.9.0.dev1/src/agent_arbiter_imap → arbiter_imap-0.9.1.dev2/src/arbiter_imap}/client.py +133 -2
- {arbiter_imap-0.9.0.dev1/src/agent_arbiter_imap → arbiter_imap-0.9.1.dev2/src/arbiter_imap}/config.py +13 -1
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/src/arbiter_imap.egg-info/PKG-INFO +2 -2
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/src/arbiter_imap.egg-info/SOURCES.txt +4 -4
- arbiter_imap-0.9.1.dev2/src/arbiter_imap.egg-info/entry_points.txt +2 -0
- arbiter_imap-0.9.1.dev2/src/arbiter_imap.egg-info/requires.txt +1 -0
- arbiter_imap-0.9.1.dev2/src/arbiter_imap.egg-info/top_level.txt +1 -0
- arbiter_imap-0.9.0.dev1/src/arbiter_imap.egg-info/entry_points.txt +0 -2
- arbiter_imap-0.9.0.dev1/src/arbiter_imap.egg-info/requires.txt +0 -1
- arbiter_imap-0.9.0.dev1/src/arbiter_imap.egg-info/top_level.txt +0 -1
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/setup.cfg +0 -0
- {arbiter_imap-0.9.0.dev1/src/agent_arbiter_imap → arbiter_imap-0.9.1.dev2/src/arbiter_imap}/py.typed +0 -0
- {arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/src/arbiter_imap.egg-info/dependency_links.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arbiter-imap
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1.dev2
|
|
4
4
|
Summary: IMAP service plugin for Arbiter
|
|
5
5
|
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
6
|
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
@@ -20,6 +20,6 @@ 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-
|
|
23
|
+
Requires-Dist: arbiter-server<0.10.0,>=0.9.1.dev2
|
|
24
24
|
|
|
25
25
|
IMAP service plugin for Arbiter.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arbiter-imap"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.1.dev2"
|
|
8
8
|
description = "IMAP service plugin for Arbiter"
|
|
9
9
|
readme = { text = "IMAP service plugin for Arbiter.", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.10,<3.15"
|
|
@@ -29,15 +29,15 @@ classifiers = [
|
|
|
29
29
|
"Topic :: Communications :: Email",
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
|
-
"arbiter-
|
|
32
|
+
"arbiter-server>=0.9.1.dev2,<0.10.0",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
36
36
|
Homepage = "https://github.com/omry/arbiter"
|
|
37
37
|
Repository = "https://github.com/omry/arbiter"
|
|
38
38
|
|
|
39
|
-
[project.entry-points."
|
|
40
|
-
imap = "
|
|
39
|
+
[project.entry-points."arbiter.services"]
|
|
40
|
+
imap = "arbiter_imap:plugin"
|
|
41
41
|
|
|
42
42
|
[tool.setuptools]
|
|
43
43
|
package-dir = {"" = "src"}
|
|
@@ -46,11 +46,11 @@ package-dir = {"" = "src"}
|
|
|
46
46
|
where = ["src"]
|
|
47
47
|
|
|
48
48
|
[tool.setuptools.package-data]
|
|
49
|
-
"
|
|
49
|
+
"arbiter_imap" = ["py.typed"]
|
|
50
50
|
|
|
51
51
|
[tool.towncrier]
|
|
52
52
|
name = "Arbiter IMAP"
|
|
53
|
-
package = "
|
|
53
|
+
package = "arbiter_imap"
|
|
54
54
|
package_dir = "src"
|
|
55
55
|
filename = "NEWS.md"
|
|
56
56
|
directory = "newsfragments"
|
|
@@ -1,37 +1,50 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Mapping
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from typing import Callable, Protocol, cast
|
|
5
6
|
|
|
6
7
|
from hydra.core.config_store import ConfigStore
|
|
7
8
|
|
|
8
|
-
from
|
|
9
|
+
from arbiter_server.artifacts import PluginArtifactStore
|
|
10
|
+
from arbiter_server.services import (
|
|
9
11
|
CapabilityDescriptor,
|
|
10
12
|
OperationDescriptor,
|
|
11
13
|
ServicePluginContext,
|
|
12
14
|
ServiceRuntimeContext,
|
|
13
15
|
)
|
|
14
|
-
from
|
|
16
|
+
from arbiter_server.version import distribution_version
|
|
15
17
|
|
|
16
18
|
from .config import (
|
|
17
19
|
IMAPAccessPolicyConfig,
|
|
18
20
|
IMAPConfig,
|
|
19
21
|
IMAPFlagMode,
|
|
22
|
+
IMAPFolderConfig,
|
|
20
23
|
register_configs as register_imap_configs,
|
|
21
24
|
resolve_imap_flag_mode,
|
|
22
25
|
resolve_system_flag_key,
|
|
23
26
|
)
|
|
24
27
|
|
|
25
|
-
from .client import FetchedIMAPMessage
|
|
28
|
+
from .client import FetchedIMAPMessage, IMAPAttachmentContent
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
SERVER_API_VERSION = "0.9"
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
class IMAPClientProtocol(Protocol):
|
|
34
|
+
def test_connection(self, *, folders: Sequence[str]) -> None: ...
|
|
35
|
+
|
|
31
36
|
def list_messages(self, *, folder: str, limit: int) -> list[FetchedIMAPMessage]: ...
|
|
32
37
|
|
|
33
38
|
def get_message(self, *, folder: str, uid: str) -> FetchedIMAPMessage: ...
|
|
34
39
|
|
|
40
|
+
def get_attachment(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
folder: str,
|
|
44
|
+
uid: str,
|
|
45
|
+
attachment_id: str,
|
|
46
|
+
) -> IMAPAttachmentContent: ...
|
|
47
|
+
|
|
35
48
|
def search_messages(
|
|
36
49
|
self,
|
|
37
50
|
*,
|
|
@@ -52,6 +65,14 @@ class IMAPClientProtocol(Protocol):
|
|
|
52
65
|
|
|
53
66
|
def delete_message(self, *, folder: str, uid: str) -> None: ...
|
|
54
67
|
|
|
68
|
+
def append_message(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
folder: str,
|
|
72
|
+
message_bytes: bytes,
|
|
73
|
+
flags: Sequence[str] = (r"\Seen",),
|
|
74
|
+
) -> None: ...
|
|
75
|
+
|
|
55
76
|
|
|
56
77
|
IMAPClientFactory = Callable[[IMAPConfig], IMAPClientProtocol]
|
|
57
78
|
|
|
@@ -76,18 +97,57 @@ FOLDER_PROPERTY = {
|
|
|
76
97
|
"type": "string",
|
|
77
98
|
"description": "Configured IMAP folder name. Defaults to the account default folder.",
|
|
78
99
|
}
|
|
100
|
+
ROOT_FOLDER_PROPERTY = {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": (
|
|
103
|
+
"Optional configured folder prefix to browse or search beneath. "
|
|
104
|
+
"Omit to start at the account root."
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
FOLDER_LIMIT_PROPERTY = {
|
|
108
|
+
"type": "integer",
|
|
109
|
+
"minimum": 1,
|
|
110
|
+
"maximum": 100,
|
|
111
|
+
"description": (
|
|
112
|
+
"Maximum number of folders to return. Results include truncated=true "
|
|
113
|
+
"when more folders match."
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
RECURSIVE_PROPERTY = {
|
|
117
|
+
"type": "boolean",
|
|
118
|
+
"description": "Whether to include all configured descendants under root.",
|
|
119
|
+
}
|
|
79
120
|
MESSAGE_ID_PROPERTY = {
|
|
80
121
|
"type": "string",
|
|
81
122
|
"description": "IMAP UID scoped to the selected account and folder.",
|
|
82
123
|
}
|
|
124
|
+
ATTACHMENT_ID_PROPERTY = {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "Attachment MIME-part id returned by imap:get_message.",
|
|
127
|
+
}
|
|
83
128
|
LIMIT_PROPERTY = {
|
|
84
129
|
"type": "integer",
|
|
85
130
|
"minimum": 1,
|
|
86
131
|
"maximum": 100,
|
|
87
132
|
"description": "Maximum number of messages to return.",
|
|
88
133
|
}
|
|
89
|
-
|
|
90
134
|
IMAP_OPERATION_DESCRIPTORS = (
|
|
135
|
+
OperationDescriptor(
|
|
136
|
+
name="list_folders",
|
|
137
|
+
description=(
|
|
138
|
+
"List configured IMAP folders for the selected account, optionally "
|
|
139
|
+
"beneath a folder prefix."
|
|
140
|
+
),
|
|
141
|
+
input_schema=_object_schema(
|
|
142
|
+
{
|
|
143
|
+
"account": ACCOUNT_PROPERTY,
|
|
144
|
+
"root": ROOT_FOLDER_PROPERTY,
|
|
145
|
+
"recursive": RECURSIVE_PROPERTY,
|
|
146
|
+
"limit": FOLDER_LIMIT_PROPERTY,
|
|
147
|
+
},
|
|
148
|
+
["account"],
|
|
149
|
+
),
|
|
150
|
+
),
|
|
91
151
|
OperationDescriptor(
|
|
92
152
|
name="list_messages",
|
|
93
153
|
description=(
|
|
@@ -118,6 +178,24 @@ IMAP_OPERATION_DESCRIPTORS = (
|
|
|
118
178
|
["account", "message_id"],
|
|
119
179
|
),
|
|
120
180
|
),
|
|
181
|
+
OperationDescriptor(
|
|
182
|
+
name="get_attachment",
|
|
183
|
+
description=(
|
|
184
|
+
"Create a one-time server artifact URL for one attachment by message "
|
|
185
|
+
"UID and attachment id. The attachment bytes are not returned in the "
|
|
186
|
+
"tool result. Use local file save only when the user explicitly asks "
|
|
187
|
+
"to save the attachment."
|
|
188
|
+
),
|
|
189
|
+
input_schema=_object_schema(
|
|
190
|
+
{
|
|
191
|
+
"account": ACCOUNT_PROPERTY,
|
|
192
|
+
"message_id": MESSAGE_ID_PROPERTY,
|
|
193
|
+
"attachment_id": ATTACHMENT_ID_PROPERTY,
|
|
194
|
+
"folder": FOLDER_PROPERTY,
|
|
195
|
+
},
|
|
196
|
+
["account", "message_id", "attachment_id"],
|
|
197
|
+
),
|
|
198
|
+
),
|
|
121
199
|
OperationDescriptor(
|
|
122
200
|
name="search_messages",
|
|
123
201
|
description=(
|
|
@@ -171,6 +249,26 @@ IMAP_OPERATION_DESCRIPTORS = (
|
|
|
171
249
|
["account", "message_id"],
|
|
172
250
|
),
|
|
173
251
|
),
|
|
252
|
+
OperationDescriptor(
|
|
253
|
+
name="search_folders",
|
|
254
|
+
description=(
|
|
255
|
+
"Search configured IMAP folders for the selected account by folder "
|
|
256
|
+
"name, description, or kind."
|
|
257
|
+
),
|
|
258
|
+
input_schema=_object_schema(
|
|
259
|
+
{
|
|
260
|
+
"account": ACCOUNT_PROPERTY,
|
|
261
|
+
"query": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"description": "Text to match against configured folder metadata.",
|
|
264
|
+
},
|
|
265
|
+
"root": ROOT_FOLDER_PROPERTY,
|
|
266
|
+
"recursive": RECURSIVE_PROPERTY,
|
|
267
|
+
"limit": FOLDER_LIMIT_PROPERTY,
|
|
268
|
+
},
|
|
269
|
+
["account", "query"],
|
|
270
|
+
),
|
|
271
|
+
),
|
|
174
272
|
OperationDescriptor(
|
|
175
273
|
name="delete_message",
|
|
176
274
|
description=(
|
|
@@ -189,6 +287,12 @@ IMAP_OPERATION_DESCRIPTORS = (
|
|
|
189
287
|
)
|
|
190
288
|
|
|
191
289
|
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class _FolderItems:
|
|
292
|
+
items: list[dict[str, object]]
|
|
293
|
+
truncated: bool
|
|
294
|
+
|
|
295
|
+
|
|
192
296
|
class IMAPRuntime:
|
|
193
297
|
service_name = "imap"
|
|
194
298
|
|
|
@@ -197,6 +301,7 @@ class IMAPRuntime:
|
|
|
197
301
|
accounts: Mapping[str, object],
|
|
198
302
|
policies: Mapping[str, object],
|
|
199
303
|
imap_client_factory: IMAPClientFactory | None = None,
|
|
304
|
+
artifact_store: PluginArtifactStore | None = None,
|
|
200
305
|
) -> None:
|
|
201
306
|
self._accounts = cast(Mapping[str, IMAPConfig], accounts)
|
|
202
307
|
self._policies = cast(
|
|
@@ -204,6 +309,7 @@ class IMAPRuntime:
|
|
|
204
309
|
policies,
|
|
205
310
|
)
|
|
206
311
|
self._imap_client_factory = imap_client_factory
|
|
312
|
+
self._artifact_store = artifact_store
|
|
207
313
|
self._validate_policy_references()
|
|
208
314
|
|
|
209
315
|
def account_summaries(self) -> dict[str, object]:
|
|
@@ -212,6 +318,7 @@ class IMAPRuntime:
|
|
|
212
318
|
imap_policy = self._policies[account.policy]
|
|
213
319
|
summaries[account_name] = {
|
|
214
320
|
"description": account.description,
|
|
321
|
+
"guidance": account.guidance,
|
|
215
322
|
"policy": account.policy,
|
|
216
323
|
"enabled": True,
|
|
217
324
|
"confirmation_required": [
|
|
@@ -221,6 +328,37 @@ class IMAPRuntime:
|
|
|
221
328
|
}
|
|
222
329
|
return summaries
|
|
223
330
|
|
|
331
|
+
def test_accounts(self) -> dict[str, object]:
|
|
332
|
+
results: dict[str, object] = {}
|
|
333
|
+
for account_name, imap_config in sorted(self._accounts.items()):
|
|
334
|
+
folders = self._test_folders(imap_config)
|
|
335
|
+
try:
|
|
336
|
+
self._make_client(imap_config).test_connection(folders=folders)
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
results[account_name] = {
|
|
339
|
+
"status": "failed",
|
|
340
|
+
"stage": "connect_auth_noop_examine",
|
|
341
|
+
"folders": folders,
|
|
342
|
+
"error_type": type(exc).__name__,
|
|
343
|
+
"message": str(exc),
|
|
344
|
+
}
|
|
345
|
+
continue
|
|
346
|
+
if not folders:
|
|
347
|
+
results[account_name] = {
|
|
348
|
+
"status": "skipped",
|
|
349
|
+
"stage": "connect_auth_noop",
|
|
350
|
+
"checks": ["connect", "noop"],
|
|
351
|
+
"reason": "no configured IMAP folders to examine read-only",
|
|
352
|
+
}
|
|
353
|
+
continue
|
|
354
|
+
results[account_name] = {
|
|
355
|
+
"status": "ok",
|
|
356
|
+
"stage": "connect_auth_noop_examine",
|
|
357
|
+
"checks": ["connect", "noop", "examine"],
|
|
358
|
+
"folders": folders,
|
|
359
|
+
}
|
|
360
|
+
return results
|
|
361
|
+
|
|
224
362
|
def list_messages(
|
|
225
363
|
self,
|
|
226
364
|
account: str,
|
|
@@ -247,6 +385,31 @@ class IMAPRuntime:
|
|
|
247
385
|
],
|
|
248
386
|
}
|
|
249
387
|
|
|
388
|
+
def list_folders(
|
|
389
|
+
self,
|
|
390
|
+
account: str,
|
|
391
|
+
root: str | None = None,
|
|
392
|
+
recursive: bool = False,
|
|
393
|
+
limit: int = 50,
|
|
394
|
+
) -> dict[str, object]:
|
|
395
|
+
imap_config = self._resolve_account_config("list_folders", account)
|
|
396
|
+
normalized_root = self._normalize_folder_root(root)
|
|
397
|
+
normalized_limit = self._normalize_folder_limit(limit)
|
|
398
|
+
folder_items = self._folder_items(
|
|
399
|
+
imap_config,
|
|
400
|
+
root=normalized_root,
|
|
401
|
+
recursive=recursive,
|
|
402
|
+
limit=normalized_limit,
|
|
403
|
+
)
|
|
404
|
+
return {
|
|
405
|
+
"account": account,
|
|
406
|
+
"root": normalized_root,
|
|
407
|
+
"recursive": recursive,
|
|
408
|
+
"limit": normalized_limit,
|
|
409
|
+
"truncated": folder_items.truncated,
|
|
410
|
+
"folders": folder_items.items,
|
|
411
|
+
}
|
|
412
|
+
|
|
250
413
|
def get_message(
|
|
251
414
|
self,
|
|
252
415
|
account: str,
|
|
@@ -274,6 +437,88 @@ class IMAPRuntime:
|
|
|
274
437
|
),
|
|
275
438
|
}
|
|
276
439
|
|
|
440
|
+
def get_attachment(
|
|
441
|
+
self,
|
|
442
|
+
account: str,
|
|
443
|
+
message_id: str,
|
|
444
|
+
attachment_id: str,
|
|
445
|
+
folder: str | None = None,
|
|
446
|
+
) -> dict[str, object]:
|
|
447
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
448
|
+
"get_attachment", account, folder
|
|
449
|
+
)
|
|
450
|
+
if not imap_policy.allow_read:
|
|
451
|
+
raise ValueError(f"get_attachment is not allowed for account: {account}")
|
|
452
|
+
if self._artifact_store is None:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
"get_attachment requires server artifact storage; "
|
|
455
|
+
"HTTP artifact delivery is unavailable"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
uid = self._normalize_message_uid(message_id)
|
|
459
|
+
normalized_attachment_id = self._normalize_attachment_id(attachment_id)
|
|
460
|
+
attachment_content = self._make_client(imap_config).get_attachment(
|
|
461
|
+
folder=folder_name,
|
|
462
|
+
uid=uid,
|
|
463
|
+
attachment_id=normalized_attachment_id,
|
|
464
|
+
)
|
|
465
|
+
attachment = attachment_content.attachment
|
|
466
|
+
artifact = self._artifact_store.create(
|
|
467
|
+
content=attachment_content.content,
|
|
468
|
+
filename=attachment.filename,
|
|
469
|
+
content_type=attachment.content_type,
|
|
470
|
+
source={
|
|
471
|
+
"account": account,
|
|
472
|
+
"folder": folder_name,
|
|
473
|
+
"message_id": uid,
|
|
474
|
+
"attachment_id": attachment.id,
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
return {
|
|
478
|
+
"account": account,
|
|
479
|
+
"folder": folder_name,
|
|
480
|
+
"message_id": uid,
|
|
481
|
+
"attachment": {
|
|
482
|
+
"id": attachment.id,
|
|
483
|
+
"filename": attachment.filename,
|
|
484
|
+
"content_type": attachment.content_type,
|
|
485
|
+
"size": attachment.size,
|
|
486
|
+
"disposition": attachment.disposition,
|
|
487
|
+
"content_id": attachment.content_id,
|
|
488
|
+
"inline": attachment.inline,
|
|
489
|
+
},
|
|
490
|
+
"delivery": "arbiter_artifact",
|
|
491
|
+
"artifact": {
|
|
492
|
+
**artifact.to_dict(),
|
|
493
|
+
"handling": {
|
|
494
|
+
"prefer_inline": False,
|
|
495
|
+
"execute_locally": True,
|
|
496
|
+
"requires_explicit_user_request": True,
|
|
497
|
+
"path_interface": (
|
|
498
|
+
"arbiter artifact with-temp <url> -- <argv...{}...>"
|
|
499
|
+
),
|
|
500
|
+
"stdin_interface": "arbiter artifact with-stdin <url> -- <argv...>",
|
|
501
|
+
"save_interface": "arbiter artifact save <url> <path>",
|
|
502
|
+
"save_requires_explicit_user_request": True,
|
|
503
|
+
"instructions": (
|
|
504
|
+
"Use the one-time URL only through an explicit artifact "
|
|
505
|
+
"reader such as `arbiter artifact get --stdout` for small "
|
|
506
|
+
"textual attachments. For binary attachments, prefer "
|
|
507
|
+
"`arbiter artifact with-temp <url> -- <argv...{}...>` "
|
|
508
|
+
"for path-based tools or "
|
|
509
|
+
"`arbiter artifact with-stdin <url> -- <argv...>` for "
|
|
510
|
+
"stdin-based tools. If the user explicitly asks to save "
|
|
511
|
+
"the attachment, use "
|
|
512
|
+
"`arbiter artifact save <url> <path>`. Do not "
|
|
513
|
+
"otherwise save, copy, or persist the file."
|
|
514
|
+
),
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
def artifact_delivery_available(self) -> bool:
|
|
520
|
+
return self._artifact_store is not None
|
|
521
|
+
|
|
277
522
|
def search_messages(
|
|
278
523
|
self,
|
|
279
524
|
account: str,
|
|
@@ -307,6 +552,38 @@ class IMAPRuntime:
|
|
|
307
552
|
],
|
|
308
553
|
}
|
|
309
554
|
|
|
555
|
+
def search_folders(
|
|
556
|
+
self,
|
|
557
|
+
account: str,
|
|
558
|
+
query: str,
|
|
559
|
+
root: str | None = None,
|
|
560
|
+
recursive: bool = True,
|
|
561
|
+
limit: int = 20,
|
|
562
|
+
) -> dict[str, object]:
|
|
563
|
+
imap_config = self._resolve_account_config("search_folders", account)
|
|
564
|
+
normalized_query = query.strip()
|
|
565
|
+
if not normalized_query:
|
|
566
|
+
raise ValueError("search_folders requires a non-empty query")
|
|
567
|
+
normalized_root = self._normalize_folder_root(root)
|
|
568
|
+
normalized_limit = self._normalize_folder_limit(limit)
|
|
569
|
+
query_text = normalized_query.casefold()
|
|
570
|
+
folder_items = self._folder_items(
|
|
571
|
+
imap_config,
|
|
572
|
+
root=normalized_root,
|
|
573
|
+
recursive=recursive,
|
|
574
|
+
limit=normalized_limit,
|
|
575
|
+
query=query_text,
|
|
576
|
+
)
|
|
577
|
+
return {
|
|
578
|
+
"account": account,
|
|
579
|
+
"query": normalized_query,
|
|
580
|
+
"root": normalized_root,
|
|
581
|
+
"recursive": recursive,
|
|
582
|
+
"limit": normalized_limit,
|
|
583
|
+
"truncated": folder_items.truncated,
|
|
584
|
+
"folders": folder_items.items,
|
|
585
|
+
}
|
|
586
|
+
|
|
310
587
|
def move_message(
|
|
311
588
|
self,
|
|
312
589
|
account: str,
|
|
@@ -390,18 +667,32 @@ class IMAPRuntime:
|
|
|
390
667
|
"message_id": uid,
|
|
391
668
|
}
|
|
392
669
|
|
|
670
|
+
def append_sent_message(
|
|
671
|
+
self,
|
|
672
|
+
*,
|
|
673
|
+
account: str,
|
|
674
|
+
folder: str,
|
|
675
|
+
message_bytes: bytes,
|
|
676
|
+
) -> None:
|
|
677
|
+
imap_config = self._resolve_account_config("append_sent_message", account)
|
|
678
|
+
folder_name = self._resolve_folder(
|
|
679
|
+
"append_sent_message",
|
|
680
|
+
imap_config,
|
|
681
|
+
folder,
|
|
682
|
+
)
|
|
683
|
+
self._make_client(imap_config).append_message(
|
|
684
|
+
folder=folder_name,
|
|
685
|
+
message_bytes=message_bytes,
|
|
686
|
+
flags=(r"\Seen",),
|
|
687
|
+
)
|
|
688
|
+
|
|
393
689
|
def _resolve_context(
|
|
394
690
|
self,
|
|
395
691
|
tool_name: str,
|
|
396
692
|
account_name: str,
|
|
397
693
|
folder: str | None,
|
|
398
694
|
) -> tuple[IMAPConfig, IMAPAccessPolicyConfig, str]:
|
|
399
|
-
imap_config = self.
|
|
400
|
-
if imap_config is None:
|
|
401
|
-
raise ValueError(
|
|
402
|
-
f"{tool_name} requires an IMAP-enabled account: {account_name}"
|
|
403
|
-
)
|
|
404
|
-
|
|
695
|
+
imap_config = self._resolve_account_config(tool_name, account_name)
|
|
405
696
|
folder_name = self._resolve_optional_folder(tool_name, imap_config, folder)
|
|
406
697
|
imap_policy = self._policies.get(imap_config.policy)
|
|
407
698
|
if imap_policy is None:
|
|
@@ -410,6 +701,14 @@ class IMAPRuntime:
|
|
|
410
701
|
)
|
|
411
702
|
return imap_config, imap_policy, folder_name
|
|
412
703
|
|
|
704
|
+
def _resolve_account_config(self, tool_name: str, account_name: str) -> IMAPConfig:
|
|
705
|
+
imap_config = self._accounts.get(account_name)
|
|
706
|
+
if imap_config is None:
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"{tool_name} requires an IMAP-enabled account: {account_name}"
|
|
709
|
+
)
|
|
710
|
+
return imap_config
|
|
711
|
+
|
|
413
712
|
def _validate_policy_references(self) -> None:
|
|
414
713
|
for account_name, imap_config in sorted(self._accounts.items()):
|
|
415
714
|
if imap_config.policy not in self._policies:
|
|
@@ -449,6 +748,12 @@ class IMAPRuntime:
|
|
|
449
748
|
raise RuntimeError("IMAP client factory is not configured")
|
|
450
749
|
return self._imap_client_factory(imap_config)
|
|
451
750
|
|
|
751
|
+
def _test_folders(self, imap_config: IMAPConfig) -> list[str]:
|
|
752
|
+
folders = sorted(imap_config.folders)
|
|
753
|
+
if imap_config.default_folder and imap_config.default_folder not in folders:
|
|
754
|
+
folders.append(imap_config.default_folder)
|
|
755
|
+
return folders
|
|
756
|
+
|
|
452
757
|
def _message_summary(
|
|
453
758
|
self,
|
|
454
759
|
imap_policy: IMAPAccessPolicyConfig,
|
|
@@ -493,6 +798,87 @@ class IMAPRuntime:
|
|
|
493
798
|
raise ValueError("IMAP message limit must be at most 100")
|
|
494
799
|
return limit
|
|
495
800
|
|
|
801
|
+
def _normalize_folder_limit(self, limit: int) -> int:
|
|
802
|
+
if limit < 1:
|
|
803
|
+
raise ValueError("IMAP folder limit must be at least 1")
|
|
804
|
+
if limit > 100:
|
|
805
|
+
raise ValueError("IMAP folder limit must be at most 100")
|
|
806
|
+
return limit
|
|
807
|
+
|
|
808
|
+
def _normalize_folder_root(self, root: str | None) -> str | None:
|
|
809
|
+
if root is None:
|
|
810
|
+
return None
|
|
811
|
+
normalized = root.strip().strip("/")
|
|
812
|
+
return normalized or None
|
|
813
|
+
|
|
814
|
+
def _folder_items(
|
|
815
|
+
self,
|
|
816
|
+
imap_config: IMAPConfig,
|
|
817
|
+
*,
|
|
818
|
+
root: str | None,
|
|
819
|
+
recursive: bool,
|
|
820
|
+
limit: int,
|
|
821
|
+
query: str | None = None,
|
|
822
|
+
) -> _FolderItems:
|
|
823
|
+
items: list[dict[str, object]] = []
|
|
824
|
+
for folder_name, folder_config in sorted(imap_config.folders.items()):
|
|
825
|
+
if not self._folder_matches_root(folder_name, root, recursive=recursive):
|
|
826
|
+
continue
|
|
827
|
+
item = self._folder_to_dict(
|
|
828
|
+
folder_name,
|
|
829
|
+
folder_config,
|
|
830
|
+
default_folder=imap_config.default_folder,
|
|
831
|
+
)
|
|
832
|
+
if query is not None and not self._folder_matches_query(item, query):
|
|
833
|
+
continue
|
|
834
|
+
items.append(item)
|
|
835
|
+
if len(items) > limit:
|
|
836
|
+
return _FolderItems(items=items[:limit], truncated=True)
|
|
837
|
+
return _FolderItems(items=items, truncated=False)
|
|
838
|
+
|
|
839
|
+
def _folder_matches_root(
|
|
840
|
+
self,
|
|
841
|
+
folder_name: str,
|
|
842
|
+
root: str | None,
|
|
843
|
+
*,
|
|
844
|
+
recursive: bool,
|
|
845
|
+
) -> bool:
|
|
846
|
+
if root is None:
|
|
847
|
+
relative = folder_name
|
|
848
|
+
elif folder_name == root:
|
|
849
|
+
return False
|
|
850
|
+
elif folder_name.startswith(f"{root}/"):
|
|
851
|
+
relative = folder_name[len(root) + 1 :]
|
|
852
|
+
else:
|
|
853
|
+
return False
|
|
854
|
+
if recursive:
|
|
855
|
+
return True
|
|
856
|
+
return "/" not in relative
|
|
857
|
+
|
|
858
|
+
def _folder_to_dict(
|
|
859
|
+
self,
|
|
860
|
+
folder_name: str,
|
|
861
|
+
folder_config: IMAPFolderConfig,
|
|
862
|
+
*,
|
|
863
|
+
default_folder: str | None,
|
|
864
|
+
) -> dict[str, object]:
|
|
865
|
+
return {
|
|
866
|
+
"name": folder_name,
|
|
867
|
+
"description": folder_config.description,
|
|
868
|
+
"kind": (
|
|
869
|
+
folder_config.kind.value if folder_config.kind is not None else None
|
|
870
|
+
),
|
|
871
|
+
"default": folder_name == default_folder,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
def _folder_matches_query(self, folder: dict[str, object], query: str) -> bool:
|
|
875
|
+
searchable = [
|
|
876
|
+
cast(str, folder["name"]),
|
|
877
|
+
cast(str, folder["description"]),
|
|
878
|
+
cast(str | None, folder["kind"]) or "",
|
|
879
|
+
]
|
|
880
|
+
return any(query in value.casefold() for value in searchable)
|
|
881
|
+
|
|
496
882
|
def _normalize_message_uid(self, message_id: str) -> str:
|
|
497
883
|
uid = message_id.strip()
|
|
498
884
|
if not uid:
|
|
@@ -501,6 +887,12 @@ class IMAPRuntime:
|
|
|
501
887
|
raise ValueError("IMAP message_id must be an IMAP UID")
|
|
502
888
|
return uid
|
|
503
889
|
|
|
890
|
+
def _normalize_attachment_id(self, attachment_id: str) -> str:
|
|
891
|
+
normalized = attachment_id.strip()
|
|
892
|
+
if not normalized:
|
|
893
|
+
raise ValueError("IMAP attachment_id must be non-empty")
|
|
894
|
+
return normalized
|
|
895
|
+
|
|
504
896
|
def _message_to_dict(
|
|
505
897
|
self,
|
|
506
898
|
message: FetchedIMAPMessage,
|
|
@@ -525,6 +917,18 @@ class IMAPRuntime:
|
|
|
525
917
|
if include_body:
|
|
526
918
|
message_dict["text_body"] = message.text_body
|
|
527
919
|
message_dict["html_body"] = message.html_body
|
|
920
|
+
message_dict["attachments"] = [
|
|
921
|
+
{
|
|
922
|
+
"id": attachment.id,
|
|
923
|
+
"filename": attachment.filename,
|
|
924
|
+
"content_type": attachment.content_type,
|
|
925
|
+
"size": attachment.size,
|
|
926
|
+
"disposition": attachment.disposition,
|
|
927
|
+
"content_id": attachment.content_id,
|
|
928
|
+
"inline": attachment.inline,
|
|
929
|
+
}
|
|
930
|
+
for attachment in message.attachments
|
|
931
|
+
]
|
|
528
932
|
return message_dict
|
|
529
933
|
|
|
530
934
|
def _visible_flags(
|
|
@@ -541,15 +945,96 @@ class IMAPRuntime:
|
|
|
541
945
|
return visible_flags
|
|
542
946
|
|
|
543
947
|
|
|
948
|
+
def _imap_account_bootstrap_template(
|
|
949
|
+
*,
|
|
950
|
+
name: str,
|
|
951
|
+
policy_name: str,
|
|
952
|
+
env_suffix: str,
|
|
953
|
+
) -> str:
|
|
954
|
+
return f"""# @package arbiter.account.imap.{name}
|
|
955
|
+
defaults:
|
|
956
|
+
# Extend the plugin-owned structured schema, then override values below.
|
|
957
|
+
- schema@_here_
|
|
958
|
+
- _self_
|
|
959
|
+
|
|
960
|
+
# Human-facing summary shown by account listing tools.
|
|
961
|
+
description: IMAP account for (${{.username}})
|
|
962
|
+
|
|
963
|
+
# Operator guidance shown to agents during discovery.
|
|
964
|
+
guidance: ""
|
|
965
|
+
|
|
966
|
+
# Matching policy generated alongside this account.
|
|
967
|
+
policy: {policy_name}
|
|
968
|
+
|
|
969
|
+
# IMAP mailbox endpoint.
|
|
970
|
+
host: imap.example.com
|
|
971
|
+
port: 993
|
|
972
|
+
|
|
973
|
+
# Credentials are read from the Arbiter process environment.
|
|
974
|
+
username: ${{oc.env:IMAP_{env_suffix}_USERNAME}}
|
|
975
|
+
password: ${{oc.env:IMAP_{env_suffix}_PASSWORD}}
|
|
976
|
+
|
|
977
|
+
# TLS mode: implicit, starttls, or none.
|
|
978
|
+
tls: implicit
|
|
979
|
+
verify_peer: true
|
|
980
|
+
timeout_seconds: 30
|
|
981
|
+
|
|
982
|
+
# Default mailbox folder for tools that accept an optional folder.
|
|
983
|
+
default_folder: INBOX
|
|
984
|
+
folders:
|
|
985
|
+
INBOX:
|
|
986
|
+
description: Primary inbox.
|
|
987
|
+
# Optional folder kind: all, archive, drafts, flagged, junk, sent, or trash.
|
|
988
|
+
# These map to IMAP special-use mailbox attributes.
|
|
989
|
+
kind:
|
|
990
|
+
"""
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _imap_policy_bootstrap_template(*, name: str) -> str:
|
|
994
|
+
return f"""# @package arbiter.policy.imap.{name}
|
|
995
|
+
defaults:
|
|
996
|
+
# Extend the plugin-owned structured schema, then override values below.
|
|
997
|
+
- schema@_here_
|
|
998
|
+
- _self_
|
|
999
|
+
|
|
1000
|
+
# Read/search are enabled by default; mutating mailbox actions are disabled.
|
|
1001
|
+
allow_read: true
|
|
1002
|
+
allow_search: true
|
|
1003
|
+
allow_move: false
|
|
1004
|
+
allow_delete: false
|
|
1005
|
+
confirmation_required: []
|
|
1006
|
+
|
|
1007
|
+
# System flags remain visible but read-only unless deliberately opened up.
|
|
1008
|
+
system_flags:
|
|
1009
|
+
seen: read_only
|
|
1010
|
+
flagged: read_only
|
|
1011
|
+
answered: read_only
|
|
1012
|
+
deleted: read_only
|
|
1013
|
+
draft: read_only
|
|
1014
|
+
user_flags: {{}}
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
|
|
544
1018
|
class IMAPServicePlugin:
|
|
545
1019
|
name = "imap"
|
|
546
1020
|
version = distribution_version("arbiter-imap", package_file=__file__)
|
|
547
|
-
|
|
1021
|
+
server_api_version = SERVER_API_VERSION
|
|
548
1022
|
|
|
549
1023
|
def register_configs(self, config_store: ConfigStore) -> None:
|
|
550
1024
|
register_imap_configs(config_store)
|
|
551
1025
|
|
|
552
1026
|
def bootstrap_config(self, *, kind: str, name: str) -> object | None:
|
|
1027
|
+
if kind == "account":
|
|
1028
|
+
env_suffix = name.upper().replace("-", "_")
|
|
1029
|
+
if not env_suffix.endswith("_ACCOUNT"):
|
|
1030
|
+
env_suffix = f"{env_suffix}_ACCOUNT"
|
|
1031
|
+
return _imap_account_bootstrap_template(
|
|
1032
|
+
name=name,
|
|
1033
|
+
policy_name=f"{name}_policy",
|
|
1034
|
+
env_suffix=env_suffix,
|
|
1035
|
+
)
|
|
1036
|
+
if kind == "policy":
|
|
1037
|
+
return _imap_policy_bootstrap_template(name=name)
|
|
553
1038
|
return None
|
|
554
1039
|
|
|
555
1040
|
def build_runtime(
|
|
@@ -564,10 +1049,15 @@ class IMAPServicePlugin:
|
|
|
564
1049
|
IMAPClientFactory,
|
|
565
1050
|
context.dependencies.get("imap_client_factory", IMAPClient),
|
|
566
1051
|
)
|
|
1052
|
+
artifact_store = cast(
|
|
1053
|
+
PluginArtifactStore | None,
|
|
1054
|
+
context.dependencies.get("artifact_store"),
|
|
1055
|
+
)
|
|
567
1056
|
return IMAPRuntime(
|
|
568
1057
|
accounts=accounts,
|
|
569
1058
|
policies=policies,
|
|
570
1059
|
imap_client_factory=imap_client_factory,
|
|
1060
|
+
artifact_store=artifact_store,
|
|
571
1061
|
)
|
|
572
1062
|
|
|
573
1063
|
def describe_capability(
|
|
@@ -583,7 +1073,14 @@ class IMAPServicePlugin:
|
|
|
583
1073
|
self,
|
|
584
1074
|
context: ServicePluginContext,
|
|
585
1075
|
) -> tuple[OperationDescriptor, ...]:
|
|
586
|
-
|
|
1076
|
+
runtime = context.runtimes.require(self.name, IMAPRuntime)
|
|
1077
|
+
if runtime.artifact_delivery_available():
|
|
1078
|
+
return IMAP_OPERATION_DESCRIPTORS
|
|
1079
|
+
return tuple(
|
|
1080
|
+
descriptor
|
|
1081
|
+
for descriptor in IMAP_OPERATION_DESCRIPTORS
|
|
1082
|
+
if descriptor.name != "get_attachment"
|
|
1083
|
+
)
|
|
587
1084
|
|
|
588
1085
|
def invoke_operation(
|
|
589
1086
|
self,
|
|
@@ -598,12 +1095,26 @@ class IMAPServicePlugin:
|
|
|
598
1095
|
folder=cast(str | None, arguments.get("folder")),
|
|
599
1096
|
limit=cast(int, arguments.get("limit", 20)),
|
|
600
1097
|
)
|
|
1098
|
+
if operation == "list_folders":
|
|
1099
|
+
return runtime.list_folders(
|
|
1100
|
+
account=cast(str, arguments.get("account")),
|
|
1101
|
+
root=cast(str | None, arguments.get("root")),
|
|
1102
|
+
recursive=cast(bool, arguments.get("recursive", False)),
|
|
1103
|
+
limit=cast(int, arguments.get("limit", 50)),
|
|
1104
|
+
)
|
|
601
1105
|
if operation == "get_message":
|
|
602
1106
|
return runtime.get_message(
|
|
603
1107
|
account=cast(str, arguments.get("account")),
|
|
604
1108
|
message_id=cast(str, arguments.get("message_id")),
|
|
605
1109
|
folder=cast(str | None, arguments.get("folder")),
|
|
606
1110
|
)
|
|
1111
|
+
if operation == "get_attachment":
|
|
1112
|
+
return runtime.get_attachment(
|
|
1113
|
+
account=cast(str, arguments.get("account")),
|
|
1114
|
+
message_id=cast(str, arguments.get("message_id")),
|
|
1115
|
+
attachment_id=cast(str, arguments.get("attachment_id")),
|
|
1116
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
1117
|
+
)
|
|
607
1118
|
if operation == "search_messages":
|
|
608
1119
|
return runtime.search_messages(
|
|
609
1120
|
account=cast(str, arguments.get("account")),
|
|
@@ -611,6 +1122,14 @@ class IMAPServicePlugin:
|
|
|
611
1122
|
folder=cast(str | None, arguments.get("folder")),
|
|
612
1123
|
limit=cast(int, arguments.get("limit", 20)),
|
|
613
1124
|
)
|
|
1125
|
+
if operation == "search_folders":
|
|
1126
|
+
return runtime.search_folders(
|
|
1127
|
+
account=cast(str, arguments.get("account")),
|
|
1128
|
+
query=cast(str, arguments.get("query")),
|
|
1129
|
+
root=cast(str | None, arguments.get("root")),
|
|
1130
|
+
recursive=cast(bool, arguments.get("recursive", True)),
|
|
1131
|
+
limit=cast(int, arguments.get("limit", 20)),
|
|
1132
|
+
)
|
|
614
1133
|
if operation == "move_message":
|
|
615
1134
|
return runtime.move_message(
|
|
616
1135
|
account=cast(str, arguments.get("account")),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from email import policy
|
|
5
6
|
from email.message import EmailMessage
|
|
6
7
|
from email.parser import BytesParser
|
|
@@ -8,11 +9,28 @@ from email.utils import formataddr, getaddresses
|
|
|
8
9
|
import imaplib
|
|
9
10
|
import re
|
|
10
11
|
import ssl
|
|
11
|
-
from typing import Any
|
|
12
|
+
from typing import Any, cast
|
|
12
13
|
|
|
13
14
|
from .config import IMAPConfig, MailTlsMode
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class IMAPAttachment:
|
|
19
|
+
id: str
|
|
20
|
+
filename: str | None
|
|
21
|
+
content_type: str
|
|
22
|
+
size: int
|
|
23
|
+
disposition: str | None
|
|
24
|
+
content_id: str | None
|
|
25
|
+
inline: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class IMAPAttachmentContent:
|
|
30
|
+
attachment: IMAPAttachment
|
|
31
|
+
content: bytes
|
|
32
|
+
|
|
33
|
+
|
|
16
34
|
@dataclass(frozen=True)
|
|
17
35
|
class FetchedIMAPMessage:
|
|
18
36
|
uid: str
|
|
@@ -26,6 +44,7 @@ class FetchedIMAPMessage:
|
|
|
26
44
|
text_body: str | None
|
|
27
45
|
html_body: str | None
|
|
28
46
|
snippet: str
|
|
47
|
+
attachments: list[IMAPAttachment] = field(default_factory=list)
|
|
29
48
|
|
|
30
49
|
|
|
31
50
|
class IMAPOperationError(RuntimeError):
|
|
@@ -36,6 +55,13 @@ class IMAPClient:
|
|
|
36
55
|
def __init__(self, config: IMAPConfig) -> None:
|
|
37
56
|
self._config = config
|
|
38
57
|
|
|
58
|
+
def test_connection(self, *, folders: Sequence[str]) -> None:
|
|
59
|
+
with self._session() as server:
|
|
60
|
+
status, data = server.noop()
|
|
61
|
+
self._expect_ok(status, data, "NOOP")
|
|
62
|
+
for folder in folders:
|
|
63
|
+
self._select_folder(server, folder, readonly=True)
|
|
64
|
+
|
|
39
65
|
def list_messages(self, *, folder: str, limit: int) -> list[FetchedIMAPMessage]:
|
|
40
66
|
with self._session() as server:
|
|
41
67
|
self._select_folder(server, folder, readonly=True)
|
|
@@ -48,6 +74,22 @@ class IMAPClient:
|
|
|
48
74
|
self._select_folder(server, folder, readonly=True)
|
|
49
75
|
return self._fetch_message(server, uid)
|
|
50
76
|
|
|
77
|
+
def get_attachment(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
folder: str,
|
|
81
|
+
uid: str,
|
|
82
|
+
attachment_id: str,
|
|
83
|
+
) -> IMAPAttachmentContent:
|
|
84
|
+
with self._session() as server:
|
|
85
|
+
self._select_folder(server, folder, readonly=True)
|
|
86
|
+
message_bytes = self._fetch_message_bytes(server, uid)
|
|
87
|
+
email_message = BytesParser(policy=policy.default).parsebytes(message_bytes)
|
|
88
|
+
return self._extract_attachment_content(
|
|
89
|
+
email_message,
|
|
90
|
+
attachment_id=attachment_id,
|
|
91
|
+
)
|
|
92
|
+
|
|
51
93
|
def search_messages(
|
|
52
94
|
self, *, folder: str, query: str, limit: int
|
|
53
95
|
) -> list[FetchedIMAPMessage]:
|
|
@@ -84,6 +126,20 @@ class IMAPClient:
|
|
|
84
126
|
self._mark_deleted(server, uid)
|
|
85
127
|
self._expunge_uid(server, uid, "expunge deleted message")
|
|
86
128
|
|
|
129
|
+
def append_message(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
folder: str,
|
|
133
|
+
message_bytes: bytes,
|
|
134
|
+
flags: Sequence[str] = (r"\Seen",),
|
|
135
|
+
) -> None:
|
|
136
|
+
with self._session() as server:
|
|
137
|
+
date_time: Any = None
|
|
138
|
+
status, data = server.append(
|
|
139
|
+
folder, f"({' '.join(flags)})", date_time, message_bytes
|
|
140
|
+
)
|
|
141
|
+
self._expect_ok(status, cast(list[Any], data), "append message")
|
|
142
|
+
|
|
87
143
|
def _session(self) -> IMAPSession:
|
|
88
144
|
return IMAPSession(self._connect())
|
|
89
145
|
|
|
@@ -155,6 +211,7 @@ class IMAPClient:
|
|
|
155
211
|
message_bytes = self._fetch_message_bytes(server, uid)
|
|
156
212
|
email_message = BytesParser(policy=policy.default).parsebytes(message_bytes)
|
|
157
213
|
text_body, html_body = self._extract_bodies(email_message)
|
|
214
|
+
attachments = self._extract_attachments(email_message)
|
|
158
215
|
snippet = self._snippet_from_body(text_body or html_body or "")
|
|
159
216
|
return FetchedIMAPMessage(
|
|
160
217
|
uid=uid,
|
|
@@ -168,6 +225,7 @@ class IMAPClient:
|
|
|
168
225
|
text_body=text_body,
|
|
169
226
|
html_body=html_body,
|
|
170
227
|
snippet=snippet,
|
|
228
|
+
attachments=attachments,
|
|
171
229
|
)
|
|
172
230
|
|
|
173
231
|
def _fetch_flags(
|
|
@@ -320,6 +378,79 @@ class IMAPClient:
|
|
|
320
378
|
)
|
|
321
379
|
return str(content)
|
|
322
380
|
|
|
381
|
+
def _extract_attachments(self, message: EmailMessage) -> list[IMAPAttachment]:
|
|
382
|
+
attachments: list[IMAPAttachment] = []
|
|
383
|
+
for index, part in enumerate(self._iter_leaf_parts(message), start=1):
|
|
384
|
+
attachment = self._attachment_metadata(part, index)
|
|
385
|
+
if attachment is None:
|
|
386
|
+
continue
|
|
387
|
+
attachments.append(attachment)
|
|
388
|
+
return attachments
|
|
389
|
+
|
|
390
|
+
def _extract_attachment_content(
|
|
391
|
+
self,
|
|
392
|
+
message: EmailMessage,
|
|
393
|
+
*,
|
|
394
|
+
attachment_id: str,
|
|
395
|
+
) -> IMAPAttachmentContent:
|
|
396
|
+
for index, part in enumerate(self._iter_leaf_parts(message), start=1):
|
|
397
|
+
attachment = self._attachment_metadata(part, index)
|
|
398
|
+
if attachment is None or attachment.id != attachment_id:
|
|
399
|
+
continue
|
|
400
|
+
content = self._part_payload_bytes(part)
|
|
401
|
+
return IMAPAttachmentContent(
|
|
402
|
+
attachment=attachment,
|
|
403
|
+
content=content,
|
|
404
|
+
)
|
|
405
|
+
raise IMAPOperationError(f"attachment not found: {attachment_id}")
|
|
406
|
+
|
|
407
|
+
def _attachment_metadata(
|
|
408
|
+
self,
|
|
409
|
+
part: EmailMessage,
|
|
410
|
+
index: int,
|
|
411
|
+
) -> IMAPAttachment | None:
|
|
412
|
+
disposition = part.get_content_disposition()
|
|
413
|
+
filename = part.get_filename()
|
|
414
|
+
content_id = part.get("Content-ID")
|
|
415
|
+
if (
|
|
416
|
+
disposition not in {"attachment", "inline"}
|
|
417
|
+
and filename is None
|
|
418
|
+
and content_id is None
|
|
419
|
+
):
|
|
420
|
+
return None
|
|
421
|
+
return IMAPAttachment(
|
|
422
|
+
id=f"part-{index}",
|
|
423
|
+
filename=filename,
|
|
424
|
+
content_type=part.get_content_type(),
|
|
425
|
+
size=len(self._part_payload_bytes(part)),
|
|
426
|
+
disposition=disposition,
|
|
427
|
+
content_id=content_id,
|
|
428
|
+
inline=disposition == "inline",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _iter_leaf_parts(self, message: EmailMessage) -> list[EmailMessage]:
|
|
432
|
+
if message.is_multipart():
|
|
433
|
+
return [
|
|
434
|
+
part
|
|
435
|
+
for part in message.walk()
|
|
436
|
+
if isinstance(part, EmailMessage) and not part.is_multipart()
|
|
437
|
+
]
|
|
438
|
+
return [message]
|
|
439
|
+
|
|
440
|
+
def _part_payload_size(self, part: EmailMessage) -> int:
|
|
441
|
+
return len(self._part_payload_bytes(part))
|
|
442
|
+
|
|
443
|
+
def _part_payload_bytes(self, part: EmailMessage) -> bytes:
|
|
444
|
+
payload = part.get_payload(decode=True)
|
|
445
|
+
if isinstance(payload, bytes):
|
|
446
|
+
return payload
|
|
447
|
+
content = part.get_content()
|
|
448
|
+
if isinstance(content, str):
|
|
449
|
+
return content.encode(part.get_content_charset() or "utf-8")
|
|
450
|
+
if isinstance(content, bytes):
|
|
451
|
+
return content
|
|
452
|
+
return b""
|
|
453
|
+
|
|
323
454
|
def _snippet_from_body(self, body: str) -> str:
|
|
324
455
|
compact = " ".join(body.split())
|
|
325
456
|
return compact[:240]
|
|
@@ -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
|
|
6
|
+
from arbiter_server.config import Policy
|
|
7
7
|
from hydra.core.config_store import ConfigStore
|
|
8
8
|
|
|
9
9
|
|
|
@@ -27,15 +27,27 @@ class IMAPConfirmationAction(str, Enum):
|
|
|
27
27
|
delete = "delete"
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class IMAPFolderKind(str, Enum):
|
|
31
|
+
all = "all"
|
|
32
|
+
archive = "archive"
|
|
33
|
+
drafts = "drafts"
|
|
34
|
+
flagged = "flagged"
|
|
35
|
+
junk = "junk"
|
|
36
|
+
sent = "sent"
|
|
37
|
+
trash = "trash"
|
|
38
|
+
|
|
39
|
+
|
|
30
40
|
@dataclass
|
|
31
41
|
class IMAPFolderConfig:
|
|
32
42
|
description: str = ""
|
|
43
|
+
kind: IMAPFolderKind | None = None
|
|
33
44
|
|
|
34
45
|
|
|
35
46
|
@dataclass
|
|
36
47
|
class IMAPConfig(Policy):
|
|
37
48
|
policy: str = "bot"
|
|
38
49
|
description: str = ""
|
|
50
|
+
guidance: str = ""
|
|
39
51
|
host: str = "localhost"
|
|
40
52
|
port: int = 993
|
|
41
53
|
username: str = ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arbiter-imap
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1.dev2
|
|
4
4
|
Summary: IMAP service plugin for Arbiter
|
|
5
5
|
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
6
|
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
@@ -20,6 +20,6 @@ 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-
|
|
23
|
+
Requires-Dist: arbiter-server<0.10.0,>=0.9.1.dev2
|
|
24
24
|
|
|
25
25
|
IMAP service plugin for Arbiter.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
|
-
src/
|
|
3
|
-
src/
|
|
4
|
-
src/
|
|
5
|
-
src/
|
|
2
|
+
src/arbiter_imap/__init__.py
|
|
3
|
+
src/arbiter_imap/client.py
|
|
4
|
+
src/arbiter_imap/config.py
|
|
5
|
+
src/arbiter_imap/py.typed
|
|
6
6
|
src/arbiter_imap.egg-info/PKG-INFO
|
|
7
7
|
src/arbiter_imap.egg-info/SOURCES.txt
|
|
8
8
|
src/arbiter_imap.egg-info/dependency_links.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
arbiter-server<0.10.0,>=0.9.1.dev2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
arbiter_imap
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
arbiter-core<0.10.0,>=0.9.0.dev1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
agent_arbiter_imap
|
|
File without changes
|
{arbiter_imap-0.9.0.dev1/src/agent_arbiter_imap → arbiter_imap-0.9.1.dev2/src/arbiter_imap}/py.typed
RENAMED
|
File without changes
|
{arbiter_imap-0.9.0.dev1 → arbiter_imap-0.9.1.dev2}/src/arbiter_imap.egg-info/dependency_links.txt
RENAMED
|
File without changes
|