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:
106
backend/db/migrations/0049_email_cache.sql
Normal file
106
backend/db/migrations/0049_email_cache.sql
Normal file
@@ -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");
|
||||
208
backend/db/schema/emails.ts
Normal file
208
backend/db/schema/emails.ts
Normal file
@@ -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<string[]>(),
|
||||
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<Array<{ name?: string | null; address?: string | null }>>(),
|
||||
to: jsonb("to").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||
cc: jsonb("cc").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||
bcc: jsonb("bcc").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||
replyTo: jsonb("reply_to").$type<Array<{ name?: string | null; address?: string | null }>>(),
|
||||
preview: text("preview"),
|
||||
flags: jsonb("flags").$type<string[]>(),
|
||||
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
|
||||
@@ -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"
|
||||
|
||||
455
backend/src/modules/email/email.sync.service.ts
Normal file
455
backend/src/modules/email/email.sync.service.ts
Normal file
@@ -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> | 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<MailAccountConnection | null> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ const sendEmail = async () => {
|
||||
<div v-if="noAccountsPresent" class="mx-auto mt-5 flex flex-col justify-center">
|
||||
<span class="font-bold text-2xl">Keine E-Mail Konten vorhanden</span>
|
||||
<UButton
|
||||
@click="router.push(`/settings/emailAccounts`)"
|
||||
@click="router.push(`/settings/emailaccounts`)"
|
||||
class="mx-auto mt-5"
|
||||
>
|
||||
+ E-Mail Konto
|
||||
|
||||
@@ -1,135 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const mode = route.params.mode
|
||||
const isCreate = computed(() => mode === "create")
|
||||
const loading = ref(false)
|
||||
|
||||
const itemInfo = ref({})
|
||||
const itemInfo = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
imapHost: "",
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
})
|
||||
|
||||
const setup = async () => {
|
||||
if(mode === "create") {
|
||||
|
||||
} else {
|
||||
itemInfo.value = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`)
|
||||
if(!isCreate.value) {
|
||||
const account = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`)
|
||||
itemInfo.value = {
|
||||
...itemInfo.value,
|
||||
...account,
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
setup()
|
||||
|
||||
const payload = () => ({
|
||||
...itemInfo.value,
|
||||
imapPort: Number(itemInfo.value.imapPort),
|
||||
smtpPort: Number(itemInfo.value.smtpPort),
|
||||
password: itemInfo.value.password || undefined,
|
||||
})
|
||||
|
||||
const createAccount = async () => {
|
||||
loading.value = true
|
||||
const res = await useNuxtApp().$api(`/api/email/accounts`, {
|
||||
method: "POST",
|
||||
body: itemInfo.value,
|
||||
body: payload(),
|
||||
})
|
||||
|
||||
if(res.success) {
|
||||
toast.add({ title: "E-Mail Konto erstellt", color: "success" })
|
||||
navigateTo("/settings/emailaccounts")
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const saveAccount = async () => {
|
||||
loading.value = true
|
||||
const res = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`, {
|
||||
method: "POST",
|
||||
body: itemInfo.value,
|
||||
body: payload(),
|
||||
})
|
||||
|
||||
if(res.success) {
|
||||
toast.add({ title: "E-Mail Konto gespeichert", color: "success" })
|
||||
navigateTo("/settings/emailaccounts")
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
}
|
||||
|
||||
const syncAccount = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}/sync`, {
|
||||
method: "POST",
|
||||
body: { limit: 50 },
|
||||
})
|
||||
|
||||
toast.add({
|
||||
title: "E-Mail Konto synchronisiert",
|
||||
description: `${res.synced?.[0]?.fetched || 0} neue Nachrichten geladen`,
|
||||
color: "success",
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Synchronisation fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Das Konto konnte nicht synchronisiert werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar :title="`E-Mail Konto ${mode === 'create' ? 'erstellen' : 'bearbeiten'}`">
|
||||
<UDashboardNavbar :title="`E-Mail Konto ${isCreate ? 'erstellen' : 'bearbeiten'}`">
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="mode === 'create'"
|
||||
icon="i-heroicons-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="navigateTo('/settings/emailaccounts')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="!isCreate"
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:loading="loading"
|
||||
@click="syncAccount"
|
||||
>
|
||||
Synchronisieren
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="isCreate"
|
||||
icon="i-heroicons-plus"
|
||||
:loading="loading"
|
||||
:disabled="!itemInfo.email || !itemInfo.password || !itemInfo.imapHost || !itemInfo.smtpHost"
|
||||
@click="createAccount"
|
||||
>
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="mode === 'edit'"
|
||||
v-if="!isCreate"
|
||||
icon="i-heroicons-check"
|
||||
:loading="loading"
|
||||
:disabled="!itemInfo.email || !itemInfo.imapHost || !itemInfo.smtpHost"
|
||||
@click="saveAccount"
|
||||
>
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UForm class="w-2/3 mx-auto mt-5">
|
||||
<UFormField
|
||||
label="E-Mail Adresse"
|
||||
>
|
||||
<UInput
|
||||
v-model="itemInfo.email"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Passwort"
|
||||
>
|
||||
<UInput
|
||||
type="password"
|
||||
v-model="itemInfo.password"
|
||||
placeholder="********"
|
||||
/>
|
||||
</UFormField>
|
||||
<div class="mx-auto max-w-4xl p-4">
|
||||
<UAlert
|
||||
v-if="!isCreate"
|
||||
class="mb-4"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
icon="i-heroicons-lock-closed"
|
||||
title="Passwort bleibt geschützt"
|
||||
description="Das gespeicherte Passwort wird nicht angezeigt. Trage nur dann ein neues Passwort ein, wenn du es ändern möchtest."
|
||||
/>
|
||||
|
||||
<USeparator label="IMAP"/>
|
||||
<UForm class="grid gap-6">
|
||||
<section class="grid gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">Konto</h2>
|
||||
<p class="text-sm text-dimmed">Diese Adresse wird als Absender und für den IMAP-Zugriff genutzt.</p>
|
||||
</div>
|
||||
|
||||
<UFormField
|
||||
label="IMAP Host"
|
||||
>
|
||||
<UInput
|
||||
v-model="itemInfo.imap_host"
|
||||
/>
|
||||
</UFormField>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<UFormField label="E-Mail Adresse">
|
||||
<UInput
|
||||
v-model="itemInfo.email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="IMAP Port"
|
||||
>
|
||||
<UInput
|
||||
type="number"
|
||||
v-model="itemInfo.imap_port"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="isCreate ? 'Passwort' : 'Neues Passwort'">
|
||||
<UInput
|
||||
v-model="itemInfo.password"
|
||||
type="password"
|
||||
:placeholder="isCreate ? '' : 'Unverändert lassen'"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UFormField
|
||||
label="IMAP SSL"
|
||||
>
|
||||
<USwitch
|
||||
v-model="itemInfo.imap_ssl"
|
||||
/>
|
||||
</UFormField>
|
||||
<USeparator label="Posteingang" />
|
||||
|
||||
<USeparator label="SMTP"/>
|
||||
<section class="grid gap-4 md:grid-cols-[1fr_140px_120px] md:items-end">
|
||||
<UFormField label="IMAP Host">
|
||||
<UInput
|
||||
v-model="itemInfo.imapHost"
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="SMTP Host"
|
||||
>
|
||||
<UInput
|
||||
v-model="itemInfo.smtp_host"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Port">
|
||||
<UInput
|
||||
v-model="itemInfo.imapPort"
|
||||
type="number"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="SMTP Port"
|
||||
>
|
||||
<UInput
|
||||
type="number"
|
||||
v-model="itemInfo.smtp_port"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="SSL">
|
||||
<USwitch
|
||||
v-model="itemInfo.imapSsl"
|
||||
/>
|
||||
</UFormField>
|
||||
</section>
|
||||
|
||||
<UFormField
|
||||
label="SMTP SSL"
|
||||
>
|
||||
<USwitch
|
||||
v-model="itemInfo.smtp_ssl"
|
||||
/>
|
||||
</UFormField>
|
||||
<USeparator label="Versand" />
|
||||
|
||||
</UForm>
|
||||
<section class="grid gap-4 md:grid-cols-[1fr_140px_120px] md:items-end">
|
||||
<UFormField label="SMTP Host">
|
||||
<UInput
|
||||
v-model="itemInfo.smtpHost"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Port">
|
||||
<UInput
|
||||
v-model="itemInfo.smtpPort"
|
||||
type="number"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="SSL">
|
||||
<USwitch
|
||||
v-model="itemInfo.smtpSsl"
|
||||
/>
|
||||
</UFormField>
|
||||
</section>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,97 +1,114 @@
|
||||
<script setup>
|
||||
|
||||
const createEMailAddress = ref("")
|
||||
const createEMailType = ref("imap")
|
||||
const showEmailAddressModal = ref(false)
|
||||
|
||||
<script setup lang="ts">
|
||||
const toast = useToast()
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const syncingAccount = ref<string | null>(null)
|
||||
|
||||
const setupPage = async () => {
|
||||
items.value = await useNuxtApp().$api("/api/email/accounts")
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await useNuxtApp().$api("/api/email/accounts")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createAccount = async () => {
|
||||
showEmailAddressModal.value = false
|
||||
const syncAccount = async (account: any) => {
|
||||
syncingAccount.value = account.id
|
||||
try {
|
||||
const res = await useNuxtApp().$api(`/api/email/accounts/${account.id}/sync`, {
|
||||
method: "POST",
|
||||
body: { limit: 50 },
|
||||
})
|
||||
|
||||
toast.add({
|
||||
title: "E-Mail Konto synchronisiert",
|
||||
description: `${res.synced?.[0]?.fetched || 0} neue Nachrichten geladen`,
|
||||
color: "success",
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Synchronisation fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Das Konto konnte nicht synchronisiert werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
syncingAccount.value = null
|
||||
await setupPage()
|
||||
}
|
||||
}
|
||||
|
||||
setupPage()
|
||||
|
||||
|
||||
const templateColumns = [
|
||||
{
|
||||
key: "email",
|
||||
label: "E-Mail Adresse:"
|
||||
},
|
||||
]
|
||||
const selectedColumns = ref(templateColumns)
|
||||
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="showEmailAddressModal"
|
||||
>
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
E-Mail Adresse
|
||||
</template>
|
||||
<!-- <UFormField
|
||||
label="E-Mail Adresse:"
|
||||
>
|
||||
|
||||
</UFormField>-->
|
||||
|
||||
<UInput
|
||||
v-model="createEMailAddress"
|
||||
/>
|
||||
<!-- <UFormField
|
||||
label="Account Typ:"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="[{key: 'imap',label:'IMAP'}]"
|
||||
option-attribute="label"
|
||||
value-attribute="key"
|
||||
v-model="createEMailType"
|
||||
/>
|
||||
</UFormField>-->
|
||||
<template #footer>
|
||||
<UButton
|
||||
@click="createAccount"
|
||||
>
|
||||
Erstellen
|
||||
</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
</UModal>
|
||||
<UDashboardNavbar title="E-Mail Konten">
|
||||
<template #right>
|
||||
<UTooltip title="In der Beta nicht verfügbar">
|
||||
<UButton
|
||||
@click="navigateTo('/settings/emailaccounts/create')"
|
||||
>
|
||||
+ E-Mail Konto
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
@click="navigateTo('/settings/emailaccounts/create')"
|
||||
>
|
||||
E-Mail Konto
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UTable
|
||||
:data="items"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
class="w-full"
|
||||
:on-select="(i) => navigateTo(`/settings/emailaccounts/edit/${i.id}`)"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
>
|
||||
<template #empty>
|
||||
<TableEmptyState label="Keine E-Mail Konten anzuzeigen" />
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
<div v-if="loading" class="space-y-2">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-20" />
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="items.length === 0"
|
||||
label="Keine E-Mail Konten anzuzeigen"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="account in items"
|
||||
v-else
|
||||
:key="account.id"
|
||||
class="flex flex-col gap-3 border-b border-(--ui-border) py-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-envelope" class="size-5 text-primary" />
|
||||
<p class="truncate font-medium">
|
||||
{{ account.email }}
|
||||
</p>
|
||||
<UBadge
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
>
|
||||
IMAP/SMTP
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-dimmed">
|
||||
<span>IMAP: {{ account.imapHost || "nicht gesetzt" }}:{{ account.imapPort || "-" }}</span>
|
||||
<span>SMTP: {{ account.smtpHost || "nicht gesetzt" }}:{{ account.smtpPort || "-" }}</span>
|
||||
<span>{{ account.hasPassword ? "Passwort hinterlegt" : "Passwort fehlt" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:loading="syncingAccount === account.id"
|
||||
@click.stop="syncAccount(account)"
|
||||
>
|
||||
Synchronisieren
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-pencil-square"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="navigateTo(`/settings/emailaccounts/edit/${account.id}`)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user