Compare commits

..
4 Commits
Author SHA1 Message Date
vasyanskandClaude Opus 5 76ada57dd7 Add a defaults button to the folder mapping dialog
Mapping the Exchange/Kerio special folders onto mailcow's by hand is
repetitive work that scales with the number of accounts. "By default"
maps Deleted Items to Trash, Junk E-mail to Junk, Sent Items to Sent and
unchecks Public Folders, which mailcow has no counterpart for.

The destination select only offered the source folder as a name to
create, so a target missing on the destination could not be selected at
all. It now also offers the current selection, and marks any name absent
from the destination as "(create)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:50:55 +07:00
vasyanskandClaude Opus 5 bb3635e517 Add a Kerio sample file to the bulk import
Only the plain four-column format had a downloadable example, leaving
the Kerio route undocumented in the UI. Each import button now carries
its own sample link underneath: the plain comma-separated layout and a
Kerio export with the Name;FullName;Description;Enable header, a
disabled row included to show what the import skips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:50:45 +07:00
vasyanskandClaude Opus 5 8bc7ff026d Add endpoint deletion
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>
2026-07-28 11:50:12 +07:00
vasyanskandClaude Opus 5 94fb410c59 Fix postgres healthcheck probing a nonexistent database
pg_isready without -d connects to a database named after the user, but
the database is imapcopier, so every probe logged a FATAL and the
healthcheck only passed because pg_isready treats "server rejects the
connection" as reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:50:02 +07:00
11 changed files with 252 additions and 39 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U imap"] test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
+1 -1
View File
@@ -8,7 +8,7 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U imap imapcopier"] test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
+33
View File
@@ -2,8 +2,11 @@ package httpapi
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"github.com/jackc/pgx/v5/pgconn"
"github.com/vasyansk/imap-copier/internal/store" "github.com/vasyansk/imap-copier/internal/store"
) )
@@ -54,6 +57,36 @@ func (s *Server) handleUpdateEndpoint(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handleDeleteEndpoint removes an endpoint that no task references. A referenced
// endpoint is refused with 409 rather than a foreign-key error, and the same
// status covers the race where a task is created between check and delete.
func (s *Server) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
used, err := s.store.CountTasksUsingEndpoint(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if used > 0 {
http.Error(w, fmt.Sprintf("endpoint is used by %d task(s) — delete them first", used), http.StatusConflict)
return
}
if err := s.store.DeleteEndpoint(r.Context(), id); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
http.Error(w, "endpoint is used by a task — delete it first", http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
eps, err := s.store.ListEndpoints(r.Context()) eps, err := s.store.ListEndpoints(r.Context())
if err != nil { if err != nil {
+1
View File
@@ -15,6 +15,7 @@ func (s *Server) Router() http.Handler {
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints) api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint) api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint) api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint)
api.HandleFunc("DELETE /api/endpoints/{id}", s.handleDeleteEndpoint)
api.HandleFunc("GET /api/tasks", s.handleListTasks) api.HandleFunc("GET /api/tasks", s.handleListTasks)
api.HandleFunc("POST /api/tasks", s.handleCreateTask) api.HandleFunc("POST /api/tasks", s.handleCreateTask)
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
+35
View File
@@ -18,6 +18,41 @@ func TestUpdateEndpoint(t *testing.T) {
} }
} }
func TestDeleteEndpoint(t *testing.T) {
s := testStore(t)
ctx := context.Background()
id, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a.com", Port: 993, TLSMode: "ssl"})
if err := s.DeleteEndpoint(ctx, id); err != nil {
t.Fatalf("delete: %v", err)
}
eps, _ := s.ListEndpoints(ctx)
if len(eps) != 0 {
t.Fatalf("endpoint not deleted: %d remain", len(eps))
}
}
func TestDeleteEndpointUsedByTaskRefused(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ep1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"})
ep2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"})
if _, err := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: ep1, DstEndpointID: ep2}); err != nil {
t.Fatalf("create task: %v", err)
}
for _, id := range []int64{ep1, ep2} {
n, err := s.CountTasksUsingEndpoint(ctx, id)
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Fatalf("count for ep %d = %d, want 1", id, n)
}
if err := s.DeleteEndpoint(ctx, id); err == nil {
t.Fatalf("delete of referenced endpoint %d succeeded, want FK violation", id)
}
}
}
func TestDeleteAccountCascadesJournal(t *testing.T) { func TestDeleteAccountCascadesJournal(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
+17
View File
@@ -26,6 +26,23 @@ func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error {
return err return err
} }
// CountTasksUsingEndpoint reports how many tasks reference the endpoint on
// either side, so a delete can be refused with a meaningful message instead of
// surfacing a raw foreign-key violation.
func (s *Store) CountTasksUsingEndpoint(ctx context.Context, id int64) (int, error) {
var n int
err := s.Pool.QueryRow(ctx,
`SELECT count(*) FROM tasks WHERE src_endpoint_id=$1 OR dst_endpoint_id=$1`, id).Scan(&n)
return n, err
}
// DeleteEndpoint removes an endpoint. Tasks reference endpoints without ON
// DELETE CASCADE, so Postgres rejects the delete while any task still uses it.
func (s *Store) DeleteEndpoint(ctx context.Context, id int64) error {
_, err := s.Pool.Exec(ctx, `DELETE FROM endpoints WHERE id=$1`, id)
return err
}
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
var e Endpoint var e Endpoint
err := s.Pool.QueryRow(ctx, err := s.Pool.QueryRow(ctx,
+2
View File
@@ -82,6 +82,8 @@ export const updateEndpoint = (
body: { role_label: string; host: string; port: number; tls_mode: TLSMode }, body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' }) ) => 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 deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
export const deleteAccount = (taskId: number, accountId: number) => export const deleteAccount = (taskId: number, accountId: number) =>
+27 -2
View File
@@ -488,11 +488,23 @@
cursor: pointer; cursor: pointer;
} }
.map-toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.map-toolbar .btn {
padding: 6px 12px;
font-size: 11px;
}
.map-all { .map-all {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -905,11 +917,24 @@ table.tbl a.rowlink:hover {
.upload-row { .upload-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
/* One import route per column: the action on top, its sample file underneath. */
.upload-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.upload-item .link-btn {
align-self: center;
text-align: center;
}
.file-btn { .file-btn {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
+65 -16
View File
@@ -20,6 +20,21 @@ function defaultDst(src: string, dstFolders: string[], initial: Record<string, s
return src return src
} }
// Exchange/Kerio special folders and their mailcow counterparts. Keyed by the
// lowercased source name so casing differences between servers don't matter.
const DEFAULT_TARGETS: Record<string, string> = {
'deleted items': 'Trash',
'deleted messages': 'Trash',
'junk e-mail': 'Junk',
'junk email': 'Junk',
spam: 'Junk',
'sent items': 'Sent',
'sent messages': 'Sent',
}
// Source folders with no counterpart on mailcow — unchecked by the defaults.
const DEFAULT_EXCLUDED = new Set(['public folders'])
export function FolderMappingModal({ export function FolderMappingModal({
open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel, open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel,
}: Props) { }: Props) {
@@ -31,16 +46,40 @@ export function FolderMappingModal({
// Options per select: all destination folders, plus the source name itself // Options per select: all destination folders, plus the source name itself
// (marked "create") when it does not already exist on the destination. // (marked "create") when it does not already exist on the destination.
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping)
// Options per select: all destination folders, plus any name not present there
// — the source folder itself and the current selection — marked "create".
const options = useMemo(() => { const options = useMemo(() => {
const set = new Set(dstFolders) const set = new Set(dstFolders)
return (src: string) => { return (src: string, current: string) => {
const opts = [...dstFolders] const opts = [...dstFolders]
if (!set.has(src)) opts.unshift(src) if (!set.has(current)) opts.unshift(current)
if (!set.has(src) && src !== current) opts.unshift(src)
return opts return opts
} }
}, [dstFolders]) }, [dstFolders])
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping) // Collapse the Exchange/Kerio folder layout onto mailcow's in one click: the
// per-account mapping is otherwise repetitive work when importing many users.
function applyDefaults() {
const nextChoice = { ...choice }
const nextSynced = { ...synced }
for (const src of srcFolders) {
const key = src.trim().toLowerCase()
if (DEFAULT_EXCLUDED.has(key)) {
nextSynced[src] = false
continue
}
const target = DEFAULT_TARGETS[key]
if (target) {
nextChoice[src] = target
nextSynced[src] = true
}
}
setChoice(nextChoice)
setSynced(nextSynced)
}
function confirm() { function confirm() {
const mapping: Record<string, string> = {} const mapping: Record<string, string> = {}
@@ -70,17 +109,27 @@ export function FolderMappingModal({
Route each source folder to an existing destination folder. Leaving a folder mapped to its own name 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 <code>Спам</code> <code>Spam</code> to avoid duplicates). creates it on the destination if missing (e.g. map <code>Спам</code> <code>Spam</code> to avoid duplicates).
</p> </p>
<label className="map-all"> <div className="map-toolbar">
<input <button
type="checkbox" type="button"
checked={srcFolders.every((f) => synced[f] !== false)} className="btn btn-ghost"
onChange={(e) => { onClick={applyDefaults}
const on = e.target.checked title="Map Deleted Items → Trash, Junk E-mail → Junk, Sent Items → Sent and skip Public Folders"
setSynced(Object.fromEntries(srcFolders.map((f) => [f, on]))) >
}} By default
/> </button>
sync all folders <label className="map-all">
</label> <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>
<div className="map-grid"> <div className="map-grid">
{srcFolders.map((src) => { {srcFolders.map((src) => {
const on = synced[src] !== false const on = synced[src] !== false
@@ -107,10 +156,10 @@ export function FolderMappingModal({
disabled={!on} disabled={!on}
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))} onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
> >
{options(src).map((f) => ( {options(src, val).map((f) => (
<option key={f} value={f}> <option key={f} value={f}>
{f} {f}
{f === src && !dstFolders.includes(src) ? ' (create)' : ''} {dstFolders.includes(f) ? '' : ' (create)'}
</option> </option>
))} ))}
</select> </select>
+25 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { createEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api' import { createEndpoint, deleteEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api'
import { useConfirm } from '../components/ConfirmProvider'
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode } const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
@@ -9,6 +10,7 @@ export function Endpoints() {
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const confirm = useConfirm()
function startEdit(ep: Endpoint) { function startEdit(ep: Endpoint) {
setEditingId(ep.id) setEditingId(ep.id)
@@ -29,6 +31,25 @@ export function Endpoints() {
useEffect(reload, []) useEffect(reload, [])
async function onDelete(ep: Endpoint) {
const ok = await confirm({
title: 'Delete endpoint',
message: `Delete endpoint "${ep.role_label}" (${ep.host}:${ep.port})?`,
confirmLabel: 'Delete',
danger: true,
})
if (!ok) return
setError(null)
try {
await deleteEndpoint(ep.id)
// The form still edits a row that no longer exists — drop back to create mode.
if (editingId === ep.id) cancelEdit()
reload()
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete endpoint')
}
}
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
e.preventDefault() e.preventDefault()
setBusy(true) setBusy(true)
@@ -159,6 +180,9 @@ export function Endpoints() {
<td className="num-cell"> <td className="num-cell">
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}> <button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
edit edit
</button>{' '}
<button type="button" className="link-btn danger" onClick={() => onDelete(ep)} disabled={busy}>
delete
</button> </button>
</td> </td>
</tr> </tr>
+45 -18
View File
@@ -260,20 +260,40 @@ export function TaskDetail({ id }: { id: number }) {
} }
} }
function downloadExampleCSV() { function downloadCSV(name: string, content: string) {
const sample = [ const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }))
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
'bob@source.example,SrcPass2,bob@dest.example,DstPass2',
'carol@source.example,SrcPass3,carol@dest.example,DstPass3',
].join('\n') + '\n'
const url = URL.createObjectURL(new Blob([sample], { type: 'text/csv' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = 'imap-copier-accounts-example.csv' a.download = name
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
// Plain import: comma-separated, no header, src_login,src_pass,dst_login,dst_pass.
function downloadExampleCSV() {
const sample =
[
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
'bob@source.example,SrcPass2,bob@dest.example,DstPass2',
'carol@source.example,SrcPass3,carol@dest.example,DstPass3',
].join('\n') + '\n'
downloadCSV('imap-copier-accounts-example.csv', sample)
}
// Kerio Connect export: semicolon-separated with a Name;FullName;Description;Enable
// header. The password lives in Description; disabled rows and admin are skipped
// on import, and the domain is supplied in the import dialog.
function downloadKerioExampleCSV() {
const sample =
[
'Name;FullName;Description;Enable',
'alice;Alice Smith;SrcPass1;Yes',
'bob;Bob Jones;SrcPass2;Yes',
'carol;Carol White (disabled, skipped);SrcPass3;No',
].join('\n') + '\n'
downloadCSV('kerio-users-example.csv', sample)
}
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) { async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
@@ -556,16 +576,23 @@ export function TaskDetail({ id }: { id: number }) {
<div className="divider-label">or bulk import</div> <div className="divider-label">or bulk import</div>
<div className="upload-row"> <div className="upload-row">
<label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}> <div className="upload-item">
{busy === 'import' ? 'Importing…' : 'Upload CSV'} <label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} /> {busy === 'import' ? 'Importing…' : 'Upload CSV'}
</label> <input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}> </label>
Import from Kerio <button type="button" className="link-btn" onClick={downloadExampleCSV}>
</button> download example.csv
<button type="button" className="link-btn" onClick={downloadExampleCSV}> </button>
download example.csv </div>
</button> <div className="upload-item">
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
Import from Kerio
</button>
<button type="button" className="link-btn" onClick={downloadKerioExampleCSV}>
download kerio example.csv
</button>
</div>
</div> </div>
</div> </div>