From 21e2bc2755307f129c26a18d0465a49f332fa3a4 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sat, 23 May 2026 20:00:05 +0200 Subject: [PATCH] E-Mail Cache und Konto-Synchronisation vorbereiten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/db/migrations/0049_email_cache.sql | 106 ++++ backend/db/schema/emails.ts | 208 ++++++++ backend/db/schema/index.ts | 1 + .../src/modules/email/email.sync.service.ts | 455 ++++++++++++++++++ backend/src/routes/emailAsUser.ts | 240 ++++++--- frontend/pages/email/new.vue | 2 +- .../settings/emailaccounts/[mode]/[[id]].vue | 237 ++++++--- .../pages/settings/emailaccounts/index.vue | 175 ++++--- 8 files changed, 1204 insertions(+), 220 deletions(-) create mode 100644 backend/db/migrations/0049_email_cache.sql create mode 100644 backend/db/schema/emails.ts create mode 100644 backend/src/modules/email/email.sync.service.ts diff --git a/backend/db/migrations/0049_email_cache.sql b/backend/db/migrations/0049_email_cache.sql new file mode 100644 index 0000000..e14f0ab --- /dev/null +++ b/backend/db/migrations/0049_email_cache.sql @@ -0,0 +1,106 @@ +CREATE TABLE "email_mailboxes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "user_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "path" text NOT NULL, + "delimiter" text, + "name" text NOT NULL, + "special_use" text, + "flags" jsonb, + "exists" integer DEFAULT 0 NOT NULL, + "unseen" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "email_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "user_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "mailbox_id" uuid NOT NULL, + "mailbox_path" text NOT NULL, + "uid" bigint NOT NULL, + "email_id" text, + "message_id" text, + "in_reply_to" text, + "thread_id" text, + "subject" text, + "from" jsonb, + "to" jsonb, + "cc" jsonb, + "bcc" jsonb, + "reply_to" jsonb, + "preview" text, + "flags" jsonb, + "seen" boolean DEFAULT false NOT NULL, + "flagged" boolean DEFAULT false NOT NULL, + "has_attachments" boolean DEFAULT false NOT NULL, + "size" bigint, + "sent_at" timestamp with time zone, + "received_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "email_message_bodies" ( + "message_id" uuid PRIMARY KEY NOT NULL, + "text" text, + "html" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "email_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "message_id" uuid NOT NULL, + "filename" text, + "content_type" text, + "content_id" text, + "disposition" text, + "size" bigint, + "checksum" text, + "storage_key" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "email_sync_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "user_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "mailbox_id" uuid NOT NULL, + "mailbox_path" text NOT NULL, + "uid_validity" bigint, + "highest_uid" bigint DEFAULT 0 NOT NULL, + "mod_seq" text, + "last_synced_at" timestamp with time zone, + "sync_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_message_bodies" ADD CONSTRAINT "email_message_bodies_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_attachments" ADD CONSTRAINT "email_attachments_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +CREATE UNIQUE INDEX "email_mailboxes_account_path_key" ON "email_mailboxes" USING btree ("account_id","path");--> statement-breakpoint +CREATE INDEX "email_mailboxes_tenant_account_idx" ON "email_mailboxes" USING btree ("tenant_id","account_id");--> statement-breakpoint +CREATE UNIQUE INDEX "email_messages_mailbox_uid_key" ON "email_messages" USING btree ("mailbox_id","uid");--> statement-breakpoint +CREATE INDEX "email_messages_account_mailbox_idx" ON "email_messages" USING btree ("account_id","mailbox_path");--> statement-breakpoint +CREATE INDEX "email_messages_received_idx" ON "email_messages" USING btree ("received_at");--> statement-breakpoint +CREATE INDEX "email_messages_message_id_idx" ON "email_messages" USING btree ("message_id");--> statement-breakpoint +CREATE INDEX "email_messages_thread_idx" ON "email_messages" USING btree ("thread_id");--> statement-breakpoint +CREATE INDEX "email_attachments_message_idx" ON "email_attachments" USING btree ("message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "email_sync_state_mailbox_key" ON "email_sync_state" USING btree ("account_id","mailbox_path");--> statement-breakpoint +CREATE INDEX "email_sync_state_tenant_account_idx" ON "email_sync_state" USING btree ("tenant_id","account_id"); diff --git a/backend/db/schema/emails.ts b/backend/db/schema/emails.ts new file mode 100644 index 0000000..1a600eb --- /dev/null +++ b/backend/db/schema/emails.ts @@ -0,0 +1,208 @@ +import { + bigint, + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" +import { userCredentials } from "./user_credentials" + +export const emailMailboxes = pgTable( + "email_mailboxes", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }), + + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + + accountId: uuid("account_id") + .notNull() + .references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }), + + path: text("path").notNull(), + delimiter: text("delimiter"), + name: text("name").notNull(), + specialUse: text("special_use"), + flags: jsonb("flags").$type(), + exists: integer("exists").notNull().default(0), + unseen: integer("unseen").notNull().default(0), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), + }, + (table) => ({ + accountPathKey: uniqueIndex("email_mailboxes_account_path_key") + .on(table.accountId, table.path), + tenantAccountIdx: index("email_mailboxes_tenant_account_idx") + .on(table.tenantId, table.accountId), + }), +) + +export const emailMessages = pgTable( + "email_messages", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }), + + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + + accountId: uuid("account_id") + .notNull() + .references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }), + + mailboxId: uuid("mailbox_id") + .notNull() + .references(() => emailMailboxes.id, { onDelete: "cascade", onUpdate: "cascade" }), + + mailboxPath: text("mailbox_path").notNull(), + uid: bigint("uid", { mode: "number" }).notNull(), + emailId: text("email_id"), + messageId: text("message_id"), + inReplyTo: text("in_reply_to"), + threadId: text("thread_id"), + subject: text("subject"), + from: jsonb("from").$type>(), + to: jsonb("to").$type>(), + cc: jsonb("cc").$type>(), + bcc: jsonb("bcc").$type>(), + replyTo: jsonb("reply_to").$type>(), + preview: text("preview"), + flags: jsonb("flags").$type(), + seen: boolean("seen").notNull().default(false), + flagged: boolean("flagged").notNull().default(false), + hasAttachments: boolean("has_attachments").notNull().default(false), + size: bigint("size", { mode: "number" }), + sentAt: timestamp("sent_at", { withTimezone: true }), + receivedAt: timestamp("received_at", { withTimezone: true }), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), + }, + (table) => ({ + mailboxUidKey: uniqueIndex("email_messages_mailbox_uid_key") + .on(table.mailboxId, table.uid), + accountMailboxIdx: index("email_messages_account_mailbox_idx") + .on(table.accountId, table.mailboxPath), + receivedIdx: index("email_messages_received_idx") + .on(table.receivedAt), + messageIdIdx: index("email_messages_message_id_idx") + .on(table.messageId), + threadIdx: index("email_messages_thread_idx") + .on(table.threadId), + }), +) + +export const emailMessageBodies = pgTable("email_message_bodies", { + messageId: uuid("message_id") + .primaryKey() + .references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }), + + text: text("text"), + html: text("html"), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), +}) + +export const emailAttachments = pgTable( + "email_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + + messageId: uuid("message_id") + .notNull() + .references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }), + + filename: text("filename"), + contentType: text("content_type"), + contentId: text("content_id"), + disposition: text("disposition"), + size: bigint("size", { mode: "number" }), + checksum: text("checksum"), + storageKey: text("storage_key"), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + messageIdx: index("email_attachments_message_idx") + .on(table.messageId), + }), +) + +export const emailSyncState = pgTable( + "email_sync_state", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }), + + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + + accountId: uuid("account_id") + .notNull() + .references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }), + + mailboxId: uuid("mailbox_id") + .notNull() + .references(() => emailMailboxes.id, { onDelete: "cascade", onUpdate: "cascade" }), + + mailboxPath: text("mailbox_path").notNull(), + uidValidity: bigint("uid_validity", { mode: "number" }), + highestUid: bigint("highest_uid", { mode: "number" }).notNull().default(0), + modSeq: text("mod_seq"), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + syncError: text("sync_error"), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), + }, + (table) => ({ + mailboxKey: uniqueIndex("email_sync_state_mailbox_key") + .on(table.accountId, table.mailboxPath), + tenantAccountIdx: index("email_sync_state_tenant_account_idx") + .on(table.tenantId, table.accountId), + }), +) + +export type EmailMailbox = typeof emailMailboxes.$inferSelect +export type NewEmailMailbox = typeof emailMailboxes.$inferInsert +export type EmailMessage = typeof emailMessages.$inferSelect +export type NewEmailMessage = typeof emailMessages.$inferInsert +export type EmailMessageBody = typeof emailMessageBodies.$inferSelect +export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert +export type EmailAttachment = typeof emailAttachments.$inferSelect +export type NewEmailAttachment = typeof emailAttachments.$inferInsert +export type EmailSyncState = typeof emailSyncState.$inferSelect +export type NewEmailSyncState = typeof emailSyncState.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 8c03b98..2be79b0 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -27,6 +27,7 @@ export * from "./customerspaces" export * from "./customerinventoryitems" export * from "./devices" export * from "./documentboxes" +export * from "./emails" export * from "./enums" export * from "./events" export * from "./entitybankaccounts" diff --git a/backend/src/modules/email/email.sync.service.ts b/backend/src/modules/email/email.sync.service.ts new file mode 100644 index 0000000..aa4b4a2 --- /dev/null +++ b/backend/src/modules/email/email.sync.service.ts @@ -0,0 +1,455 @@ +import { FastifyInstance } from "fastify" +import { and, desc, eq } from "drizzle-orm" +import { ImapFlow } from "imapflow" +import { simpleParser } from "mailparser" + +import { + emailAttachments, + emailMailboxes, + emailMessageBodies, + emailMessages, + emailSyncState, + userCredentials, +} from "../../../db/schema" +import { decrypt } from "../../utils/crypt" + +type EmailAddress = { + name?: string | null + address?: string | null +} + +type SyncOptions = { + mailbox?: string + limit?: number +} + +type MailAccountConnection = { + id: string + tenantId: number + userId: string + email: string + password: string + imapHost: string + imapPort: number + imapSsl: boolean +} + +const decryptValue = (value: unknown) => value ? decrypt(value as any) : "" + +const normalizeAddressList = (addresses: any): EmailAddress[] => { + const value = Array.isArray(addresses?.value) ? addresses.value : [] + return value.map((item: any) => ({ + name: item.name || null, + address: item.address || null, + })) +} + +const previewText = (text?: string | false | null) => { + if (!text) return null + return text.replace(/\s+/g, " ").trim().slice(0, 240) || null +} + +const flagsFromMessage = (flags: Set | string[] | undefined) => { + if (!flags) return [] + return Array.isArray(flags) ? flags : Array.from(flags) +} + +const mailboxDisplayName = (path: string) => { + const parts = path.split(/[/.]/).filter(Boolean) + return parts[parts.length - 1] || path +} + +export function emailSyncService(server: FastifyInstance) { + const getAccount = async ( + tenantId: number, + userId: string, + accountId: string, + ): Promise => { + const rows = await server.db + .select() + .from(userCredentials) + .where(and( + eq(userCredentials.id, accountId), + eq(userCredentials.tenantId, tenantId), + eq(userCredentials.userId, userId), + eq(userCredentials.type, "mail"), + )) + .limit(1) + + const row = rows[0] + if (!row) return null + + return { + id: row.id, + tenantId: row.tenantId, + userId: row.userId, + email: decryptValue(row.emailEncrypted), + password: decryptValue(row.passwordEncrypted), + imapHost: decryptValue(row.imapHostEncrypted), + imapPort: Number(row.imapPort || 993), + imapSsl: row.imapSsl !== false, + } + } + + const createClient = (account: MailAccountConnection) => new ImapFlow({ + host: account.imapHost, + port: account.imapPort, + secure: account.imapSsl, + auth: { + user: account.email, + pass: account.password, + }, + logger: false, + }) + + const upsertMailbox = async ( + account: MailAccountConnection, + mailbox: any, + status?: { exists?: number; unseen?: number }, + ) => { + const path = mailbox.path || mailbox.name + const [saved] = await server.db + .insert(emailMailboxes) + .values({ + tenantId: account.tenantId, + userId: account.userId, + accountId: account.id, + path, + delimiter: mailbox.delimiter || null, + name: mailbox.name || mailboxDisplayName(path), + specialUse: mailbox.specialUse || null, + flags: flagsFromMessage(mailbox.flags), + exists: status?.exists || 0, + unseen: status?.unseen || 0, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [emailMailboxes.accountId, emailMailboxes.path], + set: { + delimiter: mailbox.delimiter || null, + name: mailbox.name || mailboxDisplayName(path), + specialUse: mailbox.specialUse || null, + flags: flagsFromMessage(mailbox.flags), + exists: status?.exists || 0, + unseen: status?.unseen || 0, + updatedAt: new Date(), + }, + }) + .returning() + + return saved + } + + const syncMailboxes = async (account: MailAccountConnection, client: ImapFlow) => { + const savedMailboxes = [] + + for await (const mailbox of await client.list()) { + savedMailboxes.push(await upsertMailbox(account, mailbox)) + } + + return savedMailboxes + } + + const loadSyncState = async (account: MailAccountConnection, mailbox: any) => { + const rows = await server.db + .select() + .from(emailSyncState) + .where(and( + eq(emailSyncState.accountId, account.id), + eq(emailSyncState.mailboxPath, mailbox.path), + )) + .limit(1) + + return rows[0] || null + } + + const saveSyncState = async ( + account: MailAccountConnection, + mailbox: any, + highestUid: number, + uidValidity?: number | null, + syncError?: string | null, + ) => { + await server.db + .insert(emailSyncState) + .values({ + tenantId: account.tenantId, + userId: account.userId, + accountId: account.id, + mailboxId: mailbox.id, + mailboxPath: mailbox.path, + uidValidity: uidValidity || null, + highestUid, + lastSyncedAt: new Date(), + syncError: syncError || null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [emailSyncState.accountId, emailSyncState.mailboxPath], + set: { + mailboxId: mailbox.id, + uidValidity: uidValidity || null, + highestUid, + lastSyncedAt: new Date(), + syncError: syncError || null, + updatedAt: new Date(), + }, + }) + } + + const storeMessage = async ( + account: MailAccountConnection, + mailbox: any, + message: any, + ) => { + if (!message.source) return null + + const parsed = await simpleParser(message.source) + const flags = flagsFromMessage(message.flags) + const receivedAt = parsed.date || message.envelope?.date || new Date() + const threadId = parsed.inReplyTo || parsed.references?.[0] || parsed.messageId || message.emailId || null + + const [saved] = await server.db + .insert(emailMessages) + .values({ + tenantId: account.tenantId, + userId: account.userId, + accountId: account.id, + mailboxId: mailbox.id, + mailboxPath: mailbox.path, + uid: Number(message.uid), + emailId: message.emailId || null, + messageId: parsed.messageId || null, + inReplyTo: parsed.inReplyTo || null, + threadId, + subject: parsed.subject || "(kein Betreff)", + from: normalizeAddressList(parsed.from), + to: normalizeAddressList(parsed.to), + cc: normalizeAddressList(parsed.cc), + bcc: normalizeAddressList(parsed.bcc), + replyTo: normalizeAddressList(parsed.replyTo), + preview: previewText(parsed.text), + flags, + seen: flags.includes("\\Seen"), + flagged: flags.includes("\\Flagged"), + hasAttachments: Boolean(parsed.attachments?.length), + size: message.size || null, + sentAt: parsed.date || null, + receivedAt, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [emailMessages.mailboxId, emailMessages.uid], + set: { + emailId: message.emailId || null, + messageId: parsed.messageId || null, + inReplyTo: parsed.inReplyTo || null, + threadId, + subject: parsed.subject || "(kein Betreff)", + from: normalizeAddressList(parsed.from), + to: normalizeAddressList(parsed.to), + cc: normalizeAddressList(parsed.cc), + bcc: normalizeAddressList(parsed.bcc), + replyTo: normalizeAddressList(parsed.replyTo), + preview: previewText(parsed.text), + flags, + seen: flags.includes("\\Seen"), + flagged: flags.includes("\\Flagged"), + hasAttachments: Boolean(parsed.attachments?.length), + size: message.size || null, + sentAt: parsed.date || null, + receivedAt, + updatedAt: new Date(), + }, + }) + .returning() + + await server.db + .insert(emailMessageBodies) + .values({ + messageId: saved.id, + text: parsed.text || null, + html: parsed.html || null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: emailMessageBodies.messageId, + set: { + text: parsed.text || null, + html: parsed.html || null, + updatedAt: new Date(), + }, + }) + + if (parsed.attachments?.length) { + for (const attachment of parsed.attachments) { + await server.db + .insert(emailAttachments) + .values({ + messageId: saved.id, + filename: attachment.filename || null, + contentType: attachment.contentType || null, + contentId: attachment.contentId || null, + disposition: attachment.contentDisposition || null, + size: attachment.size || null, + checksum: attachment.checksum || null, + }) + .onConflictDoNothing() + } + } + + return saved + } + + const syncMailboxMessages = async ( + account: MailAccountConnection, + client: ImapFlow, + mailbox: any, + limit: number, + ) => { + const lock = await client.getMailboxLock(mailbox.path) + let highestUid = 0 + + try { + const opened: any = await client.mailboxOpen(mailbox.path) + await upsertMailbox(account, mailbox, { + exists: opened.exists || 0, + unseen: opened.unseen || 0, + }) + + const state = await loadSyncState(account, mailbox) + const searchResult = await client.search({ all: true }, { uid: true }) + const allUids = Array.isArray(searchResult) ? searchResult : [] + const newUids = allUids + .filter((uid: number) => !state?.highestUid || uid > state.highestUid) + .slice(-limit) + + highestUid = Math.max(state?.highestUid || 0, ...newUids, 0) + + if (newUids.length) { + for await (const message of client.fetch(newUids, { + uid: true, + envelope: true, + flags: true, + source: true, + size: true, + }, { uid: true })) { + await storeMessage(account, mailbox, message) + } + } + + await saveSyncState(account, mailbox, highestUid, Number(opened.uidValidity || 0)) + return { path: mailbox.path, fetched: newUids.length, highestUid } + } catch (err: any) { + await saveSyncState(account, mailbox, highestUid, null, err.message || "Sync fehlgeschlagen") + throw err + } finally { + lock.release() + } + } + + const syncAccount = async ( + tenantId: number, + userId: string, + accountId: string, + options: SyncOptions = {}, + ) => { + const account = await getAccount(tenantId, userId, accountId) + if (!account) { + throw new Error("E-Mail Konto nicht gefunden") + } + + const client = createClient(account) + const limit = Math.min(Math.max(Number(options.limit || 50), 1), 200) + + await client.connect() + + try { + const mailboxes = await syncMailboxes(account, client) + const syncTargets = options.mailbox + ? mailboxes.filter((mailbox) => mailbox.path === options.mailbox) + : mailboxes.filter((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") + + const synced = [] + for (const mailbox of syncTargets.length ? syncTargets : mailboxes.slice(0, 1)) { + synced.push(await syncMailboxMessages(account, client, mailbox, limit)) + } + + return { + accountId, + mailboxes: mailboxes.length, + synced, + } + } finally { + await client.logout().catch(() => client.close()) + } + } + + const listMailboxes = async (tenantId: number, userId: string, accountId: string) => { + return await server.db + .select() + .from(emailMailboxes) + .where(and( + eq(emailMailboxes.tenantId, tenantId), + eq(emailMailboxes.userId, userId), + eq(emailMailboxes.accountId, accountId), + )) + .orderBy(emailMailboxes.specialUse, emailMailboxes.name) + } + + const listMessages = async ( + tenantId: number, + userId: string, + accountId: string, + mailboxPath = "INBOX", + limit = 50, + ) => { + return await server.db + .select() + .from(emailMessages) + .where(and( + eq(emailMessages.tenantId, tenantId), + eq(emailMessages.userId, userId), + eq(emailMessages.accountId, accountId), + eq(emailMessages.mailboxPath, mailboxPath), + )) + .orderBy(desc(emailMessages.receivedAt)) + .limit(Math.min(Math.max(Number(limit), 1), 200)) + } + + const getMessage = async (tenantId: number, userId: string, messageId: string) => { + const rows = await server.db + .select({ + message: emailMessages, + body: emailMessageBodies, + }) + .from(emailMessages) + .leftJoin(emailMessageBodies, eq(emailMessageBodies.messageId, emailMessages.id)) + .where(and( + eq(emailMessages.tenantId, tenantId), + eq(emailMessages.userId, userId), + eq(emailMessages.id, messageId), + )) + .limit(1) + + if (!rows[0]) return null + + const attachments = await server.db + .select() + .from(emailAttachments) + .where(eq(emailAttachments.messageId, messageId)) + + return { + ...rows[0].message, + body: rows[0].body, + attachments, + } + } + + return { + syncAccount, + listMailboxes, + listMessages, + getMessage, + } +} diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts index c2d147a..de6c29b 100644 --- a/backend/src/routes/emailAsUser.ts +++ b/backend/src/routes/emailAsUser.ts @@ -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" }) + } + }) + } diff --git a/frontend/pages/email/new.vue b/frontend/pages/email/new.vue index f1fc943..bf5e4af 100644 --- a/frontend/pages/email/new.vue +++ b/frontend/pages/email/new.vue @@ -181,7 +181,7 @@ const sendEmail = async () => {
Keine E-Mail Konten vorhanden + E-Mail Konto diff --git a/frontend/pages/settings/emailaccounts/[mode]/[[id]].vue b/frontend/pages/settings/emailaccounts/[mode]/[[id]].vue index 6435276..3039448 100644 --- a/frontend/pages/settings/emailaccounts/[mode]/[[id]].vue +++ b/frontend/pages/settings/emailaccounts/[mode]/[[id]].vue @@ -1,137 +1,224 @@ \ No newline at end of file + diff --git a/frontend/pages/settings/emailaccounts/index.vue b/frontend/pages/settings/emailaccounts/index.vue index b8e9006..ab5aeb4 100644 --- a/frontend/pages/settings/emailaccounts/index.vue +++ b/frontend/pages/settings/emailaccounts/index.vue @@ -1,97 +1,114 @@ -