fix: IMAP-Konto bearbeiten + Löschen auch bei sync_running

- Store: UpdateCredentials() — Zugangsdaten + Passwort neu verschlüsseln,
  setzt status='idle', error_msg='', sync_running=false zurück
- Handler: PATCH /api/imap/{id} unterstützt nun Credential-Update
  (name/host/username vorhanden = Credential-Update, sonst sync_interval)
- Frontend: "Bearbeiten"-Button öffnet Edit-Dialog mit allen Feldern;
  Passwort-Feld leer = unverändertes Passwort
- Frontend: Löschen-Button nicht mehr durch sync_running blockiert
  (nur noch bei status=running gesperrt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-20 00:49:37 +01:00
parent 4a4136e4a6
commit c59cad92be
4 changed files with 184 additions and 3 deletions
+38 -1
View File
@@ -273,7 +273,9 @@ func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, acc)
}
// handleUpdateImapInterval updates the automatic sync interval for an IMAP account.
// handleUpdateImapInterval updates the automatic sync interval or full credentials
// of an IMAP account. When name/host/username are present the credentials are updated;
// otherwise only sync_interval_min is changed.
func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
@@ -300,12 +302,47 @@ func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request
var req struct {
SyncIntervalMin int `json:"sync_interval_min"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Credential update when name/host/username are provided.
if req.Name != "" || req.Host != "" || req.Username != "" {
if req.Name == "" || req.Host == "" || req.Username == "" {
writeError(w, http.StatusBadRequest, "name, host and username are required for credential update")
return
}
port := req.Port
if port == 0 {
port = acc.Port
}
tls := req.TLS
if tls == "" {
tls = acc.TLS
}
updated := imapstore.Account{Name: req.Name, Host: req.Host, Port: port, TLS: tls, Username: req.Username}
if err := s.imapStore.UpdateCredentials(r.Context(), id, updated, req.Password); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update credentials")
return
}
acc, err = s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to reload account")
return
}
writeJSON(w, http.StatusOK, acc)
return
}
// Sync interval update.
// 0 = disabled; otherwise must be between 5 and 1440 minutes.
if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) {
writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440")
+27
View File
@@ -282,6 +282,33 @@ func (s *Store) UpdateDone(ctx context.Context, id int64, count int) error {
return nil
}
// UpdateCredentials updates the connection details and optionally the password
// of an IMAP account. Pass an empty password to leave it unchanged.
func (s *Store) UpdateCredentials(ctx context.Context, id int64, acc Account, password string) error {
if password != "" {
enc, err := encryptPassword(password, s.encKey)
if err != nil {
return fmt.Errorf("imap store: encrypt password: %w", err)
}
_, err = s.pool.Exec(ctx,
`UPDATE imap_accounts SET name=$1, host=$2, port=$3, tls=$4, username=$5, password_enc=$6,
status='idle', error_msg='', sync_running=false WHERE id=$7`,
acc.Name, acc.Host, acc.Port, acc.TLS, acc.Username, enc, id)
if err != nil {
return fmt.Errorf("imap store: update credentials: %w", err)
}
} else {
_, err := s.pool.Exec(ctx,
`UPDATE imap_accounts SET name=$1, host=$2, port=$3, tls=$4, username=$5,
status='idle', error_msg='', sync_running=false WHERE id=$6`,
acc.Name, acc.Host, acc.Port, acc.TLS, acc.Username, id)
if err != nil {
return fmt.Errorf("imap store: update credentials (no pw): %w", err)
}
}
return nil
}
// UpdateSyncInterval sets the automatic sync interval for an account.
// intervalMin == 0 disables automatic sync.
func (s *Store) UpdateSyncInterval(ctx context.Context, id int64, intervalMin int) error {
+108 -1
View File
@@ -12,6 +12,7 @@ import {
getImapProgress,
triggerImapSync,
updateImapInterval,
updateImapAccount,
type ImapAccount,
type ImapFolder,
} from "@/lib/api";
@@ -50,6 +51,17 @@ export default function ImapPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Edit state
const [editAccount, setEditAccount] = useState<ImapAccount | null>(null);
const [editName, setEditName] = useState("");
const [editHost, setEditHost] = useState("");
const [editPort, setEditPort] = useState("993");
const [editTls, setEditTls] = useState("ssl");
const [editUsername, setEditUsername] = useState("");
const [editPassword, setEditPassword] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState("");
// Form state
const [formName, setFormName] = useState("");
const [formHost, setFormHost] = useState("");
@@ -209,6 +221,43 @@ export default function ImapPage() {
setDeleteConfirm(null);
}
function openEdit(acc: ImapAccount) {
setEditAccount(acc);
setEditName(acc.name);
setEditHost(acc.host);
setEditPort(String(acc.port));
setEditTls(acc.tls);
setEditUsername(acc.username);
setEditPassword("");
setEditError("");
}
async function handleEditSave() {
if (!editAccount) return;
if (!editName || !editHost || !editUsername) {
setEditError("Name, Host und Benutzername sind Pflichtfelder.");
return;
}
setEditSaving(true);
setEditError("");
try {
const updated = await updateImapAccount(editAccount.id, {
name: editName,
host: editHost,
port: parseInt(editPort, 10),
tls: editTls,
username: editUsername,
password: editPassword || undefined,
});
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
setEditAccount(null);
} catch (err) {
setEditError(err instanceof Error ? err.message : String(err));
} finally {
setEditSaving(false);
}
}
async function handleSyncNow(id: number) {
try {
const updated = await triggerImapSync(id);
@@ -407,10 +456,17 @@ export default function ImapPage() {
>
Sync jetzt
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openEdit(acc)}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running" || acc.sync_running}
disabled={acc.status === "running"}
onClick={() => setDeleteConfirm(acc.id)}
>
Loeschen
@@ -572,6 +628,57 @@ export default function ImapPage() {
</DialogContent>
</Dialog>
{/* Edit Account Dialog */}
<Dialog open={editAccount !== null} onOpenChange={(open) => { if (!open) setEditAccount(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>IMAP-Konto bearbeiten</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="z.B. Firmen-Mail" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Host</Label>
<Input value={editHost} onChange={(e) => setEditHost(e.target.value)} placeholder="imap.example.com" />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input value={editPort} onChange={(e) => setEditPort(e.target.value)} type="number" />
</div>
</div>
<div className="space-y-1">
<Label>TLS</Label>
<Select value={editTls} onValueChange={setEditTls}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Keine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Benutzername</Label>
<Input value={editUsername} onChange={(e) => setEditUsername(e.target.value)} placeholder="user@example.com" />
</div>
<div className="space-y-1">
<Label>Passwort <span className="text-muted-foreground text-xs">(leer lassen = unveraendert)</span></Label>
<Input value={editPassword} onChange={(e) => setEditPassword(e.target.value)} type="password" placeholder="Neues Passwort eingeben" />
</div>
{editError && <p className="text-sm text-destructive">{editError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditAccount(null)}>Abbrechen</Button>
<Button onClick={handleEditSave} disabled={editSaving}>
{editSaving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteConfirm !== null}
+10
View File
@@ -392,6 +392,16 @@ export async function updateImapInterval(id: number, intervalMin: number): Promi
});
}
export async function updateImapAccount(
id: number,
data: { name: string; host: string; port: number; tls: string; username: string; password?: string }
): Promise<ImapAccount> {
return request<ImapAccount>(`/api/imap/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
// ── POP3 ──────────────────────────────────────────────────────────────────
export interface Pop3Account {