Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 (seestore/tasks.goFolderMapping). - Store tests require
TEST_DATABASE_URL; theyt.Skipwithout it. Migrations are applied bypsqlagainst 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;ListAccountsByTaskSELECT/Scan ~L39-58; addSetAccountFolderMapping) - 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 compile — a.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(liftfolderPlanto package scope; addplanFolders; replace the plan-building loop inrunAccount~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
folderPlanto package scope and addplanFolders
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; addhandleProbeAccountFolders,handleSetAccountFolderMapping) - Modify:
internal/httpapi/router.go(register two routes)
Interfaces:
-
Consumes:
store.SetAccountFolderMapping,store.Account.FolderMapping/ExcludedFolders, existingcrypto.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>;FolderMappingModalpropinitialExcluded: string[]andonConfirm: (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);FolderMappingModalnew 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
initialExcludedto 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-actionsCSS
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
- CSV-import (or add) an account. In the accounts table, click folders.
- Modal opens with the account's freshly probed source folders, all checked.
- Uncheck one folder (e.g.
Trash) → its select greys out, shows "excluded". - Rename another (e.g.
Спам → Spam). Save. - Reopen folders on the same account → the excluded folder is unchecked and the rename is preserved (state re-read from DB).
- 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/setAccountFolderMappingsignatures 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.