E-Mail Cache und Konto-Synchronisation vorbereiten

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

View File

@@ -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
View 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

View File

@@ -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"

View 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,
}
}

View File

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

View File

@@ -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

View File

@@ -1,137 +1,224 @@
<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>
</style>
</style>

View File

@@ -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>