arbiter-imap 0.9.0.dev1__py3-none-any.whl
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.
- agent_arbiter_imap/__init__.py +638 -0
- agent_arbiter_imap/client.py +344 -0
- agent_arbiter_imap/config.py +117 -0
- agent_arbiter_imap/py.typed +1 -0
- arbiter_imap-0.9.0.dev1.dist-info/METADATA +25 -0
- arbiter_imap-0.9.0.dev1.dist-info/RECORD +9 -0
- arbiter_imap-0.9.0.dev1.dist-info/WHEEL +5 -0
- arbiter_imap-0.9.0.dev1.dist-info/entry_points.txt +2 -0
- arbiter_imap-0.9.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Callable, Protocol, cast
|
|
5
|
+
|
|
6
|
+
from hydra.core.config_store import ConfigStore
|
|
7
|
+
|
|
8
|
+
from agent_arbiter.services import (
|
|
9
|
+
CapabilityDescriptor,
|
|
10
|
+
OperationDescriptor,
|
|
11
|
+
ServicePluginContext,
|
|
12
|
+
ServiceRuntimeContext,
|
|
13
|
+
)
|
|
14
|
+
from agent_arbiter.version import distribution_version
|
|
15
|
+
|
|
16
|
+
from .config import (
|
|
17
|
+
IMAPAccessPolicyConfig,
|
|
18
|
+
IMAPConfig,
|
|
19
|
+
IMAPFlagMode,
|
|
20
|
+
register_configs as register_imap_configs,
|
|
21
|
+
resolve_imap_flag_mode,
|
|
22
|
+
resolve_system_flag_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .client import FetchedIMAPMessage
|
|
26
|
+
|
|
27
|
+
CORE_API_VERSION = "0.9"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IMAPClientProtocol(Protocol):
|
|
31
|
+
def list_messages(self, *, folder: str, limit: int) -> list[FetchedIMAPMessage]: ...
|
|
32
|
+
|
|
33
|
+
def get_message(self, *, folder: str, uid: str) -> FetchedIMAPMessage: ...
|
|
34
|
+
|
|
35
|
+
def search_messages(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
folder: str,
|
|
39
|
+
query: str,
|
|
40
|
+
limit: int,
|
|
41
|
+
) -> list[FetchedIMAPMessage]: ...
|
|
42
|
+
|
|
43
|
+
def move_message(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
source_folder: str,
|
|
47
|
+
uid: str,
|
|
48
|
+
destination_folder: str,
|
|
49
|
+
) -> None: ...
|
|
50
|
+
|
|
51
|
+
def mark_message_read(self, *, folder: str, uid: str, read: bool) -> None: ...
|
|
52
|
+
|
|
53
|
+
def delete_message(self, *, folder: str, uid: str) -> None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
IMAPClientFactory = Callable[[IMAPConfig], IMAPClientProtocol]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _object_schema(
|
|
60
|
+
properties: Mapping[str, object],
|
|
61
|
+
required: list[str],
|
|
62
|
+
) -> dict[str, object]:
|
|
63
|
+
return {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": dict(properties),
|
|
66
|
+
"required": required,
|
|
67
|
+
"additionalProperties": False,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ACCOUNT_PROPERTY = {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Configured IMAP account name.",
|
|
74
|
+
}
|
|
75
|
+
FOLDER_PROPERTY = {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Configured IMAP folder name. Defaults to the account default folder.",
|
|
78
|
+
}
|
|
79
|
+
MESSAGE_ID_PROPERTY = {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "IMAP UID scoped to the selected account and folder.",
|
|
82
|
+
}
|
|
83
|
+
LIMIT_PROPERTY = {
|
|
84
|
+
"type": "integer",
|
|
85
|
+
"minimum": 1,
|
|
86
|
+
"maximum": 100,
|
|
87
|
+
"description": "Maximum number of messages to return.",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
IMAP_OPERATION_DESCRIPTORS = (
|
|
91
|
+
OperationDescriptor(
|
|
92
|
+
name="list_messages",
|
|
93
|
+
description=(
|
|
94
|
+
"List recent messages from a configured IMAP folder on the selected "
|
|
95
|
+
"account."
|
|
96
|
+
),
|
|
97
|
+
input_schema=_object_schema(
|
|
98
|
+
{
|
|
99
|
+
"account": ACCOUNT_PROPERTY,
|
|
100
|
+
"folder": FOLDER_PROPERTY,
|
|
101
|
+
"limit": LIMIT_PROPERTY,
|
|
102
|
+
},
|
|
103
|
+
["account"],
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
OperationDescriptor(
|
|
107
|
+
name="get_message",
|
|
108
|
+
description=(
|
|
109
|
+
"Fetch one message by IMAP UID from a configured folder on the selected "
|
|
110
|
+
"account."
|
|
111
|
+
),
|
|
112
|
+
input_schema=_object_schema(
|
|
113
|
+
{
|
|
114
|
+
"account": ACCOUNT_PROPERTY,
|
|
115
|
+
"message_id": MESSAGE_ID_PROPERTY,
|
|
116
|
+
"folder": FOLDER_PROPERTY,
|
|
117
|
+
},
|
|
118
|
+
["account", "message_id"],
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
OperationDescriptor(
|
|
122
|
+
name="search_messages",
|
|
123
|
+
description=(
|
|
124
|
+
"Search messages in a configured IMAP folder using an IMAP TEXT query."
|
|
125
|
+
),
|
|
126
|
+
input_schema=_object_schema(
|
|
127
|
+
{
|
|
128
|
+
"account": ACCOUNT_PROPERTY,
|
|
129
|
+
"query": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "IMAP TEXT search query.",
|
|
132
|
+
},
|
|
133
|
+
"folder": FOLDER_PROPERTY,
|
|
134
|
+
"limit": LIMIT_PROPERTY,
|
|
135
|
+
},
|
|
136
|
+
["account", "query"],
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
OperationDescriptor(
|
|
140
|
+
name="move_message",
|
|
141
|
+
description=(
|
|
142
|
+
"Move one message by IMAP UID from a configured source folder to a "
|
|
143
|
+
"configured destination folder."
|
|
144
|
+
),
|
|
145
|
+
input_schema=_object_schema(
|
|
146
|
+
{
|
|
147
|
+
"account": ACCOUNT_PROPERTY,
|
|
148
|
+
"message_id": MESSAGE_ID_PROPERTY,
|
|
149
|
+
"destination_folder": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Configured destination folder name.",
|
|
152
|
+
},
|
|
153
|
+
"folder": FOLDER_PROPERTY,
|
|
154
|
+
},
|
|
155
|
+
["account", "message_id", "destination_folder"],
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
OperationDescriptor(
|
|
159
|
+
name="mark_message_read",
|
|
160
|
+
description="Set or clear the IMAP seen flag for one message by UID.",
|
|
161
|
+
input_schema=_object_schema(
|
|
162
|
+
{
|
|
163
|
+
"account": ACCOUNT_PROPERTY,
|
|
164
|
+
"message_id": MESSAGE_ID_PROPERTY,
|
|
165
|
+
"folder": FOLDER_PROPERTY,
|
|
166
|
+
"read": {
|
|
167
|
+
"type": "boolean",
|
|
168
|
+
"description": "Whether the message should be marked read.",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
["account", "message_id"],
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
OperationDescriptor(
|
|
175
|
+
name="delete_message",
|
|
176
|
+
description=(
|
|
177
|
+
"Delete one message by IMAP UID from a configured folder on the selected "
|
|
178
|
+
"account."
|
|
179
|
+
),
|
|
180
|
+
input_schema=_object_schema(
|
|
181
|
+
{
|
|
182
|
+
"account": ACCOUNT_PROPERTY,
|
|
183
|
+
"message_id": MESSAGE_ID_PROPERTY,
|
|
184
|
+
"folder": FOLDER_PROPERTY,
|
|
185
|
+
},
|
|
186
|
+
["account", "message_id"],
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class IMAPRuntime:
|
|
193
|
+
service_name = "imap"
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
accounts: Mapping[str, object],
|
|
198
|
+
policies: Mapping[str, object],
|
|
199
|
+
imap_client_factory: IMAPClientFactory | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
self._accounts = cast(Mapping[str, IMAPConfig], accounts)
|
|
202
|
+
self._policies = cast(
|
|
203
|
+
Mapping[str, IMAPAccessPolicyConfig],
|
|
204
|
+
policies,
|
|
205
|
+
)
|
|
206
|
+
self._imap_client_factory = imap_client_factory
|
|
207
|
+
self._validate_policy_references()
|
|
208
|
+
|
|
209
|
+
def account_summaries(self) -> dict[str, object]:
|
|
210
|
+
summaries: dict[str, object] = {}
|
|
211
|
+
for account_name, account in sorted(self._accounts.items()):
|
|
212
|
+
imap_policy = self._policies[account.policy]
|
|
213
|
+
summaries[account_name] = {
|
|
214
|
+
"description": account.description,
|
|
215
|
+
"policy": account.policy,
|
|
216
|
+
"enabled": True,
|
|
217
|
+
"confirmation_required": [
|
|
218
|
+
action.value for action in imap_policy.confirmation_required
|
|
219
|
+
],
|
|
220
|
+
"message": self._message_summary(imap_policy),
|
|
221
|
+
}
|
|
222
|
+
return summaries
|
|
223
|
+
|
|
224
|
+
def list_messages(
|
|
225
|
+
self,
|
|
226
|
+
account: str,
|
|
227
|
+
folder: str | None = None,
|
|
228
|
+
limit: int = 20,
|
|
229
|
+
) -> dict[str, object]:
|
|
230
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
231
|
+
"list_messages", account, folder
|
|
232
|
+
)
|
|
233
|
+
if not imap_policy.allow_read:
|
|
234
|
+
raise ValueError(f"list_messages is not allowed for account: {account}")
|
|
235
|
+
|
|
236
|
+
normalized_limit = self._normalize_limit(limit)
|
|
237
|
+
messages = self._make_client(imap_config).list_messages(
|
|
238
|
+
folder=folder_name,
|
|
239
|
+
limit=normalized_limit,
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"account": account,
|
|
243
|
+
"folder": folder_name,
|
|
244
|
+
"messages": [
|
|
245
|
+
self._message_to_dict(message, imap_policy, include_body=False)
|
|
246
|
+
for message in messages
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def get_message(
|
|
251
|
+
self,
|
|
252
|
+
account: str,
|
|
253
|
+
message_id: str,
|
|
254
|
+
folder: str | None = None,
|
|
255
|
+
) -> dict[str, object]:
|
|
256
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
257
|
+
"get_message", account, folder
|
|
258
|
+
)
|
|
259
|
+
if not imap_policy.allow_read:
|
|
260
|
+
raise ValueError(f"get_message is not allowed for account: {account}")
|
|
261
|
+
|
|
262
|
+
uid = self._normalize_message_uid(message_id)
|
|
263
|
+
message = self._make_client(imap_config).get_message(
|
|
264
|
+
folder=folder_name,
|
|
265
|
+
uid=uid,
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"account": account,
|
|
269
|
+
"folder": folder_name,
|
|
270
|
+
"message": self._message_to_dict(
|
|
271
|
+
message,
|
|
272
|
+
imap_policy,
|
|
273
|
+
include_body=True,
|
|
274
|
+
),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
def search_messages(
|
|
278
|
+
self,
|
|
279
|
+
account: str,
|
|
280
|
+
query: str,
|
|
281
|
+
folder: str | None = None,
|
|
282
|
+
limit: int = 20,
|
|
283
|
+
) -> dict[str, object]:
|
|
284
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
285
|
+
"search_messages", account, folder
|
|
286
|
+
)
|
|
287
|
+
if not imap_policy.allow_search:
|
|
288
|
+
raise ValueError(f"search_messages is not allowed for account: {account}")
|
|
289
|
+
|
|
290
|
+
normalized_query = query.strip()
|
|
291
|
+
if not normalized_query:
|
|
292
|
+
raise ValueError("search_messages requires a non-empty query")
|
|
293
|
+
|
|
294
|
+
normalized_limit = self._normalize_limit(limit)
|
|
295
|
+
messages = self._make_client(imap_config).search_messages(
|
|
296
|
+
folder=folder_name,
|
|
297
|
+
query=normalized_query,
|
|
298
|
+
limit=normalized_limit,
|
|
299
|
+
)
|
|
300
|
+
return {
|
|
301
|
+
"account": account,
|
|
302
|
+
"folder": folder_name,
|
|
303
|
+
"query": normalized_query,
|
|
304
|
+
"messages": [
|
|
305
|
+
self._message_to_dict(message, imap_policy, include_body=False)
|
|
306
|
+
for message in messages
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def move_message(
|
|
311
|
+
self,
|
|
312
|
+
account: str,
|
|
313
|
+
message_id: str,
|
|
314
|
+
destination_folder: str,
|
|
315
|
+
folder: str | None = None,
|
|
316
|
+
) -> dict[str, object]:
|
|
317
|
+
imap_config, imap_policy, source_folder = self._resolve_context(
|
|
318
|
+
"move_message", account, folder
|
|
319
|
+
)
|
|
320
|
+
if not imap_policy.allow_move:
|
|
321
|
+
raise ValueError(f"move_message is not allowed for account: {account}")
|
|
322
|
+
|
|
323
|
+
uid = self._normalize_message_uid(message_id)
|
|
324
|
+
normalized_destination = self._resolve_folder(
|
|
325
|
+
"move_message", imap_config, destination_folder
|
|
326
|
+
)
|
|
327
|
+
self._make_client(imap_config).move_message(
|
|
328
|
+
source_folder=source_folder,
|
|
329
|
+
uid=uid,
|
|
330
|
+
destination_folder=normalized_destination,
|
|
331
|
+
)
|
|
332
|
+
return {
|
|
333
|
+
"ok": True,
|
|
334
|
+
"account": account,
|
|
335
|
+
"source_folder": source_folder,
|
|
336
|
+
"destination_folder": normalized_destination,
|
|
337
|
+
"message_id": uid,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
def mark_message_read(
|
|
341
|
+
self,
|
|
342
|
+
account: str,
|
|
343
|
+
message_id: str,
|
|
344
|
+
folder: str | None = None,
|
|
345
|
+
read: bool = True,
|
|
346
|
+
) -> dict[str, object]:
|
|
347
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
348
|
+
"mark_message_read", account, folder
|
|
349
|
+
)
|
|
350
|
+
if resolve_imap_flag_mode(imap_policy, "\\Seen") is not IMAPFlagMode.read_write:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"mark_message_read requires read_write access to the seen flag for account: {account}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
uid = self._normalize_message_uid(message_id)
|
|
356
|
+
self._make_client(imap_config).mark_message_read(
|
|
357
|
+
folder=folder_name,
|
|
358
|
+
uid=uid,
|
|
359
|
+
read=read,
|
|
360
|
+
)
|
|
361
|
+
return {
|
|
362
|
+
"ok": True,
|
|
363
|
+
"account": account,
|
|
364
|
+
"folder": folder_name,
|
|
365
|
+
"message_id": uid,
|
|
366
|
+
"read": read,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
def delete_message(
|
|
370
|
+
self,
|
|
371
|
+
account: str,
|
|
372
|
+
message_id: str,
|
|
373
|
+
folder: str | None = None,
|
|
374
|
+
) -> dict[str, object]:
|
|
375
|
+
imap_config, imap_policy, folder_name = self._resolve_context(
|
|
376
|
+
"delete_message", account, folder
|
|
377
|
+
)
|
|
378
|
+
if not imap_policy.allow_delete:
|
|
379
|
+
raise ValueError(f"delete_message is not allowed for account: {account}")
|
|
380
|
+
|
|
381
|
+
uid = self._normalize_message_uid(message_id)
|
|
382
|
+
self._make_client(imap_config).delete_message(
|
|
383
|
+
folder=folder_name,
|
|
384
|
+
uid=uid,
|
|
385
|
+
)
|
|
386
|
+
return {
|
|
387
|
+
"ok": True,
|
|
388
|
+
"account": account,
|
|
389
|
+
"folder": folder_name,
|
|
390
|
+
"message_id": uid,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
def _resolve_context(
|
|
394
|
+
self,
|
|
395
|
+
tool_name: str,
|
|
396
|
+
account_name: str,
|
|
397
|
+
folder: str | None,
|
|
398
|
+
) -> tuple[IMAPConfig, IMAPAccessPolicyConfig, str]:
|
|
399
|
+
imap_config = self._accounts.get(account_name)
|
|
400
|
+
if imap_config is None:
|
|
401
|
+
raise ValueError(
|
|
402
|
+
f"{tool_name} requires an IMAP-enabled account: {account_name}"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
folder_name = self._resolve_optional_folder(tool_name, imap_config, folder)
|
|
406
|
+
imap_policy = self._policies.get(imap_config.policy)
|
|
407
|
+
if imap_policy is None:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"{tool_name} account references an unknown IMAP policy: {account_name}"
|
|
410
|
+
)
|
|
411
|
+
return imap_config, imap_policy, folder_name
|
|
412
|
+
|
|
413
|
+
def _validate_policy_references(self) -> None:
|
|
414
|
+
for account_name, imap_config in sorted(self._accounts.items()):
|
|
415
|
+
if imap_config.policy not in self._policies:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"IMAP account references an unknown policy: "
|
|
418
|
+
f"{account_name} -> {imap_config.policy}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _resolve_optional_folder(
|
|
422
|
+
self,
|
|
423
|
+
tool_name: str,
|
|
424
|
+
imap_config: IMAPConfig,
|
|
425
|
+
folder: str | None,
|
|
426
|
+
) -> str:
|
|
427
|
+
folder_name = folder.strip() if folder else imap_config.default_folder
|
|
428
|
+
if not folder_name:
|
|
429
|
+
raise ValueError(
|
|
430
|
+
f"{tool_name} requires folder when the account has no default_folder"
|
|
431
|
+
)
|
|
432
|
+
return self._resolve_folder(tool_name, imap_config, folder_name)
|
|
433
|
+
|
|
434
|
+
def _resolve_folder(
|
|
435
|
+
self,
|
|
436
|
+
tool_name: str,
|
|
437
|
+
imap_config: IMAPConfig,
|
|
438
|
+
folder: str,
|
|
439
|
+
) -> str:
|
|
440
|
+
folder_name = folder.strip()
|
|
441
|
+
if not folder_name:
|
|
442
|
+
raise ValueError(f"{tool_name} requires a non-empty folder")
|
|
443
|
+
if folder_name not in imap_config.folders:
|
|
444
|
+
raise ValueError(f"{tool_name} received an unconfigured folder: {folder}")
|
|
445
|
+
return folder_name
|
|
446
|
+
|
|
447
|
+
def _make_client(self, imap_config: IMAPConfig) -> IMAPClientProtocol:
|
|
448
|
+
if self._imap_client_factory is None:
|
|
449
|
+
raise RuntimeError("IMAP client factory is not configured")
|
|
450
|
+
return self._imap_client_factory(imap_config)
|
|
451
|
+
|
|
452
|
+
def _message_summary(
|
|
453
|
+
self,
|
|
454
|
+
imap_policy: IMAPAccessPolicyConfig,
|
|
455
|
+
) -> dict[str, object]:
|
|
456
|
+
flags = self._flag_summary(imap_policy)
|
|
457
|
+
return {
|
|
458
|
+
"read_allowed": imap_policy.allow_read,
|
|
459
|
+
"move_allowed": imap_policy.allow_move,
|
|
460
|
+
"delete_allowed": imap_policy.allow_delete,
|
|
461
|
+
"flags": flags,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
def _flag_summary(
|
|
465
|
+
self,
|
|
466
|
+
imap_policy: IMAPAccessPolicyConfig,
|
|
467
|
+
) -> dict[str, object]:
|
|
468
|
+
system_flags = {
|
|
469
|
+
"seen": imap_policy.system_flags.seen,
|
|
470
|
+
"flagged": imap_policy.system_flags.flagged,
|
|
471
|
+
"answered": imap_policy.system_flags.answered,
|
|
472
|
+
"deleted": imap_policy.system_flags.deleted,
|
|
473
|
+
"draft": imap_policy.system_flags.draft,
|
|
474
|
+
}
|
|
475
|
+
flags: dict[str, object] = {
|
|
476
|
+
flag_name: mode.value for flag_name, mode in system_flags.items()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
user_flags = {
|
|
480
|
+
flag_name: mode.value
|
|
481
|
+
for flag_name, mode in sorted(imap_policy.user_flags.items())
|
|
482
|
+
if mode is not IMAPFlagMode.hidden
|
|
483
|
+
}
|
|
484
|
+
if user_flags:
|
|
485
|
+
flags["user"] = user_flags
|
|
486
|
+
|
|
487
|
+
return flags
|
|
488
|
+
|
|
489
|
+
def _normalize_limit(self, limit: int) -> int:
|
|
490
|
+
if limit < 1:
|
|
491
|
+
raise ValueError("IMAP message limit must be at least 1")
|
|
492
|
+
if limit > 100:
|
|
493
|
+
raise ValueError("IMAP message limit must be at most 100")
|
|
494
|
+
return limit
|
|
495
|
+
|
|
496
|
+
def _normalize_message_uid(self, message_id: str) -> str:
|
|
497
|
+
uid = message_id.strip()
|
|
498
|
+
if not uid:
|
|
499
|
+
raise ValueError("IMAP message_id must be non-empty")
|
|
500
|
+
if not uid.isdigit():
|
|
501
|
+
raise ValueError("IMAP message_id must be an IMAP UID")
|
|
502
|
+
return uid
|
|
503
|
+
|
|
504
|
+
def _message_to_dict(
|
|
505
|
+
self,
|
|
506
|
+
message: FetchedIMAPMessage,
|
|
507
|
+
imap_policy: IMAPAccessPolicyConfig,
|
|
508
|
+
*,
|
|
509
|
+
include_body: bool,
|
|
510
|
+
) -> dict[str, object]:
|
|
511
|
+
message_dict: dict[str, object] = {
|
|
512
|
+
"id": message.uid,
|
|
513
|
+
"uid": message.uid,
|
|
514
|
+
"subject": message.subject,
|
|
515
|
+
"from": message.from_addr,
|
|
516
|
+
"to": message.to,
|
|
517
|
+
"cc": message.cc,
|
|
518
|
+
"date": message.date,
|
|
519
|
+
"flags": self._visible_flags(imap_policy, message.flags),
|
|
520
|
+
}
|
|
521
|
+
if message.rfc822_message_id:
|
|
522
|
+
message_dict["rfc822_message_id"] = message.rfc822_message_id
|
|
523
|
+
if message.snippet:
|
|
524
|
+
message_dict["snippet"] = message.snippet
|
|
525
|
+
if include_body:
|
|
526
|
+
message_dict["text_body"] = message.text_body
|
|
527
|
+
message_dict["html_body"] = message.html_body
|
|
528
|
+
return message_dict
|
|
529
|
+
|
|
530
|
+
def _visible_flags(
|
|
531
|
+
self,
|
|
532
|
+
imap_policy: IMAPAccessPolicyConfig,
|
|
533
|
+
flags: list[str],
|
|
534
|
+
) -> list[str]:
|
|
535
|
+
visible_flags: list[str] = []
|
|
536
|
+
for flag in flags:
|
|
537
|
+
mode = resolve_imap_flag_mode(imap_policy, flag)
|
|
538
|
+
if mode is IMAPFlagMode.hidden:
|
|
539
|
+
continue
|
|
540
|
+
visible_flags.append(resolve_system_flag_key(flag) or flag)
|
|
541
|
+
return visible_flags
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class IMAPServicePlugin:
|
|
545
|
+
name = "imap"
|
|
546
|
+
version = distribution_version("arbiter-imap", package_file=__file__)
|
|
547
|
+
core_api_version = CORE_API_VERSION
|
|
548
|
+
|
|
549
|
+
def register_configs(self, config_store: ConfigStore) -> None:
|
|
550
|
+
register_imap_configs(config_store)
|
|
551
|
+
|
|
552
|
+
def bootstrap_config(self, *, kind: str, name: str) -> object | None:
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
def build_runtime(
|
|
556
|
+
self,
|
|
557
|
+
accounts: Mapping[str, object],
|
|
558
|
+
policies: Mapping[str, object],
|
|
559
|
+
context: ServiceRuntimeContext,
|
|
560
|
+
) -> object:
|
|
561
|
+
from .client import IMAPClient
|
|
562
|
+
|
|
563
|
+
imap_client_factory = cast(
|
|
564
|
+
IMAPClientFactory,
|
|
565
|
+
context.dependencies.get("imap_client_factory", IMAPClient),
|
|
566
|
+
)
|
|
567
|
+
return IMAPRuntime(
|
|
568
|
+
accounts=accounts,
|
|
569
|
+
policies=policies,
|
|
570
|
+
imap_client_factory=imap_client_factory,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def describe_capability(
|
|
574
|
+
self,
|
|
575
|
+
context: ServicePluginContext,
|
|
576
|
+
) -> CapabilityDescriptor:
|
|
577
|
+
return CapabilityDescriptor(
|
|
578
|
+
name=self.name,
|
|
579
|
+
description="Read and manage mail through configured IMAP accounts.",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def describe_operations(
|
|
583
|
+
self,
|
|
584
|
+
context: ServicePluginContext,
|
|
585
|
+
) -> tuple[OperationDescriptor, ...]:
|
|
586
|
+
return IMAP_OPERATION_DESCRIPTORS
|
|
587
|
+
|
|
588
|
+
def invoke_operation(
|
|
589
|
+
self,
|
|
590
|
+
operation: str,
|
|
591
|
+
arguments: Mapping[str, object],
|
|
592
|
+
context: ServicePluginContext,
|
|
593
|
+
) -> object:
|
|
594
|
+
runtime = context.runtimes.require(self.name, IMAPRuntime)
|
|
595
|
+
if operation == "list_messages":
|
|
596
|
+
return runtime.list_messages(
|
|
597
|
+
account=cast(str, arguments.get("account")),
|
|
598
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
599
|
+
limit=cast(int, arguments.get("limit", 20)),
|
|
600
|
+
)
|
|
601
|
+
if operation == "get_message":
|
|
602
|
+
return runtime.get_message(
|
|
603
|
+
account=cast(str, arguments.get("account")),
|
|
604
|
+
message_id=cast(str, arguments.get("message_id")),
|
|
605
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
606
|
+
)
|
|
607
|
+
if operation == "search_messages":
|
|
608
|
+
return runtime.search_messages(
|
|
609
|
+
account=cast(str, arguments.get("account")),
|
|
610
|
+
query=cast(str, arguments.get("query")),
|
|
611
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
612
|
+
limit=cast(int, arguments.get("limit", 20)),
|
|
613
|
+
)
|
|
614
|
+
if operation == "move_message":
|
|
615
|
+
return runtime.move_message(
|
|
616
|
+
account=cast(str, arguments.get("account")),
|
|
617
|
+
message_id=cast(str, arguments.get("message_id")),
|
|
618
|
+
destination_folder=cast(str, arguments.get("destination_folder")),
|
|
619
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
620
|
+
)
|
|
621
|
+
if operation == "mark_message_read":
|
|
622
|
+
return runtime.mark_message_read(
|
|
623
|
+
account=cast(str, arguments.get("account")),
|
|
624
|
+
message_id=cast(str, arguments.get("message_id")),
|
|
625
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
626
|
+
read=cast(bool, arguments.get("read", True)),
|
|
627
|
+
)
|
|
628
|
+
if operation == "delete_message":
|
|
629
|
+
return runtime.delete_message(
|
|
630
|
+
account=cast(str, arguments.get("account")),
|
|
631
|
+
message_id=cast(str, arguments.get("message_id")),
|
|
632
|
+
folder=cast(str | None, arguments.get("folder")),
|
|
633
|
+
)
|
|
634
|
+
raise ValueError(f"unknown IMAP operation: {operation}")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def plugin() -> IMAPServicePlugin:
|
|
638
|
+
return IMAPServicePlugin()
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from email import policy
|
|
5
|
+
from email.message import EmailMessage
|
|
6
|
+
from email.parser import BytesParser
|
|
7
|
+
from email.utils import formataddr, getaddresses
|
|
8
|
+
import imaplib
|
|
9
|
+
import re
|
|
10
|
+
import ssl
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import IMAPConfig, MailTlsMode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FetchedIMAPMessage:
|
|
18
|
+
uid: str
|
|
19
|
+
subject: str
|
|
20
|
+
from_addr: str
|
|
21
|
+
to: list[str]
|
|
22
|
+
cc: list[str]
|
|
23
|
+
date: str
|
|
24
|
+
flags: list[str]
|
|
25
|
+
rfc822_message_id: str | None
|
|
26
|
+
text_body: str | None
|
|
27
|
+
html_body: str | None
|
|
28
|
+
snippet: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IMAPOperationError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class IMAPClient:
|
|
36
|
+
def __init__(self, config: IMAPConfig) -> None:
|
|
37
|
+
self._config = config
|
|
38
|
+
|
|
39
|
+
def list_messages(self, *, folder: str, limit: int) -> list[FetchedIMAPMessage]:
|
|
40
|
+
with self._session() as server:
|
|
41
|
+
self._select_folder(server, folder, readonly=True)
|
|
42
|
+
uids = self._search_uids(server, "ALL")
|
|
43
|
+
selected_uids = list(reversed(uids))[:limit]
|
|
44
|
+
return [self._fetch_message(server, uid) for uid in selected_uids]
|
|
45
|
+
|
|
46
|
+
def get_message(self, *, folder: str, uid: str) -> FetchedIMAPMessage:
|
|
47
|
+
with self._session() as server:
|
|
48
|
+
self._select_folder(server, folder, readonly=True)
|
|
49
|
+
return self._fetch_message(server, uid)
|
|
50
|
+
|
|
51
|
+
def search_messages(
|
|
52
|
+
self, *, folder: str, query: str, limit: int
|
|
53
|
+
) -> list[FetchedIMAPMessage]:
|
|
54
|
+
with self._session() as server:
|
|
55
|
+
self._select_folder(server, folder, readonly=True)
|
|
56
|
+
uids = self._search_uids(server, "TEXT", self._quote_search_text(query))
|
|
57
|
+
selected_uids = list(reversed(uids))[:limit]
|
|
58
|
+
return [self._fetch_message(server, uid) for uid in selected_uids]
|
|
59
|
+
|
|
60
|
+
def move_message(
|
|
61
|
+
self, *, source_folder: str, uid: str, destination_folder: str
|
|
62
|
+
) -> None:
|
|
63
|
+
with self._session() as server:
|
|
64
|
+
self._select_folder(server, source_folder, readonly=False)
|
|
65
|
+
try:
|
|
66
|
+
status, data = server.uid("MOVE", uid, destination_folder)
|
|
67
|
+
if self._move_status_supports_fallback(status, data):
|
|
68
|
+
self._copy_then_delete(server, uid, destination_folder)
|
|
69
|
+
return
|
|
70
|
+
self._expect_ok(status, data, "move message")
|
|
71
|
+
except imaplib.IMAP4.error:
|
|
72
|
+
self._copy_then_delete(server, uid, destination_folder)
|
|
73
|
+
|
|
74
|
+
def mark_message_read(self, *, folder: str, uid: str, read: bool) -> None:
|
|
75
|
+
with self._session() as server:
|
|
76
|
+
self._select_folder(server, folder, readonly=False)
|
|
77
|
+
operation = "+FLAGS.SILENT" if read else "-FLAGS.SILENT"
|
|
78
|
+
status, data = server.uid("STORE", uid, operation, r"(\Seen)")
|
|
79
|
+
self._expect_ok(status, data, "mark message read")
|
|
80
|
+
|
|
81
|
+
def delete_message(self, *, folder: str, uid: str) -> None:
|
|
82
|
+
with self._session() as server:
|
|
83
|
+
self._select_folder(server, folder, readonly=False)
|
|
84
|
+
self._mark_deleted(server, uid)
|
|
85
|
+
self._expunge_uid(server, uid, "expunge deleted message")
|
|
86
|
+
|
|
87
|
+
def _session(self) -> IMAPSession:
|
|
88
|
+
return IMAPSession(self._connect())
|
|
89
|
+
|
|
90
|
+
def _connect(self) -> imaplib.IMAP4 | imaplib.IMAP4_SSL:
|
|
91
|
+
ssl_context = self._build_ssl_context()
|
|
92
|
+
imap_client: imaplib.IMAP4 | imaplib.IMAP4_SSL
|
|
93
|
+
if self._config.tls == MailTlsMode.implicit:
|
|
94
|
+
imap_client = imaplib.IMAP4_SSL(
|
|
95
|
+
self._config.host,
|
|
96
|
+
self._config.port,
|
|
97
|
+
ssl_context=ssl_context,
|
|
98
|
+
timeout=self._config.timeout_seconds,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
imap_client = imaplib.IMAP4(
|
|
102
|
+
self._config.host,
|
|
103
|
+
self._config.port,
|
|
104
|
+
timeout=self._config.timeout_seconds,
|
|
105
|
+
)
|
|
106
|
+
if self._config.tls == MailTlsMode.starttls:
|
|
107
|
+
imap_client.starttls(ssl_context=ssl_context)
|
|
108
|
+
|
|
109
|
+
if self._config.username:
|
|
110
|
+
imap_client.login(self._config.username, self._config.password)
|
|
111
|
+
|
|
112
|
+
return imap_client
|
|
113
|
+
|
|
114
|
+
def _build_ssl_context(self) -> ssl.SSLContext:
|
|
115
|
+
if self._config.verify_peer:
|
|
116
|
+
return ssl.create_default_context()
|
|
117
|
+
|
|
118
|
+
context = ssl.create_default_context()
|
|
119
|
+
context.check_hostname = False
|
|
120
|
+
context.verify_mode = ssl.CERT_NONE
|
|
121
|
+
return context
|
|
122
|
+
|
|
123
|
+
def _select_folder(
|
|
124
|
+
self,
|
|
125
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
126
|
+
folder: str,
|
|
127
|
+
*,
|
|
128
|
+
readonly: bool,
|
|
129
|
+
) -> None:
|
|
130
|
+
status, data = server.select(folder, readonly=readonly)
|
|
131
|
+
self._expect_ok(status, data, f"select folder {folder}")
|
|
132
|
+
|
|
133
|
+
def _search_uids(
|
|
134
|
+
self,
|
|
135
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
136
|
+
*criteria: str,
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
status, data = server.uid("SEARCH", None, *criteria) # type: ignore[arg-type]
|
|
139
|
+
self._expect_ok(status, data, "search messages")
|
|
140
|
+
if not data:
|
|
141
|
+
return []
|
|
142
|
+
raw_uids = data[0]
|
|
143
|
+
if isinstance(raw_uids, bytes):
|
|
144
|
+
return [uid.decode("ascii") for uid in raw_uids.split()]
|
|
145
|
+
if isinstance(raw_uids, str):
|
|
146
|
+
return raw_uids.split()
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
def _fetch_message(
|
|
150
|
+
self,
|
|
151
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
152
|
+
uid: str,
|
|
153
|
+
) -> FetchedIMAPMessage:
|
|
154
|
+
flags = self._fetch_flags(server, uid)
|
|
155
|
+
message_bytes = self._fetch_message_bytes(server, uid)
|
|
156
|
+
email_message = BytesParser(policy=policy.default).parsebytes(message_bytes)
|
|
157
|
+
text_body, html_body = self._extract_bodies(email_message)
|
|
158
|
+
snippet = self._snippet_from_body(text_body or html_body or "")
|
|
159
|
+
return FetchedIMAPMessage(
|
|
160
|
+
uid=uid,
|
|
161
|
+
subject=email_message.get("Subject", ""),
|
|
162
|
+
from_addr=self._first_address(email_message, "From"),
|
|
163
|
+
to=self._addresses(email_message, "To"),
|
|
164
|
+
cc=self._addresses(email_message, "Cc"),
|
|
165
|
+
date=email_message.get("Date", ""),
|
|
166
|
+
flags=flags,
|
|
167
|
+
rfc822_message_id=email_message.get("Message-ID"),
|
|
168
|
+
text_body=text_body,
|
|
169
|
+
html_body=html_body,
|
|
170
|
+
snippet=snippet,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _fetch_flags(
|
|
174
|
+
self,
|
|
175
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
176
|
+
uid: str,
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
status, data = server.uid("FETCH", uid, "(FLAGS)")
|
|
179
|
+
self._expect_ok(status, data, "fetch message flags")
|
|
180
|
+
flags: list[str] = []
|
|
181
|
+
for item in data:
|
|
182
|
+
raw = self._raw_fetch_item(item)
|
|
183
|
+
if raw is None:
|
|
184
|
+
continue
|
|
185
|
+
match = re.search(rb"FLAGS \((.*?)\)", raw)
|
|
186
|
+
if match is None:
|
|
187
|
+
continue
|
|
188
|
+
flags.extend(
|
|
189
|
+
flag.decode("utf-8", errors="replace")
|
|
190
|
+
for flag in match.group(1).split()
|
|
191
|
+
)
|
|
192
|
+
return flags
|
|
193
|
+
|
|
194
|
+
def _fetch_message_bytes(
|
|
195
|
+
self,
|
|
196
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
197
|
+
uid: str,
|
|
198
|
+
) -> bytes:
|
|
199
|
+
status, data = server.uid("FETCH", uid, "(RFC822)")
|
|
200
|
+
self._expect_ok(status, data, "fetch message body")
|
|
201
|
+
for item in data:
|
|
202
|
+
if (
|
|
203
|
+
isinstance(item, tuple)
|
|
204
|
+
and len(item) >= 2
|
|
205
|
+
and isinstance(item[1], bytes)
|
|
206
|
+
):
|
|
207
|
+
return item[1]
|
|
208
|
+
raise IMAPOperationError(f"IMAP fetch for UID {uid} did not return RFC822 data")
|
|
209
|
+
|
|
210
|
+
def _copy_then_delete(
|
|
211
|
+
self,
|
|
212
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
213
|
+
uid: str,
|
|
214
|
+
destination_folder: str,
|
|
215
|
+
) -> None:
|
|
216
|
+
status, data = server.uid("COPY", uid, destination_folder)
|
|
217
|
+
self._expect_ok(status, data, "copy message")
|
|
218
|
+
self._mark_deleted(server, uid)
|
|
219
|
+
self._expunge_uid(server, uid, "expunge moved message")
|
|
220
|
+
|
|
221
|
+
def _mark_deleted(
|
|
222
|
+
self,
|
|
223
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
224
|
+
uid: str,
|
|
225
|
+
) -> None:
|
|
226
|
+
status, data = server.uid("STORE", uid, "+FLAGS.SILENT", r"(\Deleted)")
|
|
227
|
+
self._expect_ok(status, data, "mark message deleted")
|
|
228
|
+
|
|
229
|
+
def _expunge_uid(
|
|
230
|
+
self,
|
|
231
|
+
server: imaplib.IMAP4 | imaplib.IMAP4_SSL,
|
|
232
|
+
uid: str,
|
|
233
|
+
action: str,
|
|
234
|
+
) -> None:
|
|
235
|
+
status, data = server.uid("EXPUNGE", uid)
|
|
236
|
+
self._expect_ok(status, data, action)
|
|
237
|
+
|
|
238
|
+
def _expect_ok(self, status: str, data: list[Any], action: str) -> None:
|
|
239
|
+
if status.upper() != "OK":
|
|
240
|
+
raise IMAPOperationError(f"IMAP {action} failed: {status} {data!r}")
|
|
241
|
+
|
|
242
|
+
def _move_status_supports_fallback(self, status: str, data: list[Any]) -> bool:
|
|
243
|
+
normalized_status = status.upper()
|
|
244
|
+
if normalized_status not in {"BAD", "NO"}:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
response_text = " ".join(
|
|
248
|
+
(
|
|
249
|
+
item.decode("utf-8", errors="replace")
|
|
250
|
+
if isinstance(item, bytes)
|
|
251
|
+
else str(item)
|
|
252
|
+
)
|
|
253
|
+
for item in data
|
|
254
|
+
).lower()
|
|
255
|
+
return any(
|
|
256
|
+
marker in response_text
|
|
257
|
+
for marker in (
|
|
258
|
+
"move unsupported",
|
|
259
|
+
"move unavailable",
|
|
260
|
+
"move not supported",
|
|
261
|
+
"unknown command",
|
|
262
|
+
"unrecognized command",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _raw_fetch_item(self, item: object) -> bytes | None:
|
|
267
|
+
if isinstance(item, bytes):
|
|
268
|
+
return item
|
|
269
|
+
if isinstance(item, tuple) and item and isinstance(item[0], bytes):
|
|
270
|
+
return item[0]
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def _quote_search_text(self, query: str) -> str:
|
|
274
|
+
return '"' + query.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
275
|
+
|
|
276
|
+
def _addresses(self, message: EmailMessage, header_name: str) -> list[str]:
|
|
277
|
+
values = message.get_all(header_name, [])
|
|
278
|
+
return [
|
|
279
|
+
formataddr((display_name, address)) if display_name else address
|
|
280
|
+
for display_name, address in getaddresses(values)
|
|
281
|
+
if address
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
def _first_address(self, message: EmailMessage, header_name: str) -> str:
|
|
285
|
+
addresses = self._addresses(message, header_name)
|
|
286
|
+
if not addresses:
|
|
287
|
+
return ""
|
|
288
|
+
return addresses[0]
|
|
289
|
+
|
|
290
|
+
def _extract_bodies(self, message: EmailMessage) -> tuple[str | None, str | None]:
|
|
291
|
+
text_body: str | None = None
|
|
292
|
+
html_body: str | None = None
|
|
293
|
+
|
|
294
|
+
if message.is_multipart():
|
|
295
|
+
for part in message.walk():
|
|
296
|
+
email_part = part
|
|
297
|
+
if email_part.is_multipart():
|
|
298
|
+
continue
|
|
299
|
+
if email_part.get_content_disposition() == "attachment":
|
|
300
|
+
continue
|
|
301
|
+
content_type = email_part.get_content_type()
|
|
302
|
+
if content_type == "text/plain" and text_body is None:
|
|
303
|
+
text_body = self._part_content(email_part)
|
|
304
|
+
elif content_type == "text/html" and html_body is None:
|
|
305
|
+
html_body = self._part_content(email_part)
|
|
306
|
+
return text_body, html_body
|
|
307
|
+
|
|
308
|
+
content_type = message.get_content_type()
|
|
309
|
+
if content_type == "text/html":
|
|
310
|
+
return None, self._part_content(message)
|
|
311
|
+
return self._part_content(message), None
|
|
312
|
+
|
|
313
|
+
def _part_content(self, part: EmailMessage) -> str:
|
|
314
|
+
content = part.get_content()
|
|
315
|
+
if isinstance(content, str):
|
|
316
|
+
return content
|
|
317
|
+
if isinstance(content, bytes):
|
|
318
|
+
return content.decode(
|
|
319
|
+
part.get_content_charset() or "utf-8", errors="replace"
|
|
320
|
+
)
|
|
321
|
+
return str(content)
|
|
322
|
+
|
|
323
|
+
def _snippet_from_body(self, body: str) -> str:
|
|
324
|
+
compact = " ".join(body.split())
|
|
325
|
+
return compact[:240]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class IMAPSession:
|
|
329
|
+
def __init__(self, server: imaplib.IMAP4 | imaplib.IMAP4_SSL) -> None:
|
|
330
|
+
self._server = server
|
|
331
|
+
|
|
332
|
+
def __enter__(self) -> imaplib.IMAP4 | imaplib.IMAP4_SSL:
|
|
333
|
+
return self._server
|
|
334
|
+
|
|
335
|
+
def __exit__(
|
|
336
|
+
self,
|
|
337
|
+
exc_type: type[BaseException] | None,
|
|
338
|
+
exc: BaseException | None,
|
|
339
|
+
tb: object,
|
|
340
|
+
) -> None:
|
|
341
|
+
try:
|
|
342
|
+
self._server.logout()
|
|
343
|
+
except OSError:
|
|
344
|
+
return
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from agent_arbiter.config import Policy
|
|
7
|
+
from hydra.core.config_store import ConfigStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MailTlsMode(str, Enum):
|
|
11
|
+
none = "none"
|
|
12
|
+
starttls = "starttls"
|
|
13
|
+
implicit = "implicit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IMAPFlagMode(str, Enum):
|
|
17
|
+
hidden = "hidden"
|
|
18
|
+
read_only = "read_only"
|
|
19
|
+
read_write = "read_write"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IMAPConfirmationAction(str, Enum):
|
|
23
|
+
read = "read"
|
|
24
|
+
search = "search"
|
|
25
|
+
move = "move"
|
|
26
|
+
mark_read = "mark_read"
|
|
27
|
+
delete = "delete"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class IMAPFolderConfig:
|
|
32
|
+
description: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class IMAPConfig(Policy):
|
|
37
|
+
policy: str = "bot"
|
|
38
|
+
description: str = ""
|
|
39
|
+
host: str = "localhost"
|
|
40
|
+
port: int = 993
|
|
41
|
+
username: str = ""
|
|
42
|
+
password: str = ""
|
|
43
|
+
tls: MailTlsMode = MailTlsMode.implicit
|
|
44
|
+
verify_peer: bool = True
|
|
45
|
+
timeout_seconds: float = 30.0
|
|
46
|
+
default_folder: str | None = None
|
|
47
|
+
folders: dict[str, IMAPFolderConfig] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class IMAPSystemFlagsPolicyConfig:
|
|
52
|
+
seen: IMAPFlagMode = IMAPFlagMode.read_only
|
|
53
|
+
flagged: IMAPFlagMode = IMAPFlagMode.read_only
|
|
54
|
+
answered: IMAPFlagMode = IMAPFlagMode.read_only
|
|
55
|
+
deleted: IMAPFlagMode = IMAPFlagMode.read_only
|
|
56
|
+
draft: IMAPFlagMode = IMAPFlagMode.read_only
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class IMAPAccessPolicyConfig(Policy):
|
|
61
|
+
allow_read: bool = True
|
|
62
|
+
allow_search: bool = True
|
|
63
|
+
allow_move: bool = True
|
|
64
|
+
allow_delete: bool = True
|
|
65
|
+
confirmation_required: list[IMAPConfirmationAction] = field(default_factory=list)
|
|
66
|
+
system_flags: IMAPSystemFlagsPolicyConfig = field(
|
|
67
|
+
default_factory=IMAPSystemFlagsPolicyConfig
|
|
68
|
+
)
|
|
69
|
+
user_flags: dict[str, IMAPFlagMode] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
SYSTEM_FLAG_NAME_MAP = {
|
|
73
|
+
"\\Seen": "seen",
|
|
74
|
+
"\\Flagged": "flagged",
|
|
75
|
+
"\\Answered": "answered",
|
|
76
|
+
"\\Deleted": "deleted",
|
|
77
|
+
"\\Draft": "draft",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_system_flag_key(flag_name: str) -> str | None:
|
|
82
|
+
return SYSTEM_FLAG_NAME_MAP.get(flag_name)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_imap_flag_mode(
|
|
86
|
+
policy: IMAPAccessPolicyConfig,
|
|
87
|
+
flag_name: str,
|
|
88
|
+
) -> IMAPFlagMode:
|
|
89
|
+
system_flag_key = resolve_system_flag_key(flag_name)
|
|
90
|
+
if system_flag_key == "seen":
|
|
91
|
+
return policy.system_flags.seen
|
|
92
|
+
if system_flag_key == "flagged":
|
|
93
|
+
return policy.system_flags.flagged
|
|
94
|
+
if system_flag_key == "answered":
|
|
95
|
+
return policy.system_flags.answered
|
|
96
|
+
if system_flag_key == "deleted":
|
|
97
|
+
return policy.system_flags.deleted
|
|
98
|
+
if system_flag_key == "draft":
|
|
99
|
+
return policy.system_flags.draft
|
|
100
|
+
if flag_name.startswith("\\"):
|
|
101
|
+
return IMAPFlagMode.read_only
|
|
102
|
+
return policy.user_flags.get(flag_name, IMAPFlagMode.hidden)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register_configs(config_store: ConfigStore) -> None:
|
|
106
|
+
config_store.store(
|
|
107
|
+
group="arbiter/account/imap",
|
|
108
|
+
name="schema",
|
|
109
|
+
node=IMAPConfig,
|
|
110
|
+
provider="arbiter-imap",
|
|
111
|
+
)
|
|
112
|
+
config_store.store(
|
|
113
|
+
group="arbiter/policy/imap",
|
|
114
|
+
name="schema",
|
|
115
|
+
node=IMAPAccessPolicyConfig,
|
|
116
|
+
provider="arbiter-imap",
|
|
117
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arbiter-imap
|
|
3
|
+
Version: 0.9.0.dev1
|
|
4
|
+
Summary: IMAP service plugin for Arbiter
|
|
5
|
+
Author-email: Omry Yadan <omry@yadan.net>
|
|
6
|
+
Maintainer-email: Omry Yadan <omry@yadan.net>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/omry/arbiter
|
|
9
|
+
Project-URL: Repository, https://github.com/omry/arbiter
|
|
10
|
+
Keywords: agent,mcp,imap,email
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Requires-Python: <3.15,>=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: arbiter-core<0.10.0,>=0.9.0.dev1
|
|
24
|
+
|
|
25
|
+
IMAP service plugin for Arbiter.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
agent_arbiter_imap/__init__.py,sha256=QpOkIQHlmkBzzP5N_fL2GvoQYCI2OIhHynZdfiQVXpc,20844
|
|
2
|
+
agent_arbiter_imap/client.py,sha256=AcSkSgiFZ76lfp_9CvmPsX0HYSD5kplGph30ntd2W2o,12119
|
|
3
|
+
agent_arbiter_imap/config.py,sha256=Lq9V4OdawRhnJPyd47zJ96gyeiyNy-g9TQgYHQdvgR4,3080
|
|
4
|
+
agent_arbiter_imap/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
arbiter_imap-0.9.0.dev1.dist-info/METADATA,sha256=OLgl1ZMaOHOwL0rSvVLC9aYwxTQxXH2OvpY63MH-TS8,985
|
|
6
|
+
arbiter_imap-0.9.0.dev1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
arbiter_imap-0.9.0.dev1.dist-info/entry_points.txt,sha256=kww1EGT9CIlo4US329fqJUgKTOJOt0533pKxGnTqmDk,58
|
|
8
|
+
arbiter_imap-0.9.0.dev1.dist-info/top_level.txt,sha256=lWokCNTLRj3gAUsRwTziOTAfOVuZBoUUP_HM1lxG6sw,19
|
|
9
|
+
arbiter_imap-0.9.0.dev1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_arbiter_imap
|