Skip to content

Commit 410d6ab

Browse files
turianclaude
andauthored
Add --fetch-all-body-values, email.read command, fix pipeline.run default (#11)
* Add --fetch-all-body-values to email.get, add email.read command, fix pipeline.run default email.get had no way to pass fetchAllBodyValues: true (RFC 8621), so bodyValues always came back empty even when requested in --properties. Added --fetch-all-body-values flag that maps to the JMAP parameter. Added email.read convenience command that returns subject, from, to, and plain text body in a single flattened call — the most common "read an email" use case no longer requires pipeline.run with raw JMAP. Fixed pipeline.run crashing with "ValueError: Unknown json style: None" when --json is omitted. Every other command set a default; this one didn't. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add per-error-type exit codes to email.read handler Address Codex review: email.read now matches the error handling pattern used by all other handlers (ValueError→2, ClientError→5, HTTPError→3/4, Exception→6) instead of catching bare Exception for everything. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b9f7617 commit 410d6ab

1 file changed

Lines changed: 75 additions & 2 deletions

File tree

src/fastmail_cli/jmapc.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"""
33
jmapc-cli: Read-only JMAP CLI (JSON in/out)
44
5-
Commands: session.get, email.query, email.get, mailbox.query, thread.get, pipeline.run
5+
Commands: session.get, email.query, email.get, email.read, mailbox.query, thread.get, pipeline.run
66
Exit codes: 0 ok, 2 validation, 3 auth, 4 http, 5 jmap method error, 6 runtime.
77
"""
88

@@ -244,7 +244,8 @@ def handle_email_get(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
244244
account_id = resolve_account_id(session_json, args.account)
245245
ids = parse_json_arg(args.ids)
246246
props = parse_json_arg(args.properties) if args.properties else None
247-
call = EmailGet(ids=ids, properties=props)
247+
fetch_all_body_values = getattr(args, "fetch_all_body_values", False)
248+
call = EmailGet(ids=ids, properties=props, fetch_all_body_values=fetch_all_body_values)
248249
using, mrs = jmap_request(client, account_id, call, raise_errors=True)
249250
resp = mrs[0].response # EmailGetResponse
250251
capabilities_server = session_json.get("capabilities", {}).keys()
@@ -269,6 +270,63 @@ def handle_email_get(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
269270
return 6, envelope(False, "email.get", vars(args), meta_block(args.host, "unknown", []), error=err)
270271

271272

273+
def handle_email_read(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
274+
try:
275+
client, session_json = build_client(args.host, args.api_token, args.timeout, not args.insecure)
276+
account_id = resolve_account_id(session_json, args.account)
277+
278+
# Request specific properties for reading
279+
props = ["subject", "from", "to", "cc", "bcc", "textBody", "bodyValues", "receivedAt", "messageId"]
280+
call = EmailGet(ids=[args.id], properties=props, fetch_all_body_values=True)
281+
using, mrs = jmap_request(client, account_id, call, raise_errors=True)
282+
resp = mrs[0].response
283+
284+
if not resp.data:
285+
raise ValueError(f"Email not found: {args.id}")
286+
287+
email = resp.data[0]
288+
289+
# Extract plain text body
290+
body_text = ""
291+
if email.text_body and email.body_values:
292+
for part in email.text_body:
293+
part_id = getattr(part, "part_id", None) or (part.get("partId") if isinstance(part, dict) else None)
294+
if part_id and part_id in email.body_values:
295+
val = email.body_values[part_id]
296+
content = getattr(val, "value", None)
297+
if content:
298+
body_text += content
299+
300+
data = {
301+
"id": getattr(email, "id", args.id),
302+
"subject": email.subject,
303+
"from": [a.to_dict() if hasattr(a, "to_dict") else a for a in email.mail_from] if email.mail_from else None,
304+
"to": [a.to_dict() if hasattr(a, "to_dict") else a for a in email.to] if email.to else None,
305+
"body": body_text.strip(),
306+
}
307+
308+
capabilities_server = session_json.get("capabilities", {}).keys()
309+
meta = meta_block(args.host, account_id, using, capabilities_server)
310+
return 0, envelope(True, "email.read", vars(args), meta, data=data)
311+
except ValueError as exc:
312+
err = {"type": "validationError", "message": str(exc), "details": {}}
313+
return 2, envelope(False, "email.read", vars(args), meta_block(args.host, "unknown", []), error=err)
314+
except ClientError as exc:
315+
err = {
316+
"type": "jmapError",
317+
"message": str(exc),
318+
"details": {"responses": client_error_details(exc)},
319+
}
320+
return 5, envelope(False, "email.read", vars(args), meta_block(args.host, "unknown", []), error=err)
321+
except requests.HTTPError as exc:
322+
code = http_exit_code(exc.response.status_code)
323+
err = {"type": "httpError", "message": str(exc), "details": {"status": exc.response.status_code}}
324+
return code, envelope(False, "email.read", vars(args), meta_block(args.host, "unknown", []), error=err)
325+
except Exception as exc:
326+
err = {"type": "runtimeError", "message": str(exc), "details": {}}
327+
return 6, envelope(False, "email.read", vars(args), meta_block(args.host, "unknown", []), error=err)
328+
329+
272330
def find_drafts_mailbox_id(client: Client, account_id: str) -> str:
273331
"""Find the Drafts mailbox ID by querying for role=drafts."""
274332
call = MailboxQuery(filter={"role": "drafts"})
@@ -817,6 +875,13 @@ def COMMAND_SPECS() -> Dict[str, Dict[str, Any]]:
817875
"options": [
818876
{"name": "ids", "type": "json", "required": True},
819877
{"name": "properties", "type": "json", "required": False},
878+
{"name": "fetch_all_body_values", "type": "flag", "required": False},
879+
],
880+
},
881+
"email.read": {
882+
"summary": "Fetch email and return subject + plain text body",
883+
"options": [
884+
{"name": "id", "type": "string", "required": True},
820885
],
821886
},
822887
"email.draft": {
@@ -939,6 +1004,12 @@ def build_parser() -> argparse.ArgumentParser:
9391004
eg.set_defaults(json="compact")
9401005
eg.add_argument("--ids", required=True, help="JSON array of ids or @file/@-")
9411006
eg.add_argument("--properties", help="JSON array of properties")
1007+
eg.add_argument("--fetch-all-body-values", action="store_true", help="Fetch all body values")
1008+
1009+
erd = sub.add_parser("email.read", help="Fetch email and return subject + plain text body")
1010+
add_connection_opts(erd)
1011+
erd.set_defaults(json="compact")
1012+
erd.add_argument("--id", required=True, help="Email ID")
9421013

9431014
ed = sub.add_parser("email.draft", help="Create draft email (does not send)")
9441015
add_connection_opts(ed)
@@ -1010,6 +1081,7 @@ def build_parser() -> argparse.ArgumentParser:
10101081

10111082
pl = sub.add_parser("pipeline.run", help="Run raw multi-call pipeline")
10121083
add_connection_opts(pl)
1084+
pl.set_defaults(json="compact")
10131085
pl.add_argument("--input", required=True, help="Pipeline JSON (inline/@file/@-)")
10141086
pl.add_argument("--allow-unsafe", action="store_true", help="Bypass read-only allowlist")
10151087

@@ -1026,6 +1098,7 @@ def main(argv: Optional[List[str]] = None) -> int:
10261098
"session.get": handle_session_get,
10271099
"email.query": handle_email_query,
10281100
"email.get": handle_email_get,
1101+
"email.read": handle_email_read,
10291102
"email.draft": handle_email_draft,
10301103
"email.draft-reply": handle_email_draft_reply,
10311104
"email.changes": handle_email_changes,

0 commit comments

Comments
 (0)