Skip to content

Commit b43a4d1

Browse files
turianclaude
andauthored
Add blob.download command and attachment metadata to email.read (#12)
* Add blob.download command and attachment metadata to email.read Add blob.download command that downloads a blob to a local file using the JMAP session downloadUrl template (RFC 8620 Section 6.2). Returns a JSON envelope with blobId, name, contentType, output path, bytesWritten, and sha256 hash. File-only output keeps the JSON envelope convention intact. Extend email.read to include attachment metadata (partId, blobId, name, type, size) so agents can discover attachments and download them in a clean two-step workflow: email.read → blob.download. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump version to 0.2.3 Skips 0.2.2 which had a broken tag (pyproject.toml wasn't bumped before tagging, so CI failed to publish to PyPI with "File already exists"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Percent-encode downloadUrl template variables per RFC 6570 Replace raw str.format() on the JMAP downloadUrl URI template with expand_download_url() that percent-encodes each variable (accountId, blobId, name, type) using urllib.parse.quote(safe=""). Without this, attachment names containing reserved characters (#, ?, /, spaces) or non-ASCII text would produce malformed URLs. Centralizes the logic so any future use of downloadUrl goes through the same safe expansion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 410d6ab commit b43a4d1

2 files changed

Lines changed: 108 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastmail-cli"
3-
version = "0.2.1"
3+
version = "0.2.3"
44
description = "CLI for AI agents to access Fastmail via JMAP - draft responses without sending"
55
readme = "README.md"
66
license = "Apache-2.0"

src/fastmail_cli/jmapc.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22
"""
33
jmapc-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
66
Exit codes: 0 ok, 2 validation, 3 auth, 4 http, 5 jmap method error, 6 runtime.
77
"""
88

99
from __future__ import annotations
1010

1111
import argparse
12+
import hashlib
1213
import json
1314
import os
1415
import sys
1516
from datetime import datetime, timezone
17+
from pathlib import Path
1618
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
1719

1820
import requests
@@ -34,6 +36,27 @@
3436
from 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+
3760
def 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+
705792
def 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

Comments
 (0)