Skip to content

Commit 3b1afa3

Browse files
Harden inbox apply safety
1 parent 2eb96c3 commit 3b1afa3

7 files changed

Lines changed: 91 additions & 57 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ These are the active scripts this repo exposes today.
3636

3737
| Script | Command | What it does | Safety posture |
3838
|---|---|---|---|
39-
| Gmail Inbox Accelerator | `python -m src.inbox.accelerator --dry` | Loads configurable Gmail rules, searches matching messages, and previews label/archive actions | Use `--dry` before any write run |
39+
| Gmail Inbox Accelerator preview | `python -m src.inbox.accelerator` | Loads configurable Gmail rules, searches matching messages, and previews label/archive actions | Dry run by default; no Gmail or local state changes are saved |
40+
| Gmail Inbox Accelerator apply | `python -m src.inbox.accelerator --apply` | Applies reviewed rules to Gmail labels, archive state, and read state if configured | Write-capable; run only after preview review |
4041
| Inbox status check | `python -m src.inbox.accelerator --status` | Prints current state, rule index, labeled count, archived count, errors, runs, and last run | Read-only |
4142
| Inbox state reset | `python -m src.inbox.accelerator --reset` | Removes local processing state so the next run starts from rule 0 | Local state only |
4243
| Google Ads Campaign Health Audit | `python -m src.audit.campaign_health --days 30` | Pulls Google Ads campaign data and checks budget pacing, conversion health, impression share, naming, auto-tagging, and status anomalies | Read-only API workflow |
@@ -52,7 +53,7 @@ Rule-based email processing using the Gmail API. Categorizes, labels, archives,
5253
- **Rule engine** — configurable pattern matching by sender, subject, and keywords
5354
- **Batch processing** — labels and archives messages efficiently through API calls
5455
- **State persistence** — tracks progress across runs and resumes where it left off
55-
- **Dry-run mode** — previews changes before applying them
56+
- **Dry-run mode** — previews changes without modifying Gmail or local state
5657

5758
### Platform audit (`src/audit/`)
5859

@@ -84,12 +85,12 @@ Structured reporting examples show how script output can be translated into oper
8485
Start in dry-run or read-only mode before applying changes.
8586

8687
```bash
87-
python -m src.inbox.accelerator --dry
88+
python -m src.inbox.accelerator
8889
python -m src.inbox.accelerator --status
8990
python -m src.audit.campaign_health --days 30
9091
```
9192

92-
Use write-capable inbox workflows only after reviewing the dry-run output and confirming the rule configuration.
93+
Use `python -m src.inbox.accelerator --apply` only after reviewing the dry-run output and confirming the rule configuration. Avoid broad catch-all inbox rules, and treat `mark_read` as an explicit opt-in for narrow, low-risk message classes.
9394

9495
## Example output
9596

config/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
2. Create a project (or select existing)
77
3. Enable the **Gmail API**
88
4. Create OAuth 2.0 credentials (Desktop application)
9-
5. Download the credentials JSON and save as `credentials.json` in the project root
10-
6. On first run, a browser window will open for authorization — the resulting token is saved as `token.json`
9+
5. Download the credentials JSON and save it as an ignored local file named `credentials.json`
10+
6. On first authorization, the resulting token is saved as an ignored local file named `token.json`
1111

1212
Reference: [Gmail API Python Quickstart](https://developers.google.com/gmail/api/quickstart/python)
1313

14+
Do not commit OAuth credentials or tokens. The repository `.gitignore` excludes the default local filenames, but you should still treat them as private machine-local files.
15+
1416
### Gmail Rules
1517

1618
```bash
@@ -23,7 +25,9 @@ Edit `gmail_rules.json` to add your own sender patterns, subject filters, and la
2325
- `query` — Gmail search query (same syntax as the Gmail search bar)
2426
- `label` — target label (created automatically if it doesn't exist)
2527
- `archive` — whether to remove from inbox
26-
- `mark_read` — whether to mark as read
28+
- `mark_read` — whether to mark as read; keep this `false` unless the rule is narrow and intentionally low-risk
29+
30+
Run the inbox accelerator without flags first. It defaults to a dry run and does not modify Gmail or local processing state. Use `--apply` only after reviewing the matched rules.
2731

2832
## Google Ads API Setup
2933

@@ -45,6 +49,8 @@ Fill in your credentials:
4549

4650
Reference: [Google Ads API Authentication](https://developers.google.com/google-ads/api/docs/oauth/overview)
4751

52+
Do not commit `config/google-ads.yaml` or any copied output from live accounts. Public examples should use synthetic account names, IDs, and performance data only.
53+
4854
### Accounts Config
4955

5056
```bash

config/gmail_rules.example.json

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,20 @@
1818
"query": "subject:(\"has shipped\" OR \"out for delivery\" OR \"package delivered\" OR \"tracking number\") in:inbox",
1919
"label": "INBOX/RECEIPTS",
2020
"archive": true,
21-
"mark_read": true
21+
"mark_read": false
2222
},
2323
{
2424
"name": "Notifications - calendar and system",
2525
"query": "from:(calendar-notification@google.com OR noreply@alerts.example.com OR notifications@system.example.com) in:inbox",
2626
"label": "INBOX/NOTIFICATIONS",
2727
"archive": true,
28-
"mark_read": true
28+
"mark_read": false
2929
},
3030
{
3131
"name": "Promotions - known marketing senders",
3232
"query": "from:(deals@retailer.example.com OR offers@brand.example.com OR promo@store.example.com) in:inbox",
3333
"label": "INBOX/PROMOTIONS",
3434
"archive": true,
35-
"mark_read": true
36-
},
37-
{
38-
"name": "Promotions - alias catch-all",
39-
"query": "to:alias@example.com in:inbox",
40-
"label": "INBOX/PROMOTIONS",
41-
"archive": true,
42-
"mark_read": true
35+
"mark_read": false
4336
}
4437
]

examples/example-run.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,41 @@ The purpose of this file is to make the repo easier to inspect. It shows what an
88

99
| Workflow | Command | Writes to external system? |
1010
|---|---|---|
11-
| Inbox dry run | `python -m src.inbox.accelerator --dry` | No |
11+
| Inbox dry run | `python -m src.inbox.accelerator` | No |
1212
| Inbox status | `python -m src.inbox.accelerator --status` | No |
1313
| Inbox reset | `python -m src.inbox.accelerator --reset` | No external write; resets local state |
14-
| Inbox apply | `python -m src.inbox.accelerator` | Yes, after OAuth and rule review |
14+
| Inbox apply | `python -m src.inbox.accelerator --apply` | Yes, after OAuth and rule review |
1515
| Campaign health audit | `python -m src.audit.campaign_health --days 30` | No; reads Google Ads data |
1616

1717
## Inbox automation dry run
1818

1919
### Command
2020

2121
```bash
22-
python -m src.inbox.accelerator --dry
22+
python -m src.inbox.accelerator
2323
```
2424

2525
### Sample console output
2626

2727
```text
28-
Loaded 8 rules
29-
Run #3 | phase=processing | rule=2/7 | labeled=284 | archived=196
30-
[DRY RUN] Rule 2 (Platform alerts): would process 91 messages
31-
[DRY RUN] Rule 3 (Vendor reporting): would process 142 messages
32-
[DRY RUN] Rule 4 (Low-priority newsletters): would process 178 messages
33-
Rule 5 (Recruiter and priority contacts): no matches, advancing.
34-
Run #3 complete | 14s | labeled=284 | archived=196 | errors=0 | next_rule=6
28+
Loaded 5 rules
29+
Dry-run mode. Add --apply only after reviewing the rule output.
30+
Preview #3 | phase=processing | rule=0/4 | labeled=284 | archived=196
31+
[DRY RUN] Rule 0 (Intel - industry newsletters): would label 91 messages, archive
32+
[DRY RUN] Rule 1 (Receipts - transactional emails): would label 142 messages, archive
33+
[DRY RUN] Rule 2 (Receipts - shipping notifications): would label 178 messages, archive
34+
Rule 3 (Notifications - calendar and system): no matches, advancing.
35+
Preview #3 complete | 14s | labeled=284 | archived=196 | errors=0 | next_rule=5
36+
Dry run only. No Gmail changes or local state changes were saved.
3537
```
3638

3739
### Operator readout
3840

3941
| Review area | Signal | Suggested action |
4042
|---|---|---|
4143
| Rule volume | Three rules would touch 411 messages | Confirm rules are scoped correctly before write run |
42-
| Priority safety | Priority-contact rule had no matches | Confirm this is expected before archiving lower-priority mail |
43-
| State behavior | Next run resumes from rule 6 | Use `--status` before applying changes |
44+
| Notification safety | Notification rule had no matches | Confirm this is expected before applying lower-priority mail handling |
45+
| State behavior | Preview does not save local state | Use `--apply` only after reviewing the preview |
4446

4547
## Inbox status check
4648

@@ -127,5 +129,5 @@ The toolkit is designed to make recurring marketing operations work easier to in
127129

128130
- This example uses mock data.
129131
- Do not commit live exports, private campaign data, account IDs, credentials, or tokens.
130-
- Dry-run mode should be used before applying inbox changes.
132+
- Dry-run mode is the default and should be used before applying inbox changes.
131133
- Google Ads audit output should be treated as a diagnostic prompt for human review, not as an automated budget decision.

scripts/validate_repo.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
re.compile(r"BEGIN (RSA|OPENSSH|PRIVATE) KEY"),
2121
]
2222
SENSITIVE_NAME = re.compile(
23-
r"(^|/)(\.env(\..*)?|\.env\.keys|client_secret.*\.json|credentials.*\.json|token.*\.json|tokens.*\.json|google[-_]ads\.ya?ml)$",
23+
r"(^|/)(\.env(\..*)?|\.env\.keys|client_secret.*\.json|credentials.*\.json|token.*\.json|tokens.*\.json|google[-_]ads\.ya?ml|state\.json)$",
2424
re.IGNORECASE,
2525
)
2626
ALLOW_TRACKED = {
@@ -29,6 +29,16 @@
2929
}
3030
SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
3131
TEXT_SUFFIXES = {".py", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".sh", ".css", ".js", ".ts", ".html"}
32+
UNSAFE_SAMPLE_PATTERNS = {
33+
"config/gmail_rules.example.json": [
34+
(re.compile(r'"mark_read"\s*:\s*true', re.IGNORECASE), "Sample Gmail rules should not mark messages read by default"),
35+
(re.compile(r'"query"\s*:\s*"to:[^"]+@[^"]+in:inbox"', re.IGNORECASE), "Sample Gmail rules should not use broad alias catch-all queries"),
36+
],
37+
"src/inbox/rules.py": [
38+
(re.compile(r"mark_read\s*=\s*True"), "Built-in Gmail rules should not mark messages read by default"),
39+
(re.compile(r"query\s*=\s*[\"']to:[^\"']+@[^\"']+in:inbox[\"']", re.IGNORECASE), "Built-in Gmail rules should not use broad alias catch-all queries"),
40+
],
41+
}
3242

3343
def git(args: list[str]) -> subprocess.CompletedProcess[str]:
3444
return subprocess.run(["git", *args], cwd=ROOT, text=True, capture_output=True, check=False)
@@ -74,6 +84,11 @@ def main() -> int:
7484
failures.append(f"Secret-like pattern in {rel}: {pattern.pattern}")
7585
break
7686

87+
for pattern, message in UNSAFE_SAMPLE_PATTERNS.get(rel, []):
88+
if pattern.search(text):
89+
failures.append(f"{message}: {rel}")
90+
break
91+
7792
status = git(["status", "--short"]).stdout.strip()
7893
if status:
7994
failures.append("Working tree is not clean")

src/inbox/accelerator.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
batchModify. Maintains state across runs so it can resume where it left off.
77
88
Usage:
9-
python -m src.inbox.accelerator # Process inbox
10-
python -m src.inbox.accelerator --dry # Preview changes (no writes)
9+
python -m src.inbox.accelerator # Preview changes (no writes)
10+
python -m src.inbox.accelerator --apply # Apply approved rules
11+
python -m src.inbox.accelerator --dry # Explicit preview mode
1112
python -m src.inbox.accelerator --status # Print current state
1213
python -m src.inbox.accelerator --reset # Reset state to start over
1314
python -m src.inbox.accelerator --config config/gmail_rules.json
@@ -176,27 +177,35 @@ def run_accelerator(rules: list[Rule], dry_run: bool = False) -> None:
176177
- For each rule, searches for matching messages and applies actions
177178
- If a rule matches messages, re-runs it until exhausted (handles
178179
inboxes with >1,000 matching messages per rule)
179-
- Saves state after each batch so processing resumes on next run
180+
- Saves state after each applied batch so processing resumes on next run
180181
- Stops after MAX_RUNTIME_SEC to stay within scheduler limits
182+
183+
Dry-run mode is intentionally non-mutating: it does not apply Gmail
184+
changes and does not advance or save local processing state.
181185
"""
182186
service = get_gmail_service()
183187
state = load_state()
188+
if dry_run:
189+
state = dict(state)
184190
start_time = time.time()
185191

186-
state["runs"] += 1
187-
run_num = state["runs"]
192+
run_num = state["runs"] + 1
188193

189194
print(
190-
f"Run #{run_num} | phase={state['phase']} | "
195+
f"{'Preview' if dry_run else 'Run'} #{run_num} | phase={state['phase']} | "
191196
f"rule={state['rule_index']}/{len(rules) - 1} | "
192197
f"labeled={state['labeled']} | archived={state['archived']}"
193198
)
194199

195200
if state["phase"] == "done":
196201
print("All rules processed. Nothing to do. Use --reset to start over.")
197-
save_state(state)
202+
if not dry_run:
203+
save_state(state)
198204
return
199205

206+
if not dry_run:
207+
state["runs"] = run_num
208+
200209
label_id_cache: dict[str, str] = {}
201210
consecutive_errors = 0
202211

@@ -228,18 +237,20 @@ def run_accelerator(rules: list[Rule], dry_run: bool = False) -> None:
228237
print(f"Rule {idx} ({rule.name}): no matches, advancing.")
229238
state["rule_index"] += 1
230239
consecutive_errors = 0
231-
save_state(state)
240+
if not dry_run:
241+
save_state(state)
232242
continue
233243

234244
# Dry run — report what would happen
235245
if dry_run:
236246
print(
237247
f"[DRY RUN] Rule {idx} ({rule.name}): "
238-
f"would process {len(message_ids)} messages"
248+
f"would label {len(message_ids)} messages"
249+
f"{', archive' if rule.archive else ''}"
250+
f"{', mark read' if rule.mark_read else ''}"
239251
)
240252
state["rule_index"] += 1
241253
consecutive_errors = 0
242-
save_state(state)
243254
continue
244255

245256
# Apply the rule
@@ -270,18 +281,22 @@ def run_accelerator(rules: list[Rule], dry_run: bool = False) -> None:
270281
break
271282
time.sleep(2)
272283

273-
save_state(state)
284+
if not dry_run:
285+
save_state(state)
274286

275287
# Finalize
276-
state["last_run"] = datetime.now(timezone.utc).isoformat()
277-
save_state(state)
288+
if not dry_run:
289+
state["last_run"] = datetime.now(timezone.utc).isoformat()
290+
save_state(state)
278291

279292
elapsed = round(time.time() - start_time)
280293
print(
281-
f"Run #{run_num} complete | {elapsed}s | "
294+
f"{'Preview' if dry_run else 'Run'} #{run_num} complete | {elapsed}s | "
282295
f"labeled={state['labeled']} | archived={state['archived']} | "
283296
f"errors={state['errors']} | next_rule={state['rule_index']}"
284297
)
298+
if dry_run:
299+
print("Dry run only. No Gmail changes or local state changes were saved.")
285300

286301

287302
# ── CLI ──────────────────────────────────────────────────────────────────────
@@ -312,7 +327,11 @@ def main() -> None:
312327
)
313328
parser.add_argument(
314329
"--dry", action="store_true",
315-
help="Dry run — preview what would change without modifying anything",
330+
help="Explicit dry run — preview without Gmail or local state changes",
331+
)
332+
parser.add_argument(
333+
"--apply", action="store_true",
334+
help="Apply approved rules to Gmail. Without this flag, the command is a dry run.",
316335
)
317336
parser.add_argument(
318337
"--status", action="store_true",
@@ -328,6 +347,9 @@ def main() -> None:
328347
)
329348
args = parser.parse_args()
330349

350+
if args.dry and args.apply:
351+
parser.error("Use either --dry or --apply, not both.")
352+
331353
if args.status:
332354
print_status()
333355
sys.exit(0)
@@ -338,7 +360,9 @@ def main() -> None:
338360

339361
rules = get_rules(args.config)
340362
print(f"Loaded {len(rules)} rules")
341-
run_accelerator(rules, dry_run=args.dry)
363+
if not args.apply:
364+
print("Dry-run mode. Add --apply only after reviewing the rule output.")
365+
run_accelerator(rules, dry_run=not args.apply)
342366

343367

344368
if __name__ == "__main__":

src/inbox/rules.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _validate_rule_entry(entry: dict, index: int) -> None:
122122
),
123123
label="INBOX/RECEIPTS",
124124
archive=True,
125-
mark_read=True,
125+
mark_read=False,
126126
),
127127
Rule(
128128
name="Notifications - calendar and system",
@@ -133,7 +133,7 @@ def _validate_rule_entry(entry: dict, index: int) -> None:
133133
),
134134
label="INBOX/NOTIFICATIONS",
135135
archive=True,
136-
mark_read=True,
136+
mark_read=False,
137137
),
138138
Rule(
139139
name="Promotions - known marketing senders",
@@ -144,14 +144,7 @@ def _validate_rule_entry(entry: dict, index: int) -> None:
144144
),
145145
label="INBOX/PROMOTIONS",
146146
archive=True,
147-
mark_read=True,
148-
),
149-
Rule(
150-
name="Promotions - alias catch-all",
151-
query="to:alias@example.com in:inbox",
152-
label="INBOX/PROMOTIONS",
153-
archive=True,
154-
mark_read=True,
147+
mark_read=False,
155148
),
156149
]
157150

0 commit comments

Comments
 (0)