commune-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
+ """Commune CLI — official command-line interface for the Commune email API."""
2
+
3
+ __version__ = "0.1.0"
commune_cli/client.py ADDED
@@ -0,0 +1,111 @@
1
+ """CommuneClient — thin httpx wrapper with API key auth.
2
+
3
+ Auth: Authorization: Bearer comm_...
4
+
5
+ Raises:
6
+ httpx.ConnectError / httpx.TimeoutException — caller wraps with network_error()
7
+ All non-2xx responses — caller checks response.is_success and calls api_error()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ import httpx
15
+
16
+ from .state import AppState
17
+
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ class CommuneClient:
22
+ """HTTP client for the Commune API.
23
+
24
+ Usage:
25
+ client = CommuneClient.from_state(state)
26
+ r = client.get("/v1/domains")
27
+ if not r.is_success:
28
+ api_error(r, json_output=state.should_json())
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ api_key: Optional[str] = None,
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ ):
37
+ self.base_url = base_url.rstrip("/")
38
+ self.api_key = api_key
39
+ self.timeout = timeout
40
+
41
+ @classmethod
42
+ def from_state(cls, state: AppState) -> "CommuneClient":
43
+ return cls(
44
+ base_url=state.base_url,
45
+ api_key=state.api_key,
46
+ timeout=DEFAULT_TIMEOUT,
47
+ )
48
+
49
+ def _base_headers(self) -> dict[str, str]:
50
+ headers: dict[str, str] = {
51
+ "Content-Type": "application/json",
52
+ "User-Agent": "commune-cli/0.1.0",
53
+ }
54
+ if self.api_key:
55
+ headers["Authorization"] = f"Bearer {self.api_key}"
56
+ return headers
57
+
58
+ def _url(self, path: str) -> str:
59
+ return self.base_url + path
60
+
61
+ def _req(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ params: Optional[dict[str, Any]] = None,
67
+ json: Optional[Any] = None,
68
+ data: Optional[bytes] = None,
69
+ extra_headers: Optional[dict[str, str]] = None,
70
+ ) -> httpx.Response:
71
+ headers = self._base_headers()
72
+ if extra_headers:
73
+ headers.update(extra_headers)
74
+ if data is not None:
75
+ headers.pop("Content-Type", None)
76
+
77
+ if params:
78
+ params = {k: v for k, v in params.items() if v is not None}
79
+
80
+ with httpx.Client(timeout=self.timeout) as client:
81
+ return client.request(
82
+ method,
83
+ self._url(path),
84
+ headers=headers,
85
+ params=params or None,
86
+ json=json,
87
+ content=data,
88
+ )
89
+
90
+ def get(self, path: str, *, params: Optional[dict[str, Any]] = None) -> httpx.Response:
91
+ return self._req("GET", path, params=params)
92
+
93
+ def post(
94
+ self,
95
+ path: str,
96
+ *,
97
+ json: Optional[Any] = None,
98
+ data: Optional[bytes] = None,
99
+ extra_headers: Optional[dict[str, str]] = None,
100
+ params: Optional[dict[str, Any]] = None,
101
+ ) -> httpx.Response:
102
+ return self._req("POST", path, json=json, data=data, extra_headers=extra_headers, params=params)
103
+
104
+ def patch(self, path: str, *, json: Optional[Any] = None) -> httpx.Response:
105
+ return self._req("PATCH", path, json=json)
106
+
107
+ def delete(self, path: str, *, params: Optional[dict[str, Any]] = None) -> httpx.Response:
108
+ return self._req("DELETE", path, params=params)
109
+
110
+ def put(self, path: str, *, json: Optional[Any] = None) -> httpx.Response:
111
+ return self._req("PUT", path, json=json)
@@ -0,0 +1 @@
1
+ """Command modules for the Commune CLI."""
@@ -0,0 +1,127 @@
1
+ """commune attachments — upload files and get download URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..client import CommuneClient
11
+ from ..errors import api_error, auth_required_error, network_error, validation_error
12
+ from ..output import print_json, print_record, print_success
13
+ from ..state import AppState
14
+
15
+ app = typer.Typer(help="Attachment management.", no_args_is_help=True)
16
+
17
+
18
+ @app.command("upload")
19
+ def attachments_upload(
20
+ ctx: typer.Context,
21
+ file: Path = typer.Argument(..., help="Path to the file to upload.", exists=True, readable=True),
22
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
23
+ ) -> None:
24
+ """Upload a file as an attachment. POST /v1/attachments/upload.
25
+
26
+ Returns an attachment ID that can be referenced when sending emails.
27
+ """
28
+ state: AppState = ctx.obj or AppState()
29
+ if not state.has_any_auth():
30
+ auth_required_error(json_output=json_output or state.should_json())
31
+
32
+ file_path = Path(file)
33
+ if not file_path.is_file():
34
+ validation_error(f"File not found: {file}", json_output=json_output or state.should_json())
35
+
36
+ import mimetypes
37
+ mime_type, _ = mimetypes.guess_type(str(file_path))
38
+ if not mime_type:
39
+ mime_type = "application/octet-stream"
40
+
41
+ file_data = file_path.read_bytes()
42
+
43
+ client = CommuneClient.from_state(state)
44
+ try:
45
+ r = client.post(
46
+ "/v1/attachments/upload",
47
+ data=file_data,
48
+ extra_headers={
49
+ "Content-Type": mime_type,
50
+ "X-Filename": file_path.name,
51
+ },
52
+ )
53
+ except Exception as exc:
54
+ network_error(exc, json_output=json_output or state.should_json())
55
+
56
+ if not r.is_success:
57
+ api_error(r, json_output=json_output or state.should_json())
58
+
59
+ data = r.json()
60
+ if json_output or state.should_json():
61
+ print_json(data)
62
+ return
63
+
64
+ att_id = data.get("id") or data.get("attachmentId", "")
65
+ print_success(f"Uploaded [bold]{file_path.name}[/bold]. Attachment ID: [cyan]{att_id}[/cyan]")
66
+
67
+
68
+ @app.command("get")
69
+ def attachments_get(
70
+ ctx: typer.Context,
71
+ attachment_id: str = typer.Argument(..., help="Attachment ID."),
72
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
73
+ ) -> None:
74
+ """Get attachment metadata. GET /v1/attachments/{attachmentId}."""
75
+ state: AppState = ctx.obj or AppState()
76
+ if not state.has_any_auth():
77
+ auth_required_error(json_output=json_output or state.should_json())
78
+
79
+ client = CommuneClient.from_state(state)
80
+ try:
81
+ r = client.get(f"/v1/attachments/{attachment_id}")
82
+ except Exception as exc:
83
+ network_error(exc, json_output=json_output or state.should_json())
84
+
85
+ if not r.is_success:
86
+ api_error(r, json_output=json_output or state.should_json())
87
+
88
+ print_record(r.json(), json_output=json_output or state.should_json(), title="Attachment")
89
+
90
+
91
+ @app.command("url")
92
+ def attachments_url(
93
+ ctx: typer.Context,
94
+ attachment_id: str = typer.Argument(..., help="Attachment ID."),
95
+ expires_in: Optional[int] = typer.Option(
96
+ None,
97
+ "--expires-in",
98
+ help="URL expiry in seconds. Default: 3600.",
99
+ ),
100
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
101
+ ) -> None:
102
+ """Get a presigned download URL for an attachment. GET /v1/attachments/{attachmentId}/url."""
103
+ state: AppState = ctx.obj or AppState()
104
+ if not state.has_any_auth():
105
+ auth_required_error(json_output=json_output or state.should_json())
106
+
107
+ client = CommuneClient.from_state(state)
108
+ params: dict = {}
109
+ if expires_in is not None:
110
+ params["expiresIn"] = expires_in
111
+
112
+ try:
113
+ r = client.get(f"/v1/attachments/{attachment_id}/url", params=params or None)
114
+ except Exception as exc:
115
+ network_error(exc, json_output=json_output or state.should_json())
116
+
117
+ if not r.is_success:
118
+ api_error(r, json_output=json_output or state.should_json())
119
+
120
+ data = r.json()
121
+ if json_output or state.should_json():
122
+ print_json(data)
123
+ return
124
+
125
+ url = data.get("url", "")
126
+ from ..output import print_value
127
+ print_value(url, json_output=False, key="url")
@@ -0,0 +1,89 @@
1
+ """commune config — manage ~/.commune/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ..config import KNOWN_KEYS, config_path, delete_value, get_value, load_config, mask, set_value
10
+ from ..output import print_kv, print_success, print_value, print_warning
11
+
12
+ app = typer.Typer(help="Manage CLI configuration.", no_args_is_help=True)
13
+
14
+
15
+ @app.command("set")
16
+ def config_set(
17
+ key: str = typer.Argument(..., help=f"Config key. Known keys: {', '.join(KNOWN_KEYS)}."),
18
+ value: str = typer.Argument(..., help="Value to store."),
19
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
20
+ ) -> None:
21
+ """Set a config key and persist to ~/.commune/config.toml."""
22
+ if key not in KNOWN_KEYS:
23
+ known = ", ".join(KNOWN_KEYS)
24
+ print_warning(f"Unknown key '{key}'. Known keys: {known}")
25
+ set_value(key, value)
26
+ display = mask(value) if key == "api_key" else value
27
+ print_success(f"Set {key} = {display} ({config_path()})")
28
+ if json_output:
29
+ from ..output import print_json
30
+ print_json({"key": key, "set": True})
31
+
32
+
33
+ @app.command("get")
34
+ def config_get(
35
+ key: str = typer.Argument(..., help="Config key to retrieve."),
36
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
37
+ ) -> None:
38
+ """Get a single config value."""
39
+ value = get_value(key)
40
+ if value is None:
41
+ print_warning(f"Key '{key}' is not set in config.")
42
+ raise typer.Exit(1)
43
+ # Mask api_key in terminal display
44
+ display = mask(value) if key == "api_key" else value
45
+ print_value(display, json_output=json_output, key=key)
46
+
47
+
48
+ @app.command("show")
49
+ def config_show(
50
+ reveal: bool = typer.Option(False, "--reveal", help="Show full api_key (not masked)."),
51
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
52
+ ) -> None:
53
+ """Print all config values. Masks api_key unless --reveal is passed."""
54
+ cfg = load_config()
55
+ if not cfg:
56
+ print_warning(f"No config file found at {config_path()}")
57
+ raise typer.Exit(0)
58
+
59
+ pairs: dict[str, str] = {}
60
+ for k, v in cfg.items():
61
+ if k == "api_key" and not reveal:
62
+ pairs[k] = mask(v)
63
+ else:
64
+ pairs[k] = str(v)
65
+
66
+ print_kv(pairs, json_output=json_output, title=f"Config {config_path()}")
67
+
68
+
69
+ @app.command("unset")
70
+ def config_unset(
71
+ key: str = typer.Argument(..., help="Config key to remove."),
72
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
73
+ ) -> None:
74
+ """Remove a key from config."""
75
+ removed = delete_value(key)
76
+ if removed:
77
+ print_success(f"Removed '{key}' from config.")
78
+ else:
79
+ print_warning(f"Key '{key}' was not set.")
80
+ if json_output:
81
+ from ..output import print_json
82
+ print_json({"key": key, "removed": removed})
83
+
84
+
85
+ @app.command("path")
86
+ def config_path_cmd() -> None:
87
+ """Print the path to the config file."""
88
+ import sys
89
+ sys.stdout.write(str(config_path()) + "\n")
@@ -0,0 +1,130 @@
1
+ """commune data — data deletion requests (GDPR / destructive).
2
+
3
+ These commands are destructive and irreversible. They require explicit confirmation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from ..client import CommuneClient
13
+ from ..errors import api_error, auth_required_error, network_error
14
+ from ..output import print_json, print_record, print_success, print_warning, print_status
15
+ from ..state import AppState
16
+
17
+ app = typer.Typer(
18
+ help="Data deletion requests. Destructive — use with care.",
19
+ no_args_is_help=True,
20
+ )
21
+
22
+
23
+ @app.command("delete-request")
24
+ def data_delete_request(
25
+ ctx: typer.Context,
26
+ email: Optional[str] = typer.Option(None, "--email", help="Email address to delete data for."),
27
+ inbox_id: Optional[str] = typer.Option(None, "--inbox-id", help="Inbox ID scope for deletion."),
28
+ domain_id: Optional[str] = typer.Option(None, "--domain-id", help="Domain ID scope for deletion."),
29
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
30
+ ) -> None:
31
+ """Initiate a data deletion request. POST /v1/data/deletion-request.
32
+
33
+ Returns a preview of what will be deleted and a confirmation token.
34
+ Use `commune data delete-confirm <id>` to execute.
35
+ """
36
+ state: AppState = ctx.obj or AppState()
37
+ if not state.has_any_auth():
38
+ auth_required_error(json_output=json_output or state.should_json())
39
+
40
+ body: dict = {}
41
+ if email:
42
+ body["email"] = email
43
+ if inbox_id:
44
+ body["inboxId"] = inbox_id
45
+ if domain_id:
46
+ body["domainId"] = domain_id
47
+
48
+ client = CommuneClient.from_state(state)
49
+ try:
50
+ r = client.post("/v1/data/deletion-request", json=body)
51
+ except Exception as exc:
52
+ network_error(exc, json_output=json_output or state.should_json())
53
+
54
+ if not r.is_success:
55
+ api_error(r, json_output=json_output or state.should_json())
56
+
57
+ data = r.json()
58
+ if json_output or state.should_json():
59
+ print_json(data)
60
+ return
61
+
62
+ req_id = data.get("id", "")
63
+ print_warning(f"Deletion request created. ID: [bold]{req_id}[/bold]")
64
+ print_status("Review the preview above, then confirm with:")
65
+ print_status(f" commune data delete-confirm {req_id}")
66
+
67
+
68
+ @app.command("delete-confirm")
69
+ def data_delete_confirm(
70
+ ctx: typer.Context,
71
+ request_id: str = typer.Argument(..., help="Deletion request ID from `commune data delete-request`."),
72
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt. REQUIRED for non-interactive use."),
73
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
74
+ ) -> None:
75
+ """Confirm and execute a data deletion request. POST /v1/data/deletion-request/{id}/confirm.
76
+
77
+ This action is IRREVERSIBLE. Data is permanently deleted.
78
+ """
79
+ state: AppState = ctx.obj or AppState()
80
+ if not state.has_any_auth():
81
+ auth_required_error(json_output=json_output or state.should_json())
82
+
83
+ if not confirm:
84
+ if state.is_tty():
85
+ print_warning("[bold red]WARNING:[/bold red] This action permanently deletes data and cannot be undone.")
86
+ typer.confirm(f"Confirm deletion of request {request_id}?", abort=True)
87
+ else:
88
+ # Non-TTY without --yes flag: require explicit confirmation
89
+ from ..errors import validation_error
90
+ validation_error(
91
+ "Deletion requires --yes flag in non-interactive mode.",
92
+ json_output=json_output or state.should_json(),
93
+ )
94
+
95
+ client = CommuneClient.from_state(state)
96
+ try:
97
+ r = client.post(f"/v1/data/deletion-request/{request_id}/confirm")
98
+ except Exception as exc:
99
+ network_error(exc, json_output=json_output or state.should_json())
100
+
101
+ if not r.is_success:
102
+ api_error(r, json_output=json_output or state.should_json())
103
+
104
+ if json_output or state.should_json():
105
+ print_json(r.json())
106
+ return
107
+ print_success(f"Deletion confirmed. Request [bold]{request_id}[/bold] is processing.")
108
+
109
+
110
+ @app.command("delete-status")
111
+ def data_delete_status(
112
+ ctx: typer.Context,
113
+ request_id: str = typer.Argument(..., help="Deletion request ID."),
114
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
115
+ ) -> None:
116
+ """Get the status of a data deletion request. GET /v1/data/deletion-request/{id}."""
117
+ state: AppState = ctx.obj or AppState()
118
+ if not state.has_any_auth():
119
+ auth_required_error(json_output=json_output or state.should_json())
120
+
121
+ client = CommuneClient.from_state(state)
122
+ try:
123
+ r = client.get(f"/v1/data/deletion-request/{request_id}")
124
+ except Exception as exc:
125
+ network_error(exc, json_output=json_output or state.should_json())
126
+
127
+ if not r.is_success:
128
+ api_error(r, json_output=json_output or state.should_json())
129
+
130
+ print_record(r.json(), json_output=json_output or state.should_json(), title=f"Deletion Request {request_id}")
@@ -0,0 +1,126 @@
1
+ """commune delivery — delivery metrics, events, and suppressions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ..client import CommuneClient
10
+ from ..errors import api_error, auth_required_error, network_error
11
+ from ..output import print_list, print_record
12
+ from ..state import AppState
13
+
14
+ app = typer.Typer(help="Delivery metrics, events, and suppressions.", no_args_is_help=True)
15
+
16
+ PERIOD_HELP = "Time period: 1d, 7d, 30d (default: 7d)."
17
+
18
+
19
+ @app.command("metrics")
20
+ def delivery_metrics(
21
+ ctx: typer.Context,
22
+ domain_id: Optional[str] = typer.Option(None, "--domain-id", help="Filter by domain ID."),
23
+ inbox_id: Optional[str] = typer.Option(None, "--inbox-id", help="Filter by inbox ID."),
24
+ period: Optional[str] = typer.Option(None, "--period", help=PERIOD_HELP),
25
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
26
+ ) -> None:
27
+ """Get delivery metrics (sent, delivered, bounced, spam). GET /v1/delivery/metrics."""
28
+ state: AppState = ctx.obj or AppState()
29
+ if not state.has_any_auth():
30
+ auth_required_error(json_output=json_output or state.should_json())
31
+
32
+ client = CommuneClient.from_state(state)
33
+ try:
34
+ r = client.get("/v1/delivery/metrics", params={
35
+ "domainId": domain_id,
36
+ "inboxId": inbox_id,
37
+ "period": period,
38
+ })
39
+ except Exception as exc:
40
+ network_error(exc, json_output=json_output or state.should_json())
41
+
42
+ if not r.is_success:
43
+ api_error(r, json_output=json_output or state.should_json())
44
+
45
+ print_record(r.json(), json_output=json_output or state.should_json(), title="Delivery Metrics")
46
+
47
+
48
+ @app.command("events")
49
+ def delivery_events(
50
+ ctx: typer.Context,
51
+ domain_id: Optional[str] = typer.Option(None, "--domain-id", help="Filter by domain ID."),
52
+ inbox_id: Optional[str] = typer.Option(None, "--inbox-id", help="Filter by inbox ID."),
53
+ limit: Optional[int] = typer.Option(None, "--limit", help="Maximum results to return."),
54
+ cursor: Optional[str] = typer.Option(None, "--cursor", help="Pagination cursor."),
55
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
56
+ ) -> None:
57
+ """Get delivery events (sent, bounce, complaint, open, click). GET /v1/delivery/events."""
58
+ state: AppState = ctx.obj or AppState()
59
+ if not state.has_any_auth():
60
+ auth_required_error(json_output=json_output or state.should_json())
61
+
62
+ client = CommuneClient.from_state(state)
63
+ try:
64
+ r = client.get("/v1/delivery/events", params={
65
+ "domainId": domain_id,
66
+ "inboxId": inbox_id,
67
+ "limit": limit,
68
+ "cursor": cursor,
69
+ })
70
+ except Exception as exc:
71
+ network_error(exc, json_output=json_output or state.should_json())
72
+
73
+ if not r.is_success:
74
+ api_error(r, json_output=json_output or state.should_json())
75
+
76
+ print_list(
77
+ r.json(),
78
+ json_output=json_output or state.should_json(),
79
+ title="Delivery Events",
80
+ columns=[
81
+ ("Type", "type"),
82
+ ("Email", "email"),
83
+ ("Message ID", "messageId"),
84
+ ("Timestamp", "timestamp"),
85
+ ],
86
+ )
87
+
88
+
89
+ @app.command("suppressions")
90
+ def delivery_suppressions(
91
+ ctx: typer.Context,
92
+ domain_id: Optional[str] = typer.Option(None, "--domain-id", help="Filter by domain ID."),
93
+ inbox_id: Optional[str] = typer.Option(None, "--inbox-id", help="Filter by inbox ID."),
94
+ limit: Optional[int] = typer.Option(None, "--limit", help="Maximum results."),
95
+ cursor: Optional[str] = typer.Option(None, "--cursor", help="Pagination cursor."),
96
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
97
+ ) -> None:
98
+ """List suppressed email addresses (bounces + spam complaints). GET /v1/delivery/suppressions."""
99
+ state: AppState = ctx.obj or AppState()
100
+ if not state.has_any_auth():
101
+ auth_required_error(json_output=json_output or state.should_json())
102
+
103
+ client = CommuneClient.from_state(state)
104
+ try:
105
+ r = client.get("/v1/delivery/suppressions", params={
106
+ "domainId": domain_id,
107
+ "inboxId": inbox_id,
108
+ "limit": limit,
109
+ "cursor": cursor,
110
+ })
111
+ except Exception as exc:
112
+ network_error(exc, json_output=json_output or state.should_json())
113
+
114
+ if not r.is_success:
115
+ api_error(r, json_output=json_output or state.should_json())
116
+
117
+ print_list(
118
+ r.json(),
119
+ json_output=json_output or state.should_json(),
120
+ title="Suppressed Addresses",
121
+ columns=[
122
+ ("Email", "email"),
123
+ ("Reason", "reason"),
124
+ ("Suppressed At", "suppressedAt"),
125
+ ],
126
+ )
@@ -0,0 +1,81 @@
1
+ """commune dmarc — DMARC reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ..client import CommuneClient
10
+ from ..errors import api_error, auth_required_error, network_error, validation_error
11
+ from ..output import print_list, print_record
12
+ from ..state import AppState
13
+
14
+ app = typer.Typer(help="DMARC reports and summary.", no_args_is_help=True)
15
+
16
+
17
+ @app.command("reports")
18
+ def dmarc_reports(
19
+ ctx: typer.Context,
20
+ domain: str = typer.Argument(..., help="Domain name to get DMARC reports for (e.g. example.com)."),
21
+ limit: Optional[int] = typer.Option(None, "--limit", help="Maximum reports to return."),
22
+ cursor: Optional[str] = typer.Option(None, "--cursor", help="Pagination cursor."),
23
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
24
+ ) -> None:
25
+ """List DMARC aggregate reports for a domain. GET /v1/dmarc/reports."""
26
+ state: AppState = ctx.obj or AppState()
27
+ if not state.has_any_auth():
28
+ auth_required_error(json_output=json_output or state.should_json())
29
+
30
+ client = CommuneClient.from_state(state)
31
+ try:
32
+ r = client.get("/v1/dmarc/reports", params={
33
+ "domain": domain,
34
+ "limit": limit,
35
+ "cursor": cursor,
36
+ })
37
+ except Exception as exc:
38
+ network_error(exc, json_output=json_output or state.should_json())
39
+
40
+ if not r.is_success:
41
+ api_error(r, json_output=json_output or state.should_json())
42
+
43
+ print_list(
44
+ r.json(),
45
+ json_output=json_output or state.should_json(),
46
+ title=f"DMARC Reports: {domain}",
47
+ columns=[
48
+ ("ID", "id"),
49
+ ("Reporter", "reporterOrg"),
50
+ ("Start", "dateRangeBegin"),
51
+ ("End", "dateRangeEnd"),
52
+ ("Records", "recordCount"),
53
+ ],
54
+ )
55
+
56
+
57
+ @app.command("summary")
58
+ def dmarc_summary(
59
+ ctx: typer.Context,
60
+ domain: str = typer.Argument(..., help="Domain name to summarize (e.g. example.com)."),
61
+ days: Optional[int] = typer.Option(None, "--days", help="Number of days to include (default: 30)."),
62
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
63
+ ) -> None:
64
+ """Get a DMARC compliance summary for a domain. GET /v1/dmarc/summary."""
65
+ state: AppState = ctx.obj or AppState()
66
+ if not state.has_any_auth():
67
+ auth_required_error(json_output=json_output or state.should_json())
68
+
69
+ client = CommuneClient.from_state(state)
70
+ try:
71
+ r = client.get("/v1/dmarc/summary", params={
72
+ "domain": domain,
73
+ "days": days,
74
+ })
75
+ except Exception as exc:
76
+ network_error(exc, json_output=json_output or state.should_json())
77
+
78
+ if not r.is_success:
79
+ api_error(r, json_output=json_output or state.should_json())
80
+
81
+ print_record(r.json(), json_output=json_output or state.should_json(), title=f"DMARC Summary: {domain}")