Compare commits
4
Commits
6bf3a6c4ca
...
76ada57dd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ada57dd7
|
||
|
|
bb3635e517
|
||
|
|
8bc7ff026d
|
||
|
|
94fb410c59
|
@@ -8,7 +8,7 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U imap"]
|
||||
test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U imap imapcopier"]
|
||||
test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
@@ -2,8 +2,11 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
eps, err := s.store.ListEndpoints(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,7 @@ func (s *Server) Router() http.Handler {
|
||||
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
|
||||
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
|
||||
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("POST /api/tasks", s.handleCreateTask)
|
||||
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
||||
|
||||
@@ -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) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -26,6 +26,23 @@ func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error {
|
||||
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) {
|
||||
var e Endpoint
|
||||
err := s.Pool.QueryRow(ctx,
|
||||
|
||||
@@ -82,6 +82,8 @@ export const updateEndpoint = (
|
||||
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) =>
|
||||
|
||||
+27
-2
@@ -488,11 +488,23 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -905,11 +917,24 @@ table.tbl a.rowlink:hover {
|
||||
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
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 {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -20,6 +20,21 @@ function defaultDst(src: string, dstFolders: string[], initial: Record<string, s
|
||||
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({
|
||||
open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel,
|
||||
}: Props) {
|
||||
@@ -31,16 +46,40 @@ export function FolderMappingModal({
|
||||
|
||||
// Options per select: all destination folders, plus the source name itself
|
||||
// (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 set = new Set(dstFolders)
|
||||
return (src: string) => {
|
||||
return (src: string, current: string) => {
|
||||
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
|
||||
}
|
||||
}, [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() {
|
||||
const mapping: Record<string, string> = {}
|
||||
@@ -70,6 +109,15 @@ export function FolderMappingModal({
|
||||
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).
|
||||
</p>
|
||||
<div className="map-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={applyDefaults}
|
||||
title="Map Deleted Items → Trash, Junk E-mail → Junk, Sent Items → Sent and skip Public Folders"
|
||||
>
|
||||
By default
|
||||
</button>
|
||||
<label className="map-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -81,6 +129,7 @@ export function FolderMappingModal({
|
||||
/>
|
||||
sync all folders
|
||||
</label>
|
||||
</div>
|
||||
<div className="map-grid">
|
||||
{srcFolders.map((src) => {
|
||||
const on = synced[src] !== false
|
||||
@@ -107,10 +156,10 @@ export function FolderMappingModal({
|
||||
disabled={!on}
|
||||
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
|
||||
>
|
||||
{options(src).map((f) => (
|
||||
{options(src, val).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
{f === src && !dstFolders.includes(src) ? ' (create)' : ''}
|
||||
{dstFolders.includes(f) ? '' : ' (create)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 }
|
||||
|
||||
@@ -9,6 +10,7 @@ export function Endpoints() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const confirm = useConfirm()
|
||||
|
||||
function startEdit(ep: Endpoint) {
|
||||
setEditingId(ep.id)
|
||||
@@ -29,6 +31,25 @@ export function Endpoints() {
|
||||
|
||||
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) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
@@ -159,6 +180,9 @@ export function Endpoints() {
|
||||
<td className="num-cell">
|
||||
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
|
||||
edit
|
||||
</button>{' '}
|
||||
<button type="button" className="link-btn danger" onClick={() => onDelete(ep)} disabled={busy}>
|
||||
delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -260,18 +260,38 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSV(name: string, content: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Plain import: comma-separated, no header, src_login,src_pass,dst_login,dst_pass.
|
||||
function downloadExampleCSV() {
|
||||
const sample = [
|
||||
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'
|
||||
const url = URL.createObjectURL(new Blob([sample], { type: 'text/csv' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'imap-copier-accounts-example.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
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>) {
|
||||
@@ -556,17 +576,24 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
|
||||
<div className="divider-label">or bulk import</div>
|
||||
<div className="upload-row">
|
||||
<div className="upload-item">
|
||||
<label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
|
||||
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
||||
</label>
|
||||
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
|
||||
Import from Kerio
|
||||
</button>
|
||||
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
||||
download example.csv
|
||||
</button>
|
||||
</div>
|
||||
<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 className="panel">
|
||||
|
||||
Reference in New Issue
Block a user