imap-agent-cli 0.1.4__tar.gz → 0.1.6__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.
Files changed (33) hide show
  1. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/.github/workflows/publish.yml +3 -2
  2. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/AGENTS.md +12 -0
  3. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/PKG-INFO +22 -2
  4. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/README.md +21 -1
  5. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/pyproject.toml +1 -1
  6. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/__init__.py +1 -1
  7. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/cli.py +143 -2
  8. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/config.py +3 -0
  9. imap_agent_cli-0.1.6/src/imap_agent_cli/imap_client.py +731 -0
  10. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/mime.py +10 -3
  11. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/models.py +1 -0
  12. imap_agent_cli-0.1.6/src/imap_agent_cli/search.py +75 -0
  13. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/skill.py +54 -19
  14. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/test_cli.py +56 -0
  15. imap_agent_cli-0.1.6/tests/test_imap_client.py +150 -0
  16. imap_agent_cli-0.1.6/tests/test_live_no_seen.py +37 -0
  17. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/test_pymap_integration.py +1 -0
  18. imap_agent_cli-0.1.6/tests/test_search.py +115 -0
  19. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/test_skill.py +2 -0
  20. imap_agent_cli-0.1.4/src/imap_agent_cli/imap_client.py +0 -303
  21. imap_agent_cli-0.1.4/src/imap_agent_cli/search.py +0 -30
  22. imap_agent_cli-0.1.4/tests/test_search.py +0 -34
  23. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/.gitignore +0 -0
  24. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/LICENSE +0 -0
  25. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/imap_agent_cli.py +0 -0
  26. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/spec.md +0 -0
  27. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/errors.py +0 -0
  28. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/src/imap_agent_cli/render.py +0 -0
  29. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/__init__.py +0 -0
  30. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/_bootstrap.py +0 -0
  31. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/pymap_server_runner.py +0 -0
  32. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/test_config.py +0 -0
  33. {imap_agent_cli-0.1.4 → imap_agent_cli-0.1.6}/tests/test_mime.py +0 -0
@@ -1,8 +1,9 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- release:
5
- types: [published]
4
+ push:
5
+ tags:
6
+ - "[0-9]*.[0-9]*.[0-9]*"
6
7
  workflow_dispatch:
7
8
 
8
9
  jobs:
@@ -29,13 +29,16 @@ The core safety boundary is strict: never add support for sending email, deletin
29
29
  - Keep README and skill language platform-neutral and agent-tool-neutral. Avoid shell-specific syntax unless explicitly documenting a shell-specific example.
30
30
  - If changing installed-skill behavior or wording, update `src/imap_agent_cli/skill.py` and `tests/test_skill.py` together.
31
31
  - If changing MIME parsing, body rendering, draft creation, or search behavior, add focused tests for the contract being changed.
32
+ - If changing search output, preserve metadata-first behavior; search should not fetch full message bodies.
32
33
 
33
34
  ## Safety-Sensitive Implementation Notes
34
35
 
35
36
  - Reads must avoid changing message state. Preserve no-seen fetch behavior.
37
+ - `ssl_mode=required` and `ssl_mode=preferred` must never send credentials over plaintext. Plaintext login is allowed only with `ssl_mode=disabled`.
36
38
  - Draft creation is allowed only by appending a new MIME message to the detected or configured Drafts folder.
37
39
  - Reply drafts should preserve appropriate reply headers such as `In-Reply-To` and `References`.
38
40
  - Attachment downloads require explicit user/agent intent and an output directory.
41
+ - Attachment downloads should preflight target filenames before writing, so a failed multi-attachment download does not partially write files.
39
42
  - Folder-wide or all-folder operations should remain bounded by defaults and overridable limits.
40
43
  - HTML email is untrusted input. Sanitize before returning HTML and keep Markdown conversion intentionally lossy but predictable.
41
44
 
@@ -63,6 +66,14 @@ uv run --extra test python -m unittest tests.test_pymap_integration -v
63
66
 
64
67
  Set `IMAP_AGENT_CLI_TEST_PYMAP=1` in the shell before running the integration test. The test starts `pymap dict --demo-data` locally and logs in with demo credentials.
65
68
 
69
+ Run the optional live no-seen test only when intentionally validating a configured mailbox:
70
+
71
+ ```text
72
+ python -m unittest tests.test_live_no_seen -v
73
+ ```
74
+
75
+ Set `IMAP_AGENT_CLI_LIVE_TEST=1` first. The test reads one unread message and verifies flags do not change; it skips when no unread message is available.
76
+
66
77
  Build the package:
67
78
 
68
79
  ```text
@@ -73,6 +84,7 @@ uv build --no-sources
73
84
 
74
85
  - Package metadata lives in `pyproject.toml`.
75
86
  - The GitHub Actions publish workflow is `.github/workflows/publish.yml`.
87
+ - Publishing runs when a version tag such as `0.1.4` is pushed, or when the workflow is manually dispatched.
76
88
  - PyPI Trusted Publishing uses:
77
89
  - Project: `imap-agent-cli`
78
90
  - Owner: `pseudosavant`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imap-agent-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Agent-first IMAP CLI for safe email search, read, attachment download, and draft creation
5
5
  Project-URL: Homepage, https://github.com/pseudosavant/imap-agent-cli
6
6
  Project-URL: Repository, https://github.com/pseudosavant/imap-agent-cli
@@ -100,6 +100,7 @@ IMAP_AGENT_CLI_PORT=993
100
100
  IMAP_AGENT_CLI_USERNAME=me@example.com
101
101
  IMAP_AGENT_CLI_PASSWORD=your-password-or-app-password
102
102
  IMAP_AGENT_CLI_TLS=true
103
+ IMAP_AGENT_CLI_SSL_MODE=required
103
104
  IMAP_AGENT_CLI_DRAFTS_FOLDER=Drafts
104
105
  ```
105
106
 
@@ -121,6 +122,12 @@ The config file is stored at:
121
122
 
122
123
  Keep secrets in environment variables. The config file should reference password environment variable names, not contain passwords.
123
124
 
125
+ Validate setup without reading message bodies:
126
+
127
+ ```text
128
+ imap-agent-cli config check
129
+ ```
130
+
124
131
  ### Agent Skill
125
132
 
126
133
  The installed skill teaches an agentic tool how to use `imap-agent-cli` safely and effectively.
@@ -176,7 +183,10 @@ These examples are mostly useful for validating configuration or debugging what
176
183
  ```text
177
184
  imap-agent-cli folders
178
185
  imap-agent-cli search --folder INBOX --subject "invoice" --max-results 10
179
- imap-agent-cli read --folder INBOX --uid 12345 --body-format html
186
+ imap-agent-cli search --folder INBOX --from "Justin" --to "me@example.com" --max-results 10
187
+ imap-agent-cli search --folder INBOX --has-attachments --max-results 10 --max-scan 100
188
+ imap-agent-cli read --folder INBOX --uid 12345 --body-format html --include-attachments none
189
+ imap-agent-cli thread --folder INBOX --uid 12345 --include-body latest --body-format plain
180
190
  imap-agent-cli attachments --folder INBOX --uid 12345
181
191
  imap-agent-cli attachments download --folder INBOX --uid 12345 --part-id 2 --output-dir ./email-attachments
182
192
  imap-agent-cli draft create --to person@example.com --subject "Hello" --body "Draft only."
@@ -219,6 +229,14 @@ uv run --extra test python -m unittest tests.test_pymap_integration -v
219
229
 
220
230
  The integration test starts `pymap dict --demo-data` locally and logs in with `demouser` / `demopass`.
221
231
 
232
+ Run the opt-in live IMAP no-seen test only when you intentionally want to validate the configured mailbox:
233
+
234
+ ```text
235
+ python -m unittest tests.test_live_no_seen -v
236
+ ```
237
+
238
+ Set `IMAP_AGENT_CLI_LIVE_TEST=1` in your shell first. The test reads one unread message with no-seen fetch behavior and verifies the flags do not change. If no unread message exists, it skips.
239
+
222
240
  ### Project Status
223
241
 
224
242
  This repo is under active development. The behavior target is defined in [`spec.md`](./spec.md).
@@ -227,6 +245,8 @@ This repo is under active development. The behavior target is defined in [`spec.
227
245
 
228
246
  The GitHub Actions workflow is `.github/workflows/publish.yml`.
229
247
 
248
+ Publishing runs when a version tag such as `0.1.4` is pushed. The workflow can also be run manually.
249
+
230
250
  PyPI Trusted Publishing values:
231
251
 
232
252
  ```text
@@ -67,6 +67,7 @@ IMAP_AGENT_CLI_PORT=993
67
67
  IMAP_AGENT_CLI_USERNAME=me@example.com
68
68
  IMAP_AGENT_CLI_PASSWORD=your-password-or-app-password
69
69
  IMAP_AGENT_CLI_TLS=true
70
+ IMAP_AGENT_CLI_SSL_MODE=required
70
71
  IMAP_AGENT_CLI_DRAFTS_FOLDER=Drafts
71
72
  ```
72
73
 
@@ -88,6 +89,12 @@ The config file is stored at:
88
89
 
89
90
  Keep secrets in environment variables. The config file should reference password environment variable names, not contain passwords.
90
91
 
92
+ Validate setup without reading message bodies:
93
+
94
+ ```text
95
+ imap-agent-cli config check
96
+ ```
97
+
91
98
  ### Agent Skill
92
99
 
93
100
  The installed skill teaches an agentic tool how to use `imap-agent-cli` safely and effectively.
@@ -143,7 +150,10 @@ These examples are mostly useful for validating configuration or debugging what
143
150
  ```text
144
151
  imap-agent-cli folders
145
152
  imap-agent-cli search --folder INBOX --subject "invoice" --max-results 10
146
- imap-agent-cli read --folder INBOX --uid 12345 --body-format html
153
+ imap-agent-cli search --folder INBOX --from "Justin" --to "me@example.com" --max-results 10
154
+ imap-agent-cli search --folder INBOX --has-attachments --max-results 10 --max-scan 100
155
+ imap-agent-cli read --folder INBOX --uid 12345 --body-format html --include-attachments none
156
+ imap-agent-cli thread --folder INBOX --uid 12345 --include-body latest --body-format plain
147
157
  imap-agent-cli attachments --folder INBOX --uid 12345
148
158
  imap-agent-cli attachments download --folder INBOX --uid 12345 --part-id 2 --output-dir ./email-attachments
149
159
  imap-agent-cli draft create --to person@example.com --subject "Hello" --body "Draft only."
@@ -186,6 +196,14 @@ uv run --extra test python -m unittest tests.test_pymap_integration -v
186
196
 
187
197
  The integration test starts `pymap dict --demo-data` locally and logs in with `demouser` / `demopass`.
188
198
 
199
+ Run the opt-in live IMAP no-seen test only when you intentionally want to validate the configured mailbox:
200
+
201
+ ```text
202
+ python -m unittest tests.test_live_no_seen -v
203
+ ```
204
+
205
+ Set `IMAP_AGENT_CLI_LIVE_TEST=1` in your shell first. The test reads one unread message with no-seen fetch behavior and verifies the flags do not change. If no unread message exists, it skips.
206
+
189
207
  ### Project Status
190
208
 
191
209
  This repo is under active development. The behavior target is defined in [`spec.md`](./spec.md).
@@ -194,6 +212,8 @@ This repo is under active development. The behavior target is defined in [`spec.
194
212
 
195
213
  The GitHub Actions workflow is `.github/workflows/publish.yml`.
196
214
 
215
+ Publishing runs when a version tag such as `0.1.4` is pushed. The workflow can also be run manually.
216
+
197
217
  PyPI Trusted Publishing values:
198
218
 
199
219
  ```text
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "imap-agent-cli"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Agent-first IMAP CLI for safe email search, read, attachment download, and draft creation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Agent-first IMAP CLI."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.6"
@@ -31,12 +31,15 @@ Setup:
31
31
  IMAP_AGENT_CLI_USERNAME
32
32
  IMAP_AGENT_CLI_PASSWORD
33
33
  IMAP_AGENT_CLI_TLS
34
+ IMAP_AGENT_CLI_SSL_MODE
34
35
 
35
36
  Common workflows:
36
37
  imap-agent-cli folders
37
38
  imap-agent-cli search --folder INBOX --from "Justin" --max-results 10
39
+ imap-agent-cli search --folder INBOX --to "me@example.com" --has-attachments --max-results 10
38
40
  imap-agent-cli read --folder INBOX --uid 12345 --body-format metadata
39
- imap-agent-cli read --folder INBOX --uid 12345 --body-format html --max-body-chars 12000
41
+ imap-agent-cli read --folder INBOX --uid 12345 --body-format html --include-attachments none --max-body-chars 12000
42
+ imap-agent-cli thread --folder INBOX --uid 12345 --include-body latest
40
43
  imap-agent-cli attachments --folder INBOX --uid 12345
41
44
  imap-agent-cli attachments download --folder INBOX --uid 12345 --part-id 2 --output-dir ./email-attachments
42
45
  imap-agent-cli draft create --to person@example.com --subject "Subject" --body-file ./draft.txt
@@ -54,6 +57,7 @@ Commands:
54
57
  folders list folders
55
58
  search search messages
56
59
  read read a message by folder and UID
60
+ thread inspect bounded thread context
57
61
  attachments list or download attachments
58
62
  draft create new or reply drafts
59
63
 
@@ -131,6 +135,21 @@ def _max_body_chars(args: argparse.Namespace, payload: dict[str, Any] | None = N
131
135
  return load_config().defaults.max_body_chars
132
136
 
133
137
 
138
+ def _include_attachments(value: Any) -> str:
139
+ text = str(value or "metadata").strip().lower()
140
+ if text in {"false", "none", "no", "0"}:
141
+ return "none"
142
+ if text in {"true", "metadata", "yes", "1"}:
143
+ return "metadata"
144
+ raise AppError("invalid_request", "--include-attachments must be none or metadata.")
145
+
146
+
147
+ def _check_item(name: str, ok: bool, **details: Any) -> dict[str, Any]:
148
+ item: dict[str, Any] = {"name": name, "ok": ok}
149
+ item.update(details)
150
+ return item
151
+
152
+
134
153
  def _body_from_args(args: argparse.Namespace, payload: dict[str, Any]) -> str:
135
154
  if getattr(args, "body", None) is not None:
136
155
  return args.body
@@ -168,6 +187,56 @@ def cmd_config(args: argparse.Namespace) -> int:
168
187
  path = init_config(from_env=args.from_env)
169
188
  write_json({"created": True, "path": str(path)})
170
189
  return 0
190
+ if args.config_command == "check":
191
+ checks: list[dict[str, Any]] = []
192
+ report: dict[str, Any] = {"ok": False, "checks": checks}
193
+ try:
194
+ config = load_config()
195
+ checks.append(_check_item("config_loaded", True, path=str(config.path)))
196
+ profile = resolve_profile(
197
+ config,
198
+ getattr(args, "profile", None),
199
+ host=getattr(args, "host", None),
200
+ port=getattr(args, "port", None),
201
+ username=getattr(args, "username", None),
202
+ password=_read_password(args),
203
+ tls=getattr(args, "tls", None),
204
+ ssl_mode=getattr(args, "ssl_mode", None),
205
+ )
206
+ report["profile"] = {
207
+ "name": profile.name,
208
+ "host": profile.host,
209
+ "port": profile.port,
210
+ "username": profile.username,
211
+ "password_env": profile.password_env,
212
+ "has_password": bool(profile.password),
213
+ "tls": profile.tls,
214
+ "ssl_mode": profile.ssl_mode,
215
+ }
216
+ checks.append(_check_item("profile_resolved", True))
217
+ checks.append(_check_item("password_available", bool(profile.password), password_env=profile.password_env))
218
+ with ImapSession(profile, config.defaults) as session:
219
+ checks.append(_check_item("login", True, security=session.security))
220
+ capabilities = session.capabilities()
221
+ checks.append(_check_item("capabilities", True, values=capabilities))
222
+ folders = session.folders()["folders"]
223
+ checks.append(_check_item("folders", True, count=len(folders)))
224
+ default_folder = config.defaults.default_folder
225
+ selectable = {str(folder["name"]) for folder in folders if folder.get("selectable")}
226
+ default_ok = default_folder in selectable
227
+ checks.append(_check_item("default_folder", default_ok, folder=default_folder))
228
+ if default_ok:
229
+ session._select(default_folder, readonly=True)
230
+ try:
231
+ drafts_folder = session.resolve_drafts_folder()
232
+ checks.append(_check_item("drafts_folder", True, folder=drafts_folder))
233
+ except AppError as exc:
234
+ checks.append(_check_item("drafts_folder", False, error=exc.message))
235
+ except AppError as exc:
236
+ checks.append(_check_item(exc.code, False, error=exc.message))
237
+ report["ok"] = all(item["ok"] for item in checks)
238
+ write_json(report)
239
+ return 0 if report["ok"] else 1
171
240
  if args.config_command == "show":
172
241
  write_json(config_status(load_config()))
173
242
  return 0
@@ -223,6 +292,7 @@ def cmd_search(args: argparse.Namespace) -> int:
223
292
  folder = args.folder or payload.get("folder") or config.defaults.default_folder
224
293
  scope = payload.get("scope") or ("all" if args.all_folders else "recursive" if args.recursive else "folder")
225
294
  max_results = int(args.max_results or payload.get("max_results") or config.defaults.max_results)
295
+ max_scan = int(args.max_scan or payload.get("max_scan") or config.defaults.max_scan)
226
296
  with _session(args) as session:
227
297
  write_json(
228
298
  session.search(
@@ -230,9 +300,22 @@ def cmd_search(args: argparse.Namespace) -> int:
230
300
  scope=str(scope),
231
301
  subject=args.subject or payload.get("subject"),
232
302
  sender=args.sender or payload.get("from"),
303
+ recipient=args.recipient or payload.get("to"),
304
+ message_id=args.message_id or payload.get("message_id"),
305
+ text=args.text or payload.get("text"),
233
306
  since=args.since or payload.get("since"),
234
307
  before=args.before or payload.get("before"),
308
+ unseen=bool(args.unseen or payload.get("unseen", False)),
309
+ seen=bool(args.seen or payload.get("seen", False)),
310
+ answered=bool(args.answered or payload.get("answered", False)),
311
+ flagged=bool(args.flagged or payload.get("flagged", False)),
312
+ larger=args.larger or payload.get("larger"),
313
+ smaller=args.smaller or payload.get("smaller"),
314
+ has_attachments=bool(args.has_attachments or payload.get("has_attachments", False)),
235
315
  max_results=max_results,
316
+ max_scan=max_scan,
317
+ sort=str(args.sort or payload.get("sort") or "uid"),
318
+ order=str(args.order or payload.get("order") or "desc"),
236
319
  )
237
320
  )
238
321
  return 0
@@ -247,8 +330,38 @@ def cmd_read(args: argparse.Namespace) -> int:
247
330
  config = load_config()
248
331
  body_format = args.body_format or payload.get("body_format") or config.defaults.body_format
249
332
  max_body_chars = int(args.max_body_chars or payload.get("max_body_chars") or config.defaults.max_body_chars)
333
+ include_attachments = _include_attachments(args.include_attachments or payload.get("include_attachments") or "metadata")
334
+ with _session(args) as session:
335
+ write_json(
336
+ session.read(
337
+ folder=str(folder),
338
+ uid=int(uid),
339
+ body_format=str(body_format),
340
+ max_body_chars=max_body_chars,
341
+ include_attachments=include_attachments,
342
+ )
343
+ )
344
+ return 0
345
+
346
+
347
+ def cmd_thread(args: argparse.Namespace) -> int:
348
+ if not args.folder or args.uid is None:
349
+ raise AppError("invalid_request", "thread requires --folder and --uid.")
350
+ config = load_config()
351
+ max_body_chars = int(args.max_body_chars or config.defaults.max_body_chars)
352
+ max_scan = int(args.max_scan or config.defaults.max_scan)
250
353
  with _session(args) as session:
251
- write_json(session.read(folder=str(folder), uid=int(uid), body_format=str(body_format), max_body_chars=max_body_chars))
354
+ write_json(
355
+ session.thread(
356
+ folder=args.folder,
357
+ uid=args.uid,
358
+ max_messages=args.max_messages,
359
+ max_scan=max_scan,
360
+ include_body=args.include_body,
361
+ body_format=args.body_format,
362
+ max_body_chars=max_body_chars,
363
+ )
364
+ )
252
365
  return 0
253
366
 
254
367
 
@@ -362,6 +475,9 @@ def build_parser() -> argparse.ArgumentParser:
362
475
  config_init = config_sub.add_parser("init", help="create a starter config file")
363
476
  config_init.add_argument("--from-env", action="store_true")
364
477
  config_init.set_defaults(func=cmd_config)
478
+ config_check = config_sub.add_parser("check", help="validate profile configuration and IMAP connectivity")
479
+ _common_profile_args(config_check)
480
+ config_check.set_defaults(func=cmd_config)
365
481
  config_show = config_sub.add_parser("show", help="show resolved config metadata without secrets")
366
482
  config_show.set_defaults(func=cmd_config)
367
483
  config_default = config_sub.add_parser("set-default-profile", help="set the default profile")
@@ -406,9 +522,22 @@ def build_parser() -> argparse.ArgumentParser:
406
522
  search.add_argument("--all-folders", action="store_true")
407
523
  search.add_argument("--subject")
408
524
  search.add_argument("--from", dest="sender")
525
+ search.add_argument("--to", dest="recipient")
526
+ search.add_argument("--message-id")
527
+ search.add_argument("--text")
409
528
  search.add_argument("--since")
410
529
  search.add_argument("--before")
530
+ search.add_argument("--unseen", action="store_true")
531
+ search.add_argument("--seen", action="store_true")
532
+ search.add_argument("--answered", action="store_true")
533
+ search.add_argument("--flagged", action="store_true")
534
+ search.add_argument("--larger", type=int)
535
+ search.add_argument("--smaller", type=int)
536
+ search.add_argument("--has-attachments", action="store_true")
411
537
  search.add_argument("--max-results", type=int)
538
+ search.add_argument("--max-scan", type=int)
539
+ search.add_argument("--sort", choices=["uid", "date"], default=None)
540
+ search.add_argument("--order", choices=["asc", "desc"], default=None)
412
541
  search.set_defaults(func=cmd_search)
413
542
 
414
543
  read = sub.add_parser("read", help="read a message by folder and UID without marking it read")
@@ -418,8 +547,20 @@ def build_parser() -> argparse.ArgumentParser:
418
547
  read.add_argument("--uid", type=int)
419
548
  read.add_argument("--body-format", choices=["html", "markdown", "plain", "raw-html", "metadata"])
420
549
  read.add_argument("--max-body-chars", type=int)
550
+ read.add_argument("--include-attachments", choices=["none", "metadata", "false", "true"])
421
551
  read.set_defaults(func=cmd_read)
422
552
 
553
+ thread = sub.add_parser("thread", help="inspect bounded thread context for a message")
554
+ _common_profile_args(thread)
555
+ thread.add_argument("--folder", required=True)
556
+ thread.add_argument("--uid", required=True, type=int)
557
+ thread.add_argument("--max-messages", type=int, default=5)
558
+ thread.add_argument("--max-scan", type=int)
559
+ thread.add_argument("--include-body", choices=["none", "latest", "all"], default="none")
560
+ thread.add_argument("--body-format", choices=["html", "markdown", "plain", "raw-html", "metadata"], default="metadata")
561
+ thread.add_argument("--max-body-chars", type=int)
562
+ thread.set_defaults(func=cmd_thread)
563
+
423
564
  attachments = sub.add_parser("attachments", help="list attachment metadata for a message")
424
565
  _common_profile_args(attachments)
425
566
  attachments.add_argument("--folder")
@@ -67,6 +67,7 @@ def _env_profile() -> Profile | None:
67
67
  return None
68
68
  defaults = Defaults(
69
69
  max_results=_env_int(f"{ENV_PREFIX}MAX_RESULTS", 25),
70
+ max_scan=_env_int(f"{ENV_PREFIX}MAX_SCAN", 250),
70
71
  max_body_chars=_env_int(f"{ENV_PREFIX}MAX_BODY_CHARS", 12000),
71
72
  connect_timeout_seconds=_env_int(f"{ENV_PREFIX}CONNECT_TIMEOUT_SECONDS", 15),
72
73
  read_timeout_seconds=_env_int(f"{ENV_PREFIX}READ_TIMEOUT_SECONDS", 30),
@@ -229,6 +230,7 @@ profile = "default"
229
230
  format = "json"
230
231
  default_folder = "INBOX"
231
232
  max_results = 25
233
+ max_scan = 250
232
234
  max_body_chars = 12000
233
235
  connect_timeout_seconds = 15
234
236
  read_timeout_seconds = 30
@@ -288,6 +290,7 @@ def add_profile(
288
290
  raw["defaults"].setdefault("format", "json")
289
291
  raw["defaults"].setdefault("default_folder", "INBOX")
290
292
  raw["defaults"].setdefault("max_results", 25)
293
+ raw["defaults"].setdefault("max_scan", 250)
291
294
  raw["defaults"].setdefault("max_body_chars", 12000)
292
295
  raw["defaults"].setdefault("connect_timeout_seconds", 15)
293
296
  raw["defaults"].setdefault("read_timeout_seconds", 30)