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:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user