22"""
33jmapc-cli: Read-only JMAP CLI (JSON in/out)
44
5- Commands: session.get, email.query, email.get, email.read, mailbox.query, thread.get, pipeline.run
5+ Commands: session.get, email.query, email.get, email.read, mailbox.query, thread.get, blob.download, pipeline.run
66Exit codes: 0 ok, 2 validation, 3 auth, 4 http, 5 jmap method error, 6 runtime.
77"""
88
99from __future__ import annotations
1010
1111import argparse
12+ import hashlib
1213import json
1314import os
1415import sys
1516from datetime import datetime , timezone
17+ from pathlib import Path
1618from typing import Any , Dict , List , Optional , Sequence , Tuple , Union
1719
1820import requests
3436from jmapc .models import Comparator , Email , EmailAddress , EmailBodyValue
3537
3638
39+ def expand_download_url (
40+ template : str , * , account_id : str , blob_id : str , name : str , content_type : str ,
41+ ) -> str :
42+ """Expand a JMAP downloadUrl template with percent-encoded variables.
43+
44+ JMAP servers provide a downloadUrl URI template (RFC 6570 Level 1) like:
45+ https://example.com/download/{accountId}/{blobId}/{name}?type={type}
46+
47+ Plain str.format() is wrong: values like "file #1.pdf" or "text/plain"
48+ contain reserved characters that must be percent-encoded before
49+ substitution to avoid changing URL semantics.
50+ """
51+ from urllib .parse import quote
52+ return template .format (
53+ accountId = quote (account_id , safe = "" ),
54+ blobId = quote (blob_id , safe = "" ),
55+ name = quote (name , safe = "" ),
56+ type = quote (content_type , safe = "" ),
57+ )
58+
59+
3760def utc_now_iso () -> str :
3861 return datetime .now (tz = timezone .utc ).isoformat ()
3962
@@ -276,7 +299,7 @@ def handle_email_read(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
276299 account_id = resolve_account_id (session_json , args .account )
277300
278301 # Request specific properties for reading
279- props = ["subject" , "from" , "to" , "cc" , "bcc" , "textBody" , "bodyValues" , "receivedAt" , "messageId" ]
302+ props = ["subject" , "from" , "to" , "cc" , "bcc" , "textBody" , "bodyValues" , "receivedAt" , "messageId" , "attachments" ]
280303 call = EmailGet (ids = [args .id ], properties = props , fetch_all_body_values = True )
281304 using , mrs = jmap_request (client , account_id , call , raise_errors = True )
282305 resp = mrs [0 ].response
@@ -297,12 +320,25 @@ def handle_email_read(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
297320 if content :
298321 body_text += content
299322
323+ # Extract attachment metadata
324+ attachments = []
325+ if email .attachments :
326+ for att in email .attachments :
327+ attachments .append ({
328+ "partId" : att .part_id ,
329+ "blobId" : att .blob_id ,
330+ "name" : att .name ,
331+ "type" : att .type ,
332+ "size" : att .size ,
333+ })
334+
300335 data = {
301336 "id" : getattr (email , "id" , args .id ),
302337 "subject" : email .subject ,
303338 "from" : [a .to_dict () if hasattr (a , "to_dict" ) else a for a in email .mail_from ] if email .mail_from else None ,
304339 "to" : [a .to_dict () if hasattr (a , "to_dict" ) else a for a in email .to ] if email .to else None ,
305340 "body" : body_text .strip (),
341+ "attachments" : attachments ,
306342 }
307343
308344 capabilities_server = session_json .get ("capabilities" , {}).keys ()
@@ -702,6 +738,57 @@ def handle_pipeline_run(args: argparse.Namespace) -> Tuple[int, Dict[str, Any]]:
702738 return 6 , envelope (False , "pipeline.run" , vars (args ), meta_block (args .host , "unknown" , []), error = err )
703739
704740
741+ def handle_blob_download (args : argparse .Namespace ) -> Tuple [int , Dict [str , Any ]]:
742+ try :
743+ session_json = discover_session (args .host , args .timeout , not args .insecure , token = args .api_token )
744+ account_id = resolve_account_id (session_json , args .account )
745+ download_url_template = session_json .get ("downloadUrl" )
746+ if not download_url_template :
747+ raise ValueError ("Server session does not provide a downloadUrl" )
748+
749+ blob_url = expand_download_url (
750+ download_url_template ,
751+ account_id = account_id ,
752+ blob_id = args .blob_id ,
753+ name = args .name ,
754+ content_type = args .type ,
755+ )
756+ headers = {"Authorization" : f"Bearer { args .api_token } " }
757+ resp = requests .get (blob_url , headers = headers , stream = True , timeout = args .timeout , verify = not args .insecure )
758+ resp .raise_for_status ()
759+
760+ output_path = Path (args .output )
761+ sha256 = hashlib .sha256 ()
762+ bytes_written = 0
763+ with open (output_path , "wb" ) as f :
764+ for chunk in resp .iter_content (chunk_size = 65536 ):
765+ f .write (chunk )
766+ sha256 .update (chunk )
767+ bytes_written += len (chunk )
768+
769+ capabilities_server = session_json .get ("capabilities" , {}).keys ()
770+ meta = meta_block (args .host , account_id , [], capabilities_server )
771+ data = {
772+ "blobId" : args .blob_id ,
773+ "name" : args .name ,
774+ "contentType" : args .type ,
775+ "output" : str (output_path ),
776+ "bytesWritten" : bytes_written ,
777+ "sha256" : sha256 .hexdigest (),
778+ }
779+ return 0 , envelope (True , "blob.download" , vars (args ), meta , data = data )
780+ except ValueError as exc :
781+ err = {"type" : "validationError" , "message" : str (exc ), "details" : {}}
782+ return 2 , envelope (False , "blob.download" , vars (args ), meta_block (args .host , "unknown" , []), error = err )
783+ except requests .HTTPError as exc :
784+ code = http_exit_code (exc .response .status_code )
785+ err = {"type" : "httpError" , "message" : str (exc ), "details" : {"status" : exc .response .status_code }}
786+ return code , envelope (False , "blob.download" , vars (args ), meta_block (args .host , "unknown" , []), error = err )
787+ except Exception as exc :
788+ err = {"type" : "runtimeError" , "message" : str (exc ), "details" : {}}
789+ return 6 , envelope (False , "blob.download" , vars (args ), meta_block (args .host , "unknown" , []), error = err )
790+
791+
705792def handle_searchsnippet_get (args : argparse .Namespace ) -> Tuple [int , Dict [str , Any ]]:
706793 try :
707794 client , session_json = build_client (args .host , args .api_token , args .timeout , not args .insecure )
@@ -948,6 +1035,15 @@ def COMMAND_SPECS() -> Dict[str, Dict[str, Any]]:
9481035 {"name" : "properties" , "type" : "json" , "required" : False },
9491036 ],
9501037 },
1038+ "blob.download" : {
1039+ "summary" : "Download blob to file" ,
1040+ "options" : [
1041+ {"name" : "blob_id" , "type" : "string" , "required" : True },
1042+ {"name" : "name" , "type" : "string" , "required" : True },
1043+ {"name" : "type" , "type" : "string" , "required" : True },
1044+ {"name" : "output" , "type" : "string" , "required" : True },
1045+ ],
1046+ },
9511047 "events.listen" : {
9521048 "summary" : "Stream events (read-only)" ,
9531049 "options" : [
@@ -1070,6 +1166,14 @@ def build_parser() -> argparse.ArgumentParser:
10701166 ss .add_argument ("--filter" , help = "EmailQueryFilter JSON" )
10711167 ss .add_argument ("--properties" , nargs = "+" , help = "Snippet properties" )
10721168
1169+ bd = sub .add_parser ("blob.download" , help = "Download blob to file" )
1170+ add_connection_opts (bd )
1171+ bd .set_defaults (json = "compact" )
1172+ bd .add_argument ("--blob-id" , required = True , help = "Blob ID to download" )
1173+ bd .add_argument ("--name" , required = True , help = "Filename for the download" )
1174+ bd .add_argument ("--type" , required = True , help = "MIME type (e.g. application/pdf)" )
1175+ bd .add_argument ("--output" , required = True , help = "Output file path" )
1176+
10731177 ev = sub .add_parser ("events.listen" , help = "Listen to JMAP event stream" )
10741178 add_connection_opts (ev )
10751179 ev .set_defaults (json = "jsonl" )
@@ -1106,6 +1210,7 @@ def main(argv: Optional[List[str]] = None) -> int:
11061210 "mailbox.query" : handle_mailbox_query ,
11071211 "thread.get" : handle_thread_get ,
11081212 "searchsnippet.get" : handle_searchsnippet_get ,
1213+ "blob.download" : handle_blob_download ,
11091214 "events.listen" : handle_events_listen ,
11101215 "pipeline.run" : handle_pipeline_run ,
11111216 }
0 commit comments