Files
imap-copier/docs/superpowers/plans/2026-07-03-per-account-folder-mapping-and-exclusion.md
T
2026-07-03 11:35:17 +07:00

874 lines
32 KiB
Markdown

# 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<ProbeResult>`; `setAccountFolderMapping(taskId, accId, mapping, excluded): Promise<unknown>`; `FolderMappingModal` prop `initialExcluded: string[]` and `onConfirm: (mapping: Record<string,string>, excluded: string[]) => void`.
- [ ] **Step 1: Extend the API client**
In `web/src/api.ts`, add to `Account` (after `last_error?`):
```ts
folder_mapping?: Record<string, string>
excluded_folders?: string[]
```
Add near the existing probe/mapping helpers:
```ts
export const probeAccountFolders = (taskId: number, accId: number) =>
api<ProbeResult>(`/api/tasks/${taskId}/accounts/${accId}/probe`, { method: 'POST' })
export const setAccountFolderMapping = (
taskId: number,
accId: number,
mapping: Record<string, string>,
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<string, string>
initialExcluded: string[]
onConfirm: (mapping: Record<string, string>, excluded: string[]) => void
onCancel: () => void
}
export function FolderMappingModal({
open, srcFolders, dstFolders, initialMapping, initialExcluded, onConfirm, onCancel,
}: Props) {
const [choice, setChoice] = useState<Record<string, string>>({})
const [synced, setSynced] = useState<Record<string, boolean>>(() => {
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<string, string> = { ...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
<label className="map-all">
<input
type="checkbox"
checked={srcFolders.every((f) => synced[f] !== false)}
onChange={(e) => {
const on = e.target.checked
setSynced(Object.fromEntries(srcFolders.map((f) => [f, on])))
}}
/>
sync all folders
</label>
<div className="map-grid">
{srcFolders.map((src) => {
const on = synced[src] !== false
const val = valueFor(src)
const creates = !dstFolders.includes(val)
return (
<div className={`map-row${on ? '' : ' map-row-off'}`} key={src}>
<label className="map-check">
<input
type="checkbox"
checked={on}
aria-label={`Sync folder ${src}`}
onChange={(e) => setSynced((s) => ({ ...s, [src]: e.target.checked }))}
/>
</label>
<span className="map-src" title={src}>
{src}
</span>
<span className="map-arrow"></span>
<select
className="map-select"
aria-label={`Destination folder for ${src}`}
value={val}
disabled={!on}
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
>
{options(src).map((f) => (
<option key={f} value={f}>
{f}
{f === src && !dstFolders.includes(src) ? ' (create)' : ''}
</option>
))}
</select>
{on ? (
creates && <span className="map-new">new</span>
) : (
<span className="map-excluded">excluded</span>
)}
</div>
)
})}
</div>
```
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<string, string>
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<string, string>; 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<string, string>, 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<string, string>, 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 `<td className="num-cell">…</td>` action cell with:
```tsx
<td className="num-cell">
<div className="row-actions">
{a.status !== 'running' && (
<button
type="button"
className="link-btn"
onClick={() => onEditFolders(a)}
disabled={busy !== null}
>
folders
</button>
)}
{a.status === 'running' ? (
<button type="button" className="link-btn danger" onClick={() => onCancelAccount(a.id)}>
cancel
</button>
) : (
<button
type="button"
className="link-btn danger"
onClick={() => onDeleteAccount(a.id, a.src_login)}
disabled={busy !== null || data?.task.status === 'running'}
>
remove
</button>
)}
</div>
</td>
```
- [ ] **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 && (
<FolderMappingModal
key={`${mapState.creds.src_login}|${mapState.creds.dst_login}`}
open
srcFolders={mapState.src}
dstFolders={mapState.dst}
initialMapping={task.folder_mapping ?? {}}
initialExcluded={[]}
onCancel={() => setMapState(null)}
onConfirm={confirmMapping}
/>
)}
{editMap && (
<FolderMappingModal
key={`edit-${editMap.accId}`}
open
srcFolders={editMap.src}
dstFolders={editMap.dst}
initialMapping={editMap.mapping}
initialExcluded={editMap.excluded}
onCancel={() => 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-<YYYY-MM-DD>.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.