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 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 12:24:15 -07:00
parent db4e3c25a5
commit 9143eb6262
2 changed files with 304 additions and 41 deletions

View File

@@ -11,7 +11,7 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
/syncro update <number> <status> Update ticket status /syncro update <number> <status> Update ticket status
/syncro close <number> Close/resolve a ticket /syncro close <number> Close/resolve a ticket
/syncro comment <number> <text> Add a comment to a ticket /syncro comment <number> <text> Add a comment to a ticket
/syncro bill <number> Create invoice from ticket time entries /syncro bill <number> Add billable time and create invoice
/syncro search <query> Search tickets by subject/customer /syncro search <query> Search tickets by subject/customer
/syncro customers <query> Search customers /syncro customers <query> 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/<id>/comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` | | Add comment | POST | `/tickets/<id>/comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` |
**Comment fields:** **Comment fields (verified):**
- `subject` — comment header (e.g., "Update", "Resolution", "Internal Note") - `subject` required; comment header (e.g., "Update", "Resolution", "Internal Note")
- `body` — comment text (HTML supported) - `body` required; comment text (HTML supported)
- `hidden` — if true, internal-only (customer can't see) - `hidden` bool; if true, internal-only (customer can't see)
- `do_not_email` — if true, don't email customer about this comment - `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 #### 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/<id>` | | Get customer | GET | `/customers/<id>` |
| Create customer | POST | `/customers` | | Create customer | POST | `/customers` |
#### Timer Entries (add time to ticket) #### Billable Line Items
| Operation | Method | Endpoint | Body | Two verified ways to add billable time. Both produce ticket line items that transfer to invoices.
|---|---|---|---|
| Add time | POST | `/tickets/<id>/timer_entry` | `{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": N}` |
| List timers | GET | `/ticket_timers?ticket_id=<id>` |
**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/<id>/add_line_item` |
| Remove line item | POST | `/tickets/<id>/remove_line_item` |
| Update line item | PUT | `/tickets/<id>/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) - `1190473` — Labor - Remote Business (standard remote work)
- `26118` — Labor - Onsite Business - `26118` — Labor - Onsite Business
- `26184` — Labor - Emergency or After Hours 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 - `26117` — Fee - Travel Time
- `68055` — Labor - Website Labor - `68055` — Labor - Website Labor
#### Timer Entries (time tracking reference)
| Operation | Method | Endpoint |
|---|---|---|
| Add timer | POST | `/tickets/<id>/timer_entry` |
| Charge timer → line item | POST | `/tickets/<id>/charge_timer_entry` |
| Update timer | PUT | `/tickets/<id>/update_timer_entry` |
| Delete timer | POST | `/tickets/<id>/delete_timer_entry` |
| List timers | GET | `/ticket_timers?ticket_id=<id>` |
#### Invoices #### Invoices
| Operation | Method | Endpoint | Body | | Operation | Method | Endpoint | Body |
|---|---|---|---| |---|---|---|---|
| List invoices | GET | `/invoices?per_page=25` | | List invoices | GET | `/invoices?per_page=25` | — |
| Get invoice | GET | `/invoices/<id>` | | Get invoice | GET | `/invoices/<id>` | — |
| Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | | Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` |
| Delete invoice | DELETE | `/invoices/<id>` | — | | Delete invoice | DELETE | `/invoices/<id>` | — |
**"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 **Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| Add line item | POST | `/invoices/<id>/line_items` | `{"item": "...", "quantity": 1, "price": 125.00, "product_id": N}` |
### Display formatting ### Display formatting
@@ -135,41 +193,51 @@ When showing ticket detail, include:
- Created date, due date, last updated - Created date, due date, last updated
- Assigned tech - Assigned tech
- Comments (most recent first, truncated to last 5) - Comments (most recent first, truncated to last 5)
- Time entries if any - Line items / billing status
- Billing status
### Billing workflow ### Billing workflow
**ALWAYS ask the user for minutes and labor type before logging any time entry. Never assume a default.** **ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.**
**ALWAYS show a preview of the ticket comment/notes to the user before posting. Wait for confirmation.** **ALWAYS show a preview of the comment to the user before posting. Wait for confirmation.**
When `/syncro bill <number>` is called: When `/syncro bill <number>` is called:
1. Get ticket details 1. Get ticket details
2. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" 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. 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. Post comment: `POST /tickets/{id}/comment`
4. Add timer entry: `POST /tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable: true`, `product_id`, `notes` 5. Add billable line item: `POST /tickets/{id}/add_line_item` with quantity in decimal hours, `price_retail`, `name`, `description`
5. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` 6. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
6. Update ticket status to "Invoiced" 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 ```bash
# Step 1: comment (notes only) # Step 1: Post comment
curl -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -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 # Step 2: Add billable line item (convert minutes to decimal hours)
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc.
START=$(date -u -d "60 minutes ago" +"%Y-%m-%dT%H:%M:%SZ") curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
curl -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -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 <number> <text> --time 60 --labor remote` is called: `--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055
- 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
### Error handling ### Error handling

View File

@@ -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 "<command>"
```
**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