autotouch-cli 0.2.21__tar.gz → 0.2.22__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 (64) hide show
  1. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/PKG-INFO +57 -1
  2. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/PKG-INFO +57 -1
  3. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/SOURCES.txt +1 -0
  4. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/docs/research-table/reference/autotouch-cli-pypi.md +56 -0
  5. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/pyproject.toml +1 -1
  6. autotouch_cli-0.2.22/scripts/migrations/20260305_force_formatter_autorun_on_source_update.py +98 -0
  7. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/smart_table_cli.py +417 -0
  8. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/dependency_links.txt +0 -0
  9. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/entry_points.txt +0 -0
  10. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/requires.txt +0 -0
  11. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/autotouch_cli.egg-info/top_level.txt +0 -0
  12. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/__init__.py +0 -0
  13. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/add_column_unique_index.py +0 -0
  14. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/attach_csv_import_leads_to_research_table.py +0 -0
  15. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/bundle_sequences_backend.py +0 -0
  16. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/check_agent_traces.py +0 -0
  17. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/check_column_mode.py +0 -0
  18. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/exit_terminal_leads_from_sequences.py +0 -0
  19. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/fetch_lead.py +0 -0
  20. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/fix_lead_titles_from_csv.py +0 -0
  21. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250106_add_column_position.py +0 -0
  22. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250108_fix_legacy_column_fields.py +0 -0
  23. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250109_add_user_fields_to_tables.py +0 -0
  24. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250117_add_call_logs_webhook_indexes.py +0 -0
  25. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250117_rename_call_logs_collection.py +0 -0
  26. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250119_create_leads_unique_email_index.py +0 -0
  27. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250123_add_filter_indexes.py +0 -0
  28. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250123_add_llm_responses_collection.py +0 -0
  29. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250128_migrate_user_ids_to_objectid.py +0 -0
  30. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250208_backfill_task_research_values.py +0 -0
  31. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250604_add_origin_indexes.py +0 -0
  32. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250608_cleanup_agent_metadata.py +0 -0
  33. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250608_rename_agent_metadata_to_metadata.py +0 -0
  34. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250922_add_activity_indexes.py +0 -0
  35. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250926_migrate_single_to_arrays.py +0 -0
  36. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250928_add_missing_timestamp_fields.py +0 -0
  37. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250929_add_task_join_indexes.py +0 -0
  38. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250929_add_task_join_indexes_safe.py +0 -0
  39. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20250929_create_shared_phone_cache.py +0 -0
  40. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20251007_add_rows_position_id_index.py +0 -0
  41. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20251109_add_ttl_for_llm_and_preview_traces.py +0 -0
  42. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20260113_normalize_table_filter_operators.py +0 -0
  43. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20260113_set_user_permissions_user_admin.py +0 -0
  44. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20260204_sync_lead_owner_from_tasks.py +0 -0
  45. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/20260303_add_webhook_subscription_collections.py +0 -0
  46. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/migrate_org_user_credits.py +0 -0
  47. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/set_default_lead_status.py +0 -0
  48. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/migrations/update_lead_owner_from_tasks.py +0 -0
  49. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/reassign_sequence_owner.py +0 -0
  50. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/run_sidecar_orchestrator_demo.py +0 -0
  51. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/test_crm_company_policy.py +0 -0
  52. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/test_sequences_instantly_e2e.py +0 -0
  53. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/test_sequences_personal_e2e.py +0 -0
  54. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/test_task_error_logger.py +0 -0
  55. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/scripts/verify_azurite_voicemail.py +0 -0
  56. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/setup.cfg +0 -0
  57. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_custom.py +0 -0
  58. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_integration.py +0 -0
  59. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_multi_titles.py +0 -0
  60. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_pipeline.py +0 -0
  61. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_simple.py +0 -0
  62. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_contactout_v2_bulk.py +0 -0
  63. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_lead_required_fields.py +0 -0
  64. {autotouch_cli-0.2.21 → autotouch_cli-0.2.22}/tests/test_phone_provider_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autotouch-cli
3
- Version: 0.2.21
3
+ Version: 0.2.22
4
4
  Summary: Autotouch Smart Table CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -102,6 +102,11 @@ Notes:
102
102
  - `--wait` polls import status to terminal state.
103
103
  - `--checkpoint-file` stores state and blocks accidental duplicate re-imports.
104
104
  - `--validate-only` validates CSV parse/shape on server without writing rows.
105
+ - Blacklist controls on optimized import:
106
+ - company-domain filtering is ON by default
107
+ - email filtering is OFF by default
108
+ - disable company filtering with `--no-check-company-blacklist`
109
+ - enable email filtering with `--check-email-blacklist`
105
110
  - Safety assertions:
106
111
  - `--expected-rows <N>`
107
112
  - `--require-columns col_a,col_b`
@@ -109,6 +114,43 @@ Notes:
109
114
  - `--require-non-empty key:ratio`
110
115
  - For async imports with `--wait`, assertions are rechecked postflight via task manifest verification.
111
116
  - Use `--allow-reimport` if you intentionally want to run the same file again.
117
+ - Import responses include `blacklist_summary` with company/email `skipped_count` and `enforced` flags.
118
+
119
+ Example enabling both company and email enforcement:
120
+
121
+ ```bash
122
+ autotouch rows import-csv \
123
+ --table-id <TABLE_ID> \
124
+ --confirm-table-id <TABLE_ID> \
125
+ --file contacts.csv \
126
+ --checkpoint-file .autotouch-import.json \
127
+ --check-email-blacklist \
128
+ --wait \
129
+ --output json
130
+ ```
131
+
132
+ Manage blacklist entries (native CLI, admin identity required):
133
+
134
+ ```bash
135
+ # List current entries
136
+ autotouch blacklist list --type-filter all --limit 100
137
+
138
+ # Add entries
139
+ autotouch blacklist add --type domain --value competitor.com --reason "Do not contact"
140
+ autotouch blacklist add --type email --value blocked@example.com --reason "Unsubscribed"
141
+
142
+ # Bulk-import entries from CSV/TXT
143
+ autotouch blacklist import --file blacklist.csv
144
+
145
+ # Check/filter email sets (auto-chunked for large lists)
146
+ autotouch blacklist check --emails-file recipients.csv --emails-column email --summary-only
147
+ autotouch blacklist filter --emails-file recipients.csv --emails-column email --summary-only
148
+ ```
149
+
150
+ Recommended timing:
151
+ - Add suppressions early (unsubscribed addresses, existing customers, competitors, and clear non-ICP domains).
152
+ - Before billable enrichments (`llm_enrichment`, `email_finder`, `phone_finder`), run blacklist filtering and enrich only clean candidates.
153
+ - Run one final blacklist check/filter before outreach execution.
112
154
 
113
155
  ## Import modes
114
156
 
@@ -140,6 +182,7 @@ Auto-run is configured per column (`autoRun`), not per table.
140
182
  Important:
141
183
  - Insert events do not run `onSourceUpdate` columns.
142
184
  - Imports may queue auto-run dispatch evaluation, but only columns whose `autoRun` policy matches the event are queued.
185
+ - Formatter columns are normalized server-side to `onSourceUpdate` (attempted `never`/`onInsert` values are overridden).
143
186
 
144
187
  ## Create a column
145
188
 
@@ -164,6 +207,7 @@ Notes:
164
207
  - `add_to_crm` is optional and non-billable.
165
208
  - Email/phone enrichment does not require creating/running `add_to_crm`.
166
209
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
210
+ - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
167
211
  - `sync_to_table` supports both:
168
212
  - single destination: `destinationTableId` + `columnMappings`
169
213
  - router mode: `routes[]` (first matching route wins)
@@ -171,6 +215,7 @@ Notes:
171
215
  - `sequenceId`
172
216
  - `sourceLeadColumn` pointing to a lead-id producing column (`add_to_crm` or `lead_finder` output)
173
217
  - auto-attaches research context defaults during enrollment (`source_table_id`, plus optional table name); favorite fields resolve from current starred columns when explicit `fieldIds` are not provided
218
+ - star/favorite high-signal fields so call-sidecar context stays quick to scan and AI draft context stays focused
174
219
 
175
220
  If you create custom `llm_enrichment` schemas:
176
221
  - Use strict field-map schema shape (no root `type/properties` wrapper).
@@ -227,6 +272,8 @@ Design guidance:
227
272
  - default behavior for agents: write full context/content, not summaries or truncation
228
273
  - only truncate/summarize when there is a hard limit (storage/model/provider/payload), and explicitly mark truncation
229
274
  - keep intent separate from raw context so downstream logic can change without losing source data
275
+ - for calling workflows, favorite/star key fields so users can access the most relevant context quickly in sidecar views
276
+ - for AI-generated emails/copy, include both intent and full context in imports so drafts are grounded, not generic
230
277
  - keep one entity per row and dedupe on a stable identity key
231
278
  - keep snippets/summaries optional and derived from raw context, never a replacement for it
232
279
  - normalize intent labels if you need deterministic automation
@@ -516,6 +563,15 @@ autotouch columns run-next \
516
563
  --wait
517
564
  ```
518
565
 
566
+ Blacklist gate (recommended before billable runs):
567
+
568
+ ```bash
569
+ autotouch blacklist filter \
570
+ --emails-file candidates.csv \
571
+ --emails-column work_email \
572
+ --output json
573
+ ```
574
+
519
575
  ## Job truth contract (agent-safe)
520
576
 
521
577
  - Treat a run as started only when you receive `job_id`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autotouch-cli
3
- Version: 0.2.21
3
+ Version: 0.2.22
4
4
  Summary: Autotouch Smart Table CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -102,6 +102,11 @@ Notes:
102
102
  - `--wait` polls import status to terminal state.
103
103
  - `--checkpoint-file` stores state and blocks accidental duplicate re-imports.
104
104
  - `--validate-only` validates CSV parse/shape on server without writing rows.
105
+ - Blacklist controls on optimized import:
106
+ - company-domain filtering is ON by default
107
+ - email filtering is OFF by default
108
+ - disable company filtering with `--no-check-company-blacklist`
109
+ - enable email filtering with `--check-email-blacklist`
105
110
  - Safety assertions:
106
111
  - `--expected-rows <N>`
107
112
  - `--require-columns col_a,col_b`
@@ -109,6 +114,43 @@ Notes:
109
114
  - `--require-non-empty key:ratio`
110
115
  - For async imports with `--wait`, assertions are rechecked postflight via task manifest verification.
111
116
  - Use `--allow-reimport` if you intentionally want to run the same file again.
117
+ - Import responses include `blacklist_summary` with company/email `skipped_count` and `enforced` flags.
118
+
119
+ Example enabling both company and email enforcement:
120
+
121
+ ```bash
122
+ autotouch rows import-csv \
123
+ --table-id <TABLE_ID> \
124
+ --confirm-table-id <TABLE_ID> \
125
+ --file contacts.csv \
126
+ --checkpoint-file .autotouch-import.json \
127
+ --check-email-blacklist \
128
+ --wait \
129
+ --output json
130
+ ```
131
+
132
+ Manage blacklist entries (native CLI, admin identity required):
133
+
134
+ ```bash
135
+ # List current entries
136
+ autotouch blacklist list --type-filter all --limit 100
137
+
138
+ # Add entries
139
+ autotouch blacklist add --type domain --value competitor.com --reason "Do not contact"
140
+ autotouch blacklist add --type email --value blocked@example.com --reason "Unsubscribed"
141
+
142
+ # Bulk-import entries from CSV/TXT
143
+ autotouch blacklist import --file blacklist.csv
144
+
145
+ # Check/filter email sets (auto-chunked for large lists)
146
+ autotouch blacklist check --emails-file recipients.csv --emails-column email --summary-only
147
+ autotouch blacklist filter --emails-file recipients.csv --emails-column email --summary-only
148
+ ```
149
+
150
+ Recommended timing:
151
+ - Add suppressions early (unsubscribed addresses, existing customers, competitors, and clear non-ICP domains).
152
+ - Before billable enrichments (`llm_enrichment`, `email_finder`, `phone_finder`), run blacklist filtering and enrich only clean candidates.
153
+ - Run one final blacklist check/filter before outreach execution.
112
154
 
113
155
  ## Import modes
114
156
 
@@ -140,6 +182,7 @@ Auto-run is configured per column (`autoRun`), not per table.
140
182
  Important:
141
183
  - Insert events do not run `onSourceUpdate` columns.
142
184
  - Imports may queue auto-run dispatch evaluation, but only columns whose `autoRun` policy matches the event are queued.
185
+ - Formatter columns are normalized server-side to `onSourceUpdate` (attempted `never`/`onInsert` values are overridden).
143
186
 
144
187
  ## Create a column
145
188
 
@@ -164,6 +207,7 @@ Notes:
164
207
  - `add_to_crm` is optional and non-billable.
165
208
  - Email/phone enrichment does not require creating/running `add_to_crm`.
166
209
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
210
+ - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
167
211
  - `sync_to_table` supports both:
168
212
  - single destination: `destinationTableId` + `columnMappings`
169
213
  - router mode: `routes[]` (first matching route wins)
@@ -171,6 +215,7 @@ Notes:
171
215
  - `sequenceId`
172
216
  - `sourceLeadColumn` pointing to a lead-id producing column (`add_to_crm` or `lead_finder` output)
173
217
  - auto-attaches research context defaults during enrollment (`source_table_id`, plus optional table name); favorite fields resolve from current starred columns when explicit `fieldIds` are not provided
218
+ - star/favorite high-signal fields so call-sidecar context stays quick to scan and AI draft context stays focused
174
219
 
175
220
  If you create custom `llm_enrichment` schemas:
176
221
  - Use strict field-map schema shape (no root `type/properties` wrapper).
@@ -227,6 +272,8 @@ Design guidance:
227
272
  - default behavior for agents: write full context/content, not summaries or truncation
228
273
  - only truncate/summarize when there is a hard limit (storage/model/provider/payload), and explicitly mark truncation
229
274
  - keep intent separate from raw context so downstream logic can change without losing source data
275
+ - for calling workflows, favorite/star key fields so users can access the most relevant context quickly in sidecar views
276
+ - for AI-generated emails/copy, include both intent and full context in imports so drafts are grounded, not generic
230
277
  - keep one entity per row and dedupe on a stable identity key
231
278
  - keep snippets/summaries optional and derived from raw context, never a replacement for it
232
279
  - normalize intent labels if you need deterministic automation
@@ -516,6 +563,15 @@ autotouch columns run-next \
516
563
  --wait
517
564
  ```
518
565
 
566
+ Blacklist gate (recommended before billable runs):
567
+
568
+ ```bash
569
+ autotouch blacklist filter \
570
+ --emails-file candidates.csv \
571
+ --emails-column work_email \
572
+ --output json
573
+ ```
574
+
519
575
  ## Job truth contract (agent-safe)
520
576
 
521
577
  - Treat a run as started only when you receive `job_id`.
@@ -48,6 +48,7 @@ scripts/migrations/20260113_normalize_table_filter_operators.py
48
48
  scripts/migrations/20260113_set_user_permissions_user_admin.py
49
49
  scripts/migrations/20260204_sync_lead_owner_from_tasks.py
50
50
  scripts/migrations/20260303_add_webhook_subscription_collections.py
51
+ scripts/migrations/20260305_force_formatter_autorun_on_source_update.py
51
52
  scripts/migrations/migrate_org_user_credits.py
52
53
  scripts/migrations/set_default_lead_status.py
53
54
  scripts/migrations/update_lead_owner_from_tasks.py
@@ -93,6 +93,11 @@ Notes:
93
93
  - `--wait` polls import status to terminal state.
94
94
  - `--checkpoint-file` stores state and blocks accidental duplicate re-imports.
95
95
  - `--validate-only` validates CSV parse/shape on server without writing rows.
96
+ - Blacklist controls on optimized import:
97
+ - company-domain filtering is ON by default
98
+ - email filtering is OFF by default
99
+ - disable company filtering with `--no-check-company-blacklist`
100
+ - enable email filtering with `--check-email-blacklist`
96
101
  - Safety assertions:
97
102
  - `--expected-rows <N>`
98
103
  - `--require-columns col_a,col_b`
@@ -100,6 +105,43 @@ Notes:
100
105
  - `--require-non-empty key:ratio`
101
106
  - For async imports with `--wait`, assertions are rechecked postflight via task manifest verification.
102
107
  - Use `--allow-reimport` if you intentionally want to run the same file again.
108
+ - Import responses include `blacklist_summary` with company/email `skipped_count` and `enforced` flags.
109
+
110
+ Example enabling both company and email enforcement:
111
+
112
+ ```bash
113
+ autotouch rows import-csv \
114
+ --table-id <TABLE_ID> \
115
+ --confirm-table-id <TABLE_ID> \
116
+ --file contacts.csv \
117
+ --checkpoint-file .autotouch-import.json \
118
+ --check-email-blacklist \
119
+ --wait \
120
+ --output json
121
+ ```
122
+
123
+ Manage blacklist entries (native CLI, admin identity required):
124
+
125
+ ```bash
126
+ # List current entries
127
+ autotouch blacklist list --type-filter all --limit 100
128
+
129
+ # Add entries
130
+ autotouch blacklist add --type domain --value competitor.com --reason "Do not contact"
131
+ autotouch blacklist add --type email --value blocked@example.com --reason "Unsubscribed"
132
+
133
+ # Bulk-import entries from CSV/TXT
134
+ autotouch blacklist import --file blacklist.csv
135
+
136
+ # Check/filter email sets (auto-chunked for large lists)
137
+ autotouch blacklist check --emails-file recipients.csv --emails-column email --summary-only
138
+ autotouch blacklist filter --emails-file recipients.csv --emails-column email --summary-only
139
+ ```
140
+
141
+ Recommended timing:
142
+ - Add suppressions early (unsubscribed addresses, existing customers, competitors, and clear non-ICP domains).
143
+ - Before billable enrichments (`llm_enrichment`, `email_finder`, `phone_finder`), run blacklist filtering and enrich only clean candidates.
144
+ - Run one final blacklist check/filter before outreach execution.
103
145
 
104
146
  ## Import modes
105
147
 
@@ -131,6 +173,7 @@ Auto-run is configured per column (`autoRun`), not per table.
131
173
  Important:
132
174
  - Insert events do not run `onSourceUpdate` columns.
133
175
  - Imports may queue auto-run dispatch evaluation, but only columns whose `autoRun` policy matches the event are queued.
176
+ - Formatter columns are normalized server-side to `onSourceUpdate` (attempted `never`/`onInsert` values are overridden).
134
177
 
135
178
  ## Create a column
136
179
 
@@ -155,6 +198,7 @@ Notes:
155
198
  - `add_to_crm` is optional and non-billable.
156
199
  - Email/phone enrichment does not require creating/running `add_to_crm`.
157
200
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
201
+ - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
158
202
  - `sync_to_table` supports both:
159
203
  - single destination: `destinationTableId` + `columnMappings`
160
204
  - router mode: `routes[]` (first matching route wins)
@@ -162,6 +206,7 @@ Notes:
162
206
  - `sequenceId`
163
207
  - `sourceLeadColumn` pointing to a lead-id producing column (`add_to_crm` or `lead_finder` output)
164
208
  - auto-attaches research context defaults during enrollment (`source_table_id`, plus optional table name); favorite fields resolve from current starred columns when explicit `fieldIds` are not provided
209
+ - star/favorite high-signal fields so call-sidecar context stays quick to scan and AI draft context stays focused
165
210
 
166
211
  If you create custom `llm_enrichment` schemas:
167
212
  - Use strict field-map schema shape (no root `type/properties` wrapper).
@@ -218,6 +263,8 @@ Design guidance:
218
263
  - default behavior for agents: write full context/content, not summaries or truncation
219
264
  - only truncate/summarize when there is a hard limit (storage/model/provider/payload), and explicitly mark truncation
220
265
  - keep intent separate from raw context so downstream logic can change without losing source data
266
+ - for calling workflows, favorite/star key fields so users can access the most relevant context quickly in sidecar views
267
+ - for AI-generated emails/copy, include both intent and full context in imports so drafts are grounded, not generic
221
268
  - keep one entity per row and dedupe on a stable identity key
222
269
  - keep snippets/summaries optional and derived from raw context, never a replacement for it
223
270
  - normalize intent labels if you need deterministic automation
@@ -507,6 +554,15 @@ autotouch columns run-next \
507
554
  --wait
508
555
  ```
509
556
 
557
+ Blacklist gate (recommended before billable runs):
558
+
559
+ ```bash
560
+ autotouch blacklist filter \
561
+ --emails-file candidates.csv \
562
+ --emails-column work_email \
563
+ --output json
564
+ ```
565
+
510
566
  ## Job truth contract (agent-safe)
511
567
 
512
568
  - Treat a run as started only when you receive `job_id`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "autotouch-cli"
7
- version = "0.2.21"
7
+ version = "0.2.22"
8
8
  description = "Autotouch Smart Table CLI"
9
9
  readme = "docs/research-table/reference/autotouch-cli-pypi.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Normalize formatter columns to auto-run on source updates.
4
+
5
+ Why:
6
+ - Formatter columns should be reactive transforms.
7
+ - Legacy records may still have auto_run=never/onInsert or a conflicting legacy autoRun field.
8
+
9
+ What this migration does:
10
+ 1) Finds formatter columns with missing/legacy auto-run values.
11
+ 2) Sets `auto_run` to `onSourceUpdate`.
12
+ 3) Removes legacy `autoRun` to avoid precedence conflicts in payload serialization.
13
+
14
+ Usage:
15
+ python scripts/migrations/20260305_force_formatter_autorun_on_source_update.py
16
+
17
+ Environment variables:
18
+ MONGODB_URI (default: mongodb://localhost:27017)
19
+ MONGODB_DB_NAME (default: autotouch)
20
+ DRY_RUN (default: false)
21
+ """
22
+
23
+ import asyncio
24
+ import os
25
+ from datetime import datetime
26
+ from typing import Any, Dict
27
+
28
+ from motor.motor_asyncio import AsyncIOMotorClient
29
+
30
+
31
+ MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
32
+ MONGODB_DB_NAME = os.getenv("MONGODB_DB_NAME", "autotouch")
33
+ DRY_RUN = os.getenv("DRY_RUN", "false").strip().lower() == "true"
34
+
35
+
36
+ def _formatter_query() -> Dict[str, Any]:
37
+ return {
38
+ "kind": "formatter",
39
+ "$or": [
40
+ {"auto_run": {"$exists": False}},
41
+ {"auto_run": {"$in": [None, "", "never", "onInsert"]}},
42
+ {"autoRun": {"$exists": True}},
43
+ ],
44
+ }
45
+
46
+
47
+ async def run() -> None:
48
+ client = AsyncIOMotorClient(MONGODB_URI)
49
+ db = client[MONGODB_DB_NAME]
50
+
51
+ try:
52
+ print(f"Starting formatter auto-run migration on db={MONGODB_DB_NAME}")
53
+ print(f"Timestamp: {datetime.utcnow().isoformat()}Z")
54
+ print(f"DRY_RUN={DRY_RUN}")
55
+
56
+ query = _formatter_query()
57
+ candidates = await db.columns.count_documents(query)
58
+ print(f"Formatter columns to normalize: {candidates}")
59
+
60
+ if candidates == 0:
61
+ print("No formatter columns require migration.")
62
+ return
63
+
64
+ sample = await db.columns.find(
65
+ query,
66
+ {"_id": 1, "key": 1, "auto_run": 1, "autoRun": 1},
67
+ ).limit(5).to_list(length=5)
68
+ print("Sample candidates:")
69
+ for col in sample:
70
+ print(
71
+ f"- id={col.get('_id')} key={col.get('key')} "
72
+ f"auto_run={col.get('auto_run')} autoRun={col.get('autoRun')}"
73
+ )
74
+
75
+ if DRY_RUN:
76
+ print("DRY_RUN enabled; no updates applied.")
77
+ return
78
+
79
+ now = datetime.utcnow()
80
+ result = await db.columns.update_many(
81
+ query,
82
+ {
83
+ "$set": {
84
+ "auto_run": "onSourceUpdate",
85
+ "updated_at": now,
86
+ },
87
+ "$unset": {"autoRun": ""},
88
+ },
89
+ )
90
+ print(f"Matched: {result.matched_count}")
91
+ print(f"Modified: {result.modified_count}")
92
+ print("Migration complete.")
93
+ finally:
94
+ client.close()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ asyncio.run(run())
@@ -659,6 +659,150 @@ def _load_json_input(
659
659
  return default
660
660
 
661
661
 
662
+ def _normalize_email_value(value: Any) -> Optional[str]:
663
+ text = str(value or "").strip().lower()
664
+ return text or None
665
+
666
+
667
+ def _split_cli_email_values(raw_values: Optional[List[str]]) -> List[str]:
668
+ emails: List[str] = []
669
+ for raw in raw_values or []:
670
+ for piece in str(raw).replace("\n", ",").split(","):
671
+ normalized = _normalize_email_value(piece)
672
+ if normalized:
673
+ emails.append(normalized)
674
+ return emails
675
+
676
+
677
+ def _parse_email_payload(payload: Any, *, context: str) -> List[str]:
678
+ raw_values: List[Any]
679
+ if isinstance(payload, list):
680
+ raw_values = payload
681
+ elif isinstance(payload, dict) and isinstance(payload.get("emails"), list):
682
+ raw_values = payload.get("emails") or []
683
+ else:
684
+ print(
685
+ f"ERROR: {context} must be a JSON array or an object with an 'emails' array",
686
+ file=sys.stderr,
687
+ )
688
+ sys.exit(2)
689
+
690
+ emails: List[str] = []
691
+ for item in raw_values:
692
+ normalized = _normalize_email_value(item)
693
+ if normalized:
694
+ emails.append(normalized)
695
+ return emails
696
+
697
+
698
+ def _load_emails_from_file(file_path: str, *, csv_column: Optional[str]) -> List[str]:
699
+ resolved_path = os.path.abspath(os.path.expanduser(str(file_path)))
700
+ suffix = Path(resolved_path).suffix.lower()
701
+
702
+ try:
703
+ if suffix == ".json":
704
+ with open(resolved_path, "r", encoding="utf-8") as f:
705
+ payload = json.load(f)
706
+ return _parse_email_payload(payload, context=f"emails file '{resolved_path}'")
707
+
708
+ if suffix == ".csv":
709
+ with open(resolved_path, "r", encoding="utf-8", newline="") as f:
710
+ reader = csv.DictReader(f)
711
+ rows = list(reader)
712
+ fieldnames = [name for name in (reader.fieldnames or []) if name]
713
+
714
+ if not fieldnames:
715
+ return []
716
+
717
+ preferred = str(csv_column or "").strip().lower()
718
+ selected_column: Optional[str] = None
719
+ if preferred:
720
+ for field_name in fieldnames:
721
+ if str(field_name).strip().lower() == preferred:
722
+ selected_column = field_name
723
+ break
724
+
725
+ if selected_column is None:
726
+ normalized_fields = {
727
+ str(field_name).strip().lower(): field_name for field_name in fieldnames
728
+ }
729
+ for candidate in ("email", "work_email", "personal_email", "primary_email"):
730
+ if candidate in normalized_fields:
731
+ selected_column = normalized_fields[candidate]
732
+ break
733
+
734
+ if selected_column is None:
735
+ selected_column = fieldnames[0]
736
+
737
+ emails: List[str] = []
738
+ for row in rows:
739
+ normalized = _normalize_email_value((row or {}).get(selected_column))
740
+ if normalized:
741
+ emails.append(normalized)
742
+ return emails
743
+
744
+ emails: List[str] = []
745
+ with open(resolved_path, "r", encoding="utf-8") as f:
746
+ for line in f:
747
+ for piece in str(line).replace("\n", "").split(","):
748
+ normalized = _normalize_email_value(piece)
749
+ if normalized:
750
+ emails.append(normalized)
751
+ return emails
752
+
753
+ except FileNotFoundError:
754
+ print(f"ERROR: emails file not found: {resolved_path}", file=sys.stderr)
755
+ sys.exit(2)
756
+ except Exception as exc:
757
+ print(f"ERROR: failed to read emails file '{resolved_path}': {exc}", file=sys.stderr)
758
+ sys.exit(2)
759
+
760
+
761
+ def _dedupe_keep_order(values: List[str]) -> List[str]:
762
+ seen: set[str] = set()
763
+ deduped: List[str] = []
764
+ for value in values:
765
+ if value in seen:
766
+ continue
767
+ seen.add(value)
768
+ deduped.append(value)
769
+ return deduped
770
+
771
+
772
+ def _collect_blacklist_emails(args: argparse.Namespace) -> List[str]:
773
+ emails: List[str] = []
774
+ emails.extend(_split_cli_email_values(getattr(args, "email", None)))
775
+
776
+ raw_emails_json = getattr(args, "emails_json", None)
777
+ if raw_emails_json:
778
+ payload = _parse_json_string(str(raw_emails_json), "emails")
779
+ emails.extend(_parse_email_payload(payload, context="--emails-json"))
780
+
781
+ emails_file = getattr(args, "emails_file", None)
782
+ if emails_file:
783
+ emails.extend(
784
+ _load_emails_from_file(
785
+ str(emails_file),
786
+ csv_column=getattr(args, "emails_column", None),
787
+ )
788
+ )
789
+
790
+ deduped = _dedupe_keep_order([email for email in emails if email])
791
+ if not deduped:
792
+ print(
793
+ "ERROR: provide emails via --email, --emails-json, or --emails-file",
794
+ file=sys.stderr,
795
+ )
796
+ sys.exit(2)
797
+ return deduped
798
+
799
+
800
+ def _chunk_list(values: List[str], size: int) -> List[List[str]]:
801
+ if size <= 0:
802
+ return [values]
803
+ return [values[idx: idx + size] for idx in range(0, len(values), size)]
804
+
805
+
662
806
  def _request_api(
663
807
  method: str,
664
808
  path: str,
@@ -2239,6 +2383,211 @@ def cmd_tables_create(args: argparse.Namespace) -> None:
2239
2383
  _print_json(data, compact=args.compact)
2240
2384
 
2241
2385
 
2386
+ def cmd_blacklist_list(args: argparse.Namespace) -> None:
2387
+ token = _resolve_token(args.token, required=True)
2388
+ params: Dict[str, Any] = {
2389
+ "type_filter": str(getattr(args, "type_filter", "all") or "all"),
2390
+ "page": max(1, int(getattr(args, "page", 1) or 1)),
2391
+ "limit": max(1, int(getattr(args, "limit", 100) or 100)),
2392
+ }
2393
+ search = str(getattr(args, "search", "") or "").strip()
2394
+ if search:
2395
+ params["search"] = search
2396
+
2397
+ data = _request_api(
2398
+ "GET",
2399
+ "/api/blacklist",
2400
+ base_url=args.base_url,
2401
+ token=token,
2402
+ use_x_api_key=args.use_x_api_key,
2403
+ params=params,
2404
+ timeout=args.timeout,
2405
+ verbose=args.verbose,
2406
+ )
2407
+ _print_json(data, compact=args.compact)
2408
+
2409
+
2410
+ def cmd_blacklist_add(args: argparse.Namespace) -> None:
2411
+ token = _resolve_token(args.token, required=True)
2412
+ entry_type = str(args.type).strip().lower()
2413
+ value = str(args.value or "").strip().lower()
2414
+
2415
+ if entry_type == "domain":
2416
+ value = value.replace("https://", "").replace("http://", "")
2417
+ value = value.lstrip("@")
2418
+ value = value.split("/", 1)[0]
2419
+
2420
+ payload = {
2421
+ "type": entry_type,
2422
+ "value": value,
2423
+ "reason": str(getattr(args, "reason", "") or ""),
2424
+ "notes": str(getattr(args, "notes", "") or ""),
2425
+ }
2426
+ data = _request_api(
2427
+ "POST",
2428
+ "/api/blacklist",
2429
+ base_url=args.base_url,
2430
+ token=token,
2431
+ use_x_api_key=args.use_x_api_key,
2432
+ payload=payload,
2433
+ timeout=args.timeout,
2434
+ verbose=args.verbose,
2435
+ )
2436
+ _print_json(data, compact=args.compact)
2437
+
2438
+
2439
+ def cmd_blacklist_remove(args: argparse.Namespace) -> None:
2440
+ token = _resolve_token(args.token, required=True)
2441
+ data = _request_api(
2442
+ "DELETE",
2443
+ f"/api/blacklist/{args.entry_id}",
2444
+ base_url=args.base_url,
2445
+ token=token,
2446
+ use_x_api_key=args.use_x_api_key,
2447
+ timeout=args.timeout,
2448
+ verbose=args.verbose,
2449
+ )
2450
+ _print_json(data, compact=args.compact)
2451
+
2452
+
2453
+ def cmd_blacklist_import(args: argparse.Namespace) -> None:
2454
+ token = _resolve_token(args.token, required=True)
2455
+ source_path = os.path.abspath(os.path.expanduser(str(args.file)))
2456
+ data = _request_multipart_api(
2457
+ "POST",
2458
+ "/api/blacklist/import",
2459
+ base_url=args.base_url,
2460
+ token=token,
2461
+ use_x_api_key=args.use_x_api_key,
2462
+ file_path=source_path,
2463
+ file_field="file",
2464
+ timeout=args.timeout,
2465
+ verbose=args.verbose,
2466
+ )
2467
+ if isinstance(data, dict):
2468
+ data.setdefault("source", source_path)
2469
+ _print_json(data, compact=args.compact)
2470
+
2471
+
2472
+ def cmd_blacklist_check(args: argparse.Namespace) -> None:
2473
+ token = _resolve_token(args.token, required=True)
2474
+ emails = _collect_blacklist_emails(args)
2475
+ chunk_size = max(1, min(int(getattr(args, "chunk_size", 1000) or 1000), 1000))
2476
+
2477
+ all_results: List[Dict[str, Any]] = []
2478
+ chunk_summaries: List[Dict[str, Any]] = []
2479
+ for chunk_index, chunk in enumerate(_chunk_list(emails, chunk_size), start=1):
2480
+ response = _request_api(
2481
+ "POST",
2482
+ "/api/blacklist/check",
2483
+ base_url=args.base_url,
2484
+ token=token,
2485
+ use_x_api_key=args.use_x_api_key,
2486
+ payload={"emails": chunk},
2487
+ timeout=args.timeout,
2488
+ verbose=args.verbose,
2489
+ )
2490
+ if not isinstance(response, dict):
2491
+ print("ERROR: unexpected response from /api/blacklist/check", file=sys.stderr)
2492
+ sys.exit(1)
2493
+
2494
+ chunk_results = response.get("results") if isinstance(response.get("results"), list) else []
2495
+ chunk_summary = response.get("summary") if isinstance(response.get("summary"), dict) else {}
2496
+ all_results.extend([result for result in chunk_results if isinstance(result, dict)])
2497
+ chunk_summaries.append(
2498
+ {
2499
+ "chunk": chunk_index,
2500
+ "size": len(chunk),
2501
+ "summary": chunk_summary,
2502
+ }
2503
+ )
2504
+
2505
+ total_blacklisted = sum(
2506
+ 1
2507
+ for result in all_results
2508
+ if bool(result.get("is_blacklisted"))
2509
+ )
2510
+ output: Dict[str, Any] = {
2511
+ "input_count": len(emails),
2512
+ "chunks": len(chunk_summaries),
2513
+ "chunk_size": chunk_size,
2514
+ "summary": {
2515
+ "total_checked": len(all_results),
2516
+ "total_blacklisted": total_blacklisted,
2517
+ "total_clean": max(0, len(all_results) - total_blacklisted),
2518
+ },
2519
+ }
2520
+ if not bool(getattr(args, "summary_only", False)):
2521
+ output["results"] = all_results
2522
+ if len(chunk_summaries) > 1:
2523
+ output["chunk_summaries"] = chunk_summaries
2524
+ _print_json(output, compact=args.compact)
2525
+
2526
+
2527
+ def cmd_blacklist_filter(args: argparse.Namespace) -> None:
2528
+ token = _resolve_token(args.token, required=True)
2529
+ emails = _collect_blacklist_emails(args)
2530
+ chunk_size = max(1, min(int(getattr(args, "chunk_size", 10000) or 10000), 10000))
2531
+
2532
+ clean_emails: List[str] = []
2533
+ blacklisted_emails: List[Dict[str, Any]] = []
2534
+ chunk_summaries: List[Dict[str, Any]] = []
2535
+ for chunk_index, chunk in enumerate(_chunk_list(emails, chunk_size), start=1):
2536
+ response = _request_api(
2537
+ "POST",
2538
+ "/api/blacklist/filter",
2539
+ base_url=args.base_url,
2540
+ token=token,
2541
+ use_x_api_key=args.use_x_api_key,
2542
+ payload={"emails": chunk},
2543
+ timeout=args.timeout,
2544
+ verbose=args.verbose,
2545
+ )
2546
+ if not isinstance(response, dict):
2547
+ print("ERROR: unexpected response from /api/blacklist/filter", file=sys.stderr)
2548
+ sys.exit(1)
2549
+
2550
+ chunk_clean = response.get("clean_emails") if isinstance(response.get("clean_emails"), list) else []
2551
+ chunk_blocked = (
2552
+ response.get("blacklisted_emails")
2553
+ if isinstance(response.get("blacklisted_emails"), list)
2554
+ else []
2555
+ )
2556
+ chunk_summary = response.get("summary") if isinstance(response.get("summary"), dict) else {}
2557
+ clean_emails.extend(
2558
+ [
2559
+ normalized
2560
+ for normalized in (_normalize_email_value(item) for item in chunk_clean)
2561
+ if normalized
2562
+ ]
2563
+ )
2564
+ blacklisted_emails.extend([item for item in chunk_blocked if isinstance(item, dict)])
2565
+ chunk_summaries.append(
2566
+ {
2567
+ "chunk": chunk_index,
2568
+ "size": len(chunk),
2569
+ "summary": chunk_summary,
2570
+ }
2571
+ )
2572
+
2573
+ output: Dict[str, Any] = {
2574
+ "input_count": len(emails),
2575
+ "chunks": len(chunk_summaries),
2576
+ "chunk_size": chunk_size,
2577
+ "summary": {
2578
+ "total_provided": len(emails),
2579
+ "total_clean": len(clean_emails),
2580
+ "total_blacklisted": len(blacklisted_emails),
2581
+ },
2582
+ }
2583
+ if not bool(getattr(args, "summary_only", False)):
2584
+ output["clean_emails"] = clean_emails
2585
+ output["blacklisted_emails"] = blacklisted_emails
2586
+ if len(chunk_summaries) > 1:
2587
+ output["chunk_summaries"] = chunk_summaries
2588
+ _print_json(output, compact=args.compact)
2589
+
2590
+
2242
2591
  def cmd_rows_add(args: argparse.Namespace) -> None:
2243
2592
  token = _resolve_token(args.token, required=True)
2244
2593
  records_payload = _load_json_input(
@@ -3616,6 +3965,74 @@ def build_parser() -> argparse.ArgumentParser:
3616
3965
  _add_api_common_arguments(ptc)
3617
3966
  ptc.set_defaults(func=cmd_tables_create)
3618
3967
 
3968
+ # blacklist
3969
+ pbl = sub.add_parser("blacklist", help="Organization blacklist operations (admin)")
3970
+ blacklist_sub = pbl.add_subparsers(dest="blacklist_cmd", required=True)
3971
+
3972
+ pbll = blacklist_sub.add_parser("list", help="List blacklist entries")
3973
+ pbll.add_argument("--type-filter", choices=["all", "email", "domain"], default="all")
3974
+ pbll.add_argument("--search", help="Filter by value/reason/notes")
3975
+ pbll.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
3976
+ pbll.add_argument("--limit", type=int, default=100, help="Entries per page (default: 100)")
3977
+ _add_api_common_arguments(pbll)
3978
+ pbll.set_defaults(func=cmd_blacklist_list)
3979
+
3980
+ pbla = blacklist_sub.add_parser("add", help="Add a blacklist entry")
3981
+ pbla.add_argument("--type", choices=["email", "domain"], required=True, help="Entry type")
3982
+ pbla.add_argument("--value", required=True, help="Email or domain value")
3983
+ pbla.add_argument("--reason", default="", help="Optional reason")
3984
+ pbla.add_argument("--notes", default="", help="Optional notes")
3985
+ _add_api_common_arguments(pbla)
3986
+ pbla.set_defaults(func=cmd_blacklist_add)
3987
+
3988
+ pblr = blacklist_sub.add_parser("remove", help="Remove a blacklist entry by id")
3989
+ pblr.add_argument("--entry-id", required=True)
3990
+ _add_api_common_arguments(pblr)
3991
+ pblr.set_defaults(func=cmd_blacklist_remove)
3992
+
3993
+ pbli = blacklist_sub.add_parser("import", help="Import blacklist entries from CSV/TXT")
3994
+ pbli.add_argument("--file", required=True, help="Path to CSV/TXT file")
3995
+ _add_api_common_arguments(pbli)
3996
+ pbli.set_defaults(func=cmd_blacklist_import)
3997
+
3998
+ pblc = blacklist_sub.add_parser("check", help="Check whether emails are blacklisted")
3999
+ pblc.add_argument(
4000
+ "--email",
4001
+ action="append",
4002
+ help="Email value (repeatable; comma-separated also supported)",
4003
+ )
4004
+ pblc.add_argument("--emails-json", help="JSON array or object with emails[]")
4005
+ pblc.add_argument("--emails-file", help="Path to .txt/.csv/.json email list")
4006
+ pblc.add_argument("--emails-column", default="email", help="CSV column for emails (default: email)")
4007
+ pblc.add_argument(
4008
+ "--chunk-size",
4009
+ type=int,
4010
+ default=1000,
4011
+ help="Request chunk size (max 1000, default: 1000)",
4012
+ )
4013
+ pblc.add_argument("--summary-only", action="store_true", help="Omit per-email results")
4014
+ _add_api_common_arguments(pblc)
4015
+ pblc.set_defaults(func=cmd_blacklist_check)
4016
+
4017
+ pblf = blacklist_sub.add_parser("filter", help="Filter blacklisted emails from a list")
4018
+ pblf.add_argument(
4019
+ "--email",
4020
+ action="append",
4021
+ help="Email value (repeatable; comma-separated also supported)",
4022
+ )
4023
+ pblf.add_argument("--emails-json", help="JSON array or object with emails[]")
4024
+ pblf.add_argument("--emails-file", help="Path to .txt/.csv/.json email list")
4025
+ pblf.add_argument("--emails-column", default="email", help="CSV column for emails (default: email)")
4026
+ pblf.add_argument(
4027
+ "--chunk-size",
4028
+ type=int,
4029
+ default=10000,
4030
+ help="Request chunk size (max 10000, default: 10000)",
4031
+ )
4032
+ pblf.add_argument("--summary-only", action="store_true", help="Omit full clean/blocked lists")
4033
+ _add_api_common_arguments(pblf)
4034
+ pblf.set_defaults(func=cmd_blacklist_filter)
4035
+
3619
4036
  # rows
3620
4037
  pr = sub.add_parser("rows", help="Row operations")
3621
4038
  rows_sub = pr.add_subparsers(dest="rows_cmd", required=True)
File without changes