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

32 KiB

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:

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:

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:

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 compilea.FolderMapping/ExcludedFolders and SetAccountFolderMapping undefined.

  • Step 4: Add struct fields

In internal/store/accounts.go, extend the Account struct (after LastError string):

	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:

	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):

		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:

// 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
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:

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):

// 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:

	// 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
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):

	LastError       string            `json:"last_error,omitempty"`
	FolderMapping   map[string]string `json:"folder_mapping"`
	ExcludedFolders []string          `json:"excluded_folders"`

And in accountDTO, populate them:

		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:

// 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:

// 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:

// 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:

	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
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?):

  folder_mapping?: Record<string, string>
  excluded_folders?: string[]

Add near the existing probe/mapping helpers:

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:

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:

  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:

        <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:

.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
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):

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:

  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):

  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:

  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:

                    <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:

      {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:

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