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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arbiter-imap
3
- Version: 0.9.0.dev1
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-core<0.10.0,>=0.9.0.dev1
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.0.dev1"
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-core>=0.9.0.dev1,<0.10.0",
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."agent_arbiter.services"]
40
- imap = "agent_arbiter_imap:plugin"
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
- "agent_arbiter_imap" = ["py.typed"]
49
+ "arbiter_imap" = ["py.typed"]
50
50
 
51
51
  [tool.towncrier]
52
52
  name = "Arbiter IMAP"
53
- package = "agent_arbiter_imap"
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 agent_arbiter.services import (
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 agent_arbiter.version import distribution_version
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
- CORE_API_VERSION = "0.9"
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._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
-
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
- core_api_version = CORE_API_VERSION
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
- return IMAP_OPERATION_DESCRIPTORS
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 agent_arbiter.config import Policy
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.0.dev1
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-core<0.10.0,>=0.9.0.dev1
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/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
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,2 @@
1
+ [arbiter.services]
2
+ imap = arbiter_imap:plugin
@@ -0,0 +1 @@
1
+ arbiter-server<0.10.0,>=0.9.1.dev2
@@ -1,2 +0,0 @@
1
- [agent_arbiter.services]
2
- imap = agent_arbiter_imap:plugin
@@ -1 +0,0 @@
1
- arbiter-core<0.10.0,>=0.9.0.dev1
@@ -1 +0,0 @@
1
- agent_arbiter_imap