E-Mail Cache und Konto-Synchronisation vorbereiten

KI-AGENT: Ergänzt Tabellen für lokalen E-Mail-Cache, IMAP-Sync-Service und Inbox-API. Überarbeitet außerdem die E-Mail-Konto-Seiten mit sicherer Passwortbehandlung und manuellem Sync.
This commit is contained in:
2026-05-23 20:00:05 +02:00
parent c699d2ade8
commit 21e2bc2755
8 changed files with 1204 additions and 220 deletions

View File

@@ -1,17 +1,53 @@
import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { and, eq } from "drizzle-orm"
import { sendMailAsUser } from "../utils/emailengine"
import { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema"
// Pfad ggf. anpassen
import { emailSyncService } from "../modules/email/email.sync.service"
// @ts-ignore
import MailComposer from "nodemailer/lib/mail-composer/index.js"
import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) {
const emailSync = emailSyncService(server)
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
const accountResponse = (row: any) => ({
id: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
userId: row.userId,
tenantId: row.tenantId,
type: row.type,
email: encryptedValue(row.emailEncrypted),
smtpHost: encryptedValue(row.smtpHostEncrypted),
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
smtpSsl: row.smtpSsl,
imapHost: encryptedValue(row.imapHostEncrypted),
imapPort: row.imapPort ? Number(row.imapPort) : null,
imapSsl: row.imapSsl,
hasPassword: Boolean(row.passwordEncrypted),
})
const accountCredentials = (row: any) => ({
...accountResponse(row),
password: encryptedValue(row.passwordEncrypted),
})
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
const accountWhere = (tenantId: number, userId: string, id?: string) => {
const conditions = [
eq(userCredentials.tenantId, tenantId),
eq(userCredentials.userId, userId),
eq(userCredentials.type, "mail"),
]
if (id) conditions.push(eq(userCredentials.id, id))
return and(...conditions)
}
// ======================================================================
@@ -28,34 +64,49 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const body = req.body as {
email: string
password: string
smtp_host: string
smtp_port: number
smtp_ssl: boolean
imap_host: string
imap_port: number
imap_ssl: boolean
smtpHost?: string
smtpPort?: number
smtpSsl?: boolean
imapHost?: string
imapPort?: number
imapSsl?: boolean
smtp_host?: string
smtp_port?: number
smtp_ssl?: boolean
imap_host?: string
imap_port?: number
imap_ssl?: boolean
}
// -----------------------------
// UPDATE EXISTING
// -----------------------------
if (id) {
const rows = await server.db
.select({ id: userCredentials.id })
.from(userCredentials)
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
.limit(1)
if (!rows[0]) return reply.code(404).send({ error: "Account not found" })
const saveData = {
emailEncrypted: body.email ? encrypt(body.email) : undefined,
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
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"),
updatedAt: new Date(),
}
await server.db
.update(userCredentials)
//@ts-ignore
.set(saveData)
.where(eq(userCredentials.id, id))
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
return reply.send({ success: true })
}
@@ -71,13 +122,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
emailEncrypted: encrypt(body.email),
passwordEncrypted: encrypt(body.password),
smtpHostEncrypted: encrypt(body.smtp_host),
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
imapHostEncrypted: encrypt(body.imap_host),
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
imapPort: bodyValue(body, "imapPort", "imap_port"),
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
}
//@ts-ignore
@@ -110,24 +161,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, id))
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
.limit(1)
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Not found" })
const returnData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
const cleanKey = key.replace("Encrypted", "")
// @ts-ignore
returnData[cleanKey] = decrypt(val as string)
} else {
returnData[key] = val
}
})
return reply.send(returnData)
return reply.send(accountResponse(row))
}
// ============================================================
@@ -136,24 +176,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.tenantId, req.user.tenant_id))
.where(accountWhere(req.user.tenant_id, req.user.user_id))
const accounts = rows.map(row => {
const temp: any = {}
console.log(row)
Object.entries(row).forEach(([key, val]) => {
console.log(key,val)
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
temp[key.replace("Encrypted", "")] = decrypt(val)
} else {
temp[key] = val
}
})
return temp
})
return reply.send(accounts)
return reply.send(rows.map(accountResponse))
} catch (err) {
console.error("GET /email/accounts error:", err)
@@ -183,21 +208,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, body.account))
.where(accountWhere(req.user.tenant_id, req.user.user_id, body.account))
.limit(1)
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Account not found" })
const accountData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
} else {
accountData[key] = val
}
})
const accountData = accountCredentials(row)
// -------------------------
// SEND EMAIL VIA SMTP
@@ -243,14 +260,31 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const mail = new MailComposer(message)
const raw = await mail.compile().build()
let savedToSent = false
for await (const mailbox of await imap.list()) {
if (mailbox.specialUse === "\\Sent") {
await imap.mailboxOpen(mailbox.path)
await imap.append(mailbox.path, raw, ["\\Seen"])
await imap.logout()
savedToSent = true
break
}
}
if (!savedToSent) {
const sentFallbacks = ["Sent", "Gesendet", "INBOX.Sent"]
for (const path of sentFallbacks) {
try {
await imap.append(path, raw, ["\\Seen"])
savedToSent = true
break
} catch (err) {
// Fallback wird nur genutzt, wenn der Ordner existiert.
}
}
}
await imap.logout()
return reply.send({ success: true })
} catch (err) {
@@ -259,4 +293,80 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
}
})
server.post("/email/accounts/:id/sync", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const body = (req.body || {}) as { mailbox?: string; limit?: number }
const result = await emailSync.syncAccount(
req.user.tenant_id,
req.user.user_id,
id,
body,
)
return reply.send({ success: true, ...result })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail Sync fehlgeschlagen" })
}
})
server.get("/email/accounts/:id/mailboxes", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
return reply.send(await emailSync.listMailboxes(req.user.tenant_id, req.user.user_id, id))
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Postfächer konnten nicht geladen werden" })
}
})
server.get("/email/accounts/:id/messages", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const query = req.query as { mailbox?: string; limit?: string }
return reply.send(await emailSync.listMessages(
req.user.tenant_id,
req.user.user_id,
id,
query.mailbox || "INBOX",
Number(query.limit || 50),
))
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mails konnten nicht geladen werden" })
}
})
server.get("/email/messages/:id", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send(message)
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
}
})
}