arbiter-imap 0.9.0.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.
@@ -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,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "arbiter-imap"
7
+ version = "0.9.0.dev1"
8
+ description = "IMAP service plugin for Arbiter"
9
+ readme = { text = "IMAP service plugin for Arbiter.", content-type = "text/markdown" }
10
+ requires-python = ">=3.10,<3.15"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Omry Yadan", email = "omry@yadan.net" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Omry Yadan", email = "omry@yadan.net" },
17
+ ]
18
+ keywords = ["agent", "mcp", "imap", "email"]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Communications :: Email",
30
+ ]
31
+ dependencies = [
32
+ "arbiter-core>=0.9.0.dev1,<0.10.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/omry/arbiter"
37
+ Repository = "https://github.com/omry/arbiter"
38
+
39
+ [project.entry-points."agent_arbiter.services"]
40
+ imap = "agent_arbiter_imap:plugin"
41
+
42
+ [tool.setuptools]
43
+ package-dir = {"" = "src"}
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.setuptools.package-data]
49
+ "agent_arbiter_imap" = ["py.typed"]
50
+
51
+ [tool.towncrier]
52
+ name = "Arbiter IMAP"
53
+ package = "agent_arbiter_imap"
54
+ package_dir = "src"
55
+ filename = "NEWS.md"
56
+ directory = "newsfragments"
57
+ issue_format = "#{issue}"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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,11 @@
1
+ pyproject.toml
2
+ src/agent_arbiter_imap/__init__.py
3
+ src/agent_arbiter_imap/client.py
4
+ src/agent_arbiter_imap/config.py
5
+ src/agent_arbiter_imap/py.typed
6
+ src/arbiter_imap.egg-info/PKG-INFO
7
+ src/arbiter_imap.egg-info/SOURCES.txt
8
+ src/arbiter_imap.egg-info/dependency_links.txt
9
+ src/arbiter_imap.egg-info/entry_points.txt
10
+ src/arbiter_imap.egg-info/requires.txt
11
+ src/arbiter_imap.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [agent_arbiter.services]
2
+ imap = agent_arbiter_imap:plugin
@@ -0,0 +1 @@
1
+ arbiter-core<0.10.0,>=0.9.0.dev1
@@ -0,0 +1 @@
1
+ agent_arbiter_imap