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:
@@ -273,7 +273,9 @@ func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, acc)
|
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) {
|
func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.imapStore == nil {
|
if s.imapStore == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
@@ -300,12 +302,47 @@ func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
SyncIntervalMin int `json:"sync_interval_min"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
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.
|
// 0 = disabled; otherwise must be between 5 and 1440 minutes.
|
||||||
if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) {
|
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")
|
writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440")
|
||||||
|
|||||||
@@ -282,6 +282,33 @@ func (s *Store) UpdateDone(ctx context.Context, id int64, count int) error {
|
|||||||
return nil
|
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.
|
// UpdateSyncInterval sets the automatic sync interval for an account.
|
||||||
// intervalMin == 0 disables automatic sync.
|
// intervalMin == 0 disables automatic sync.
|
||||||
func (s *Store) UpdateSyncInterval(ctx context.Context, id int64, intervalMin int) error {
|
func (s *Store) UpdateSyncInterval(ctx context.Context, id int64, intervalMin int) error {
|
||||||
|
|||||||
+108
-1
@@ -12,6 +12,7 @@ import {
|
|||||||
getImapProgress,
|
getImapProgress,
|
||||||
triggerImapSync,
|
triggerImapSync,
|
||||||
updateImapInterval,
|
updateImapInterval,
|
||||||
|
updateImapAccount,
|
||||||
type ImapAccount,
|
type ImapAccount,
|
||||||
type ImapFolder,
|
type ImapFolder,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -50,6 +51,17 @@ export default function ImapPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
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
|
// Form state
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formHost, setFormHost] = useState("");
|
const [formHost, setFormHost] = useState("");
|
||||||
@@ -209,6 +221,43 @@ export default function ImapPage() {
|
|||||||
setDeleteConfirm(null);
|
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) {
|
async function handleSyncNow(id: number) {
|
||||||
try {
|
try {
|
||||||
const updated = await triggerImapSync(id);
|
const updated = await triggerImapSync(id);
|
||||||
@@ -407,10 +456,17 @@ export default function ImapPage() {
|
|||||||
>
|
>
|
||||||
Sync jetzt
|
Sync jetzt
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEdit(acc)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={acc.status === "running" || acc.sync_running}
|
disabled={acc.status === "running"}
|
||||||
onClick={() => setDeleteConfirm(acc.id)}
|
onClick={() => setDeleteConfirm(acc.id)}
|
||||||
>
|
>
|
||||||
Loeschen
|
Loeschen
|
||||||
@@ -572,6 +628,57 @@ export default function ImapPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteConfirm !== null}
|
open={deleteConfirm !== null}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
// ── POP3 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Pop3Account {
|
export interface Pop3Account {
|
||||||
|
|||||||
Reference in New Issue
Block a user