imap-agent-cli 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Agent-first IMAP CLI."""
2
+
3
+ __version__ = "0.1.0"
imap_agent_cli/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import __version__
10
+ from .config import add_profile, config_status, init_config, load_config, remove_profile, resolve_profile, set_default_profile
11
+ from .errors import AppError, ConfigError
12
+ from .imap_client import ImapSession
13
+ from .mime import create_draft_message, parse_message
14
+ from .render import write_error, write_json
15
+
16
+
17
+ def _read_json_arg(value: str) -> dict[str, Any]:
18
+ if value == "-":
19
+ text = sys.stdin.read()
20
+ else:
21
+ text = Path(value).read_text(encoding="utf-8")
22
+ try:
23
+ payload = json.loads(text)
24
+ except json.JSONDecodeError as exc:
25
+ raise AppError("invalid_request", f"invalid JSON: {exc}") from exc
26
+ if not isinstance(payload, dict):
27
+ raise AppError("invalid_request", "JSON input must be an object.")
28
+ return payload
29
+
30
+
31
+ def _read_password(args: argparse.Namespace) -> str | None:
32
+ if getattr(args, "password_stdin", False):
33
+ return sys.stdin.readline().rstrip("\r\n")
34
+ return None
35
+
36
+
37
+ def _common_profile_args(parser: argparse.ArgumentParser) -> None:
38
+ parser.add_argument("--profile")
39
+ parser.add_argument("--host")
40
+ parser.add_argument("--port", type=int)
41
+ parser.add_argument("--username")
42
+ parser.add_argument("--password-stdin", action="store_true")
43
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
44
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
45
+ parser.add_argument("--ssl-mode", choices=["required", "preferred", "disabled"])
46
+
47
+
48
+ def _session(args: argparse.Namespace) -> ImapSession:
49
+ config = load_config()
50
+ profile = resolve_profile(
51
+ config,
52
+ getattr(args, "profile", None),
53
+ host=getattr(args, "host", None),
54
+ port=getattr(args, "port", None),
55
+ username=getattr(args, "username", None),
56
+ password=_read_password(args),
57
+ tls=getattr(args, "tls", None),
58
+ ssl_mode=getattr(args, "ssl_mode", None),
59
+ )
60
+ return ImapSession(profile, config.defaults)
61
+
62
+
63
+ def _max_body_chars(args: argparse.Namespace, payload: dict[str, Any] | None = None) -> int:
64
+ if getattr(args, "max_body_chars", None):
65
+ return int(args.max_body_chars)
66
+ if payload and payload.get("max_body_chars"):
67
+ return int(payload["max_body_chars"])
68
+ return load_config().defaults.max_body_chars
69
+
70
+
71
+ def _body_from_args(args: argparse.Namespace, payload: dict[str, Any]) -> str:
72
+ if getattr(args, "body", None) is not None:
73
+ return args.body
74
+ if getattr(args, "body_file", None):
75
+ return Path(args.body_file).read_text(encoding="utf-8")
76
+ if "body" in payload:
77
+ return str(payload["body"])
78
+ raise AppError("invalid_request", "draft body is required via --body, --body-file, or JSON body.")
79
+
80
+
81
+ def _string_list(value: Any) -> list[str]:
82
+ if value is None:
83
+ return []
84
+ if isinstance(value, str):
85
+ return [value]
86
+ if isinstance(value, list):
87
+ items: list[str] = []
88
+ for item in value:
89
+ if isinstance(item, str):
90
+ items.append(item)
91
+ elif isinstance(item, dict) and item.get("email"):
92
+ email = str(item["email"])
93
+ name = str(item.get("name", "")).strip()
94
+ items.append(f"{name} <{email}>" if name else email)
95
+ return items
96
+ raise AppError("invalid_request", "address fields must be strings, lists of strings, or address objects.")
97
+
98
+
99
+ def _parse_repeated_addresses(values: list[str] | None) -> list[str]:
100
+ return values or []
101
+
102
+
103
+ def cmd_config(args: argparse.Namespace) -> int:
104
+ if args.config_command == "init":
105
+ path = init_config(from_env=args.from_env)
106
+ write_json({"created": True, "path": str(path)})
107
+ return 0
108
+ if args.config_command == "show":
109
+ write_json(config_status(load_config()))
110
+ return 0
111
+ if args.config_command == "set-default-profile":
112
+ path = set_default_profile(args.name)
113
+ write_json({"updated": True, "path": str(path), "default_profile": args.name})
114
+ return 0
115
+ if args.config_command == "add-profile":
116
+ path = add_profile(
117
+ args.name,
118
+ host=args.host,
119
+ port=args.port,
120
+ username=args.username,
121
+ password_env=args.password_env,
122
+ tls=args.tls,
123
+ ssl_mode=args.ssl_mode,
124
+ drafts_folder=args.drafts_folder or "",
125
+ )
126
+ write_json({"updated": True, "path": str(path), "profile": args.name})
127
+ return 0
128
+ if args.config_command == "remove-profile":
129
+ path = remove_profile(args.name)
130
+ write_json({"updated": True, "path": str(path), "removed_profile": args.name})
131
+ return 0
132
+ raise AppError("invalid_request", "unsupported config command.")
133
+
134
+
135
+ def cmd_profiles(args: argparse.Namespace) -> int:
136
+ config = load_config()
137
+ write_json({"profiles": [profile.name for profile in config.profiles.values()], "default": config.defaults.profile})
138
+ return 0
139
+
140
+
141
+ def cmd_folders(args: argparse.Namespace) -> int:
142
+ with _session(args) as session:
143
+ write_json(session.folders())
144
+ return 0
145
+
146
+
147
+ def cmd_search(args: argparse.Namespace) -> int:
148
+ payload: dict[str, Any] = _read_json_arg(args.json_input) if args.json_input else {}
149
+ config = load_config()
150
+ folder = args.folder or payload.get("folder") or config.defaults.default_folder
151
+ scope = payload.get("scope") or ("all" if args.all_folders else "recursive" if args.recursive else "folder")
152
+ max_results = int(args.max_results or payload.get("max_results") or config.defaults.max_results)
153
+ with _session(args) as session:
154
+ write_json(
155
+ session.search(
156
+ folder=folder,
157
+ scope=str(scope),
158
+ subject=args.subject or payload.get("subject"),
159
+ sender=args.sender or payload.get("from"),
160
+ since=args.since or payload.get("since"),
161
+ before=args.before or payload.get("before"),
162
+ max_results=max_results,
163
+ )
164
+ )
165
+ return 0
166
+
167
+
168
+ def cmd_read(args: argparse.Namespace) -> int:
169
+ payload: dict[str, Any] = _read_json_arg(args.json_input) if args.json_input else {}
170
+ folder = args.folder or payload.get("folder")
171
+ uid = args.uid or payload.get("uid")
172
+ if not folder or uid is None:
173
+ raise AppError("invalid_request", "read requires --folder and --uid.")
174
+ config = load_config()
175
+ body_format = args.body_format or payload.get("body_format") or config.defaults.body_format
176
+ max_body_chars = int(args.max_body_chars or payload.get("max_body_chars") or config.defaults.max_body_chars)
177
+ with _session(args) as session:
178
+ write_json(session.read(folder=str(folder), uid=int(uid), body_format=str(body_format), max_body_chars=max_body_chars))
179
+ return 0
180
+
181
+
182
+ def cmd_attachments(args: argparse.Namespace) -> int:
183
+ if not args.folder or args.uid is None:
184
+ raise AppError("invalid_request", "attachments requires --folder and --uid.")
185
+ with _session(args) as session:
186
+ write_json(session.attachments(folder=args.folder, uid=args.uid))
187
+ return 0
188
+
189
+
190
+ def cmd_attachments_download(args: argparse.Namespace) -> int:
191
+ if not args.all and not args.part_id:
192
+ raise AppError("invalid_request", "attachment download requires --part-id or --all.")
193
+ with _session(args) as session:
194
+ write_json(
195
+ session.download_attachments(
196
+ folder=args.folder,
197
+ uid=args.uid,
198
+ output_dir=Path(args.output_dir),
199
+ part_id=args.part_id,
200
+ all_parts=args.all,
201
+ include_inline=args.include_inline,
202
+ overwrite=args.overwrite,
203
+ )
204
+ )
205
+ return 0
206
+
207
+
208
+ def cmd_draft_create(args: argparse.Namespace) -> int:
209
+ payload: dict[str, Any] = _read_json_arg(args.json_input) if args.json_input else {}
210
+ body = _body_from_args(args, payload)
211
+ to = _parse_repeated_addresses(args.to) or _string_list(payload.get("to"))
212
+ if not to:
213
+ raise AppError("invalid_request", "draft create requires at least one recipient.")
214
+ config = load_config()
215
+ body_format = args.body_format or payload.get("body_format") or "plain"
216
+ attachments = [Path(path) for path in (args.attachment or payload.get("attachments") or [])]
217
+ with _session(args) as session:
218
+ message = create_draft_message(
219
+ sender=session.profile.username,
220
+ to=to,
221
+ cc=_parse_repeated_addresses(args.cc) or _string_list(payload.get("cc")),
222
+ bcc=_parse_repeated_addresses(args.bcc) or _string_list(payload.get("bcc")),
223
+ subject=args.subject or payload.get("subject") or "",
224
+ body=body,
225
+ body_format=str(body_format),
226
+ attachments=attachments,
227
+ )
228
+ write_json(session.append_draft(message, drafts_folder=args.drafts_folder or payload.get("drafts_folder")))
229
+ return 0
230
+
231
+
232
+ def _reply_recipients(source_message: Any) -> list[str]:
233
+ reply_to = source_message.get_all("reply-to", [])
234
+ if reply_to:
235
+ return _string_list([", ".join(reply_to)])
236
+ from_values = source_message.get_all("from", [])
237
+ return _string_list([", ".join(from_values)])
238
+
239
+
240
+ def _reply_subject(source_subject: str) -> str:
241
+ value = source_subject.strip()
242
+ return value if value.lower().startswith("re:") else f"Re: {value}"
243
+
244
+
245
+ def cmd_draft_reply(args: argparse.Namespace) -> int:
246
+ payload: dict[str, Any] = _read_json_arg(args.json_input) if args.json_input else {}
247
+ source = payload.get("source") if isinstance(payload.get("source"), dict) else {}
248
+ folder = args.folder or source.get("folder")
249
+ uid = args.uid or source.get("uid")
250
+ if not folder or uid is None:
251
+ raise AppError("invalid_request", "draft reply requires source --folder and --uid.")
252
+ body = _body_from_args(args, payload)
253
+ body_format = args.body_format or payload.get("body_format") or "plain"
254
+ attachments = [Path(path) for path in (args.attachment or payload.get("attachments") or [])]
255
+ with _session(args) as session:
256
+ raw = session._fetch_raw(str(folder), int(uid))
257
+ source_message = parse_message(raw)
258
+ to = _parse_repeated_addresses(args.to) or _string_list(payload.get("to")) or _reply_recipients(source_message)
259
+ source_message_id = str(source_message.get("message-id", ""))
260
+ source_refs = str(source_message.get("references", "")).strip()
261
+ references = f"{source_refs} {source_message_id}".strip()
262
+ message = create_draft_message(
263
+ sender=session.profile.username,
264
+ to=to,
265
+ cc=_parse_repeated_addresses(args.cc) or _string_list(payload.get("cc")),
266
+ bcc=_parse_repeated_addresses(args.bcc) or _string_list(payload.get("bcc")),
267
+ subject=args.subject or payload.get("subject") or _reply_subject(str(source_message.get("subject", ""))),
268
+ body=body,
269
+ body_format=str(body_format),
270
+ attachments=attachments,
271
+ in_reply_to=source_message_id or None,
272
+ references=references or None,
273
+ )
274
+ write_json(session.append_draft(message, drafts_folder=args.drafts_folder or payload.get("drafts_folder")))
275
+ return 0
276
+
277
+
278
+ def build_parser() -> argparse.ArgumentParser:
279
+ parser = argparse.ArgumentParser(prog="imap-agent-cli")
280
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
281
+ sub = parser.add_subparsers(dest="command", required=True)
282
+
283
+ config = sub.add_parser("config")
284
+ config_sub = config.add_subparsers(dest="config_command", required=True)
285
+ config_init = config_sub.add_parser("init")
286
+ config_init.add_argument("--from-env", action="store_true")
287
+ config_init.set_defaults(func=cmd_config)
288
+ config_show = config_sub.add_parser("show")
289
+ config_show.set_defaults(func=cmd_config)
290
+ config_default = config_sub.add_parser("set-default-profile")
291
+ config_default.add_argument("name")
292
+ config_default.set_defaults(func=cmd_config)
293
+ config_add = config_sub.add_parser("add-profile")
294
+ config_add.add_argument("name")
295
+ config_add.add_argument("--host", required=True)
296
+ config_add.add_argument("--port", type=int, default=993)
297
+ config_add.add_argument("--username", required=True)
298
+ config_add.add_argument("--password-env", required=True)
299
+ config_add.add_argument("--tls", dest="tls", action="store_true", default=True)
300
+ config_add.add_argument("--no-tls", dest="tls", action="store_false")
301
+ config_add.add_argument("--ssl-mode", choices=["required", "preferred", "disabled"], default="required")
302
+ config_add.add_argument("--drafts-folder", default="")
303
+ config_add.set_defaults(func=cmd_config)
304
+ config_remove = config_sub.add_parser("remove-profile")
305
+ config_remove.add_argument("name")
306
+ config_remove.set_defaults(func=cmd_config)
307
+
308
+ profiles = sub.add_parser("profiles")
309
+ profiles.set_defaults(func=cmd_profiles)
310
+
311
+ folders = sub.add_parser("folders")
312
+ _common_profile_args(folders)
313
+ folders.set_defaults(func=cmd_folders)
314
+
315
+ search = sub.add_parser("search")
316
+ _common_profile_args(search)
317
+ search.add_argument("--json", dest="json_input")
318
+ search.add_argument("--folder")
319
+ search.add_argument("--recursive", action="store_true")
320
+ search.add_argument("--all-folders", action="store_true")
321
+ search.add_argument("--subject")
322
+ search.add_argument("--from", dest="sender")
323
+ search.add_argument("--since")
324
+ search.add_argument("--before")
325
+ search.add_argument("--max-results", type=int)
326
+ search.set_defaults(func=cmd_search)
327
+
328
+ read = sub.add_parser("read")
329
+ _common_profile_args(read)
330
+ read.add_argument("--json", dest="json_input")
331
+ read.add_argument("--folder")
332
+ read.add_argument("--uid", type=int)
333
+ read.add_argument("--body-format", choices=["html", "markdown", "plain", "raw-html", "metadata"])
334
+ read.add_argument("--max-body-chars", type=int)
335
+ read.set_defaults(func=cmd_read)
336
+
337
+ attachments = sub.add_parser("attachments")
338
+ _common_profile_args(attachments)
339
+ attachments.add_argument("--folder")
340
+ attachments.add_argument("--uid", type=int)
341
+ attachments.set_defaults(func=cmd_attachments)
342
+ attachments_sub = attachments.add_subparsers(dest="attachment_command")
343
+ download = attachments_sub.add_parser("download")
344
+ _common_profile_args(download)
345
+ download.add_argument("--folder", required=True)
346
+ download.add_argument("--uid", required=True, type=int)
347
+ download.add_argument("--part-id")
348
+ download.add_argument("--all", action="store_true")
349
+ download.add_argument("--output-dir", required=True)
350
+ download.add_argument("--include-inline", action="store_true")
351
+ download.add_argument("--overwrite", action="store_true")
352
+ download.set_defaults(func=cmd_attachments_download)
353
+
354
+ draft = sub.add_parser("draft")
355
+ draft_sub = draft.add_subparsers(dest="draft_command", required=True)
356
+ create = draft_sub.add_parser("create")
357
+ _common_profile_args(create)
358
+ create.add_argument("--json", dest="json_input")
359
+ create.add_argument("--to", action="append")
360
+ create.add_argument("--cc", action="append")
361
+ create.add_argument("--bcc", action="append")
362
+ create.add_argument("--subject")
363
+ create.add_argument("--body")
364
+ create.add_argument("--body-file")
365
+ create.add_argument("--body-format", choices=["html", "markdown", "plain"], default=None)
366
+ create.add_argument("--attachment", action="append")
367
+ create.add_argument("--drafts-folder")
368
+ create.set_defaults(func=cmd_draft_create)
369
+
370
+ reply = draft_sub.add_parser("reply")
371
+ _common_profile_args(reply)
372
+ reply.add_argument("--json", dest="json_input")
373
+ reply.add_argument("--folder")
374
+ reply.add_argument("--uid", type=int)
375
+ reply.add_argument("--to", action="append")
376
+ reply.add_argument("--cc", action="append")
377
+ reply.add_argument("--bcc", action="append")
378
+ reply.add_argument("--subject")
379
+ reply.add_argument("--body")
380
+ reply.add_argument("--body-file")
381
+ reply.add_argument("--body-format", choices=["html", "markdown", "plain"], default=None)
382
+ reply.add_argument("--attachment", action="append")
383
+ reply.add_argument("--drafts-folder")
384
+ reply.set_defaults(func=cmd_draft_reply)
385
+
386
+ return parser
387
+
388
+
389
+ def main(argv: list[str] | None = None) -> int:
390
+ parser = build_parser()
391
+ args = parser.parse_args(argv)
392
+ try:
393
+ return int(args.func(args))
394
+ except AppError as exc:
395
+ write_error(exc)
396
+ return exc.exit_code
397
+ except ConfigError as exc:
398
+ write_error(exc)
399
+ return exc.exit_code
400
+ except KeyboardInterrupt:
401
+ write_error(AppError("interrupted", "interrupted by user", exit_code=130))
402
+ return 130
403
+
404
+
405
+ if __name__ == "__main__":
406
+ raise SystemExit(main())