foxreach-cli 0.2.0__tar.gz → 0.6.0__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 (54) hide show
  1. foxreach_cli-0.6.0/CHANGELOG.md +108 -0
  2. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/PKG-INFO +11 -6
  3. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/README.md +3 -1
  4. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/__init__.py +1 -1
  5. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/client_factory.py +11 -0
  6. foxreach_cli-0.6.0/foxreach_cli/commands/accounts.py +305 -0
  7. foxreach_cli-0.6.0/foxreach_cli/commands/agency.py +116 -0
  8. foxreach_cli-0.6.0/foxreach_cli/commands/analytics.py +177 -0
  9. foxreach_cli-0.6.0/foxreach_cli/commands/audit_log.py +36 -0
  10. foxreach_cli-0.6.0/foxreach_cli/commands/block_list.py +91 -0
  11. foxreach_cli-0.6.0/foxreach_cli/commands/campaigns.py +518 -0
  12. foxreach_cli-0.6.0/foxreach_cli/commands/copy.py +71 -0
  13. foxreach_cli-0.6.0/foxreach_cli/commands/events.py +43 -0
  14. foxreach_cli-0.6.0/foxreach_cli/commands/inbox.py +457 -0
  15. foxreach_cli-0.6.0/foxreach_cli/commands/jobs.py +74 -0
  16. foxreach_cli-0.6.0/foxreach_cli/commands/lead_finder.py +539 -0
  17. foxreach_cli-0.6.0/foxreach_cli/commands/lead_labels.py +123 -0
  18. foxreach_cli-0.6.0/foxreach_cli/commands/leads.py +531 -0
  19. foxreach_cli-0.6.0/foxreach_cli/commands/managed_inboxes.py +171 -0
  20. foxreach_cli-0.6.0/foxreach_cli/commands/me.py +38 -0
  21. foxreach_cli-0.6.0/foxreach_cli/commands/sequences.py +240 -0
  22. foxreach_cli-0.6.0/foxreach_cli/commands/team.py +188 -0
  23. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/templates.py +53 -5
  24. foxreach_cli-0.6.0/foxreach_cli/commands/tracking_domains.py +98 -0
  25. foxreach_cli-0.6.0/foxreach_cli/commands/verification.py +134 -0
  26. foxreach_cli-0.6.0/foxreach_cli/commands/warmup.py +226 -0
  27. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/webhooks.py +69 -3
  28. foxreach_cli-0.6.0/foxreach_cli/commands/workspaces.py +35 -0
  29. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/main.py +41 -1
  30. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/pyproject.toml +10 -4
  31. foxreach_cli-0.6.0/tests/__init__.py +0 -0
  32. foxreach_cli-0.6.0/tests/test_commands.py +874 -0
  33. foxreach_cli-0.6.0/tests/test_lead_finder_reveal.py +107 -0
  34. foxreach_cli-0.6.0/tests/test_pa3_account_checks.py +28 -0
  35. foxreach_cli-0.6.0/tests/test_pa3_agency.py +78 -0
  36. foxreach_cli-0.6.0/tests/test_pa3_analytics.py +78 -0
  37. foxreach_cli-0.6.0/tests/test_pa3_audit_log.py +18 -0
  38. foxreach_cli-0.6.0/tests/test_pa3_lead_finder.py +158 -0
  39. foxreach_cli-0.6.0/tests/test_pa3_template_gallery.py +38 -0
  40. foxreach_cli-0.6.0/tests/test_pa3_warmup_tags.py +58 -0
  41. foxreach_cli-0.6.0/tests/test_request_shapes.py +283 -0
  42. foxreach_cli-0.6.0/tests/test_sdk_floor.py +35 -0
  43. foxreach_cli-0.2.0/foxreach_cli/commands/accounts.py +0 -87
  44. foxreach_cli-0.2.0/foxreach_cli/commands/analytics.py +0 -69
  45. foxreach_cli-0.2.0/foxreach_cli/commands/campaigns.py +0 -259
  46. foxreach_cli-0.2.0/foxreach_cli/commands/inbox.py +0 -196
  47. foxreach_cli-0.2.0/foxreach_cli/commands/leads.py +0 -207
  48. foxreach_cli-0.2.0/foxreach_cli/commands/sequences.py +0 -126
  49. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/.github/workflows/publish.yml +0 -0
  50. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/.gitignore +0 -0
  51. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/LICENSE +0 -0
  52. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/__init__.py +0 -0
  53. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/config.py +0 -0
  54. {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/output.py +0 -0
@@ -0,0 +1,108 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.6.0] - 2026-11-04
6
+
7
+ Requires `foxreach>=0.6.0` (the commands below import names and call methods that SDK 0.6.0 adds).
8
+
9
+ ### Added
10
+
11
+ - `campaigns duplicate|share|preview|test-email|bulk-remove-leads|activity|cohorts|lead-emails`: copy a campaign, turn its public report page on or off, render copy, send one real test email (asks first), remove many leads, and read the activity feed and daily cohorts.
12
+ - `sequences variants list|add|update|delete|stats --campaign ID --step ID`: A/B variants of a sequence step and a per-variant comparison.
13
+ - `leads bulk-delete|bulk-tag|bulk-untag|bulk-verify|export|variables`: bulk lead actions by `--lead-id` or `--filter-*`, a CSV export that waits and downloads, and the merge-tag list. `bulk-delete` shows the campaigns affected and asks first; `bulk-verify` spends verification credits.
14
+ - `workspace show|rename|members|invite|set-role|remove|invites|revoke-invite|resend-invite|defaults` and `api-keys list|create|revoke`. Team changes and key management need a key with the opt-in `admin` scope.
15
+ - `webhooks test`, `webhooks deliveries` and `events list`.
16
+ - `inbox mark-read`, `mark-all-read`, `bulk-update`, `bulk-delete`, `notes list|add|delete` and `sent`.
17
+ - `agency overview|clients|create-client|rename-client|delete-client|branding|set-branding`, `audit-log list`, `analytics account|report|export-report|leaderboard|activity|campaign-daily|campaign-steps`, `lead-finder create-list|get-list|rename-list|delete-list|list-items|add-items|remove-items|reveal-list|add-list-to-leads|export-list|create-saved-search|get-saved-search|update-saved-search|delete-saved-search|run-saved-search`, `warmup create-tag|update-tag|delete-tag|assign-tag|unassign-tag`, `accounts test-connection|check-domain-auth`, `templates publish|unpublish|clone`. Each prints the API's JSON. `lead-finder reveal-list` spends reveal credits; `agency delete-client` is irreversible.
18
+
19
+ - `--workspace ID` (or `FOXREACH_WORKSPACE_ID`): run any command as a client workspace of an Agency key.
20
+ - `workspaces list`: show the workspaces the key can act as.
21
+ - `me`: show the API key in use, its workspace, the plan, the rate limit and the credit balances.
22
+ - `jobs list [--type T] [--status S] [--page N] [--limit N]` and `jobs get JOB_ID`: read the status, progress and result counters of background jobs (lead imports, verification runs).
23
+ - `block-list list|add|remove|export`: manage the workspace block list. `add` takes any number of emails or domains and `--note`; `remove` takes entry IDs; `export` writes CSV to stdout or `-o FILE`.
24
+
25
+ - `campaigns cohort-detail CAMPAIGN_ID DATE`: the emails and replies of one UTC day (each side cut at 200 rows).
26
+ - `inbox drafts list|get|edit|send|regenerate|dismiss`: AI reply drafts. `send` sends the stored draft as a real email.
27
+ - `lead-labels list|create|update|delete|assign|unassign`, `leads set-interest LEAD_ID STATUS|clear`, and `leads list --interest-status` / `--label-id`.
28
+ - `copy judge` (free) and `copy rewrite` (1 AI credit): score or rewrite a subject and body (`--body` or `--body-file`).
29
+ - `webhooks resume ID` (turn a webhook paused after 10 failed deliveries back on) and `webhooks create --reply-categories`.
30
+
31
+ ### Fixed
32
+
33
+ - `lead-finder reveal` revealed nothing: it posted `{"id": ...}` for each `--person-ids` value, and the API looks a person up by name plus company or domain, so every person came back `unsearchable`. It now takes `--from-file` (the JSON printed by `lead-finder search`, a JSON list of person rows, or `-` for stdin) and posts those rows; `--person-ids` picks rows from that file. `--person-ids` without `--from-file` now exits with an error instead of spending a request.
34
+
35
+ ## [0.5.0] - 2026-11-03
36
+
37
+ ### Added
38
+
39
+ - `leads create --custom KEY=VALUE` (repeatable) sets custom fields on a new lead.
40
+ - `leads update --custom KEY=VALUE` and `--unset-custom KEY` change or remove individual custom fields; the lead's other custom fields are kept.
41
+ - `leads import --mapping` accepts `"custom:<name>"` as a target: `{"z123": "custom:p234"}` saves the `z123` column as the custom field `p234`. Columns not in the mapping are still left out.
42
+ - `leads list --provider VALUE`: filter leads by recipient mail provider (google, microsoft, zoho, yahoo, godaddy, other, gateway, unresolved).
43
+ - `leads providers`: show how your leads split across mail providers, how many sit behind a security gateway, and how much has been checked.
44
+ - `campaigns esp-mix CAMPAIGN_ID`: show how a campaign's mailboxes and leads split across mail providers.
45
+
46
+ ### Fixed
47
+
48
+ - Table output of `inbox stats`, `inbox conversation`, `leads activity`, `verification` credits and `tracking-domains` read camelCase keys the SDK no longer returns, so counts showed 0 and sender or campaign showed blank. They now read the SDK's snake_case keys.
49
+
50
+ ## [0.4.2] - 2026-10-21
51
+
52
+ Requires `foxreach>=0.4.2`.
53
+
54
+ ### Added
55
+
56
+ - `leads list --email ADDRESS`: exact, case-insensitive email match.
57
+ - `inbox get --mark-read`: mark the thread read while fetching it.
58
+ - `sequences create --name`.
59
+ - `templates create --category` and `templates update --category` (for example `email` or `reply_macro`).
60
+
61
+ ### Changed
62
+
63
+ - `inbox get` no longer marks the thread read. The API stopped marking threads read on read; pass `--mark-read` (or use `inbox update ID --read`) to mark it.
64
+ - `sequences create --subject` is now required, matching the API.
65
+ - `sequences create --step` is removed. The API assigns the step number and ignored the value.
66
+ - `sequences list` shows a Name column instead of Type (sequences have no type); `templates list` and `templates get` show the template's category.
67
+
68
+ ### Fixed
69
+
70
+ - `foxreach_cli/__init__.py` said 0.4.0 while `pyproject.toml` said 0.4.1. Both now say 0.4.2, and `tests/test_request_shapes.py::test_version_sources_agree` asserts it.
71
+
72
+ ## [0.4.1] - 2026-10-21
73
+
74
+ ### Added
75
+
76
+ - `campaigns list-leads <id>` — paginated leads enrolled in a campaign (`GET /campaigns/{id}/leads`)
77
+ - `campaigns list-accounts <id>` — email accounts assigned to a campaign (`GET /campaigns/{id}/accounts`)
78
+ - `accounts google-oauth-start` — start Google Workspace OAuth flow; returns a redirect URL (`POST /email-accounts/google-oauth/start`)
79
+ - `lead-finder filter-options` — available filter keys, industries, and countries (`GET /lead-finder/filter-options`)
80
+ - `lead-finder count` — count people matching filters without using a search page (`POST /lead-finder/count`)
81
+ - `lead-finder companies-count` — count companies matching filters (`POST /lead-finder/companies/count`)
82
+ - `lead-finder companies-search` — search companies by filters (`POST /lead-finder/companies/search`)
83
+ - `lead-finder add-to-leads` — move revealed contacts into leads workspace (`POST /lead-finder/add-to-leads`)
84
+ - `lead-finder reveals-get <batch_id>` — get a single reveal batch (`GET /lead-finder/reveals/{batch_id}`)
85
+ - `leads import` — bulk import leads from a CSV file with column mapping (`POST /leads/import`)
86
+ - `managed-inboxes domains-search <query>` — search available domains by keyword (`GET /managed-inboxes/domains/search`)
87
+ - `managed-inboxes domains-suggest <domain>` — get domain name suggestions (`GET /managed-inboxes/domains/suggest`)
88
+ - `managed-inboxes quote` — get a price quote for a managed inbox order (`POST /managed-inboxes/orders/quote`)
89
+ - `managed-inboxes get-order <order_id>` — get a managed inbox order by ID (`GET /managed-inboxes/orders/{id}`)
90
+
91
+ ### Changed
92
+
93
+ - CLI test mocks are now spec'd with `create_autospec` on resource classes so a renamed SDK method call fails the test with `AttributeError` instead of silently passing.
94
+
95
+ ## [0.4.0] - 2026-10-21
96
+
97
+ ### Added
98
+ - New command groups: `warmup` (pool/stats/logs/tags), `lead-finder` (credits/search/reveal/reveals/lists/saved-searches), `managed-inboxes` (config/check-domain/orders/mailboxes), `tracking-domains` (list/add/verify/delete)
99
+ - New commands on `accounts`: `create`, `update`, `reconnect`, `enable-warmup`, `disable-warmup`, `health`, `warmup`
100
+ - New command on `inbox`: `delete`
101
+ - New command on `leads`: `tags`
102
+
103
+ ### Fixed
104
+ - Single version source: `__version__` in `foxreach_cli/__init__.py`; `pyproject.toml` aligned to 0.4.0
105
+ - Dependency bumped from `foxreach>=0.2.0` to `foxreach>=0.4.0`
106
+
107
+ ### Notes
108
+ - PyPI publishing is pending; not yet published to pypi.org
@@ -1,11 +1,11 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: foxreach-cli
3
- Version: 0.2.0
3
+ Version: 0.6.0
4
4
  Summary: CLI for the FoxReach cold email outreach API
5
5
  Project-URL: Homepage, https://foxreach.io
6
- Project-URL: Documentation, https://docs.foxreach.io/sdks/cli
6
+ Project-URL: Documentation, https://www.foxreach.io/docs/sdks/cli
7
7
  Project-URL: Repository, https://github.com/foxreach/foxreach-cli
8
- Project-URL: Changelog, https://docs.foxreach.io/changelog
8
+ Project-URL: Changelog, https://www.foxreach.io/docs/changelog
9
9
  Author-email: FoxReach <dev@foxreach.io>
10
10
  License: MIT
11
11
  License-File: LICENSE
@@ -23,8 +23,11 @@ Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Topic :: Communications :: Email
24
24
  Requires-Python: >=3.9
25
25
  Requires-Dist: click>=8.0
26
- Requires-Dist: foxreach>=0.2.0
26
+ Requires-Dist: foxreach>=0.6.0
27
27
  Requires-Dist: rich>=13.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Requires-Dist: respx>=0.20; extra == 'dev'
28
31
  Description-Content-Type: text/markdown
29
32
 
30
33
  # FoxReach CLI
@@ -62,6 +65,7 @@ export FOXREACH_BASE_URL=https://api.foxreach.io/api/v1 # optional
62
65
  ```bash
63
66
  foxreach leads list
64
67
  foxreach leads list --search "jane" --status active
68
+ foxreach leads list --email jane@example.com # exact, case-insensitive match
65
69
  foxreach leads get cld_abc123
66
70
  foxreach leads create --email jane@example.com --first-name Jane --company "Acme"
67
71
  foxreach leads update cld_abc123 --company "New Corp"
@@ -115,7 +119,8 @@ foxreach accounts delete acc_abc123
115
119
  ```bash
116
120
  foxreach inbox list
117
121
  foxreach inbox list --unread --category interested
118
- foxreach inbox get rpl_abc123
122
+ foxreach inbox get rpl_abc123 # does not mark the thread read
123
+ foxreach inbox get rpl_abc123 --mark-read # marks it read
119
124
  foxreach inbox update rpl_abc123 --read --category qualified
120
125
  ```
121
126
 
@@ -33,6 +33,7 @@ export FOXREACH_BASE_URL=https://api.foxreach.io/api/v1 # optional
33
33
  ```bash
34
34
  foxreach leads list
35
35
  foxreach leads list --search "jane" --status active
36
+ foxreach leads list --email jane@example.com # exact, case-insensitive match
36
37
  foxreach leads get cld_abc123
37
38
  foxreach leads create --email jane@example.com --first-name Jane --company "Acme"
38
39
  foxreach leads update cld_abc123 --company "New Corp"
@@ -86,7 +87,8 @@ foxreach accounts delete acc_abc123
86
87
  ```bash
87
88
  foxreach inbox list
88
89
  foxreach inbox list --unread --category interested
89
- foxreach inbox get rpl_abc123
90
+ foxreach inbox get rpl_abc123 # does not mark the thread read
91
+ foxreach inbox get rpl_abc123 --mark-read # marks it read
90
92
  foxreach inbox update rpl_abc123 --read --category qualified
91
93
  ```
92
94
 
@@ -1,3 +1,3 @@
1
1
  """FoxReach CLI — command-line interface for the FoxReach API."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.6.0"
@@ -8,6 +8,14 @@ from . import config as cfg
8
8
  from .output import print_error
9
9
 
10
10
 
11
+ _workspace_id: str | None = None
12
+
13
+
14
+ def set_workspace(workspace_id: str | None) -> None:
15
+ global _workspace_id
16
+ _workspace_id = workspace_id or None
17
+
18
+
11
19
  def get_client() -> FoxReach:
12
20
  api_key = cfg.get_api_key()
13
21
  if not api_key:
@@ -19,4 +27,7 @@ def get_client() -> FoxReach:
19
27
  if base_url:
20
28
  kwargs["base_url"] = base_url
21
29
 
30
+ if _workspace_id:
31
+ kwargs["workspace_id"] = _workspace_id
32
+
22
33
  return FoxReach(**kwargs)
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import asdict
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from foxreach import EmailAccountCreate, EmailAccountUpdate, FoxReachError, ReconnectRequest
10
+
11
+ from ..client_factory import get_client
12
+ from ..output import print_detail, print_error, print_json, print_meta, print_success, print_table
13
+
14
+
15
+ @click.group()
16
+ def accounts() -> None:
17
+ """Manage email accounts."""
18
+
19
+
20
+ @accounts.command("list")
21
+ @click.option("--page", default=1, type=int)
22
+ @click.option("--limit", default=50, type=int)
23
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
24
+ def accounts_list(page: int, limit: int, as_json: bool) -> None:
25
+ """List email accounts."""
26
+ client = get_client()
27
+ try:
28
+ result = client.email_accounts.list(page=page, page_size=limit)
29
+ except FoxReachError as e:
30
+ print_error(str(e))
31
+ sys.exit(1)
32
+
33
+ if as_json:
34
+ print_json({"data": [asdict(a) for a in result.data], "meta": asdict(result.meta)})
35
+ return
36
+
37
+ if not result.data:
38
+ click.echo("No email accounts found.")
39
+ return
40
+
41
+ print_table(
42
+ result.data,
43
+ ["ID", "Email", "Display Name", "Active", "Daily Limit", "Sent Today", "Health"],
44
+ {
45
+ "ID": "id", "Email": "email", "Display Name": "display_name",
46
+ "Active": "is_active", "Daily Limit": "daily_limit",
47
+ "Sent Today": "sent_today", "Health": "health_score",
48
+ },
49
+ )
50
+ print_meta(result.meta)
51
+
52
+
53
+ @accounts.command("get")
54
+ @click.argument("account_id")
55
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
56
+ def accounts_get(account_id: str, as_json: bool) -> None:
57
+ """Get an email account by ID."""
58
+ client = get_client()
59
+ try:
60
+ account = client.email_accounts.get(account_id)
61
+ except FoxReachError as e:
62
+ print_error(str(e))
63
+ sys.exit(1)
64
+
65
+ if as_json:
66
+ print_json(account)
67
+ return
68
+
69
+ print_detail(account, [
70
+ "id", "email", "display_name", "provider", "is_active",
71
+ "daily_limit", "sent_today", "warmup_enabled",
72
+ "health_score", "bounce_rate", "reply_rate",
73
+ "connection_status", "created_at",
74
+ ])
75
+
76
+
77
+ @accounts.command("delete")
78
+ @click.argument("account_id")
79
+ @click.confirmation_option(prompt="Are you sure you want to delete this email account?")
80
+ def accounts_delete(account_id: str) -> None:
81
+ """Delete an email account."""
82
+ client = get_client()
83
+ try:
84
+ client.email_accounts.delete(account_id)
85
+ except FoxReachError as e:
86
+ print_error(str(e))
87
+ sys.exit(1)
88
+ print_success(f"Email account deleted: {account_id}")
89
+
90
+
91
+ @accounts.command("create")
92
+ @click.option("--email", required=True, help="Email address")
93
+ @click.option("--password", required=True, hide_input=True, help="App password")
94
+ @click.option("--smtp-host", required=True, help="SMTP host")
95
+ @click.option("--smtp-port", required=True, type=int, help="SMTP port")
96
+ @click.option("--imap-host", required=True, help="IMAP host")
97
+ @click.option("--imap-port", required=True, type=int, help="IMAP port")
98
+ @click.option("--provider", default="other", help="Provider (gmail/outlook/other)")
99
+ @click.option("--display-name", default=None, help="Display name")
100
+ @click.option("--daily-limit", default=None, type=int, help="Daily send limit")
101
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
102
+ def accounts_create(
103
+ email: str,
104
+ password: str,
105
+ smtp_host: str,
106
+ smtp_port: int,
107
+ imap_host: str,
108
+ imap_port: int,
109
+ provider: str,
110
+ display_name: Optional[str],
111
+ daily_limit: Optional[int],
112
+ as_json: bool,
113
+ ) -> None:
114
+ """Create a new email account. Requires write scope."""
115
+ client = get_client()
116
+ try:
117
+ account = client.email_accounts.create(EmailAccountCreate(
118
+ email=email,
119
+ password=password,
120
+ smtp_host=smtp_host,
121
+ smtp_port=smtp_port,
122
+ imap_host=imap_host,
123
+ imap_port=imap_port,
124
+ provider=provider,
125
+ display_name=display_name,
126
+ daily_limit=daily_limit,
127
+ ))
128
+ except FoxReachError as e:
129
+ print_error(str(e))
130
+ sys.exit(1)
131
+
132
+ if as_json:
133
+ print_json(asdict(account))
134
+ else:
135
+ print_success(f"Email account created: {account.id} ({account.email})")
136
+
137
+
138
+ @accounts.command("update")
139
+ @click.argument("account_id")
140
+ @click.option("--display-name", default=None, help="Display name")
141
+ @click.option("--daily-limit", default=None, type=int, help="Daily send limit")
142
+ @click.option("--timezone", default=None, help="Timezone")
143
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
144
+ def accounts_update(
145
+ account_id: str,
146
+ display_name: Optional[str],
147
+ daily_limit: Optional[int],
148
+ timezone: Optional[str],
149
+ as_json: bool,
150
+ ) -> None:
151
+ """Update an email account's settings. Requires write scope."""
152
+ client = get_client()
153
+ try:
154
+ account = client.email_accounts.update(account_id, EmailAccountUpdate(
155
+ display_name=display_name,
156
+ daily_limit=daily_limit,
157
+ timezone=timezone,
158
+ ))
159
+ except FoxReachError as e:
160
+ print_error(str(e))
161
+ sys.exit(1)
162
+
163
+ if as_json:
164
+ print_json(asdict(account))
165
+ else:
166
+ print_success(f"Email account updated: {account_id}")
167
+
168
+
169
+ @accounts.command("reconnect")
170
+ @click.argument("account_id")
171
+ @click.option("--password", required=True, hide_input=True, help="New app password")
172
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
173
+ def accounts_reconnect(account_id: str, password: str, as_json: bool) -> None:
174
+ """Reconnect an account with a new app password. Requires write scope."""
175
+ client = get_client()
176
+ try:
177
+ account = client.email_accounts.reconnect(account_id, ReconnectRequest(password=password))
178
+ except FoxReachError as e:
179
+ print_error(str(e))
180
+ sys.exit(1)
181
+
182
+ if as_json:
183
+ print_json(asdict(account))
184
+ else:
185
+ print_success(f"Email account reconnected: {account_id}")
186
+
187
+
188
+ @accounts.command("enable-warmup")
189
+ @click.argument("account_id")
190
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
191
+ def accounts_enable_warmup(account_id: str, as_json: bool) -> None:
192
+ """Enable warmup for an account. Requires write scope."""
193
+ client = get_client()
194
+ try:
195
+ account = client.email_accounts.enable_warmup(account_id)
196
+ except FoxReachError as e:
197
+ print_error(str(e))
198
+ sys.exit(1)
199
+
200
+ if as_json:
201
+ print_json(asdict(account))
202
+ else:
203
+ print_success(f"Warmup enabled: {account_id}")
204
+
205
+
206
+ @accounts.command("disable-warmup")
207
+ @click.argument("account_id")
208
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
209
+ def accounts_disable_warmup(account_id: str, as_json: bool) -> None:
210
+ """Disable warmup for an account. Requires write scope."""
211
+ client = get_client()
212
+ try:
213
+ account = client.email_accounts.disable_warmup(account_id)
214
+ except FoxReachError as e:
215
+ print_error(str(e))
216
+ sys.exit(1)
217
+
218
+ if as_json:
219
+ print_json(asdict(account))
220
+ else:
221
+ print_success(f"Warmup disabled: {account_id}")
222
+
223
+
224
+ @accounts.command("health")
225
+ @click.argument("account_id")
226
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
227
+ def accounts_health(account_id: str, as_json: bool) -> None:
228
+ """Get deliverability and warmup health metrics for an account."""
229
+ client = get_client()
230
+ try:
231
+ h = client.email_accounts.health(account_id)
232
+ except FoxReachError as e:
233
+ print_error(str(e))
234
+ sys.exit(1)
235
+
236
+ if as_json:
237
+ print_json({
238
+ "healthScore": h.health_score,
239
+ "warmupEnabled": h.warmup_enabled,
240
+ })
241
+ return
242
+
243
+ score = h.health_score if h.health_score is not None else "—"
244
+ click.echo(f" Health Score: {score}")
245
+ click.echo(f" Warmup: {h.warmup_enabled}")
246
+
247
+
248
+ @accounts.command("warmup")
249
+ @click.argument("account_id")
250
+ @click.option("--json", "as_json", is_flag=True, default=True, help="Output as JSON (default)")
251
+ def accounts_warmup(account_id: str, as_json: bool) -> None:
252
+ """Get warmup progress and pool stats for an account."""
253
+ client = get_client()
254
+ try:
255
+ result = client.email_accounts.warmup_detail(account_id)
256
+ except FoxReachError as e:
257
+ print_error(str(e))
258
+ sys.exit(1)
259
+ print_json(result)
260
+
261
+
262
+ @accounts.command("google-oauth-start")
263
+ @click.option("--json", "as_json", is_flag=True, default=True, help="Output as JSON (default)")
264
+ def accounts_google_oauth_start(as_json: bool) -> None:
265
+ """Start Google OAuth flow. Returns a browser redirect URL to complete the connection."""
266
+ client = get_client()
267
+ try:
268
+ result = client.email_accounts.google_oauth_start()
269
+ except FoxReachError as e:
270
+ print_error(str(e))
271
+ sys.exit(1)
272
+ print_json(result)
273
+
274
+
275
+ @accounts.command("test-connection")
276
+ @click.argument("email")
277
+ @click.argument("password")
278
+ @click.argument("smtp_host")
279
+ @click.argument("imap_host")
280
+ @click.option("--smtp-port", "smtp_port", default=None)
281
+ @click.option("--imap-port", "imap_port", default=None)
282
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
283
+ def accounts_test_connection(email, password, smtp_host, imap_host, smtp_port, imap_port, as_json: bool) -> None:
284
+ """Check SMTP and IMAP login for a mailbox without saving anything. The password goes to the mail provider only. Requires write scope."""
285
+ client = get_client()
286
+ try:
287
+ result = client.email_accounts.test_connection(email, password, smtp_host, imap_host, smtp_port=smtp_port, imap_port=imap_port)
288
+ except FoxReachError as e:
289
+ print_error(str(e))
290
+ sys.exit(1)
291
+ print_json(result)
292
+
293
+
294
+ @accounts.command("check-domain-auth")
295
+ @click.argument("account_id")
296
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
297
+ def accounts_check_domain_auth(account_id, as_json: bool) -> None:
298
+ """Run a fresh SPF, DKIM and DMARC check for an account's sending domain. Requires write scope."""
299
+ client = get_client()
300
+ try:
301
+ result = client.email_accounts.check_domain_auth(account_id)
302
+ except FoxReachError as e:
303
+ print_error(str(e))
304
+ sys.exit(1)
305
+ print_json(result)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from foxreach import FoxReachError
8
+
9
+ from ..client_factory import get_client
10
+ from ..output import print_error, print_json
11
+
12
+
13
+ @click.group("agency")
14
+ def agency() -> None:
15
+ """Agency client workspaces and stored branding settings (Agency keys only)."""
16
+
17
+
18
+ @agency.command("overview")
19
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
20
+ def agency_overview(as_json: bool) -> None:
21
+ """Totals across every client workspace. Requires read scope on an Agency key."""
22
+ client = get_client()
23
+ try:
24
+ result = client.agency.overview()
25
+ except FoxReachError as e:
26
+ print_error(str(e))
27
+ sys.exit(1)
28
+ print_json(result)
29
+
30
+
31
+ @agency.command("clients")
32
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
33
+ def agency_clients(as_json: bool) -> None:
34
+ """List the agency owner's workspaces with health and usage. Requires read scope."""
35
+ client = get_client()
36
+ try:
37
+ result = client.agency.list_clients()
38
+ except FoxReachError as e:
39
+ print_error(str(e))
40
+ sys.exit(1)
41
+ print_json(result)
42
+
43
+
44
+ @agency.command("create-client")
45
+ @click.argument("name")
46
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
47
+ def agency_create_client(name, as_json: bool) -> None:
48
+ """Create a client workspace. Needs a key created by the agency owner."""
49
+ client = get_client()
50
+ try:
51
+ result = client.agency.create_client(name)
52
+ except FoxReachError as e:
53
+ print_error(str(e))
54
+ sys.exit(1)
55
+ print_json(result)
56
+
57
+
58
+ @agency.command("rename-client")
59
+ @click.argument("client_id")
60
+ @click.argument("name")
61
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
62
+ def agency_rename_client(client_id, name, as_json: bool) -> None:
63
+ """Rename a client workspace. Needs a key created by the agency owner."""
64
+ client = get_client()
65
+ try:
66
+ result = client.agency.rename_client(client_id, name)
67
+ except FoxReachError as e:
68
+ print_error(str(e))
69
+ sys.exit(1)
70
+ print_json(result)
71
+
72
+
73
+ @agency.command("delete-client")
74
+ @click.argument("client_id")
75
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
76
+ def agency_delete_client(client_id, as_json: bool) -> None:
77
+ """Permanently delete a client workspace and its data. Irreversible."""
78
+ client = get_client()
79
+ try:
80
+ result = client.agency.delete_client(client_id)
81
+ except FoxReachError as e:
82
+ print_error(str(e))
83
+ sys.exit(1)
84
+ print_json(result)
85
+
86
+
87
+ @agency.command("branding")
88
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
89
+ def agency_branding(as_json: bool) -> None:
90
+ """Read the stored branding settings (not applied to client workspaces yet). Requires read scope."""
91
+ client = get_client()
92
+ try:
93
+ result = client.agency.get_branding()
94
+ except FoxReachError as e:
95
+ print_error(str(e))
96
+ sys.exit(1)
97
+ print_json(result)
98
+
99
+
100
+ @agency.command("set-branding")
101
+ @click.option("--brand-name", "brand_name", default=None)
102
+ @click.option("--logo-url", "logo_url", default=None)
103
+ @click.option("--favicon-url", "favicon_url", default=None)
104
+ @click.option("--primary-color", "primary_color", default=None)
105
+ @click.option("--accent-color", "accent_color", default=None)
106
+ @click.option("--hide-watermark", "hide_watermark", default=None)
107
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
108
+ def agency_set_branding(brand_name, logo_url, favicon_url, primary_color, accent_color, hide_watermark, as_json: bool) -> None:
109
+ """Change the branding fields you pass. Needs a key created by the agency owner."""
110
+ client = get_client()
111
+ try:
112
+ result = client.agency.update_branding(brand_name=brand_name, logo_url=logo_url, favicon_url=favicon_url, primary_color=primary_color, accent_color=accent_color, hide_watermark=hide_watermark)
113
+ except FoxReachError as e:
114
+ print_error(str(e))
115
+ sys.exit(1)
116
+ print_json(result)