Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
@@ -0,0 +1,824 @@
# Ticket-Centric Spam Cleanup Handoff — 2026-07-09
## Purpose
This handoff captures the current state of the new ticket-centric Redmine maintenance substrate and the spam cleanup workflow for the `customer-service` project.
The immediate business goal is to reduce visible spam clutter in `customer-service` without disrupting existing NMI production scripts or doing unsafe bulk Redmine writes.
## Important Safety / Operating Rules
1. **Do not modify NMI scripts right now.** Existing NMI cron scripts are reference material only.
2. **Dry-run first.** Redmine mutations require explicit `--apply`.
3. **Reads do not impersonate.** Ordinary sync/search/dry-run planning should use normal API access.
4. **Writes impersonate `rebot`.** Spam cleanup / ticket maintenance writes should use `X-Redmine-Switch-User: rebot` (Harry Botter), because Redmine activity logs should show automated cleanup under that account, not Jason.
5. **Treat impersonation like sudo.** Use it only where the write is supposed to happen under that agent account.
6. **LAN first.** The validated target so far is the LAN Redmine copy at `http://192.168.50.170`, project `customer-service`.
7. **Confirm status IDs before applying.** Existing evidence suggests `status_id=5` is closed, but verify before first real apply if uncertain.
## Relevant Files
- Tool: `/home/jason/projects/redmine/redmine_tickets.py`
- Tests: `/home/jason/projects/redmine/tests/test_redmine_tickets.py`
- Plan: `/home/jason/projects/redmine/docs/ticket_sync_tool_plan.md`
- General project handoff: `/home/jason/projects/redmine/HANDOFF.md`
- Helpdesk API include docs: `/home/jason/projects/redmine/docs/redmine_issue_api_helpdesk_include.md`
- Existing contact tool: `/home/jason/projects/redmine/redmine_contacts.py`
## Why `redmine_tickets.py` Exists
The earlier `redmine_contacts.py` contact conveyor is useful for contact enrichment and contact-level routing, but it is not the best first tool for clearing visible spam from `customer-service` because the UI pain is ticket-level: the top/open tickets themselves are clutter.
`redmine_tickets.py` is the new ticket-centric substrate. It syncs enriched issue payloads, searches cached tickets, and runs idempotent ticket actions.
## Modern Helpdesk Access
Do **not** recreate RebotSlick `PullHelpdeskInfo` behavior. The old separate helpdesk pull/cache pattern is obsolete because the local Redmine Helpdesk plugin now supports:
```text
GET /issues/:id.json?include=journals,helpdesk
```
This returns normal Redmine issue data plus embedded `helpdesk_ticket` metadata, including contact/email/source fields.
## Current Architecture
### Ticket payload cache
Per-ticket JSON files are stored under a configurable cache directory, e.g.:
```text
.cache/redmine_tickets_lan/customer-service/41788.json
.cache/redmine_tickets_lan/customer-service/41789.json
```
### Ticket/action state
State is currently manifest-backed:
```text
.cache/redmine_tickets_lan/manifest.json
```
But the code now separates payload cache from state storage:
- `TicketCache` handles ticket JSON cache + delegates state operations.
- `ManifestTicketStateStore` is the current state backend.
- The abstraction is intentionally small so SQLite can replace the manifest later for thousands of tickets.
Required state-store methods:
```python
get_ticket_state(issue_id)
upsert_ticket_state(issue_id, fields)
has_action(issue_id, action_name)
record_action(issue_id, action_name, result, metadata=None)
```
## Implemented Commands
### Sync enriched tickets
```bash
cd /home/jason/projects/redmine
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
sync \
--status-id open \
--max-tickets 10 \
--order newest-updated-first
```
Behavior:
- fetches issue stubs/pages from Redmine
- fetches full issue payloads with `include=journals,helpdesk`
- writes only missing/updated tickets to cache
- second identical run skips unchanged tickets
Verified output:
```text
Sync complete: seen=10 fetched=10 skipped_cached=0 written=10
Sync complete: seen=10 fetched=0 skipped_cached=10 written=0
```
### Search cached tickets
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
search \
--max-tickets 10 \
--order newest-updated-first
```
Validated that this matches the UI top-open-ticket pattern and includes spam-looking tickets such as:
```text
#41788 Up to 20% Off Select Fans & Blowers
#41789 Up to 20% Off Select Fans & Blowers
```
### Spam check cached tickets
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
spam-check
```
Validated result:
```text
#41788 SPAM score=2 from=reply@globalindustrial.com Up to 20% Off Select Fans & Blowers
#41789 SPAM score=2 from=reply@globalindustrial.com Up to 20% Off Select Fans & Blowers
```
Record local idempotency only (no Redmine mutation):
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
spam-check \
--apply
```
After this, a second `spam-check` checks 0 tickets because `spam-check` action markers are recorded.
### Plan closure for spam tickets
Dry-run:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10
```
Validated output:
```text
#41788 close_payload={"notes": "Closed as likely spam by redmine_tickets.py close-if-spam.", "status_id": 5} contact_id=621 contact_spam=4/10 likely_spammer=True Up to 20% Off Select Fans & Blowers
#41789 close_payload={"notes": "Closed as likely spam by redmine_tickets.py close-if-spam.", "status_id": 5} contact_id=621 contact_spam=4/10 likely_spammer=True Up to 20% Off Select Fans & Blowers
Close-if-spam complete: planned=2 apply=False
Dry run only. Re-run with --apply to close Redmine issues and record idempotency markers.
```
No Redmine mutation has been applied yet.
## Actual Closure Command (Pending Approval)
After confirming `status_id=5` is the correct closed status on the LAN copy, this is the command that would close the two planned tickets and record idempotency markers:
```bash
cd /home/jason/projects/redmine
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Expected write behavior:
- sends `PUT /issues/:id.json`
- payload:
```json
{
"issue": {
"status_id": 5,
"notes": "Closed as likely spam by redmine_tickets.py close-if-spam."
}
}
```
- includes header:
```text
X-Redmine-Switch-User: rebot
```
- records local actions:
- `close-if-spam = closed`
- `contact-spam-review = likely-spammer` or `single-ticket-spam`
## Tests / Verification
Current test command:
```bash
python3 -m unittest discover -s tests -p 'test_redmine_tickets.py' -v
```
Last verified:
```text
Ran 11 tests
OK
```
Compile command:
```bash
python3 -m py_compile redmine_tickets.py tests/test_redmine_tickets.py redmine_contacts.py
```
Last verified: OK.
## 25-Ticket LAN Evaluation — 2026-07-09
Jason confirmed `status_id=5` is Closed. A fresh throwaway cache was used so old action markers would not hide results:
```text
.cache/redmine_tickets_lan_25_eval
```
Commands run:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_eval \
sync \
--status-id open \
--max-tickets 25 \
--order newest-updated-first
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan_25_eval \
--project customer-service \
spam-check \
--apply
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_eval \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10
```
Verified output summary:
```text
Sync complete: seen=25 fetched=25 skipped_cached=0 written=25
Spam check complete: checked=25 apply=True
Close-if-spam complete: planned=5 apply=False
SUMMARY cached_tickets=25
SUMMARY spam=5 not_spam=20
```
Closure candidates produced by dry-run:
```text
#41753 Automated unfulfillable FBA inventory removal notification — from fba-noreply@amazon.com — contact_id=51 — contact_spam=10/10 — likely_spammer=True
#41760 Celebrate America's 250th: Save Up To 40% Off — from reply@globalindustrial.com — contact_id=621 — contact_spam=4/10 — likely_spammer=True
#41761 Celebrate America's 250th: Save Up To 40% Off — from reply@globalindustrial.com — contact_id=621 — contact_spam=4/10 — likely_spammer=True
#41767 — — from enews-noreply@qnap.com — contact_id=3162 — contact_spam=10/10 — likely_spammer=True
#41776 Your favorite sale ENDS THURSDAY ⏳ — from noreply@mail.vistaprint.com — contact_id=3795 — contact_spam=10/10 — likely_spammer=True
```
Important finding from first run: the scorer conflated **spam/promotional clutter** with **automated operational notifications**. Example: `#41753` from `fba-noreply@amazon.com` is likely an automated Amazon/FBA operational notification, not normal spam.
No Redmine close mutation was applied during this 25-ticket evaluation.
## Refined Promotional-Junk Evaluation — 2026-07-09
The scorer was refined to target Jason's immediate goal: quickly and reliably close **obvious unsolicited promotional/sales junk**, such as Walmart/Uline/GlobalIndustrial/Vistaprint/QNAP-style promotional mail and lead-gen/sales outreach from people trying to sell LDR something without an established relationship.
False-positive protections added:
- `automated-operational`: Amazon/FBA/order/API/payment-style notifications are excluded from the default auto-close class.
- Customer/vendor operational threads are protected from staff-reply contamination: LDR staff-authored journal notes are no longer scored as spam evidence.
- Standalone `SEO` in customer operational context is not treated as a sales trigger.
Regression tests now cover:
- promotional sale email => `spam-promotional`
- cleaning-service sales pitch => `spam-promotional`
- Amazon/FBA notification => `automated-operational`, not spam
- real FBA returns inquiry with LDR pricing reply => not spam
- customer operational context mentioning SEO => not spam
Fresh final cache:
```text
.cache/redmine_tickets_lan_25_promotional_eval_final_1783600119
```
Final verified summary:
```text
Sync complete: seen=25 fetched=25 skipped_cached=0 written=25
Close-if-spam complete: planned=6 apply=False
SUMMARY categories={'unknown-review': 17, 'automated-operational': 2, 'spam-promotional': 6}
```
Current safe close candidates from the final dry-run:
```text
#41767 — from enews-noreply@qnap.com — QNAP newsletter/promo
#41776 Your favorite sale ENDS THURSDAY ⏳ — from noreply@mail.vistaprint.com
#41788 Up to 20% Off Select Fans & Blowers — from reply@globalindustrial.com
#41789 Up to 20% Off Select Fans & Blowers — from reply@globalindustrial.com
#41804 Do you serve e-commerce brands? — from d.elliott@fulfillmentviewpro.com — 3PL lead-gen/sales pitch
#41834 Japan’s “JC-STAR Level 1” — from enews-noreply@qnap.com — QNAP promotional/newsletter item
```
No Redmine close mutation was applied during this final evaluation.
### LAN apply verification — 2026-07-09
Jason approved moving toward closing the obvious junk. The following command was run against the final 25-ticket cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_promotional_eval_final_1783600119 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41767
#41776
#41788
#41789
#41804
#41834
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41767 status=Closed close_note_author=Harry B. close_note_found=True
41776 status=Closed close_note_author=Harry B. close_note_found=True
41788 status=Closed close_note_author=Harry B. close_note_found=True
41789 status=Closed close_note_author=Harry B. close_note_found=True
41804 status=Closed close_note_author=Harry B. close_note_found=True
41834 status=Closed close_note_author=Harry B. close_note_found=True
```
## Current Limitations / Known Next Improvements
1. `manifest.json` is acceptable for the current tracer, but SQLite should be considered once caches/actions cover thousands of tickets.
2. Spam scoring is still intentionally simple and reviewable. It now distinguishes `spam-promotional`, `automated-operational`, and `unknown-review`; future classes can be added as patterns emerge.
3. Contact-level spammer handling is only advisory right now. The tool reports `likely_spammer=True`, but does not yet tag the contact.
4. First real close apply for each newly expanded batch should happen on LAN only, then immediately re-sync/verify status and activity author.
5. The production Redmine path should remain out of scope until LAN write behavior is proven and reviewed.
## 50-Ticket Refined Promotional-Junk Dry Run — 2026-07-09
Fresh cache:
```text
.cache/redmine_tickets_lan_50_promotional_eval_refined_1783600628
```
Verified summary:
```text
Sync complete: seen=50 fetched=50 skipped_cached=0 written=50
Close-if-spam complete: planned=9 apply=False
SUMMARY categories={'unknown-review': 32, 'spam-promotional': 9, 'automated-operational': 9}
```
Promotional close candidates:
```text
#41685 Samsung promo — noreply_samsung@sea.samsung.com — "Jason, we’ve missed you. We just can't stop innovating."
#41687 QNAP promo — enews-noreply@qnap.com — "Qsync Now Supports File Backup on Mac"
#41698 Vistaprint promo — noreply@mail.vistaprint.com — "Life’s best moments, treasured forever"
#41705 Popmenu/restaurant promo — noreply@popmenu.com — "Take a break from cooking!"
#41722 3PL lead-gen/sales pitch — hello@offer.search3pl.com — "Live fulfilment leads waiting for you to connect"
#41738 Vistaprint promo — noreply@mail.vistaprint.com — "Up to 15% off top business products that’ll draw them in"
#41746 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com — "Hi LDR, here are our upcoming events and nominations"
#41760 Global Industrial sale — reply@globalindustrial.com — "Celebrate America's 250th: Save Up To 40% Off"
#41761 Global Industrial sale — reply@globalindustrial.com — "Celebrate America's 250th: Save Up To 40% Off"
```
Operational/system notifications excluded from auto-close:
```text
#41694 USPS eReceipt
#41704 2024 UNPAID INVOICES $3,413.55
#41714 Undelivered Mail Returned to Sender
#41716 Critical security alert
#41726 UPS Invoice Now Available
#41753 Automated unfulfillable FBA inventory removal notification
#41765 Amazon Listings API changes
#41772 Cintas Payment Status
#41823 Amazon order confirmation
```
No Redmine mutation was applied during this 50-ticket dry run.
### 50-ticket LAN apply verification — 2026-07-09
Jason approved applying the 9-candidate promotional set. The following command was run against the exact reviewed cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_50_promotional_eval_refined_1783600628 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41685
#41687
#41698
#41705
#41722
#41738
#41746
#41760
#41761
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41685 status=Closed close_note_author=Harry B. close_note_found=True
41687 status=Closed close_note_author=Harry B. close_note_found=True
41698 status=Closed close_note_author=Harry B. close_note_found=True
41705 status=Closed close_note_author=Harry B. close_note_found=True
41722 status=Closed close_note_author=Harry B. close_note_found=True
41738 status=Closed close_note_author=Harry B. close_note_found=True
41746 status=Closed close_note_author=Harry B. close_note_found=True
41760 status=Closed close_note_author=Harry B. close_note_found=True
41761 status=Closed close_note_author=Harry B. close_note_found=True
```
## Recommended Next Session Steps
1. If Jason approves the current 27-candidate production 200-ticket set, apply closures on production using `--impersonate-user rebot` and the exact cache `.cache/redmine_tickets_prod_200_promotional_eval_1783612885`.
2. Optionally apply the 3 `maybe-customer` contact tags from the same cache.
3. Re-sync/inspect via Redmine API/UI and verify closed status, close-note author `Harry B.`, second-apply idempotency, and local action markers.
4. Continue with another bounded production dry-run batch.
## Customer identification update — 2026-07-09
Jason confirmed the following contacts/patterns are customers and should be tagged `Customer`:
```text
koki-takeda@shinkiya-inc.com
hello@bohoflow.co
hi5@bavariatrachten.com
maia@solride.org
mike@solride.org
info@u2wear.com
sales@u2wear.com
any Cyberstorm email
Esire-family emails
```
Important correction: do **not** treat incidental substrings such as `desiree` in Indeed/applicant email aliases as Esire-family customer matches.
Production contact tagging was applied with `impersonate_user='rebot'`:
```text
MATCHED_COUNT 31
UPDATED_COUNT 25
SKIPPED_ALREADY_CUSTOMER 6
```
Two accidental applicant matches were corrected immediately:
```text
4253 conversation-desireethrasher-customerreturnsinspect-0n3@indeedemail.com: removed Customer, left applicant
3934 conversation-desireethrasher-customerreturnsinspect-9nv@indeedemail.com: removed Customer, left applicant
```
After customer tagging, re-running the production 200 close plan still produced 27 candidates and kept `#41238` removed from the plan.
## Customer-tag safety gate — 2026-07-09
New hard rule: before closing any ticket as spam, `redmine_tickets.py` fetches the Helpdesk contact and checks `tag_list`. If the contact has tag `customer`, the ticket is **not** closed as spam. Absence of the `customer` tag does **not** imply spam.
Jason also clarified the closure risk model:
- Closed tickets are not deleted.
- If the other party replies to a closed Helpdesk ticket, Redmine reopens it as `New Message`.
- Therefore, stale 30+ day tickets that match a verified `spam-promotional` profile are reasonable fast-close candidates once contact safety gates pass.
- However, if a ticket was previously closed and then reopened because the contact messaged again, do **not** auto-close it again. Escalate instead: bump priority and optionally set a due date 1–3 days out.
A separate action now exists to tag contacts needing manual/customer review:
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir <cache> \
tag-maybe-customer [--apply]
```
This adds `maybe-customer` to contacts with inbound/established-business inquiry evidence, using `--impersonate-user rebot` for writes.
Tests now cover 20 cases, including the customer-tag skip and `maybe-customer` tag action.
## Production 200-Ticket Dry Run With Customer Gate — 2026-07-09
Production cache:
```text
.cache/redmine_tickets_prod_200_promotional_eval_1783612885
```
Re-running the close plan after adding the contact `customer` safety gate produced:
```text
Close-if-spam complete: planned=27 apply=False
```
The customer-tag gate removed `#41238` from the close plan, which was the suspicious `manager@sonisha.com` / `Account Queries` candidate.
Current 27 close candidates:
```text
#41098
#41114
#41121
#41122
#41127
#41128
#41153
#41164
#41167
#41168
#41201
#41222
#41226
#41227
#41242
#41248
#41269
#41293
#41297
#41315
#41331
#41340
#41341
#41347
#41368
#41373
#41850
```
Maybe-customer dry-run produced 3 contact tag candidates:
```text
contact_id=4470 source_issue_id=41424 tag_list=["maybe-customer"]
contact_id=3919 source_issue_id=41448 tag_list=["maybe-customer"]
contact_id=4476 source_issue_id=41504 tag_list=["maybe-customer"]
```
No production mutation was applied after adding this guard.
## Production 100-Ticket Promotional-Junk Dry Run — 2026-07-09
Production URL:
```text
https://rm.ldrprep.com
```
Fresh cache:
```text
.cache/redmine_tickets_prod_100_promotional_eval_1783609390
```
Verified summary:
```text
Sync complete: seen=100 fetched=100 skipped_cached=0 written=100
Close-if-spam complete: planned=20 apply=False
SUMMARY categories={'unknown-review': 59, 'automated-operational': 21, 'spam-promotional': 20}
```
Promotional close candidates:
```text
#41397 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com
#41430 Vistaprint sale — noreply@mail.vistaprint.com
#41431 Business Summit promo — replycincinnati@partner.bizjournals.com
#41657 Global Industrial Kee Safety promo — reply@globalindustrial.com
#41658 Global Industrial Kee Safety promo — reply@globalindustrial.com
#41685 Samsung promo — noreply_samsung@sea.samsung.com
#41687 QNAP promo — enews-noreply@qnap.com
#41698 Vistaprint promo — noreply@mail.vistaprint.com
#41705 Popmenu restaurant promo — noreply@popmenu.com
#41722 3PL lead-gen sales pitch — hello@offer.search3pl.com
#41738 Vistaprint promo — noreply@mail.vistaprint.com
#41746 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com
#41760 Global Industrial sale — reply@globalindustrial.com
#41761 Global Industrial sale — reply@globalindustrial.com
#41767 QNAP newsletter/promo — enews-noreply@qnap.com
#41776 Vistaprint sale — noreply@mail.vistaprint.com
#41788 Global Industrial sale — reply@globalindustrial.com
#41789 Global Industrial sale — reply@globalindustrial.com
#41804 3PL lead-gen sales pitch — d.elliott@fulfillmentviewpro.com
#41834 QNAP promotional/newsletter item — enews-noreply@qnap.com
```
No production mutation was applied during this dry run.
### Production apply verification — 2026-07-09
Jason approved applying the 20-candidate promotional set. The following command was run against the exact reviewed production cache:
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_100_promotional_eval_1783609390 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41397
#41430
#41431
#41657
#41658
#41685
#41687
#41698
#41705
#41722
#41738
#41746
#41760
#41761
#41767
#41776
#41788
#41789
#41804
#41834
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41397 status=Closed close_note_author=Harry B. close_note_found=True
41430 status=Closed close_note_author=Harry B. close_note_found=True
41431 status=Closed close_note_author=Harry B. close_note_found=True
41657 status=Closed close_note_author=Harry B. close_note_found=True
41658 status=Closed close_note_author=Harry B. close_note_found=True
41685 status=Closed close_note_author=Harry B. close_note_found=True
41687 status=Closed close_note_author=Harry B. close_note_found=True
41698 status=Closed close_note_author=Harry B. close_note_found=True
41705 status=Closed close_note_author=Harry B. close_note_found=True
41722 status=Closed close_note_author=Harry B. close_note_found=True
41738 status=Closed close_note_author=Harry B. close_note_found=True
41746 status=Closed close_note_author=Harry B. close_note_found=True
41760 status=Closed close_note_author=Harry B. close_note_found=True
41761 status=Closed close_note_author=Harry B. close_note_found=True
41767 status=Closed close_note_author=Harry B. close_note_found=True
41776 status=Closed close_note_author=Harry B. close_note_found=True
41788 status=Closed close_note_author=Harry B. close_note_found=True
41789 status=Closed close_note_author=Harry B. close_note_found=True
41804 status=Closed close_note_author=Harry B. close_note_found=True
41834 status=Closed close_note_author=Harry B. close_note_found=True
```
## 100-Ticket Refined Promotional-Junk Dry Run — 2026-07-09
Fresh cache:
```text
.cache/redmine_tickets_lan_100_promotional_eval_refined_1783601115
```
Verified summary:
```text
Sync complete: seen=100 fetched=100 skipped_cached=0 written=100
Close-if-spam complete: planned=9 apply=False
SUMMARY categories={'unknown-review': 68, 'spam-promotional': 9, 'automated-operational': 23}
```
Promotional close candidates:
```text
#41227 Global Industrial clearance — reply@globalindustrial.com — "Clearance: Bestsellers At Best Prices"
#41297 Vistaprint sale — noreply@mail.vistaprint.com — "Up to 30% off select customer faves, like mugs, postcards & more"
#41340 Global Industrial AC/fans — reply@globalindustrial.com — "Advanced AC, Wall Fans, And Cooling Units"
#41373 TikTok Shop promo — taylor@shop.tiktok.com — "The simplest way to start selling this June"
#41397 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com — "Hi LDR, here are our upcoming events and nominations"
#41430 Vistaprint sale — noreply@mail.vistaprint.com — "Email exclusive! Up to 40% off select top products ENDS TOMORROW"
#41431 Business Summit promo — replycincinnati@partner.bizjournals.com — "Regional Business Summit Returns August 5"
#41657 Global Industrial Kee Safety promo — reply@globalindustrial.com — "'Kee Safety' Modular & Durable Fall Prevention Equipment"
#41658 Global Industrial Kee Safety promo — reply@globalindustrial.com — "'Kee Safety' Modular & Durable Fall Prevention Equipment"
```
False-positive protections added/verified during this 100-ticket run:
- VoIP voicemail / call transcription tickets are `automated-operational`, not promotional spam.
- Inbound buying/relationship inquiries like RePackify Gaylord purchase interest and u2wear returns-provider follow-up remain `unknown-review`, not auto-close.
- Receipts, bounces, security alerts, invoices, Amazon/FBA/order notices remain `automated-operational`, not auto-close.
No Redmine mutation was applied during this 100-ticket dry run.
### 100-ticket LAN apply verification — 2026-07-09
Jason approved applying the 9-candidate promotional set. The following command was run against the exact reviewed cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_100_promotional_eval_refined_1783601115 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41227
#41297
#41340
#41373
#41397
#41430
#41431
#41657
#41658
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41227 status=Closed close_note_author=Harry B. close_note_found=True
41297 status=Closed close_note_author=Harry B. close_note_found=True
41340 status=Closed close_note_author=Harry B. close_note_found=True
41373 status=Closed close_note_author=Harry B. close_note_found=True
41397 status=Closed close_note_author=Harry B. close_note_found=True
41430 status=Closed close_note_author=Harry B. close_note_found=True
41431 status=Closed close_note_author=Harry B. close_note_found=True
41657 status=Closed close_note_author=Harry B. close_note_found=True
41658 status=Closed close_note_author=Harry B. close_note_found=True
```