The endpoints screen could only create and edit servers, so a mistyped or retired endpoint stayed in the list forever. Tasks reference endpoints without ON DELETE CASCADE, so a referenced endpoint is refused with 409 and a count of the tasks using it rather than cascading away migration history. The foreign-key violation is mapped to the same status to cover a task created between check and delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
// REST client for the imap-copier control API.
|
|
// All requests carry the session cookie; a 401 anywhere bounces to #/login.
|
|
|
|
export type TLSMode = 'ssl' | 'starttls' | 'plain'
|
|
|
|
export interface Endpoint {
|
|
id: number
|
|
role_label: string
|
|
host: string
|
|
port: number
|
|
tls_mode: TLSMode
|
|
}
|
|
|
|
export interface Task {
|
|
id: number
|
|
name: string
|
|
src_endpoint_id: number
|
|
dst_endpoint_id: number
|
|
status: string
|
|
folder_mapping?: Record<string, string>
|
|
schedule_interval_seconds?: number
|
|
broken?: boolean
|
|
next_run_at?: string | null
|
|
}
|
|
|
|
export type TestStatus = 'pending' | 'ok' | 'fail' | string
|
|
|
|
export interface Account {
|
|
id: number
|
|
src_login: string
|
|
dst_login: string
|
|
test_src_status: TestStatus
|
|
test_dst_status: TestStatus
|
|
status: string
|
|
copied: number
|
|
skipped: number
|
|
errors: number
|
|
last_error?: string
|
|
folder_mapping?: Record<string, string>
|
|
excluded_folders?: string[]
|
|
}
|
|
|
|
export interface TaskDetail {
|
|
task: Task
|
|
accounts: Account[]
|
|
}
|
|
|
|
export class ApiError extends Error {}
|
|
|
|
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(path, { credentials: 'include', ...opts })
|
|
if (res.status === 401) {
|
|
location.hash = '#/login'
|
|
throw new ApiError('unauthorized')
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.text()
|
|
throw new ApiError(body || res.statusText)
|
|
}
|
|
const ct = res.headers.get('content-type') || ''
|
|
if (ct.includes('application/json')) return res.json() as Promise<T>
|
|
return res.text() as unknown as T
|
|
}
|
|
|
|
const jsonBody = (body: unknown): RequestInit => ({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
export const login = (user: string, pass: string) => api('/api/login', jsonBody({ user, pass }))
|
|
|
|
export const logout = () => api('/api/logout', { method: 'POST' })
|
|
|
|
export const listEndpoints = () => api<Endpoint[]>('/api/endpoints')
|
|
|
|
export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) =>
|
|
api<{ id: number }>('/api/endpoints', jsonBody(body))
|
|
|
|
export const updateEndpoint = (
|
|
id: number,
|
|
body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
|
|
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' })
|
|
|
|
export const deleteEndpoint = (id: number) => api(`/api/endpoints/${id}`, { method: 'DELETE' })
|
|
|
|
export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
|
|
|
|
export const deleteAccount = (taskId: number, accountId: number) =>
|
|
api(`/api/tasks/${taskId}/accounts/${accountId}`, { method: 'DELETE' })
|
|
|
|
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<ProbeResult>(`/api/tasks/${taskId}/probe`, jsonBody(creds))
|
|
|
|
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',
|
|
})
|
|
|
|
export const listTasks = () => api<Task[]>('/api/tasks')
|
|
|
|
export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`)
|
|
|
|
export const createTask = (body: {
|
|
name: string
|
|
src_endpoint_id: number
|
|
dst_endpoint_id: number
|
|
folder_mapping?: Record<string, string>
|
|
}) => api<{ id: number }>('/api/tasks', jsonBody(body))
|
|
|
|
export const createAccount = (
|
|
id: number,
|
|
body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string },
|
|
) => api<{ id: number }>(`/api/tasks/${id}/accounts`, jsonBody(body))
|
|
|
|
export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' })
|
|
|
|
export const runTask = (id: number, accountIds?: number[]) =>
|
|
api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' })
|
|
|
|
export interface Run {
|
|
id: number
|
|
task_id: number
|
|
status: string
|
|
started_at: string
|
|
finished_at: string | null
|
|
total_copied: number
|
|
total_skipped: number
|
|
total_errors: number
|
|
trigger: string
|
|
}
|
|
|
|
export const setTaskSchedule = (taskId: number, intervalSeconds: number) =>
|
|
api(`/api/tasks/${taskId}/schedule`, { ...jsonBody({ interval_seconds: intervalSeconds }), method: 'PUT' })
|
|
|
|
export const listRuns = (taskId: number) => api<Run[]>(`/api/tasks/${taskId}/runs`)
|
|
|
|
export interface AccountError {
|
|
id: number
|
|
account_id: number
|
|
run_id: number
|
|
kind: string // folder | message | account
|
|
folder: string
|
|
message_ref: string
|
|
error: string
|
|
created_at: string
|
|
}
|
|
|
|
export const listAccountErrors = (taskId: number, accountId: number) =>
|
|
api<AccountError[]>(`/api/tasks/${taskId}/accounts/${accountId}/errors`)
|
|
|
|
export const importCSV = (id: number, file: File) => {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
|
}
|
|
|
|
// A Kerio Connect export carries no domain, so the operator supplies it here;
|
|
// the login and password apply to both sides of the migration.
|
|
export const importKerioCSV = (id: number, file: File, domain: string) => {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('format', 'kerio')
|
|
fd.append('domain', domain)
|
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
|
}
|