foxreach-cli 0.2.0__tar.gz → 0.5.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.5.0/CHANGELOG.md +76 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/PKG-INFO +11 -6
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/README.md +3 -1
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/__init__.py +1 -1
- foxreach_cli-0.5.0/foxreach_cli/commands/accounts.py +272 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/campaigns.py +81 -1
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/inbox.py +55 -8
- foxreach_cli-0.5.0/foxreach_cli/commands/lead_finder.py +254 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/leads.py +148 -3
- foxreach_cli-0.5.0/foxreach_cli/commands/managed_inboxes.py +171 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/sequences.py +8 -8
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/templates.py +9 -5
- foxreach_cli-0.5.0/foxreach_cli/commands/tracking_domains.py +98 -0
- foxreach_cli-0.5.0/foxreach_cli/commands/verification.py +134 -0
- foxreach_cli-0.5.0/foxreach_cli/commands/warmup.py +151 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/main.py +10 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/pyproject.toml +10 -4
- foxreach_cli-0.5.0/tests/__init__.py +0 -0
- foxreach_cli-0.5.0/tests/test_commands.py +689 -0
- foxreach_cli-0.5.0/tests/test_request_shapes.py +140 -0
- foxreach_cli-0.2.0/foxreach_cli/commands/accounts.py +0 -87
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/.github/workflows/publish.yml +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/.gitignore +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/LICENSE +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/client_factory.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/__init__.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/analytics.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/webhooks.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/config.py +0 -0
- {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/output.py +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.5.0] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `leads create --custom KEY=VALUE` (repeatable) sets custom fields on a new lead.
|
|
8
|
+
- `leads update --custom KEY=VALUE` and `--unset-custom KEY` change or remove individual custom fields; the lead's other custom fields are kept.
|
|
9
|
+
- `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.
|
|
10
|
+
- `leads list --provider VALUE`: filter leads by recipient mail provider (google, microsoft, zoho, yahoo, godaddy, other, gateway, unresolved).
|
|
11
|
+
- `leads providers`: show how your leads split across mail providers, how many sit behind a security gateway, and how much has been checked.
|
|
12
|
+
- `campaigns esp-mix CAMPAIGN_ID`: show how a campaign's mailboxes and leads split across mail providers.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
## [0.4.2] - 2026-10-21
|
|
19
|
+
|
|
20
|
+
Requires `foxreach>=0.4.2`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `leads list --email ADDRESS`: exact, case-insensitive email match.
|
|
25
|
+
- `inbox get --mark-read`: mark the thread read while fetching it.
|
|
26
|
+
- `sequences create --name`.
|
|
27
|
+
- `templates create --category` and `templates update --category` (for example `email` or `reply_macro`).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `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.
|
|
32
|
+
- `sequences create --subject` is now required, matching the API.
|
|
33
|
+
- `sequences create --step` is removed. The API assigns the step number and ignored the value.
|
|
34
|
+
- `sequences list` shows a Name column instead of Type (sequences have no type); `templates list` and `templates get` show the template's category.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `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.
|
|
39
|
+
|
|
40
|
+
## [0.4.1] - 2026-10-21
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `campaigns list-leads <id>` — paginated leads enrolled in a campaign (`GET /campaigns/{id}/leads`)
|
|
45
|
+
- `campaigns list-accounts <id>` — email accounts assigned to a campaign (`GET /campaigns/{id}/accounts`)
|
|
46
|
+
- `accounts google-oauth-start` — start Google Workspace OAuth flow; returns a redirect URL (`POST /email-accounts/google-oauth/start`)
|
|
47
|
+
- `lead-finder filter-options` — available filter keys, industries, and countries (`GET /lead-finder/filter-options`)
|
|
48
|
+
- `lead-finder count` — count people matching filters without using a search page (`POST /lead-finder/count`)
|
|
49
|
+
- `lead-finder companies-count` — count companies matching filters (`POST /lead-finder/companies/count`)
|
|
50
|
+
- `lead-finder companies-search` — search companies by filters (`POST /lead-finder/companies/search`)
|
|
51
|
+
- `lead-finder add-to-leads` — move revealed contacts into leads workspace (`POST /lead-finder/add-to-leads`)
|
|
52
|
+
- `lead-finder reveals-get <batch_id>` — get a single reveal batch (`GET /lead-finder/reveals/{batch_id}`)
|
|
53
|
+
- `leads import` — bulk import leads from a CSV file with column mapping (`POST /leads/import`)
|
|
54
|
+
- `managed-inboxes domains-search <query>` — search available domains by keyword (`GET /managed-inboxes/domains/search`)
|
|
55
|
+
- `managed-inboxes domains-suggest <domain>` — get domain name suggestions (`GET /managed-inboxes/domains/suggest`)
|
|
56
|
+
- `managed-inboxes quote` — get a price quote for a managed inbox order (`POST /managed-inboxes/orders/quote`)
|
|
57
|
+
- `managed-inboxes get-order <order_id>` — get a managed inbox order by ID (`GET /managed-inboxes/orders/{id}`)
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- 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.
|
|
62
|
+
|
|
63
|
+
## [0.4.0] - 2026-10-21
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- 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)
|
|
67
|
+
- New commands on `accounts`: `create`, `update`, `reconnect`, `enable-warmup`, `disable-warmup`, `health`, `warmup`
|
|
68
|
+
- New command on `inbox`: `delete`
|
|
69
|
+
- New command on `leads`: `tags`
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
- Single version source: `__version__` in `foxreach_cli/__init__.py`; `pyproject.toml` aligned to 0.4.0
|
|
73
|
+
- Dependency bumped from `foxreach>=0.2.0` to `foxreach>=0.4.0`
|
|
74
|
+
|
|
75
|
+
### Notes
|
|
76
|
+
- 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.5.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.5.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
|
|
|
@@ -0,0 +1,272 @@
|
|
|
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)
|
|
@@ -69,7 +69,7 @@ def campaigns_get(campaign_id: str, as_json: bool) -> None:
|
|
|
69
69
|
|
|
70
70
|
print_detail(campaign, [
|
|
71
71
|
"id", "name", "status", "timezone", "sending_days",
|
|
72
|
-
"sending_start_hour", "sending_end_hour", "daily_limit",
|
|
72
|
+
"sending_start_hour", "sending_end_hour", "daily_limit", "esp_matching",
|
|
73
73
|
"total_leads", "total_sent", "total_delivered",
|
|
74
74
|
"total_bounced", "total_replied", "total_opened",
|
|
75
75
|
"created_at", "started_at", "completed_at",
|
|
@@ -82,6 +82,7 @@ def campaigns_get(campaign_id: str, as_json: bool) -> None:
|
|
|
82
82
|
@click.option("--daily-limit", default=None, type=int, help="Max emails per day")
|
|
83
83
|
@click.option("--start-hour", default=None, type=int, help="Sending start hour (0-23)")
|
|
84
84
|
@click.option("--end-hour", default=None, type=int, help="Sending end hour (0-23)")
|
|
85
|
+
@click.option("--esp-matching/--no-esp-matching", default=None, help="Send first emails from a mailbox on the recipient's provider")
|
|
85
86
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
86
87
|
def campaigns_create(
|
|
87
88
|
name: str,
|
|
@@ -89,6 +90,7 @@ def campaigns_create(
|
|
|
89
90
|
daily_limit: Optional[int],
|
|
90
91
|
start_hour: Optional[int],
|
|
91
92
|
end_hour: Optional[int],
|
|
93
|
+
esp_matching: Optional[bool],
|
|
92
94
|
as_json: bool,
|
|
93
95
|
) -> None:
|
|
94
96
|
"""Create a new campaign."""
|
|
@@ -100,6 +102,7 @@ def campaigns_create(
|
|
|
100
102
|
daily_limit=daily_limit,
|
|
101
103
|
sending_start_hour=start_hour,
|
|
102
104
|
sending_end_hour=end_hour,
|
|
105
|
+
esp_matching=esp_matching,
|
|
103
106
|
))
|
|
104
107
|
except FoxReachError as e:
|
|
105
108
|
print_error(str(e))
|
|
@@ -118,6 +121,7 @@ def campaigns_create(
|
|
|
118
121
|
@click.option("--daily-limit", default=None, type=int, help="Max emails per day")
|
|
119
122
|
@click.option("--start-hour", default=None, type=int, help="Sending start hour")
|
|
120
123
|
@click.option("--end-hour", default=None, type=int, help="Sending end hour")
|
|
124
|
+
@click.option("--esp-matching/--no-esp-matching", default=None, help="Send first emails from a mailbox on the recipient's provider")
|
|
121
125
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
122
126
|
def campaigns_update(
|
|
123
127
|
campaign_id: str,
|
|
@@ -126,6 +130,7 @@ def campaigns_update(
|
|
|
126
130
|
daily_limit: Optional[int],
|
|
127
131
|
start_hour: Optional[int],
|
|
128
132
|
end_hour: Optional[int],
|
|
133
|
+
esp_matching: Optional[bool],
|
|
129
134
|
as_json: bool,
|
|
130
135
|
) -> None:
|
|
131
136
|
"""Update a campaign."""
|
|
@@ -137,6 +142,7 @@ def campaigns_update(
|
|
|
137
142
|
daily_limit=daily_limit,
|
|
138
143
|
sending_start_hour=start_hour,
|
|
139
144
|
sending_end_hour=end_hour,
|
|
145
|
+
esp_matching=esp_matching,
|
|
140
146
|
))
|
|
141
147
|
except FoxReachError as e:
|
|
142
148
|
print_error(str(e))
|
|
@@ -257,3 +263,77 @@ def campaigns_add_accounts(campaign_id: str, account_ids: str) -> None:
|
|
|
257
263
|
print_error(str(e))
|
|
258
264
|
sys.exit(1)
|
|
259
265
|
print_success(f"Added {result.get('added', len(ids))} accounts to campaign {campaign_id}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@campaigns.command("list-leads")
|
|
269
|
+
@click.argument("campaign_id")
|
|
270
|
+
@click.option("--page", default=1, type=int)
|
|
271
|
+
@click.option("--limit", default=50, type=int)
|
|
272
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
273
|
+
def campaigns_list_leads(campaign_id: str, page: int, limit: int, as_json: bool) -> None:
|
|
274
|
+
"""List leads enrolled in a campaign."""
|
|
275
|
+
client = get_client()
|
|
276
|
+
try:
|
|
277
|
+
result = client.campaigns.list_leads(campaign_id, page=page, page_size=limit)
|
|
278
|
+
except FoxReachError as e:
|
|
279
|
+
print_error(str(e))
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
if as_json:
|
|
282
|
+
print_json(result)
|
|
283
|
+
return
|
|
284
|
+
data = result.get("data", []) if isinstance(result, dict) else result
|
|
285
|
+
if not data:
|
|
286
|
+
click.echo("No leads found.")
|
|
287
|
+
return
|
|
288
|
+
print_table(data, ["ID", "Email", "Status"], {"ID": "id", "Email": "email", "Status": "status"})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@campaigns.command("list-accounts")
|
|
292
|
+
@click.argument("campaign_id")
|
|
293
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
294
|
+
def campaigns_list_accounts(campaign_id: str, as_json: bool) -> None:
|
|
295
|
+
"""List email accounts assigned to a campaign."""
|
|
296
|
+
client = get_client()
|
|
297
|
+
try:
|
|
298
|
+
result = client.campaigns.list_accounts(campaign_id)
|
|
299
|
+
except FoxReachError as e:
|
|
300
|
+
print_error(str(e))
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
if as_json:
|
|
303
|
+
print_json(result)
|
|
304
|
+
return
|
|
305
|
+
data = result.get("data", []) if isinstance(result, dict) else result
|
|
306
|
+
if not data:
|
|
307
|
+
click.echo("No accounts found.")
|
|
308
|
+
return
|
|
309
|
+
print_table(data, ["ID", "Email"], {"ID": "id", "Email": "email"})
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@campaigns.command("esp-mix")
|
|
313
|
+
@click.argument("campaign_id")
|
|
314
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
315
|
+
def campaigns_esp_mix(campaign_id: str, as_json: bool) -> None:
|
|
316
|
+
"""Show how a campaign's mailboxes and leads split across mail providers."""
|
|
317
|
+
client = get_client()
|
|
318
|
+
try:
|
|
319
|
+
result = client.campaigns.get_esp_mix(campaign_id)
|
|
320
|
+
except FoxReachError as e:
|
|
321
|
+
print_error(str(e))
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
if as_json:
|
|
324
|
+
print_json(result)
|
|
325
|
+
return
|
|
326
|
+
mix = result.get("data", {}) if isinstance(result, dict) else {}
|
|
327
|
+
senders = mix.get("senders", {})
|
|
328
|
+
leads = mix.get("leads", {})
|
|
329
|
+
click.echo(f"Provider matching: {'on' if mix.get('enabled') else 'off'}")
|
|
330
|
+
rows = [
|
|
331
|
+
{"provider": name, "mailboxes": senders.get(key, 0), "leads": leads.get(key, 0)}
|
|
332
|
+
for name, key in (("Google", "google"), ("Microsoft", "microsoft"), ("Other", "other"))
|
|
333
|
+
]
|
|
334
|
+
rows.append({"provider": "Not looked up yet", "mailboxes": "", "leads": leads.get("unresolved", 0)})
|
|
335
|
+
print_table(rows, ["Provider", "Mailboxes", "Leads"], {"Provider": lambda r: r["provider"], "Mailboxes": lambda r: r["mailboxes"], "Leads": lambda r: r["leads"]})
|
|
336
|
+
# The SDK returns snake_case keys (total_leads), the raw API camelCase.
|
|
337
|
+
total = mix.get("total_leads", mix.get("totalLeads", 0))
|
|
338
|
+
pct = mix.get("resolved_pct", mix.get("resolvedPct", 0))
|
|
339
|
+
click.echo(f"{total} leads, {pct}% looked up")
|
|
@@ -77,12 +77,13 @@ def inbox_list(
|
|
|
77
77
|
|
|
78
78
|
@inbox.command("get")
|
|
79
79
|
@click.argument("reply_id")
|
|
80
|
+
@click.option("--mark-read", is_flag=True, default=False, help="Also mark the thread as read")
|
|
80
81
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
81
|
-
def inbox_get(reply_id: str, as_json: bool) -> None:
|
|
82
|
-
"""Get a thread by ID."""
|
|
82
|
+
def inbox_get(reply_id: str, mark_read: bool, as_json: bool) -> None:
|
|
83
|
+
"""Get a thread by ID. Does not mark it read unless --mark-read is passed."""
|
|
83
84
|
client = get_client()
|
|
84
85
|
try:
|
|
85
|
-
thread = client.inbox.get(reply_id)
|
|
86
|
+
thread = client.inbox.get(reply_id, mark_read=mark_read)
|
|
86
87
|
except FoxReachError as e:
|
|
87
88
|
print_error(str(e))
|
|
88
89
|
sys.exit(1)
|
|
@@ -123,6 +124,20 @@ def inbox_update(reply_id: str, read: Optional[bool], starred: Optional[bool], c
|
|
|
123
124
|
print_success(f"Thread updated: {reply_id}")
|
|
124
125
|
|
|
125
126
|
|
|
127
|
+
@inbox.command("delete")
|
|
128
|
+
@click.argument("reply_id")
|
|
129
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this thread?")
|
|
130
|
+
def inbox_delete(reply_id: str) -> None:
|
|
131
|
+
"""Delete an inbox thread. Requires write scope."""
|
|
132
|
+
client = get_client()
|
|
133
|
+
try:
|
|
134
|
+
client.inbox.delete(reply_id)
|
|
135
|
+
except FoxReachError as e:
|
|
136
|
+
print_error(str(e))
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
print_success(f"Thread deleted: {reply_id}")
|
|
139
|
+
|
|
140
|
+
|
|
126
141
|
@inbox.command("conversation")
|
|
127
142
|
@click.argument("reply_id")
|
|
128
143
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
@@ -147,7 +162,7 @@ def inbox_conversation(reply_id: str, as_json: bool) -> None:
|
|
|
147
162
|
for msg in messages:
|
|
148
163
|
direction = msg.get("direction", "?")
|
|
149
164
|
ts = msg.get("timestamp", "")[:19] if msg.get("timestamp") else "\u2014"
|
|
150
|
-
from_email = msg.get("fromEmail", "")
|
|
165
|
+
from_email = msg.get("from_email", msg.get("fromEmail", ""))
|
|
151
166
|
subject = msg.get("subject", "")
|
|
152
167
|
click.echo(f" [{direction:>8}] {ts} {from_email} {subject}")
|
|
153
168
|
|
|
@@ -169,7 +184,7 @@ def inbox_reply(reply_id: str, body: str, subject: Optional[str], as_json: bool)
|
|
|
169
184
|
if as_json:
|
|
170
185
|
print_json(result)
|
|
171
186
|
else:
|
|
172
|
-
print_success(f"Reply sent (message ID: {result.get('messageId', 'unknown')})")
|
|
187
|
+
print_success(f"Reply sent (message ID: {result.get('message_id', result.get('messageId', 'unknown'))})")
|
|
173
188
|
|
|
174
189
|
|
|
175
190
|
@inbox.command("stats")
|
|
@@ -190,7 +205,39 @@ def inbox_stats(as_json: bool) -> None:
|
|
|
190
205
|
click.echo(f" Total: {result.get('total', 0)}")
|
|
191
206
|
click.echo(f" Unread: {result.get('unread', 0)}")
|
|
192
207
|
click.echo(f" Interested: {result.get('interested', 0)}")
|
|
193
|
-
click.echo(f" Not Interested: {result.get('notInterested', 0)}")
|
|
194
|
-
click.echo(f" Out of Office: {result.get('outOfOffice', 0)}")
|
|
208
|
+
click.echo(f" Not Interested: {result.get('not_interested', result.get('notInterested', 0))}")
|
|
209
|
+
click.echo(f" Out of Office: {result.get('out_of_office', result.get('outOfOffice', 0))}")
|
|
195
210
|
click.echo(f" Uncategorized: {result.get('uncategorized', 0)}")
|
|
196
|
-
click.echo(f" Total Sent: {result.get('totalSent', 0)}")
|
|
211
|
+
click.echo(f" Total Sent: {result.get('total_sent', result.get('totalSent', 0))}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@inbox.command("categorize-stats")
|
|
215
|
+
@click.option("--campaign", "campaign_id", help="Scope to one campaign")
|
|
216
|
+
@click.option("--step", "sequence_step", type=int, help="Scope to one sequence step")
|
|
217
|
+
@click.option("--variant", "variant_id", help="Scope to one variant")
|
|
218
|
+
@click.option(
|
|
219
|
+
"--group-by",
|
|
220
|
+
type=click.Choice(["variant", "step", "day"]),
|
|
221
|
+
help="Bucket counts by variant / step / day",
|
|
222
|
+
)
|
|
223
|
+
@click.option("--from", "from_date", help="ISO 8601 date (UTC)")
|
|
224
|
+
@click.option("--to", "to_date", help="ISO 8601 date (UTC)")
|
|
225
|
+
@click.option("--json", "as_json", is_flag=True, default=True, help="JSON output (default)")
|
|
226
|
+
def inbox_categorize_stats(
|
|
227
|
+
campaign_id, sequence_step, variant_id, group_by, from_date, to_date, as_json
|
|
228
|
+
):
|
|
229
|
+
"""Per-variant/step/day reply categorization stats. Used by the cold.md autoresearch loop."""
|
|
230
|
+
client = get_client()
|
|
231
|
+
try:
|
|
232
|
+
result = client.inbox.categorize_stats(
|
|
233
|
+
campaign_id=campaign_id,
|
|
234
|
+
sequence_step=sequence_step,
|
|
235
|
+
variant_id=variant_id,
|
|
236
|
+
group_by=group_by,
|
|
237
|
+
from_date=from_date,
|
|
238
|
+
to_date=to_date,
|
|
239
|
+
)
|
|
240
|
+
except FoxReachError as e:
|
|
241
|
+
print_error(str(e))
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
print_json(result)
|