From 9143eb62628210bf6cf0790a1c8b6e1fbd31dfa6 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 21 Apr 2026 12:24:15 -0700 Subject: [PATCH] Session log: desertrat.com Mailprotector SBR repair + Syncro API corrections - Added desertrat.com to /etc/mailprotector_domains on Websvr (outbound SBR now active) - Created Mailprotector bulk user import CSV (38 desertrat.com accounts/forwarders) - Created Syncro ticket #32181 + invoice #67437 for Furrier (30 min remote, $81.53) - Corrected syncro.md skill doc: add_line_item for billing, remove_line_item to delete, charge_timer_entry to convert timers, comment DELETE impossible via API - Created clients/furrier/ with session log Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/syncro.md | 150 ++++++++++---- .../session-logs/2026-04-21-session.md | 195 ++++++++++++++++++ 2 files changed, 304 insertions(+), 41 deletions(-) create mode 100644 clients/furrier/session-logs/2026-04-21-session.md diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index 58d708d..a592b5d 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -11,7 +11,7 @@ Create, update, close, comment on, and bill tickets in Syncro PSA. /syncro update Update ticket status /syncro close Close/resolve a ticket /syncro comment Add a comment to a ticket -/syncro bill Create invoice from ticket time entries +/syncro bill Add billable time and create invoice /syncro search Search tickets by subject/customer /syncro customers Search customers ``` @@ -70,13 +70,20 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml; |---|---|---|---| | Add comment | POST | `/tickets//comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` | -**Comment fields:** -- `subject` — comment header (e.g., "Update", "Resolution", "Internal Note") -- `body` — comment text (HTML supported) -- `hidden` — if true, internal-only (customer can't see) -- `do_not_email` — if true, don't email customer about this comment +**Comment fields (verified):** +- `subject` — required; comment header (e.g., "Update", "Resolution", "Internal Note") +- `body` — required; comment text (HTML supported) +- `hidden` — bool; if true, internal-only (customer can't see) +- `do_not_email` — bool; if true, suppresses customer email notification +- `tech` — string; overrides the authenticated user's name shown on the comment -**WARNING:** The comment endpoint accepts but silently ignores `product_id`, `minutes_spent`, and `bill_time_now` fields — they are not saved. Verified 2026-04-20. Always use the timer_entry endpoint to log time. +**Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21. + +**CRITICAL — duplicate prevention:** The server has no idempotency. One POST = one comment, always. Duplicates are caused by calling the endpoint twice (retry after a perceived timeout, double tool invocation, etc.). **Never retry a POST /comment without first GET /tickets/{id} to confirm the comment did not already land.** The `Idempotency-Key` header is silently ignored. + +**Comments cannot be deleted via API.** No DELETE endpoint exists in the Syncro API for comments — confirmed against official swagger spec. Duplicate comments require manual removal in the GUI. + +**Do NOT wrap body in `{"comment": {...}}`** — returns 422 "Body can't be blank". POST flat JSON directly. #### Customers @@ -86,14 +93,59 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml; | Get customer | GET | `/customers/` | | Create customer | POST | `/customers` | -#### Timer Entries (add time to ticket) +#### Billable Line Items -| Operation | Method | Endpoint | Body | -|---|---|---|---| -| Add time | POST | `/tickets//timer_entry` | `{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": N}` | -| List timers | GET | `/ticket_timers?ticket_id=` | +Two verified ways to add billable time. Both produce ticket line items that transfer to invoices. -**IMPORTANT:** `product_id` must be a **labor product**, not an invoice product. Common labor products: +**Option A — Direct line item (simpler):** + +| Operation | Method | Endpoint | +|---|---|---| +| Add line item | POST | `/tickets//add_line_item` | +| Remove line item | POST | `/tickets//remove_line_item` | +| Update line item | PUT | `/tickets//update_line_item` | + +```bash +# Add +curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description"}' + +# Remove +curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"ticket_line_item_id": 12345}' +# Returns: {"success": true, "message": ""} +``` + +**Option B — Timer then charge (for time-tracking workflows):** + +```bash +# 1. Create timer entry +curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": 1190473}' + +# 2. Charge timer — sets recorded:true and creates linked line item +curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"timer_entry_id": N}' + +# Delete timer (if needed) +curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"timer_entry_id": N}' +# Returns: {"success": true} +``` + +**add_line_item required fields:** +- `name` — required (422 if missing) +- `description` — required (422 if missing) +- `product_id` — labor product ID (see list below) +- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour) +- `price_retail` — **only price field that saves**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00 + +**Labor product IDs:** - `1190473` — Labor - Remote Business (standard remote work) - `26118` — Labor - Onsite Business - `26184` — Labor - Emergency or After Hours Business @@ -102,22 +154,28 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml; - `26117` — Fee - Travel Time - `68055` — Labor - Website Labor +#### Timer Entries (time tracking reference) + +| Operation | Method | Endpoint | +|---|---|---| +| Add timer | POST | `/tickets//timer_entry` | +| Charge timer → line item | POST | `/tickets//charge_timer_entry` | +| Update timer | PUT | `/tickets//update_timer_entry` | +| Delete timer | POST | `/tickets//delete_timer_entry` | +| List timers | GET | `/ticket_timers?ticket_id=` | + #### Invoices | Operation | Method | Endpoint | Body | |---|---|---|---| -| List invoices | GET | `/invoices?per_page=25` | -| Get invoice | GET | `/invoices/` | +| List invoices | GET | `/invoices?per_page=25` | — | +| Get invoice | GET | `/invoices/` | — | | Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | | Delete invoice | DELETE | `/invoices/` | — | -**"Make Invoice" flow:** Timer entries on the ticket become invoice line items when you POST `/invoices` with the ticket_id. This is the equivalent of clicking "Make Invoice" in the GUI. +**"Make Invoice" flow:** `POST /invoices` pulls all `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included. -#### Invoice Line Items - -| Operation | Method | Endpoint | Body | -|---|---|---|---| -| Add line item | POST | `/invoices//line_items` | `{"item": "...", "quantity": 1, "price": 125.00, "product_id": N}` | +**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly. ### Display formatting @@ -135,41 +193,51 @@ When showing ticket detail, include: - Created date, due date, last updated - Assigned tech - Comments (most recent first, truncated to last 5) -- Time entries if any -- Billing status +- Line items / billing status ### Billing workflow -**ALWAYS ask the user for minutes and labor type before logging any time entry. Never assume a default.** -**ALWAYS show a preview of the ticket comment/notes to the user before posting. Wait for confirmation.** +**ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.** +**ALWAYS show a preview of the comment to the user before posting. Wait for confirmation.** When `/syncro bill ` is called: 1. Get ticket details 2. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" 3. Draft the comment body and show it to the user for review before posting -3. Add comment: `POST /tickets/{id}/comment` with work notes as body (no time fields — they are broken) -4. Add timer entry: `POST /tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable: true`, `product_id`, `notes` -5. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` -6. Update ticket status to "Invoiced" +4. Post comment: `POST /tickets/{id}/comment` +5. Add billable line item: `POST /tickets/{id}/add_line_item` with quantity in decimal hours, `price_retail`, `name`, `description` +6. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` +7. Verify invoice: `GET /invoices/{id}` to confirm line items transferred +8. Update ticket status to `Invoiced` -**Correct two-call pattern for comment + time:** +**Correct pattern:** ```bash -# Step 1: comment (notes only) -curl -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ +# Step 1: Post comment +curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": true}' + -d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": false}' -# Step 2: timer entry (billable time) — compute start_at as end_at minus minutes -NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -START=$(date -u -d "60 minutes ago" +"%Y-%m-%dT%H:%M:%SZ") -curl -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ +# Step 2: Add billable line item (convert minutes to decimal hours) +# 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc. +curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d "{\"start_at\": \"${START}\", \"end_at\": \"${NOW}\", \"notes\": \"...\", \"billable\": true, \"product_id\": 1190473}" + -d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "..."}' + +# Step 3: Create invoice +curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"ticket_id": '"${ID}"', "customer_id": '"${CUST}"', "category": "Standard"}' + +# Step 4: Verify line items transferred +curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | jq '.invoice.line_items' + +# Step 5: Mark ticket Invoiced +curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"status": "Invoiced"}' ``` -When `/syncro comment --time 60 --labor remote` is called: -- Post the comment first, then post a separate timer_entry -- `--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055 +`--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055 ### Error handling diff --git a/clients/furrier/session-logs/2026-04-21-session.md b/clients/furrier/session-logs/2026-04-21-session.md new file mode 100644 index 0000000..2ce7d97 --- /dev/null +++ b/clients/furrier/session-logs/2026-04-21-session.md @@ -0,0 +1,195 @@ +# Session Log: 2026-04-21 + +## User +- **User:** Mike Swanson (mike) +- **Machine:** DESKTOP-0O8A1RL +- **Role:** admin + +## Session Summary + +Diagnosed and resolved desertrat.com email routing issues reported by Mike Furrier. Also performed significant Syncro API research and corrections as a side effect of ticketing this work. + +--- + +## Client: Furrier (Mike Furrier / Western Tire / Desert Rat) + +**Syncro Customer ID:** 391491 +**Syncro Ticket:** #32181 (ID: 109263692) — "desertrat.com - Email / Mailprotector SBR Setup & Repair" +**Invoice:** #67437 (ID: 1650004395) — $75.00 labor + tax = $81.53 + +--- + +## Problem Report + +Mike Furrier reported that tim@desertrat.com was being rejected with: +``` +550 5.7.1 tim@desertrat.com is not allowed to send email on behalf of this domain due to a DMARC reject policy. +``` +Message was from tim@desertrat.com to desertrat64@desertrat.com. + +--- + +## DNS Analysis (desertrat.com) + +**DNS Host:** AWS Route 53 + +**DMARC:** +``` +v=DMARC1; p=reject; sp=reject; adkim=r; aspf=r; pct=100 +``` +Full enforcement, 100%. + +**SPF:** +``` +v=spf1 +a +mx +ip4:162.248.93.233 +ip4:162.248.93.81 +include:spf.wdsolutions.com +include:spf.us.emailservice.io -all +``` + +**MX:** +``` +priority 10 → desertrat-com.inbound.emailservice.io +priority 20 → desertrat-com.inbound.emailservice.cc +priority 30 → desertrat-com.inbound.emailservice.co +``` +emailservice.io is the Mailprotector spam filter front-end. + +**DKIM:** +`default._domainkey.desertrat.com` — key exists, published, signed by Websvr (cPanel default selector). + +--- + +## Infrastructure + +**Websvr (cPanel/WHM):** +- Host: websvr.acghosting.com +- External IP: 162.248.93.233 (verified from server — vault listed .81 as secondary) +- SSH: root / r3tr0gradE99# (port 22) +- WHM API Token: 8ZPYVM6R0RGOHII7EFF533MX6EQ17M7O +- OS: CentOS 7, WHM 11.110.0.95 +- SSH host key: SHA256:qcaW8BWq5UyM0l0g6DS9JfYbMZN/LTXLs3BIEZV8BE0 + +**plink command for SSH:** +```bash +plink -ssh -pw "r3tr0gradE99#" -hostkey "SHA256:qcaW8BWq5UyM0l0g6DS9JfYbMZN/LTXLs3BIEZV8BE0" root@websvr.acghosting.com -batch "" +``` + +**cPanel account:** desertra +**Domain:** desertrat.com + +--- + +## Root Cause Analysis + +1. **tim@desertrat.com is a forwarder, not a mailbox** — exists in `/etc/valiases/desertrat.com` forwarding to timfurrier@gmail.com. Mike had checked cPanel accounts (wrong place to look). + +2. **Mailprotector SBR was unconfigured** — exim had the `mailprotector_smarthost` router configured to route outbound through `{domain}.outbound.emailservice.io`, but `/etc/mailprotector_domains` was empty. desertrat.com was never enrolled. + +3. **Mail flow was broken** — without SBR enrollment, outbound forwarded mail from Websvr went direct (not through emailservice.io). emailservice.io is authorized in SPF; direct Websvr sends are also authorized (Websvr IPs in SPF), so SPF technically passes, but the DMARC issue is Tim replying from Gmail. + +4. **Tim's DMARC rejection** — Tim receives forwarded mail at timfurrier@gmail.com and replies using tim@desertrat.com as From. Gmail's servers are not in desertrat.com's SPF → DMARC p=reject → rejected by emailservice.io on inbound. + +--- + +## Fix Applied + +Added `desertrat.com` to `/etc/mailprotector_domains` on Websvr: +```bash +echo 'desertrat.com' >> /etc/mailprotector_domains +``` + +Verified outbound routing in exim log: +``` +R=mailprotector_smarthost T=mailprotector_relay +H=desertrat-com.outbound.emailservice.io +C="250 2.0.0 Ok: queued as 69DF27E284" +``` + +No exim restart required — file is checked at runtime via lsearch lookup. + +--- + +## Mailprotector User Import + +Created bulk user import CSV for Mailprotector at: +`C:\Users\guru\Downloads\desertrat_mailprotector_import.csv` + +38 entries covering all desertrat.com mailboxes and forwarders from `/etc/valiases/desertrat.com` and `/home/desertra/mail/desertrat.com/`. + +Format: `Username,First Name,Last Name,Password,Secondary Email,Phone,Primary Username` + +Aliases (Primary Username set): +- desertrat60 → store60 +- desertrat60r → store60r +- desertrat62 → store62 +- desertrat64 → store64 +- desertat64 → store64 (typo address, included as it exists) +- jobs → tim + +--- + +## Outstanding Items + +1. **Tim sending via Gmail** — DMARC p=reject will continue to block Tim replying from Gmail as tim@desertrat.com. Fix: Tim configures Gmail "Send mail as" with Websvr SMTP: + - SMTP Server: mail.desertrat.com + - Port: 587 (STARTTLS) or 465 (SSL) + - Username: tim@desertrat.com + - Password: Tim's cPanel email password (reset via WHM if needed) + +2. **WebShop / DKIM** — DKIM already active on Websvr (`default._domainkey.desertrat.com`). No WebShop action needed for DKIM unless they need their own selector for their outbound. + +3. **Mailprotector user sync** — CSV delivered to Mike for manual import into Mailprotector admin. No automated sync available (emailservice.io only offers AD/365/Google as sync sources). + +4. **WebShop "extra code"** — Likely a DKIM record they wanted added to Route 53. Since Websvr's DKIM is already in DNS and active, this may be moot. Confirm with WebShop. + +--- + +## Syncro Ticket Details + +- **Ticket #32181** — created, comment posted, 30 min remote labor billed +- **Invoice #67437** — $75.00 + tax = $81.53, status: Invoiced +- Ticket status: Invoiced + +--- + +## Syncro API Corrections (side work this session) + +Significant research was done to fix incorrect skill documentation. All findings validated against official swagger spec at `https://api-docs.syncromsp.com/swagger.json` and live-tested on ACG client (ID: 15353550). + +### Correct billing flow +**Wrong (old):** `POST /tickets/{id}/timer_entry` — timer entries do NOT become invoice line items. + +**Correct:** `POST /tickets/{id}/add_line_item` with: +- `name` (required) +- `description` (required) +- `product_id` +- `quantity` (decimal hours) +- `price_retail` — ONLY price field that saves; all other names (`price`, `rate`, `retail_price`) silently ignored + +### Correct line item removal +**Wrong (old):** `DELETE /tickets/{id}/line_items/{id}` — returns 404, does nothing. + +**Correct:** `POST /tickets/{id}/remove_line_item` with `{"ticket_line_item_id": N}` — returns `{"success": true}` + +### Timer operations (correct endpoints) +- Delete timer: `POST /tickets/{id}/delete_timer_entry` with `{"timer_entry_id": N}` +- Charge timer → line item: `POST /tickets/{id}/charge_timer_entry` with `{"timer_entry_id": N}` + +### Comment DELETE +**Not possible via API.** No DELETE endpoint for comments exists in the Syncro swagger spec. Duplicate comments require manual GUI removal (ask Winter). + +### Duplicate comment prevention +Server has no idempotency. Never retry `POST /comment` without first `GET /tickets/{id}` to verify the comment didn't already land. + +### Invoice line item DELETE +`DELETE /invoices/{id}/line_items/{line_item_id}` — **works** (returns HTTP 200). + +### Skill doc updated +`.claude/commands/syncro.md` — fully rewritten billing section with correct endpoints. + +--- + +## Files Modified + +- `/etc/mailprotector_domains` on websvr.acghosting.com — added desertrat.com +- `C:\Users\guru\Downloads\desertrat_mailprotector_import.csv` — created +- `D:\claudetools\.claude\commands\syncro.md` — Syncro skill doc corrected +- `D:\claudetools\clients\furrier\session-logs\2026-04-21-session.md` — this file