From 0bb584fe1043d72cb6576ae024bd3930d5ad5026 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Thu, 2 Jul 2026 14:39:31 +0700 Subject: [PATCH] feat: folder mapping UI on account add (probe + map modal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADD now probes both connections, lists folders on each side, and opens a mapping modal to route source->destination folders (e.g. Спам -> Spam) so we append into the existing folder instead of creating a duplicate. - store: SetTaskFolderMapping (+ round-trip test) - httpapi: POST /tasks/{id}/probe (test both, return folder lists), PUT /tasks/{id}/folder-mapping - web: FolderMappingModal (reuses Modal, size=lg), submitAccount probes then opens the modal; confirm creates the account and saves the task mapping Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/httpapi/accounts.go | 71 +++++++++++++++++ internal/httpapi/router.go | 2 + internal/store/foldermap_test.go | 34 +++++++++ internal/store/tasks.go | 8 ++ web/src/api.ts | 19 +++++ web/src/app.css | 62 ++++++++++++++- web/src/components/FolderMappingModal.tsx | 92 +++++++++++++++++++++++ web/src/components/Modal.tsx | 5 +- web/src/pages/TaskDetail.tsx | 43 ++++++++++- 9 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 internal/store/foldermap_test.go create mode 100644 web/src/components/FolderMappingModal.tsx diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index e5d6a02..73ef363 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/vasyansk/imap-copier/internal/crypto" + "github.com/vasyansk/imap-copier/internal/imapx" "github.com/vasyansk/imap-copier/internal/store" ) @@ -30,6 +31,76 @@ func accountDTO(a store.Account) AccountView { } } +// handleProbeFolders tests both logins with the given credentials and returns +// the folder list on each side, so the UI can offer a source->destination +// folder mapping before the account is created. Credentials are not stored. +func (s *Server) handleProbeFolders(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), taskID) + if err != nil { + http.Error(w, "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 + } + var body struct { + SrcLogin string `json:"src_login"` + SrcPass string `json:"src_pass"` + DstLogin string `json:"dst_login"` + DstPass string `json:"dst_pass"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + probe := func(ep store.Endpoint, login, pass string) 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(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, body.SrcLogin, body.SrcPass), + "dst": probe(dstEP, body.DstLogin, body.DstPass), + }) +} + +// handleSetFolderMapping replaces the task's src->dst folder mapping. +func (s *Server) handleSetFolderMapping(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + var body struct { + Mapping map[string]string `json:"mapping"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if err := s.store.SetTaskFolderMapping(r.Context(), taskID, body.Mapping); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + func pathID(r *http.Request, name string) (int64, error) { return strconv.ParseInt(r.PathValue(name), 10, 64) } diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index f05ac16..94cafec 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -20,6 +20,8 @@ func (s *Server) Router() http.Handler { api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) api.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask) api.HandleFunc("POST /api/tasks/{id}/accounts", s.handleCreateAccount) + api.HandleFunc("POST /api/tasks/{id}/probe", s.handleProbeFolders) + api.HandleFunc("PUT /api/tasks/{id}/folder-mapping", s.handleSetFolderMapping) api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount) api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV) api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts) diff --git a/internal/store/foldermap_test.go b/internal/store/foldermap_test.go new file mode 100644 index 0000000..a184a52 --- /dev/null +++ b/internal/store/foldermap_test.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + "testing" +) + +func TestSetTaskFolderMappingRoundTrip(t *testing.T) { + s := testStore(t) + ctx := context.Background() + e1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"}) + e2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: e1, DstEndpointID: e2}) + + m := map[string]string{"Спам": "Spam", "Отправленные": "Sent"} + if err := s.SetTaskFolderMapping(ctx, taskID, m); err != nil { + t.Fatalf("set mapping: %v", err) + } + got, err := s.GetTask(ctx, taskID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.FolderMapping["Спам"] != "Spam" || got.FolderMapping["Отправленные"] != "Sent" { + t.Fatalf("mapping round-trip failed: %+v", got.FolderMapping) + } + // nil clears to empty object, not null + if err := s.SetTaskFolderMapping(ctx, taskID, nil); err != nil { + t.Fatalf("clear: %v", err) + } + got, _ = s.GetTask(ctx, taskID) + if got.FolderMapping == nil { + t.Fatal("mapping should be non-nil empty map after clear") + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index eeb2a9c..b0b8b86 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -57,6 +57,14 @@ func (s *Store) ListTasks(ctx context.Context) ([]Task, error) { return out, rows.Err() } +func (s *Store) SetTaskFolderMapping(ctx context.Context, id int64, mapping map[string]string) error { + if mapping == nil { + mapping = map[string]string{} + } + _, err := s.Pool.Exec(ctx, `UPDATE tasks SET folder_mapping=$2 WHERE id=$1`, id, mapping) + return err +} + func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string) error { _, err := s.Pool.Exec(ctx, `UPDATE tasks SET status=$2 WHERE id=$1`, id, status) return err diff --git a/web/src/api.ts b/web/src/api.ts index 7a2ca86..f488af9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -84,6 +84,25 @@ export const deleteAccount = (taskId: number, accountId: number) => export const cancelAccount = (taskId: number, accountId: number) => api(`/api/tasks/${taskId}/accounts/${accountId}/cancel`, { method: 'POST' }) +export interface ProbeSide { + ok: boolean + folders?: string[] + error?: string +} + +export interface ProbeResult { + src: ProbeSide + dst: ProbeSide +} + +export const probeFolders = ( + taskId: number, + creds: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }, +) => api(`/api/tasks/${taskId}/probe`, jsonBody(creds)) + +export const setFolderMapping = (taskId: number, mapping: Record) => + api(`/api/tasks/${taskId}/folder-mapping`, { ...jsonBody({ mapping }), method: 'PUT' }) + export const listTasks = () => api('/api/tasks') export const getTask = (id: number) => api(`/api/tasks/${id}`) diff --git a/web/src/app.css b/web/src/app.css index a0f1f11..aca8ece 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -314,7 +314,6 @@ .modal-dialog { width: 100%; - max-width: 440px; background: var(--bg-panel-raised); border: 1px solid var(--border-bright); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); @@ -323,6 +322,67 @@ animation: modal-rise 0.14s ease-out; } +.modal-md { + max-width: 440px; +} + +.modal-lg { + max-width: 680px; +} + +/* folder mapping */ +.map-hint { + margin: 0 0 16px; + font-size: 12px; + line-height: 1.5; + color: var(--fg-dim); +} + +.map-hint code { + color: var(--accent-strong); + font-family: var(--font-mono); +} + +.map-grid { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 52vh; + overflow-y: auto; + margin-bottom: 20px; +} + +.map-row { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + align-items: center; + gap: 10px; +} + +.map-src { + font-family: var(--font-mono); + font-size: 13px; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.map-arrow { + color: var(--accent); +} + +.map-select { + width: 100%; +} + +.map-new { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--info); +} + .modal-title { font-family: var(--font-mono); font-size: 12px; diff --git a/web/src/components/FolderMappingModal.tsx b/web/src/components/FolderMappingModal.tsx new file mode 100644 index 0000000..bf6e03b --- /dev/null +++ b/web/src/components/FolderMappingModal.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react' +import { Modal } from './Modal' + +type Props = { + open: boolean + srcFolders: string[] + dstFolders: string[] + initialMapping: Record + onConfirm: (mapping: Record) => void + onCancel: () => void +} + +// Pick a sensible default destination for a source folder: an explicit prior +// mapping, else an exact same-name match on the destination, else keep the name. +function defaultDst(src: string, dstFolders: string[], initial: Record): string { + if (initial[src]) return initial[src] + if (dstFolders.includes(src)) return src + return src +} + +export function FolderMappingModal({ open, srcFolders, dstFolders, initialMapping, onConfirm, onCancel }: Props) { + const [choice, setChoice] = useState>({}) + + // Options per select: all destination folders, plus the source name itself + // (marked "create") when it does not already exist on the destination. + const options = useMemo(() => { + const set = new Set(dstFolders) + return (src: string) => { + const opts = [...dstFolders] + if (!set.has(src)) opts.unshift(src) + return opts + } + }, [dstFolders]) + + const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping) + + function confirm() { + const mapping: Record = { ...initialMapping } + for (const src of srcFolders) { + const dst = valueFor(src) + if (dst === src) delete mapping[src] + else mapping[src] = dst + } + onConfirm(mapping) + } + + return ( + +
+

+ Route each source folder to an existing destination folder. Leaving a folder mapped to its own name + creates it on the destination if missing (e.g. map СпамSpam to avoid duplicates). +

+
+ {srcFolders.map((src) => { + const val = valueFor(src) + const creates = !dstFolders.includes(val) + return ( +
+ + {src} + + + + {creates && new} +
+ ) + })} +
+
+ + +
+
+
+ ) +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 322d546..bb4df2c 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -6,6 +6,7 @@ type ModalProps = { title?: string onClose: () => void children: ReactNode + size?: 'md' | 'lg' } function focusable(root: HTMLElement | null): HTMLElement[] { @@ -19,7 +20,7 @@ function focusable(root: HTMLElement | null): HTMLElement[] { // Reusable modal shell: portal to , ESC to close, Tab focus-trap, // focus-on-open (prefers [data-modal-autofocus]), focus restore on close, // click-on-overlay to close, and body scroll lock while open. -export function Modal({ open, title, onClose, children }: ModalProps) { +export function Modal({ open, title, onClose, children, size = 'md' }: ModalProps) { const dialogRef = useRef(null) const prevFocus = useRef(null) const onCloseRef = useRef(onClose) @@ -78,7 +79,7 @@ export function Modal({ open, title, onClose, children }: ModalProps) { if (e.target === e.currentTarget) onClose() }} > -
+
{title &&
{title}
} {children}
diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 2f466b3..24230bc 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeFolders, runTask, setFolderMapping, testAccounts, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' +import { FolderMappingModal } from '../components/FolderMappingModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -72,7 +73,8 @@ export function TaskDetail({ id }: { id: number }) { const [notFound, setNotFound] = useState(false) const [log, setLog] = useState<{ type: string; text: string }[]>([]) const [form, setForm] = useState(emptyAccount) - const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | null>(null) + const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | 'probe' | null>(null) + const [mapState, setMapState] = useState<{ src: string[]; dst: string[]; creds: typeof emptyAccount } | null>(null) const confirm = useConfirm() const [error, setError] = useState(null) const [live, setLive] = useState>({}) @@ -164,11 +166,35 @@ export function TaskDetail({ id }: { id: number }) { async function submitAccount(e: FormEvent) { e.preventDefault() + setBusy('probe') + setError(null) + try { + const creds = { ...form } + const res = await probeFolders(id, creds) + 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(`Connection test failed — ${parts.join('; ')}`) + return + } + setMapState({ src: res.src.folders ?? [], dst: res.dst.folders ?? [], creds }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to test connections') + } finally { + setBusy(null) + } + } + + async function confirmMapping(mapping: Record) { + if (!mapState) return setBusy('add') setError(null) try { - await createAccount(id, form) + await createAccount(id, mapState.creds) + await setFolderMapping(id, mapping) setForm(emptyAccount) + setMapState(null) reload() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add account') @@ -382,7 +408,7 @@ export function TaskDetail({ id }: { id: number }) {
@@ -510,6 +536,15 @@ export function TaskDetail({ id }: { id: number }) { + + setMapState(null)} + onConfirm={confirmMapping} + /> ) }