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.
- foxreach_cli-0.6.0/CHANGELOG.md +108 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/PKG-INFO +11 -6
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/README.md +3 -1
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/__init__.py +1 -1
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/client_factory.py +11 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/accounts.py +305 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/agency.py +116 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/analytics.py +177 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/audit_log.py +36 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/block_list.py +91 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/campaigns.py +518 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/copy.py +71 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/events.py +43 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/inbox.py +457 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/jobs.py +74 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/lead_finder.py +539 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/lead_labels.py +123 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/leads.py +531 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/managed_inboxes.py +171 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/me.py +38 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/sequences.py +240 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/team.py +188 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/templates.py +53 -5
- foxreach_cli-0.6.0/foxreach_cli/commands/tracking_domains.py +98 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/verification.py +134 -0
- foxreach_cli-0.6.0/foxreach_cli/commands/warmup.py +226 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/webhooks.py +69 -3
- foxreach_cli-0.6.0/foxreach_cli/commands/workspaces.py +35 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/main.py +41 -1
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/pyproject.toml +10 -4
- foxreach_cli-0.6.0/tests/__init__.py +0 -0
- foxreach_cli-0.6.0/tests/test_commands.py +874 -0
- foxreach_cli-0.6.0/tests/test_lead_finder_reveal.py +107 -0
- foxreach_cli-0.6.0/tests/test_pa3_account_checks.py +28 -0
- foxreach_cli-0.6.0/tests/test_pa3_agency.py +78 -0
- foxreach_cli-0.6.0/tests/test_pa3_analytics.py +78 -0
- foxreach_cli-0.6.0/tests/test_pa3_audit_log.py +18 -0
- foxreach_cli-0.6.0/tests/test_pa3_lead_finder.py +158 -0
- foxreach_cli-0.6.0/tests/test_pa3_template_gallery.py +38 -0
- foxreach_cli-0.6.0/tests/test_pa3_warmup_tags.py +58 -0
- foxreach_cli-0.6.0/tests/test_request_shapes.py +283 -0
- foxreach_cli-0.6.0/tests/test_sdk_floor.py +35 -0
- foxreach_cli-0.2.0/foxreach_cli/commands/accounts.py +0 -87
- foxreach_cli-0.2.0/foxreach_cli/commands/analytics.py +0 -69
- foxreach_cli-0.2.0/foxreach_cli/commands/campaigns.py +0 -259
- foxreach_cli-0.2.0/foxreach_cli/commands/inbox.py +0 -196
- foxreach_cli-0.2.0/foxreach_cli/commands/leads.py +0 -207
- foxreach_cli-0.2.0/foxreach_cli/commands/sequences.py +0 -126
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/.github/workflows/publish.yml +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/.gitignore +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/LICENSE +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/commands/__init__.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.6.0}/foxreach_cli/config.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: foxreach-cli
|
|
3
|
-
Version: 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://
|
|
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://
|
|
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.
|
|
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
|
|
|
@@ -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)
|