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.
Files changed (30) hide show
  1. foxreach_cli-0.5.0/CHANGELOG.md +76 -0
  2. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/PKG-INFO +11 -6
  3. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/README.md +3 -1
  4. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/__init__.py +1 -1
  5. foxreach_cli-0.5.0/foxreach_cli/commands/accounts.py +272 -0
  6. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/campaigns.py +81 -1
  7. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/inbox.py +55 -8
  8. foxreach_cli-0.5.0/foxreach_cli/commands/lead_finder.py +254 -0
  9. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/leads.py +148 -3
  10. foxreach_cli-0.5.0/foxreach_cli/commands/managed_inboxes.py +171 -0
  11. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/sequences.py +8 -8
  12. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/templates.py +9 -5
  13. foxreach_cli-0.5.0/foxreach_cli/commands/tracking_domains.py +98 -0
  14. foxreach_cli-0.5.0/foxreach_cli/commands/verification.py +134 -0
  15. foxreach_cli-0.5.0/foxreach_cli/commands/warmup.py +151 -0
  16. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/main.py +10 -0
  17. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/pyproject.toml +10 -4
  18. foxreach_cli-0.5.0/tests/__init__.py +0 -0
  19. foxreach_cli-0.5.0/tests/test_commands.py +689 -0
  20. foxreach_cli-0.5.0/tests/test_request_shapes.py +140 -0
  21. foxreach_cli-0.2.0/foxreach_cli/commands/accounts.py +0 -87
  22. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/.github/workflows/publish.yml +0 -0
  23. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/.gitignore +0 -0
  24. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/LICENSE +0 -0
  25. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/client_factory.py +0 -0
  26. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/__init__.py +0 -0
  27. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/analytics.py +0 -0
  28. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/commands/webhooks.py +0 -0
  29. {foxreach_cli-0.2.0 → foxreach_cli-0.5.0}/foxreach_cli/config.py +0 -0
  30. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: foxreach-cli
3
- Version: 0.2.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://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.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
 
@@ -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.5.0"
@@ -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)