diff --git a/backend/db/migrations/0067_email_account_enabled.sql b/backend/db/migrations/0067_email_account_enabled.sql new file mode 100644 index 0000000..94eae29 --- /dev/null +++ b/backend/db/migrations/0067_email_account_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_credentials" + ADD COLUMN IF NOT EXISTS "email_enabled" boolean DEFAULT true NOT NULL; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index c8904de..16d9fde 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1788799700000, "tag": "0066_email_entity_links", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1788801000000, + "tag": "0067_email_account_enabled", + "breakpoints": true } ] } diff --git a/backend/db/schema/user_credentials.ts b/backend/db/schema/user_credentials.ts index f1ae045..b7ad995 100644 --- a/backend/db/schema/user_credentials.ts +++ b/backend/db/schema/user_credentials.ts @@ -35,6 +35,7 @@ export const userCredentials = pgTable("user_credentials", { smtpSsl: boolean("smtp_ssl"), type: credentialTypesEnum("type").notNull(), + emailEnabled: boolean("email_enabled").notNull().default(true), imapPort: numeric("imap_port"), imapSsl: boolean("imap_ssl"), diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts index 017c193..315c2ad 100644 --- a/backend/src/routes/emailAsUser.ts +++ b/backend/src/routes/emailAsUser.ts @@ -1,12 +1,13 @@ import nodemailer from "nodemailer" import { FastifyInstance } from "fastify" -import { and, desc, eq } from "drizzle-orm" +import { and, desc, eq, isNotNull } from "drizzle-orm" import { encrypt, decrypt } from "../utils/crypt" import { customers, emailEntityLinks, emailMessages, + emailSyncState, plants, projects, userCredentials, @@ -54,6 +55,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { userId: row.userId, tenantId: row.tenantId, type: row.type, + emailEnabled: row.emailEnabled !== false, email, displayName: email || `Mailkonto ${String(row.id).slice(0, 8)} muss repariert werden`, smtpHost, @@ -68,6 +70,24 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { } } + const accountResponseWithStatus = async (row: any) => { + const syncRows = await server.db + .select({ lastSyncedAt: emailSyncState.lastSyncedAt }) + .from(emailSyncState) + .where(and( + eq(emailSyncState.accountId, row.id), + eq(emailSyncState.tenantId, row.tenantId), + isNotNull(emailSyncState.lastSyncedAt), + )) + .orderBy(desc(emailSyncState.lastSyncedAt)) + .limit(1) + + return { + ...accountResponse(row), + lastSyncedAt: syncRows[0]?.lastSyncedAt || null, + } + } + const accountCredentials = (row: any) => { const account = accountResponse(row) if (!account.credentialsReadable) { @@ -148,13 +168,14 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { reply.header("Access-Control-Expose-Headers", "Authorization, Content-Disposition, Content-Type, Content-Length") } - const accountWhere = (tenantId: number, userId: string, id?: string) => { + const accountWhere = (tenantId: number, userId: string, id?: string, enabledOnly = false) => { const conditions = [ eq(userCredentials.tenantId, tenantId), eq(userCredentials.userId, userId), eq(userCredentials.type, "mail"), ] if (id) conditions.push(eq(userCredentials.id, id)) + if (enabledOnly) conditions.push(eq(userCredentials.emailEnabled, true)) return and(...conditions) } @@ -185,6 +206,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { imap_host?: string imap_port?: number imap_ssl?: boolean + emailEnabled?: boolean } // ----------------------------- @@ -208,6 +230,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined, imapPort: bodyValue(body, "imapPort", "imap_port"), imapSsl: bodyValue(body, "imapSsl", "imap_ssl"), + emailEnabled: typeof body.emailEnabled === "boolean" ? body.emailEnabled : undefined, updatedAt: new Date(), } @@ -227,6 +250,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { userId: req.user.user_id, tenantId: req.user.tenant_id, type: "mail", + emailEnabled: body.emailEnabled !== false, emailEncrypted: encrypt(body.email), passwordEncrypted: encrypt(body.password), @@ -262,6 +286,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { } const { id } = req.params as { id?: string } + const query = (req.query || {}) as { includeDisabled?: string | boolean } // ============================================================ // LOAD SINGLE ACCOUNT @@ -276,7 +301,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { const row = rows[0] if (!row) return reply.code(404).send({ error: "Not found" }) - return reply.send(accountResponse(row)) + return reply.send(await accountResponseWithStatus(row)) } // ============================================================ @@ -285,9 +310,14 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { const rows = await server.db .select() .from(userCredentials) - .where(accountWhere(req.user.tenant_id, req.user.user_id)) + .where(accountWhere( + req.user.tenant_id, + req.user.user_id, + undefined, + query.includeDisabled !== "true" && query.includeDisabled !== true, + )) - return reply.send(rows.map(accountResponse)) + return reply.send(await Promise.all(rows.map(accountResponseWithStatus))) } catch (err) { console.error("GET /email/accounts error:", err) @@ -319,7 +349,7 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { const rows = await server.db .select() .from(userCredentials) - .where(accountWhere(req.user.tenant_id, req.user.user_id, body.account)) + .where(accountWhere(req.user.tenant_id, req.user.user_id, body.account, true)) .limit(1) const row = rows[0] @@ -444,6 +474,17 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { const { id } = req.params as { id: string } const body = (req.body || {}) as { mailbox?: string; limit?: number } + const accountRows = await server.db + .select({ emailEnabled: userCredentials.emailEnabled }) + .from(userCredentials) + .where(accountWhere(req.user.tenant_id, req.user.user_id, id)) + .limit(1) + + if (!accountRows[0]) return reply.code(404).send({ error: "E-Mail-Konto nicht gefunden" }) + if (!accountRows[0].emailEnabled) { + return reply.code(409).send({ error: "Das E-Mail-Konto ist deaktiviert" }) + } + const result = await emailSync.syncAccount( req.user.tenant_id, req.user.user_id, diff --git a/frontend/pages/settings/emailaccounts/index.vue b/frontend/pages/settings/emailaccounts/index.vue index 6f824b7..6e6b9b0 100644 --- a/frontend/pages/settings/emailaccounts/index.vue +++ b/frontend/pages/settings/emailaccounts/index.vue @@ -3,16 +3,58 @@ const toast = useToast() const items = ref([]) const loading = ref(true) const syncingAccount = ref(null) +const updatingAccount = ref(null) + +const formatLastUpdated = (value?: string | null) => { + if (!value) return "Noch nicht synchronisiert" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "Unbekannt" + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(date) +} const setupPage = async () => { loading.value = true try { - items.value = await useNuxtApp().$api("/api/email/accounts") + items.value = await useNuxtApp().$api("/api/email/accounts", { + query: { includeDisabled: true }, + }) } finally { loading.value = false } } +const toggleAccount = async (account: any) => { + updatingAccount.value = account.id + const nextEnabled = account.emailEnabled === false + + try { + await useNuxtApp().$api(`/api/email/accounts/${account.id}`, { + method: "POST", + body: { emailEnabled: nextEnabled }, + }) + toast.add({ + title: nextEnabled ? "E-Mail-Konto aktiviert" : "E-Mail-Konto deaktiviert", + description: nextEnabled + ? "Das Konto erscheint wieder in der Mailoberfläche." + : "Das Konto wird in der Mailoberfläche nicht mehr angezeigt.", + color: "success", + }) + await setupPage() + } catch (err: any) { + toast.add({ + title: "Status konnte nicht geändert werden", + description: err?.data?.error || err?.message, + color: "error", + }) + } finally { + updatingAccount.value = null + } +} + const syncAccount = async (account: any) => { syncingAccount.value = account.id try { @@ -82,11 +124,19 @@ setupPage() > IMAP/SMTP + + {{ account.emailEnabled === false ? "Deaktiviert" : "Aktiv" }} +
IMAP: {{ account.imapHost || "nicht gesetzt" }}:{{ account.imapPort || "-" }} SMTP: {{ account.smtpHost || "nicht gesetzt" }}:{{ account.smtpPort || "-" }} {{ account.hasPassword ? "Passwort hinterlegt" : "Passwort fehlt" }} + Zuletzt aktualisiert: {{ formatLastUpdated(account.lastSyncedAt) }}

Die verschlüsselten Zugangsdaten sind nicht lesbar. Öffne das Konto und trage alle Zugangsdaten neu ein. @@ -99,11 +149,20 @@ setupPage() color="neutral" variant="soft" :loading="syncingAccount === account.id" - :disabled="account.credentialsReadable === false" + :disabled="account.credentialsReadable === false || account.emailEnabled === false" @click.stop="syncAccount(account)" > Synchronisieren + + {{ account.emailEnabled === false ? "Aktivieren" : "Deaktivieren" }} +