diff --git a/docs/plans/email-sync-testing-checklist.md b/docs/plans/email-sync-testing-checklist.md index b7a7f5d..eb29479 100644 --- a/docs/plans/email-sync-testing-checklist.md +++ b/docs/plans/email-sync-testing-checklist.md @@ -16,91 +16,91 @@ Generated from audit of `scripts/imap_sync.py`. ## Unit tests — phrase filter -- [ ] `_has_rejection_or_ats_signal` — rejection phrase at char 1501 (boundary) -- [ ] `_has_rejection_or_ats_signal` — right single quote `\u2019` in "don't forget" -- [ ] `_has_rejection_or_ats_signal` — left single quote `\u2018` in "don't forget" -- [ ] `_has_rejection_or_ats_signal` — ATS subject phrase only checked against subject, not body -- [ ] `_has_rejection_or_ats_signal` — spam subject prefix `@` match -- [ ] `_has_rejection_or_ats_signal` — `"UNFORTUNATELY"` (uppercase → lowercased correctly) -- [ ] `_has_rejection_or_ats_signal` — phrase in body quoted thread (beyond 1500 chars) is not blocked +- [x] `_has_rejection_or_ats_signal` — rejection phrase at char 1501 (boundary) +- [x] `_has_rejection_or_ats_signal` — right single quote `\u2019` in "don't forget" +- [x] `_has_rejection_or_ats_signal` — left single quote `\u2018` in "don't forget" +- [x] `_has_rejection_or_ats_signal` — ATS subject phrase only checked against subject, not body +- [x] `_has_rejection_or_ats_signal` — spam subject prefix `@` match +- [x] `_has_rejection_or_ats_signal` — `"UNFORTUNATELY"` (uppercase → lowercased correctly) +- [x] `_has_rejection_or_ats_signal` — phrase in body quoted thread (beyond 1500 chars) is not blocked ## Unit tests — folder quoting -- [ ] `_quote_folder("TO DO JOBS")` → `'"TO DO JOBS"'` -- [ ] `_quote_folder("INBOX")` → `"INBOX"` (no spaces, no quotes added) -- [ ] `_quote_folder('My "Jobs"')` → `'"My \\"Jobs\\""'` -- [ ] `_search_folder` — folder doesn't exist → returns `[]`, no exception -- [ ] `_search_folder` — special folder `"[Gmail]/All Mail"` (brackets + slash) +- [x] `_quote_folder("TO DO JOBS")` → `'"TO DO JOBS"'` +- [x] `_quote_folder("INBOX")` → `"INBOX"` (no spaces, no quotes added) +- [x] `_quote_folder('My "Jobs"')` → `'"My \\"Jobs\\""'` +- [x] `_search_folder` — folder doesn't exist → returns `[]`, no exception +- [x] `_search_folder` — special folder `"[Gmail]/All Mail"` (brackets + slash) ## Unit tests — message-ID dedup -- [ ] `_get_existing_message_ids` — NULL message_id in DB excluded from set -- [ ] `_get_existing_message_ids` — empty string `""` excluded from set -- [ ] `_get_existing_message_ids` — job with no contacts returns empty set -- [ ] `_parse_message` — email with no Message-ID header returns `None` -- [ ] `_parse_message` — email with RFC2047-encoded subject decodes correctly -- [ ] No email is inserted twice across two sync runs (integration) +- [x] `_get_existing_message_ids` — NULL message_id in DB excluded from set +- [x] `_get_existing_message_ids` — empty string `""` excluded from set +- [x] `_get_existing_message_ids` — job with no contacts returns empty set +- [x] `_parse_message` — email with no Message-ID header returns `None` +- [x] `_parse_message` — email with RFC2047-encoded subject decodes correctly +- [x] No email is inserted twice across two sync runs (integration) ## Unit tests — classifier & signal -- [ ] `classify_stage_signal` — returns one of 5 labels or `None` -- [ ] `classify_stage_signal` — returns `None` on LLM error -- [ ] `classify_stage_signal` — returns `"neutral"` when no label matched in LLM output -- [ ] `classify_stage_signal` — strips `` blocks -- [ ] `_scan_unmatched_leads` — skips when `signal is None` -- [ ] `_scan_unmatched_leads` — skips when `signal == "rejected"` -- [ ] `_scan_unmatched_leads` — proceeds when `signal == "neutral"` -- [ ] `extract_lead_info` — returns `(None, None)` on bad JSON -- [ ] `extract_lead_info` — returns `(None, None)` on LLM error +- [x] `classify_stage_signal` — returns one of 5 labels or `None` +- [x] `classify_stage_signal` — returns `None` on LLM error +- [x] `classify_stage_signal` — returns `"neutral"` when no label matched in LLM output +- [x] `classify_stage_signal` — strips `` blocks +- [x] `_scan_unmatched_leads` — skips when `signal is None` +- [x] `_scan_unmatched_leads` — skips when `signal == "rejected"` +- [x] `_scan_unmatched_leads` — proceeds when `signal == "neutral"` +- [x] `extract_lead_info` — returns `(None, None)` on bad JSON +- [x] `extract_lead_info` — returns `(None, None)` on LLM error ## Integration tests — TODO label scan -- [ ] `_scan_todo_label` — `todo_label` empty string → returns 0 -- [ ] `_scan_todo_label` — `todo_label` missing from config → returns 0 -- [ ] `_scan_todo_label` — folder doesn't exist on IMAP server → returns 0, no crash -- [ ] `_scan_todo_label` — email matches company + action keyword → contact attached -- [ ] `_scan_todo_label` — email matches company but no action keyword → skipped -- [ ] `_scan_todo_label` — email matches no company term → skipped -- [ ] `_scan_todo_label` — duplicate message-ID → not re-inserted -- [ ] `_scan_todo_label` — stage_signal set when classifier returns non-neutral -- [ ] `_scan_todo_label` — body fallback (company only in body[:300]) → still matches -- [ ] `_scan_todo_label` — email handled by `sync_job_emails` first not re-added by label scan +- [x] `_scan_todo_label` — `todo_label` empty string → returns 0 +- [x] `_scan_todo_label` — `todo_label` missing from config → returns 0 +- [x] `_scan_todo_label` — folder doesn't exist on IMAP server → returns 0, no crash +- [x] `_scan_todo_label` — email matches company + action keyword → contact attached +- [x] `_scan_todo_label` — email matches company but no action keyword → skipped +- [x] `_scan_todo_label` — email matches no company term → skipped +- [x] `_scan_todo_label` — duplicate message-ID → not re-inserted +- [x] `_scan_todo_label` — stage_signal set when classifier returns non-neutral +- [x] `_scan_todo_label` — body fallback (company only in body[:300]) → still matches +- [x] `_scan_todo_label` — email handled by `sync_job_emails` first not re-added by label scan ## Integration tests — unmatched leads -- [ ] `_scan_unmatched_leads` — genuine lead inserted with synthetic URL `email://domain/hash` -- [ ] `_scan_unmatched_leads` — same email not re-inserted on second sync run -- [ ] `_scan_unmatched_leads` — duplicate synthetic URL skipped -- [ ] `_scan_unmatched_leads` — `extract_lead_info` returns `(None, None)` → no insertion -- [ ] `_scan_unmatched_leads` — rejection phrase in body → blocked before LLM -- [ ] `_scan_unmatched_leads` — rejection phrase in quoted thread > 1500 chars → passes filter (acceptable) +- [x] `_scan_unmatched_leads` — genuine lead inserted with synthetic URL `email://domain/hash` +- [x] `_scan_unmatched_leads` — same email not re-inserted on second sync run +- [x] `_scan_unmatched_leads` — duplicate synthetic URL skipped +- [x] `_scan_unmatched_leads` — `extract_lead_info` returns `(None, None)` → no insertion +- [x] `_scan_unmatched_leads` — rejection phrase in body → blocked before LLM +- [x] `_scan_unmatched_leads` — rejection phrase in quoted thread > 1500 chars → passes filter (acceptable) ## Integration tests — full sync -- [ ] `sync_all` with no active jobs → returns dict with all 6 keys incl. `todo_attached: 0` -- [ ] `sync_all` return dict shape identical on all code paths -- [ ] `sync_all` with `job_ids` filter → only syncs those jobs -- [ ] `sync_all` `dry_run=True` → no DB writes -- [ ] `sync_all` `on_stage` callback fires: "connecting", "job N/M", "scanning todo label", "scanning leads" -- [ ] `sync_all` IMAP connection error → caught, returned in `errors` list -- [ ] `sync_all` per-job exception → other jobs still sync +- [x] `sync_all` with no active jobs → returns dict with all 6 keys incl. `todo_attached: 0` +- [x] `sync_all` return dict shape identical on all code paths +- [x] `sync_all` with `job_ids` filter → only syncs those jobs +- [x] `sync_all` `dry_run=True` → no DB writes +- [x] `sync_all` `on_stage` callback fires: "connecting", "job N/M", "scanning todo label", "scanning leads" +- [x] `sync_all` IMAP connection error → caught, returned in `errors` list +- [x] `sync_all` per-job exception → other jobs still sync ## Config / UI -- [ ] Settings UI field for `todo_label` (currently YAML-only) -- [ ] Warn in sync summary when `todo_label` folder not found on server -- [ ] Clear error message when `config/email.yaml` is missing -- [ ] `test_email_classify.py --verbose` shows correct blocking phrase for each BLOCK +- [x] Settings UI field for `todo_label` (currently YAML-only) +- [x] Warn in sync summary when `todo_label` folder not found on server +- [x] Clear error message when `config/email.yaml` is missing +- [x] `test_email_classify.py --verbose` shows correct blocking phrase for each BLOCK ## Backlog — Known issues -- [ ] **The Ladders emails confuse the classifier** — promotional/job alert emails from `@theladders.com` are matching the recruitment keyword filter and being treated as leads. Fix: add a sender-based skip rule in `_scan_unmatched_leads` for known job board senders (similar to how LinkedIn Alert emails are short-circuited before the LLM classifier). Senders to exclude: `@theladders.com`, and audit for others (Glassdoor alerts, Indeed digest, ZipRecruiter, etc.). +- [x] **The Ladders emails confuse the classifier** — promotional/job alert emails from `@theladders.com` are matching the recruitment keyword filter and being treated as leads. Fix: add a sender-based skip rule in `_scan_unmatched_leads` for known job board senders (similar to how LinkedIn Alert emails are short-circuited before the LLM classifier). Senders to exclude: `@theladders.com`, and audit for others (Glassdoor alerts, Indeed digest, ZipRecruiter, etc.). --- ## Performance & edge cases -- [ ] Email with 10 000-char body → truncated to 4000 chars, no crash -- [ ] Email with binary attachment → `_parse_message` returns valid dict, no crash -- [ ] Email with multiple `text/plain` MIME parts → first part taken -- [ ] `get_all_message_ids` with 100 000 rows → completes in < 1s +- [x] Email with 10 000-char body → truncated to 4000 chars, no crash +- [x] Email with binary attachment → `_parse_message` returns valid dict, no crash +- [x] Email with multiple `text/plain` MIME parts → first part taken +- [x] `get_all_message_ids` with 100 000 rows → completes in < 1s