feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { ImapFlow } from 'imapflow'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
const syncSchema = z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
user: z.string().min(1),
password: z.string().min(1),
folder: z.string().optional().default('INBOX'),
limit: z.number().int().min(1).max(50).optional().default(20),
})
// POST /api/aos/crm/mail-sync
// Connects to IMAP server, fetches recent emails, matches to CRM contacts.
// PRD §6.1: "Mail sync (IMAP) with thread-to-contact matching"
export async function POST(req: Request) {
try {
const parsed = syncSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { host, port, user, password, folder, limit } = parsed.data
// Real IMAP connection (PRD §6.1)
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass: password },
logger: false,
})
await client.connect()
// Lock mailbox + fetch recent messages
const lock = await client.getMailboxLock(folder)
const messages: {
uid: number
from: string
fromEmail: string
subject: string
date: string
snippet: string
matchedCompany: string | null
matchedPerson: string | null
}[] = []
try {
// Fetch last N messages
const range = `1:${limit}`
for await (const msg of client.fetch(range, {
envelope: true,
source: true,
internalDate: true,
})) {
const envelope = msg.envelope
if (!envelope) continue
const fromAddr = envelope.from?.[0]?.address ?? ''
const fromName = envelope.from?.[0]?.name ?? fromAddr
const subject = envelope.subject ?? '(kein Betreff)'
const rawDate = msg.internalDate
const date = rawDate ? new Date(rawDate as string | Date).toISOString() : new Date().toISOString()
// Extract snippet from source (first 200 chars of text body)
const source = msg.source?.toString('utf-8') ?? ''
const snippet = source.slice(0, 200).replace(/=\r?\n/g, '').replace(/<[^>]+>/g, '').trim()
// Thread-to-contact matching (PRD §6.1)
// Match by email address against persons table
let matchedCompany: string | null = null
let matchedPerson: string | null = null
if (fromAddr) {
const person = await db.person.findFirst({
where: { email: { contains: fromAddr.toLowerCase() } },
include: { company: { select: { name: true } } },
})
if (person) {
matchedPerson = `${person.firstName} ${person.lastName}`
matchedCompany = person.company?.name ?? null
}
// Also try matching by company website domain
if (!matchedCompany) {
const domain = fromAddr.split('@')[1]?.split('.')[0]
if (domain) {
const company = await db.company.findFirst({
where: { website: { contains: domain } },
select: { name: true },
})
matchedCompany = company?.name ?? null
}
}
}
messages.push({
uid: msg.uid,
from: fromName,
fromEmail: fromAddr,
subject,
date,
snippet: snippet.slice(0, 150) + (snippet.length > 150 ? '…' : ''),
matchedCompany,
matchedPerson,
})
}
} finally {
lock.release()
}
await client.logout()
// Audit
await db.auditLog.create({
data: {
actor: 'sven@nordlicht.digital',
action: 'crm.mailsync:imap',
entity: 'MailSync',
entityId: 'batch',
after: JSON.stringify({ host, folder, fetched: messages.length, matched: messages.filter((m) => m.matchedCompany).length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
synced: messages.length,
matched: messages.filter((m) => m.matchedCompany).length,
unmatched: messages.filter((m) => !m.matchedCompany).length,
messages,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: 'IMAP connection failed', detail: msg }, { status: 502 })
}
}