diff --git a/docs/superpowers/plans/2026-07-03-per-account-folder-mapping-and-exclusion.md b/docs/superpowers/plans/2026-07-03-per-account-folder-mapping-and-exclusion.md new file mode 100644 index 0000000..2c06815 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-per-account-folder-mapping-and-exclusion.md @@ -0,0 +1,873 @@ +# Per-account Folder Mapping & Exclusion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every account its own source→destination folder mapping and let the operator exclude folders from the copy, editable per account via a "folders" button. + +**Architecture:** Move folder config from `tasks.folder_mapping` to two new per-account JSONB columns (`folder_mapping`, `excluded_folders`), migrating existing task data onto accounts. The orchestrator builds each account's copy plan from its own config via a pure, unit-tested `planFolders` helper. New HTTP endpoints re-probe an existing account (server-side password decrypt) and persist its mapping. The React `FolderMappingModal` gains per-folder sync checkboxes; a per-row "folders" button opens it in edit mode. + +**Tech Stack:** Go 1.x (net/http, pgx v5, golang-migrate), Postgres 18, React 19 + Vite + TypeScript. + +## Global Constraints + +- Non-destructive: never drop `tasks.folder_mapping`; migration copies out of it. +- Default behavior must stay backward-compatible: empty mapping + empty exclusions = sync every folder name-matched. +- New source folders (not in an account's config) default to **synced**, never silently excluded. +- pgx v5 auto-(de)serializes JSONB into Go `map[string]string` / `[]string` — scan/pass the Go value directly (see `store/tasks.go` `FolderMapping`). +- Store tests require `TEST_DATABASE_URL`; they `t.Skip` without it. Migrations are applied by `psql` against the test DB before running (see Task 1 verify). +- Backend verification commands: `go build ./...`, `go vet ./...`, `go test ./...`. +- Frontend verification: `cd web && npm run build` (tsc + vite), `npx oxlint src/`. +- Commit after each task. + +--- + +### Task 1: Per-account folder-config storage (migration + store) + +**Files:** +- Create: `migrations/0003_account_folder_mapping.up.sql` +- Create: `migrations/0003_account_folder_mapping.down.sql` +- Modify: `internal/store/accounts.go` (Account struct ~L8-22; `ListAccountsByTask` SELECT/Scan ~L39-58; add `SetAccountFolderMapping`) +- Test: `internal/store/accounts_test.go` + +**Interfaces:** +- Produces: `store.Account.FolderMapping map[string]string`, `store.Account.ExcludedFolders []string`; `func (s *Store) SetAccountFolderMapping(ctx context.Context, id int64, mapping map[string]string, excluded []string) error`. + +- [ ] **Step 1: Write the migration up/down** + +`migrations/0003_account_folder_mapping.up.sql`: +```sql +ALTER TABLE accounts ADD COLUMN folder_mapping JSONB NOT NULL DEFAULT '{}'; +ALTER TABLE accounts ADD COLUMN excluded_folders JSONB NOT NULL DEFAULT '[]'; + +-- Carry the existing task-wide mapping onto its accounts so behavior is preserved. +UPDATE accounts a SET folder_mapping = t.folder_mapping + FROM tasks t WHERE a.task_id = t.id AND t.folder_mapping <> '{}'::jsonb; +``` + +`migrations/0003_account_folder_mapping.down.sql`: +```sql +ALTER TABLE accounts DROP COLUMN excluded_folders; +ALTER TABLE accounts DROP COLUMN folder_mapping; +``` + +- [ ] **Step 2: Write the failing store test** + +Add to `internal/store/accounts_test.go`: +```go +func TestSetAccountFolderMapping(t *testing.T) { + s := testStore(t) + ctx := context.Background() + epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"}) + epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst}) + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"}) + + // Fresh account defaults: empty map, empty exclusions (not nil after scan). + accs, _ := s.ListAccountsByTask(ctx, taskID) + if len(accs) != 1 || len(accs[0].FolderMapping) != 0 || len(accs[0].ExcludedFolders) != 0 { + t.Fatalf("defaults: map=%v excl=%v", accs[0].FolderMapping, accs[0].ExcludedFolders) + } + + if err := s.SetAccountFolderMapping(ctx, accID, + map[string]string{"Спам": "Spam"}, []string{"Trash"}); err != nil { + t.Fatalf("set: %v", err) + } + accs, _ = s.ListAccountsByTask(ctx, taskID) + a := accs[0] + if a.FolderMapping["Спам"] != "Spam" { + t.Fatalf("mapping not persisted: %v", a.FolderMapping) + } + if len(a.ExcludedFolders) != 1 || a.ExcludedFolders[0] != "Trash" { + t.Fatalf("excluded not persisted: %v", a.ExcludedFolders) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/store/ -run TestSetAccountFolderMapping` (with a migrated `TEST_DATABASE_URL`). +Expected: FAIL to **compile** — `a.FolderMapping`/`ExcludedFolders` and `SetAccountFolderMapping` undefined. + +- [ ] **Step 4: Add struct fields** + +In `internal/store/accounts.go`, extend the `Account` struct (after `LastError string`): +```go + LastError string + FolderMapping map[string]string + ExcludedFolders []string +``` + +- [ ] **Step 5: Select + scan the new columns** + +In `ListAccountsByTask`, add both columns to the SELECT and the Scan. New SELECT: +```go + rows, err := s.Pool.Query(ctx, + `SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc, + test_src_status, test_dst_status, status, copied_count, skipped_count, + error_count, last_error, folder_mapping, excluded_folders + FROM accounts WHERE task_id=$1 ORDER BY id`, taskID) +``` +New Scan (add the two trailing pointers): +```go + if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc, + &a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors, &a.LastError, + &a.FolderMapping, &a.ExcludedFolders); err != nil { + return nil, err + } +``` + +- [ ] **Step 6: Add the setter** + +Append to `internal/store/accounts.go`: +```go +// SetAccountFolderMapping persists an account's per-folder rename map and the +// set of source folders to skip. nil is normalized to empty so JSONB stays +// '{}' / '[]' rather than null. +func (s *Store) SetAccountFolderMapping(ctx context.Context, id int64, mapping map[string]string, excluded []string) error { + if mapping == nil { + mapping = map[string]string{} + } + if excluded == nil { + excluded = []string{} + } + _, err := s.Pool.Exec(ctx, + `UPDATE accounts SET folder_mapping=$2, excluded_folders=$3 WHERE id=$1`, + id, mapping, excluded) + return err +} +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `go test ./internal/store/ -run TestSetAccountFolderMapping -v` +Expected: PASS. + +- [ ] **Step 8: Verify build + full store tests** + +Run: `go build ./... && go vet ./... && go test ./internal/store/` +Expected: all pass. + +- [ ] **Step 9: Commit** + +```bash +git add migrations/0003_account_folder_mapping.up.sql migrations/0003_account_folder_mapping.down.sql \ + internal/store/accounts.go internal/store/accounts_test.go +git commit -m "feat(store): per-account folder_mapping + excluded_folders columns" +``` + +--- + +### Task 2: Orchestrator plan honors per-account mapping + exclusions + +**Files:** +- Modify: `internal/orchestrator/orchestrator.go` (lift `folderPlan` to package scope; add `planFolders`; replace the plan-building loop in `runAccount` ~L271-293) +- Test: Create `internal/orchestrator/planfolders_test.go` + +**Interfaces:** +- Consumes: `store.Account.FolderMapping`, `store.Account.ExcludedFolders` (Task 1). +- Produces: package-level `type folderPlan struct { src, dst string; total int64 }`; `func planFolders(folders []string, mapping map[string]string, excluded []string) []folderPlan`. + +- [ ] **Step 1: Write the failing unit test** + +`internal/orchestrator/planfolders_test.go`: +```go +package orchestrator + +import "testing" + +func names(ps []folderPlan) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.src + "->" + p.dst + } + return out +} + +func TestPlanFolders(t *testing.T) { + folders := []string{"INBOX", "Спам", "Trash", "Sent"} + + // Empty config: every folder syncs to its own name, in order. + got := planFolders(folders, nil, nil) + if len(got) != 4 || got[0].src != "INBOX" || got[0].dst != "INBOX" { + t.Fatalf("empty config: %v", names(got)) + } + + // Rename + exclusion. + got = planFolders(folders, + map[string]string{"Спам": "Spam"}, + []string{"Trash"}) + if len(got) != 3 { + t.Fatalf("want 3 plans (Trash excluded), got %v", names(got)) + } + for _, p := range got { + if p.src == "Trash" { + t.Fatalf("excluded folder present: %v", names(got)) + } + if p.src == "Спам" && p.dst != "Spam" { + t.Fatalf("rename not applied: %v", names(got)) + } + if p.src == "INBOX" && p.dst != "INBOX" { + t.Fatalf("unmapped folder should keep its name: %v", names(got)) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/orchestrator/ -run TestPlanFolders` +Expected: FAIL to compile — `folderPlan` is a local type inside `runAccount`, `planFolders` undefined. + +- [ ] **Step 3: Lift `folderPlan` to package scope and add `planFolders`** + +In `internal/orchestrator/orchestrator.go`, add near the top (package level, e.g. above `runAccount`): +```go +// folderPlan is one source folder scheduled for copy and its destination name. +type folderPlan struct { + src, dst string + total int64 +} + +// planFolders decides which of an account's source folders to copy and where. +// Excluded source folders are dropped; a folder absent from mapping keeps its +// own name. Order follows the input folder list. +func planFolders(folders []string, mapping map[string]string, excluded []string) []folderPlan { + skip := make(map[string]struct{}, len(excluded)) + for _, e := range excluded { + skip[e] = struct{}{} + } + plan := make([]folderPlan, 0, len(folders)) + for _, f := range folders { + if _, ok := skip[f]; ok { + continue + } + df := f + if m, ok := mapping[f]; ok && m != "" { + df = m + } + plan = append(plan, folderPlan{src: f, dst: df}) + } + return plan +} +``` + +- [ ] **Step 4: Rewrite the plan-building loop in `runAccount`** + +Replace the existing block (the local `type folderPlan struct {...}`, `plan := make(...)`, and the `for _, folder := range folders { ... df := folder; if m, ok := task.FolderMapping[folder]; ... }` loop, currently ~L271-293) with: +```go + // Planning pass: decide folders from the account's own config, then EXAMINE + // each to learn message counts for the overall progress bar. + plan := planFolders(folders, a.FolderMapping, a.ExcludedFolders) + var grandTotal int64 + for i := range plan { + if actx.Err() != nil { + break + } + n, cerr := imapx.FolderMessageCount(src, plan[i].src) + if cerr != nil && actx.Err() == nil { + slog.Warn("count folder failed", "account", a.ID, "folder", plan[i].src, "err", cerr) + } + plan[i].total = n + grandTotal += n + } +``` +Leave the subsequent `o.hub.Publish(... "plan" ...)` and the copy loop (`for _, fp := range plan`) unchanged — they already read `fp.src`, `fp.dst`, `fp.total`. + +- [ ] **Step 5: Run the unit test to verify it passes** + +Run: `go test ./internal/orchestrator/ -run TestPlanFolders -v` +Expected: PASS. + +- [ ] **Step 6: Verify build + vet + full test suite** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: all pass (store tests skip without `TEST_DATABASE_URL`; that's fine here). + +- [ ] **Step 7: Commit** + +```bash +git add internal/orchestrator/orchestrator.go internal/orchestrator/planfolders_test.go +git commit -m "feat(orchestrator): build copy plan from per-account mapping + exclusions" +``` + +--- + +### Task 3: HTTP — probe existing account + persist its mapping + +**Files:** +- Modify: `internal/httpapi/accounts.go` (AccountView + accountDTO; add `handleProbeAccountFolders`, `handleSetAccountFolderMapping`) +- Modify: `internal/httpapi/router.go` (register two routes) + +**Interfaces:** +- Consumes: `store.SetAccountFolderMapping`, `store.Account.FolderMapping/ExcludedFolders`, existing `crypto.Decrypt`, `imapx.TestLogin`, `s.store.ListAccountsByTask`. +- Produces: routes `POST /api/tasks/{id}/accounts/{accountId}/probe`, `PUT /api/tasks/{id}/accounts/{accountId}/folder-mapping`; `AccountView.FolderMapping`, `AccountView.ExcludedFolders`. + +- [ ] **Step 1: Expose the config on AccountView** + +In `internal/httpapi/accounts.go`, add to `AccountView` (after `LastError`): +```go + LastError string `json:"last_error,omitempty"` + FolderMapping map[string]string `json:"folder_mapping"` + ExcludedFolders []string `json:"excluded_folders"` +``` +And in `accountDTO`, populate them: +```go + LastError: a.LastError, + FolderMapping: a.FolderMapping, + ExcludedFolders: a.ExcludedFolders, +``` + +- [ ] **Step 2: Add a helper to fetch one account by id** + +The store exposes `ListAccountsByTask` only. Add a small lookup inside the handler (no new store method needed) — a private helper in `accounts.go`: +```go +// findAccount returns the account with accID under taskID, or ok=false. +func (s *Server) findAccount(r *http.Request, taskID, accID int64) (store.Account, bool) { + accs, err := s.store.ListAccountsByTask(r.Context(), taskID) + if err != nil { + return store.Account{}, false + } + for _, a := range accs { + if a.ID == accID { + return a, true + } + } + return store.Account{}, false +} +``` + +- [ ] **Step 3: Add the probe-by-account handler** + +Append to `internal/httpapi/accounts.go`. It reuses the same probe shape as `handleProbeFolders` but decrypts stored passwords: +```go +// handleProbeAccountFolders re-probes an existing account using its stored +// (encrypted) credentials, so the operator can (re)map an account added via CSV. +func (s *Server) handleProbeAccountFolders(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + accID, err := pathID(r, "accountId") + if err != nil { + http.Error(w, "bad account id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), taskID) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + a, ok := s.findAccount(r, taskID, accID) + if !ok { + http.Error(w, "account not found", http.StatusNotFound) + return + } + srcEP, err := s.store.GetEndpoint(r.Context(), task.SrcEndpointID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + dstEP, err := s.store.GetEndpoint(r.Context(), task.DstEndpointID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + srcPass, err := crypto.Decrypt(s.cfg.EncKey, a.SrcPassEnc) + if err != nil { + http.Error(w, "decrypt", http.StatusInternalServerError) + return + } + dstPass, err := crypto.Decrypt(s.cfg.EncKey, a.DstPassEnc) + if err != nil { + http.Error(w, "decrypt", http.StatusInternalServerError) + return + } + probe := func(ep store.Endpoint, login string, pass []byte) map[string]any { + folders, err := imapx.TestLogin(r.Context(), + imapx.Endpoint{Host: ep.Host, Port: ep.Port, TLSMode: ep.TLSMode}, + strings.TrimSpace(login), strings.TrimSpace(string(pass))) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "folders": []string{}} + } + return map[string]any{"ok": true, "folders": folders} + } + writeJSON(w, http.StatusOK, map[string]any{ + "src": probe(srcEP, a.SrcLogin, srcPass), + "dst": probe(dstEP, a.DstLogin, dstPass), + }) +} +``` + +- [ ] **Step 4: Add the per-account mapping setter handler** + +Append to `internal/httpapi/accounts.go`: +```go +// handleSetAccountFolderMapping persists one account's rename map + excluded set. +func (s *Server) handleSetAccountFolderMapping(w http.ResponseWriter, r *http.Request) { + accID, err := pathID(r, "accountId") + if err != nil { + http.Error(w, "bad account id", http.StatusBadRequest) + return + } + var body struct { + Mapping map[string]string `json:"mapping"` + Excluded []string `json:"excluded"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if err := s.store.SetAccountFolderMapping(r.Context(), accID, body.Mapping, body.Excluded); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} +``` + +- [ ] **Step 5: Register the routes** + +In `internal/httpapi/router.go`, after the existing `.../accounts/{accountId}/cancel` line: +```go + api.HandleFunc("POST /api/tasks/{id}/accounts/{accountId}/probe", s.handleProbeAccountFolders) + api.HandleFunc("PUT /api/tasks/{id}/accounts/{accountId}/folder-mapping", s.handleSetAccountFolderMapping) +``` + +- [ ] **Step 6: Verify build + vet** + +Run: `go build ./... && go vet ./...` +Expected: pass (confirms `s.cfg.EncKey`, `crypto.Decrypt`, `imapx.TestLogin`, `writeJSON` all resolve). + +- [ ] **Step 7: Commit** + +```bash +git add internal/httpapi/accounts.go internal/httpapi/router.go +git commit -m "feat(api): probe existing account + persist per-account folder mapping" +``` + +--- + +### Task 4: Frontend API client + exclusion checkboxes in the mapping modal + +**Files:** +- Modify: `web/src/api.ts` (Account fields; `probeAccountFolders`; `setAccountFolderMapping`) +- Modify: `web/src/components/FolderMappingModal.tsx` (checkbox column, `initialExcluded`, `onConfirm(mapping, excluded)`) +- Modify: `web/src/app.css` (checkbox + excluded-row styles) + +**Interfaces:** +- Consumes: backend routes from Task 3. +- Produces: `probeAccountFolders(taskId, accId): Promise`; `setAccountFolderMapping(taskId, accId, mapping, excluded): Promise`; `FolderMappingModal` prop `initialExcluded: string[]` and `onConfirm: (mapping: Record, excluded: string[]) => void`. + +- [ ] **Step 1: Extend the API client** + +In `web/src/api.ts`, add to `Account` (after `last_error?`): +```ts + folder_mapping?: Record + excluded_folders?: string[] +``` +Add near the existing probe/mapping helpers: +```ts +export const probeAccountFolders = (taskId: number, accId: number) => + api(`/api/tasks/${taskId}/accounts/${accId}/probe`, { method: 'POST' }) + +export const setAccountFolderMapping = ( + taskId: number, + accId: number, + mapping: Record, + excluded: string[], +) => + api(`/api/tasks/${taskId}/accounts/${accId}/folder-mapping`, { + ...jsonBody({ mapping, excluded }), + method: 'PUT', + }) +``` + +- [ ] **Step 2: Add exclusion to FolderMappingModal — props + state** + +In `web/src/components/FolderMappingModal.tsx`, change the `Props` type and signature: +```ts +type Props = { + open: boolean + srcFolders: string[] + dstFolders: string[] + initialMapping: Record + initialExcluded: string[] + onConfirm: (mapping: Record, excluded: string[]) => void + onCancel: () => void +} + +export function FolderMappingModal({ + open, srcFolders, dstFolders, initialMapping, initialExcluded, onConfirm, onCancel, +}: Props) { + const [choice, setChoice] = useState>({}) + const [synced, setSynced] = useState>(() => { + const excl = new Set(initialExcluded) + return Object.fromEntries(srcFolders.map((f) => [f, !excl.has(f)])) + }) +``` + +- [ ] **Step 3: Update `confirm()` to emit excluded** + +Replace the `confirm()` body: +```ts + function confirm() { + const mapping: Record = { ...initialMapping } + const excluded: string[] = [] + for (const src of srcFolders) { + if (synced[src] === false) { + excluded.push(src) + delete mapping[src] + continue + } + const dst = valueFor(src) + if (dst === src) delete mapping[src] + else mapping[src] = dst + } + onConfirm(mapping, excluded) + } +``` + +- [ ] **Step 4: Add the checkbox + "sync all" to the JSX** + +Add a select-all control above `.map-grid` and a checkbox per row. Replace the `.map-grid` block: +```tsx + +
+ {srcFolders.map((src) => { + const on = synced[src] !== false + const val = valueFor(src) + const creates = !dstFolders.includes(val) + return ( +
+ + + {src} + + + + {on ? ( + creates && new + ) : ( + excluded + )} +
+ ) + })} +
+``` +Note: `.map-row` grid now has a leading checkbox column — update its `grid-template-columns` in Step 5. + +- [ ] **Step 5: Add CSS for the checkbox column + excluded row** + +In `web/src/app.css`, replace the `.map-row` rule and add new rules: +```css +.map-row { + display: grid; + grid-template-columns: auto 1fr auto 1fr auto; + align-items: center; + gap: 10px; +} + +.map-row-off .map-src, +.map-row-off .map-arrow { + color: var(--fg-faint); + text-decoration: line-through; +} + +.map-check { + display: flex; + align-items: center; +} + +.map-check input, +.map-all input { + accent-color: var(--accent); + cursor: pointer; +} + +.map-all { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); + cursor: pointer; +} + +.map-excluded { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-faint); +} +``` + +- [ ] **Step 6: Verify build + lint** + +Run: `cd web && npm run build && npx oxlint src/` +Expected: build succeeds; only the two pre-existing oxlint warnings (`ConfirmProvider` fast-refresh, `TaskDetail` exhaustive-deps). This step will still show a type error at the `FolderMappingModal` call site in `TaskDetail.tsx` (missing `initialExcluded`, new `onConfirm` arity) — that is fixed in Task 5. If building Task 4 alone, temporarily expect that call-site error; it resolves once Task 5 lands. (Preferred: run Task 5 before the final build.) + +- [ ] **Step 7: Commit** + +```bash +git add web/src/api.ts web/src/components/FolderMappingModal.tsx web/src/app.css +git commit -m "feat(web): folder-sync checkboxes + per-account mapping API client" +``` + +--- + +### Task 5: Frontend TaskDetail — "folders" button, edit mode, per-account save + +**Files:** +- Modify: `web/src/pages/TaskDetail.tsx` + +**Interfaces:** +- Consumes: `probeAccountFolders`, `setAccountFolderMapping` (Task 4); `FolderMappingModal` new props (Task 4); `Account.folder_mapping/excluded_folders` (Task 4). + +- [ ] **Step 1: Import the new API functions** + +In `web/src/pages/TaskDetail.tsx`, extend the import from `../api` to add `probeAccountFolders` and `setAccountFolderMapping` (keep the rest). Remove `setFolderMapping` from the import (single-add stops using it — see Step 4): +```ts +import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setFolderMapping, testAccounts, type TaskDetail as TaskDetailData } from '../api' +``` +(Keep `setFolderMapping` in the import only if still referenced; after Step 4 it is not — drop it to avoid an unused-import lint error.) + +- [ ] **Step 2: Add edit-modal state** + +Next to `const [mapState, setMapState] = useState<...>(null)`, add: +```ts + const [editMap, setEditMap] = useState<{ + accId: number + src: string[] + dst: string[] + mapping: Record + excluded: string[] + } | null>(null) +``` + +- [ ] **Step 3: Add the open-editor + save handlers** + +Add two functions inside the component (near `onDeleteAccount`): +```ts + async function onEditFolders(a: { id: number; src_login: string; folder_mapping?: Record; excluded_folders?: string[] }) { + setBusy('probe') + setError(null) + try { + const res = await probeAccountFolders(id, a.id) + if (!res.src.ok || !res.dst.ok) { + const parts: string[] = [] + if (!res.src.ok) parts.push(`source: ${res.src.error ?? 'login failed'}`) + if (!res.dst.ok) parts.push(`destination: ${res.dst.error ?? 'login failed'}`) + setError(`Folder probe failed — ${parts.join('; ')}`) + return + } + setEditMap({ + accId: a.id, + src: res.src.folders ?? [], + dst: res.dst.folders ?? [], + mapping: a.folder_mapping ?? {}, + excluded: a.excluded_folders ?? [], + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to probe folders') + } finally { + setBusy(null) + } + } + + async function saveEditMapping(mapping: Record, excluded: string[]) { + if (!editMap) return + setBusy('add') + setError(null) + try { + await setAccountFolderMapping(id, editMap.accId, mapping, excluded) + setEditMap(null) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save mapping') + } finally { + setBusy(null) + } + } +``` + +- [ ] **Step 4: Switch single-add to per-account save** + +In `confirmMapping`, replace the task-level call. Change `onConfirm` signature to accept `excluded` and persist to the new account: +```ts + async function confirmMapping(mapping: Record, excluded: string[]) { + if (!mapState) return + setBusy('add') + setError(null) + try { + const { id: accId } = await createAccount(id, mapState.creds) + await setAccountFolderMapping(id, accId, mapping, excluded) + setForm(emptyAccount) + setMapState(null) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add account') + } finally { + setBusy(null) + } + } +``` +(`createAccount` already returns `{ id }`.) After this, `setFolderMapping` is unused — ensure it is removed from the Step 1 import. + +- [ ] **Step 5: Add the "folders" button to the actions cell** + +In the accounts table actions cell, put a `folders` button left of remove/cancel. Replace the last `…` action cell with: +```tsx + +
+ {a.status !== 'running' && ( + + )} + {a.status === 'running' ? ( + + ) : ( + + )} +
+ +``` + +- [ ] **Step 6: Pass `initialExcluded` to the add-modal and render the edit-modal** + +Update the existing add-modal usage to pass `initialExcluded={[]}` (new accounts sync all) — `onConfirm={confirmMapping}` now matches the `(mapping, excluded)` arity: +```tsx + {mapState && ( + setMapState(null)} + onConfirm={confirmMapping} + /> + )} + {editMap && ( + setEditMap(null)} + onConfirm={saveEditMapping} + /> + )} +``` + +- [ ] **Step 7: Add `.row-actions` CSS** + +In `web/src/app.css`, add: +```css +.row-actions { + display: inline-flex; + gap: 12px; + align-items: center; + justify-content: flex-end; +} +``` + +- [ ] **Step 8: Verify build + lint** + +Run: `cd web && npm run build && npx oxlint src/` +Expected: build succeeds; only the two pre-existing oxlint warnings. No type errors. + +- [ ] **Step 9: Commit** + +```bash +git add web/src/pages/TaskDetail.tsx web/src/app.css +git commit -m "feat(web): per-account folders button + edit-mapping modal" +``` + +--- + +### Task 6: End-to-end verification on prod + +Per project rules, functional verification runs on prod after deploy (Web via playwright-cli). This task has no code; it gates "done". + +- [ ] **Step 1: Build + apply migration + deploy** + +Run: `make build && make up` (migration 0003 auto-applies at startup via golang-migrate). Wait for `curl -fsS http://localhost/healthz`. + +- [ ] **Step 2: Migration sanity — existing accounts preserved** + +Open an existing task that had a task-level mapping. Confirm accounts still run and copy the same folders (migrated mapping carried over). + +- [ ] **Step 3: Per-account exclusion + rename E2E** + +1. CSV-import (or add) an account. In the accounts table, click **folders**. +2. Modal opens with the account's freshly probed source folders, all checked. +3. Uncheck one folder (e.g. `Trash`) → its select greys out, shows "excluded". +4. Rename another (e.g. `Спам → Spam`). Save. +5. Reopen **folders** on the same account → the excluded folder is unchecked and the rename is preserved (state re-read from DB). +6. Run the task. Verify: the excluded folder is NOT copied (absent from progress/log), the renamed folder lands under its new name on the destination. + +- [ ] **Step 4: Record result** + +Save an E2E note under `./swarm-report/per-account-folder-mapping-.md` with pass/fail per step. + +--- + +## Self-Review + +- **Spec coverage:** per-account model (Task 1) ✓; migration of task mapping (Task 1 up.sql) ✓; exclusion list w/ safe default (Task 1 defaults + Task 2 planFolders) ✓; orchestrator honors config (Task 2) ✓; probe-by-account decrypt (Task 3) ✓; PUT per-account mapping (Task 3) ✓; AccountView exposes config (Task 3) ✓; api.ts + modal checkboxes (Task 4) ✓; folders button + edit mode + single-add switch (Task 5) ✓; tests: store round-trip + defaults (Task 1), planFolders unit (Task 2), E2E (Task 6) ✓. +- **Placeholder scan:** none — all steps carry real code/commands. +- **Type consistency:** `folderPlan{src,dst,total}` package-level (Task 2) matches copy-loop usage; `SetAccountFolderMapping(id, mapping, excluded)` identical across Tasks 1/3; `onConfirm(mapping, excluded)` arity consistent across modal (Task 4) and both call sites (Task 5); `probeAccountFolders`/`setAccountFolderMapping` signatures match between api.ts (Task 4) and TaskDetail (Task 5). +- **Note:** Task 4 built in isolation leaves a transient type error at the modal call site until Task 5; documented in Task 4 Step 6. Prefer running Tasks 4→5 back-to-back before a clean build. diff --git a/docs/superpowers/specs/2026-07-03-per-account-folder-mapping-and-exclusion-design.md b/docs/superpowers/specs/2026-07-03-per-account-folder-mapping-and-exclusion-design.md new file mode 100644 index 0000000..ded25c3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-per-account-folder-mapping-and-exclusion-design.md @@ -0,0 +1,160 @@ +# Per-account folder mapping & folder exclusion — design + +**Date:** 2026-07-03 +**Status:** awaiting user review + +## Context + +Two gaps in the current folder-mapping feature: + +1. **No per-account mapping for bulk (CSV) accounts.** Folder mapping is collected + only in the single "Add account" flow. Accounts imported from CSV inherit the + task-wide mapping and cannot be mapped individually. The user wants a **folders** + button per account row (left of remove) that re-probes that account's live + folders and opens the mapping modal for it. +2. **No exclusion.** Mapping can only rename `src → dst`; it cannot skip folders + the user doesn't want to copy (Spam, Trash, per-client junk). The user wants + **checkboxes** to choose which source folders are synced. + +### Current architecture (as-is) + +- `tasks.folder_mapping` (JSONB `map[src]dst`) — **one mapping for the whole task**, + applied to every account by the orchestrator: `df := task.FolderMapping[folder]` + (`internal/orchestrator/orchestrator.go:283-286`). +- Single-add flow: `probeFolders` (form creds) → `FolderMappingModal` → `createAccount` + then `setFolderMapping` (task-level) (`TaskDetail.tsx:167-204`). +- `handleProbeFolders` takes **plaintext** creds from the request body + (`internal/httpapi/accounts.go:39-83`). CSV accounts store only encrypted + passwords, so re-probing an existing account needs a server-side decrypt path. +- `store.Account` has **no** mapping field. + +## Decision: fully per-account model + +Folder configuration moves from the task to the account. `tasks.folder_mapping` +is retired as the source of truth (column kept, no longer read) and its data is +migrated onto each account. This matches the intent ("each account its own +folders") and removes the current oddity where adding one account rewrites the +whole task's mapping. + +Exclusion is stored as a **separate `excluded_folders` list** (not a sentinel in +the mapping). Rationale: a source folder that is *not configured* (new folder +appearing after mapping, or a CSV account never opened) must **default to synced** +— a copy tool must never silently drop mail. An exclusion list gives that safe +default; an inclusion list would silently skip new folders. + +### Semantics + +For an account, given its live source folders: + +- `excluded_folders: string[]` — source folder names to **skip** entirely. +- `folder_mapping: map[src]dst` — rename for synced folders; a src absent from the + map syncs to its own name (existing behavior). +- Default (empty mapping, empty exclusions) = sync every folder name-matched = + current behavior. Backward compatible. + +## Data model + +**Migration `0003_account_folder_mapping`:** + +```sql +-- up +ALTER TABLE accounts ADD COLUMN folder_mapping JSONB NOT NULL DEFAULT '{}'; +ALTER TABLE accounts ADD COLUMN excluded_folders JSONB NOT NULL DEFAULT '[]'; +-- carry the existing task-wide mapping onto its accounts +UPDATE accounts a SET folder_mapping = t.folder_mapping + FROM tasks t WHERE a.task_id = t.id AND t.folder_mapping <> '{}'; +-- down +ALTER TABLE accounts DROP COLUMN excluded_folders; +ALTER TABLE accounts DROP COLUMN folder_mapping; +``` + +**`store.Account`** gains: +- `FolderMapping map[string]string` +- `ExcludedFolders []string` + +`ListAccountsByTask` selects/scans both (JSONB). New helper: +- `SetAccountFolderMapping(ctx, id int64, mapping map[string]string, excluded []string) error` + +`tasks.folder_mapping` column and `SetTaskFolderMapping` remain but are no longer +read by the orchestrator (kept for the migration source and to avoid a destructive +change; single-add stops writing it). + +## Orchestrator + +Extract the per-folder plan decision into a small **pure, testable** helper so the +exclusion/mapping logic can be unit-tested without IMAP: + +```go +// planFolders returns the (src,dst) pairs to copy for an account, dropping +// excluded source folders and applying renames. +func planFolders(folders []string, mapping map[string]string, excluded []string) []folderPlan +``` + +`runAccount` replaces the inline `task.FolderMapping[folder]` loop with +`planFolders(folders, a.FolderMapping, a.ExcludedFolders)`, then counts messages +per surviving folder as today. Excluded folders never enter the plan (not counted, +not scanned, not copied). + +## HTTP API + +- **`POST /api/tasks/{id}/accounts/{accId}/probe`** — new. Loads the account, + decrypts its stored src/dst passwords, reuses the existing `imapx.TestLogin` + probe, returns `{src:{ok,folders,error}, dst:{...}}`. No creds in the request. +- **`PUT /api/tasks/{id}/accounts/{accId}/folder-mapping`** — new. Body + `{mapping: map[string]string, excluded: string[]}` → `SetAccountFolderMapping`. +- **`AccountView` / `accountDTO`** expose `folder_mapping` and `excluded_folders` + so the modal can seed saved state when reopened. +- Existing task-level `POST /probe` stays (single-add probes before the account + exists). Task-level `PUT /folder-mapping` handler stays but is unused by the UI. + +## Frontend + +**`api.ts`** +- `Account` interface gains `folder_mapping?: Record` and + `excluded_folders?: string[]`. +- `probeAccountFolders(taskId, accId)` → `ProbeResult`. +- `setAccountFolderMapping(taskId, accId, mapping, excluded)`. + +**`FolderMappingModal`** — add exclusion: +- New props `initialExcluded: string[]`, and `onConfirm(mapping, excluded)`. +- Each row gets a leading **checkbox** (checked = sync). Unchecking greys/disables + that row's dst `