# 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.