/** * GET /api/mail/lead-followups?days=30 * Leads (sales / vendor, AD_User) created in the last `days` whose LATEST e-mail * in the mailpiler archive is an incoming one (from the lead) and that got NO * reaction from us since then — neither an outgoing mail to that address nor a * contact activity (C_ContactActivity) logged after the mail. Only the archive * is consulted (one indexed lookup per lead, no IMAP). Nothing is stored. * Automatic replies from the lead (out-of-office etc.) are skipped when picking * the latest incoming mail. Staff only. Never throws to the client. */ import { string } from 'alga-js' import { gateFromEvent } from '../../utils/chatAccess' import { archiveAvailable, searchArchive } from '../../utils/mail/mailArchive' import { isValidEmail } from '../../utils/mail/leadMailbox' import fetchHelper from '../../utils/fetchHelper' import refreshTokenHelper from '../../utils/refreshTokenHelper' const DEFAULT_DAYS = 30 const MAX_DAYS = 365 const MAX_LEADS = 1000 const ARCHIVE_ROWS = 60 // newest mails per address — enough to find the latest inbound + any reply after it const CONCURRENCY = 5 const AUTO_REPLY = /^(automatische antwort|automatic reply|auto(-|\s)?reply|autoreply|out of office|abwesenheit|abwesenheitsnotiz|ooo:|lesebestätigung|read receipt|zugestellt:|delivery status|unzustellbar|undeliverable)/i const ts = (v: any): number => { if (!v) return 0 const n = new Date(v).getTime() return Number.isNaN(n) ? 0 : n } export default defineEventHandler(async (event) => { let token = '' try { token = (await gateFromEvent(event)).token } catch (err: any) { return { status: err?.statusCode || 403, message: 'Forbidden', rows: [] } } if (!archiveAvailable()) return { status: 503, message: 'Mail archive not configured', rows: [] } const q = getQuery(event) const days = Math.min(MAX_DAYS, Math.max(1, Math.round(Number(q.days)) || DEFAULT_DAYS)) // Expired leads (AD_User.LeadStatus = E) are skipped by default — nobody waits // for an answer on a closed lead; `includeExpired=1` lists them anyway (badged). const includeExpired = ['1', 'true', 'yes'].includes(String(q.includeExpired ?? '').toLowerCase()) const sinceDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) const since = sinceDate.toISOString().slice(0, 10) const started = Date.now() const get = async (url: string) => { try { return await fetchHelper(event, url, 'GET', token, null) } catch { const fresh = await refreshTokenHelper(event) return await fetchHelper(event, url, 'GET', fresh, null) } } try { // 1) Leads created in the window. const leadFilter = `(isSalesLead eq true OR isVendorLead eq true) AND Created ge '${since}'` const leadRes: any = await get(`models/ad_user?$filter=${string.urlEncode(leadFilter)}&$select=Name,EMail,companyname,Phone,Created,LeadStatus&$orderby=${string.urlEncode('Created desc')}&$top=${MAX_LEADS}`) const leads: any[] = (leadRes?.records || []).map((u: any) => ({ id: Number(u.id), name: String(u.Name || u.name || ''), companyname: String(u.companyname || u.CompanyName || ''), phone: String(u.Phone || u.phone || ''), email: String(u.EMail || u.eMail || '').trim().toLowerCase(), created: u.Created || null, leadStatus: String(u.LeadStatus?.id || u.leadStatus?.id || ''), expired: String(u.LeadStatus?.id || u.leadStatus?.id || '') === 'E' })) const expiredSkipped = includeExpired ? 0 : leads.filter((l) => l.expired).length // 2) Contact activities of the window, newest first, grouped per lead. An activity // can only be logged after the lead exists, so `Created ge since` is complete. const actByUser = new Map() try { const act: any = await get(`models/c_contactactivity?$filter=${string.urlEncode(`Created ge '${since}'`)}&$select=AD_User_ID,StartDate,Created,Description,ContactActivityType&$orderby=${string.urlEncode('Created desc')}&$top=5000`) for (const a of (act?.records || [])) { const userId = Number(a?.AD_User_ID?.id) if (!userId) continue const date = Math.max(ts(a?.StartDate), ts(a?.Created)) if (!date) continue if (!actByUser.has(userId)) actByUser.set(userId, []) actByUser.get(userId)!.push({ date, iso: new Date(date).toISOString(), description: String(a?.Description || ''), type: String(a?.ContactActivityType?.identifier || a?.ContactActivityType || '') }) } } catch (e: any) { console.warn('[lead-followups] activity lookup failed, continuing without activities:', e?.data ?? e?.message ?? e) } // 3) Archive lookup per lead address (bounded concurrency). const rows: any[] = [] let withoutEmail = 0 let errors = 0 let inboundLeads = 0 const seenEmails = new Set() const work = leads.filter((l) => { if (!includeExpired && l.expired) return false if (!isValidEmail(l.email)) { withoutEmail++; return false } if (seenEmails.has(l.email)) return false // duplicate lead records of one address: check once seenEmails.add(l.email) return true }) const checkLead = async (lead: any) => { let mails: any[] try { mails = (await searchArchive(lead.email, ARCHIVE_ROWS)).rows } catch (e: any) { errors++ console.warn('[lead-followups] archive lookup failed for lead', lead.id, e?.message || e) return } // newest first — the first non-automatic incoming mail is the one that needs an answer const inIndex = mails.findIndex((m) => m.direction === 'in' && !AUTO_REPLY.test(String(m.subject || '').trim())) if (inIndex < 0) return inboundLeads++ const latestIn = mails[inIndex] const inDate = ts(latestIn.date) const repliedByMail = mails.slice(0, inIndex).some((m) => m.direction === 'out' && ts(m.date) > inDate) if (repliedByMail) return const acts = actByUser.get(lead.id) || [] if (acts.some((a) => a.date > inDate)) return const lastOut = mails.find((m) => m.direction === 'out') const lastAct = acts[0] || null rows.push({ ...lead, lastInbound: { id: latestIn.uid, date: latestIn.date, subject: String(latestIn.subject || ''), from: latestIn.from?.address || lead.email }, inboundCount: mails.filter((m) => m.direction === 'in').length, lastOwnMail: lastOut ? { date: lastOut.date, subject: String(lastOut.subject || '') } : null, lastActivity: lastAct ? { date: lastAct.iso, description: lastAct.description, type: lastAct.type } : null, daysWaiting: inDate ? Math.max(0, Math.floor((Date.now() - inDate) / 86_400_000)) : null }) } let cursor = 0 const worker = async () => { while (cursor < work.length) { const lead = work[cursor++] await checkLead(lead) } } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, work.length) }, worker)) rows.sort((a, b) => ts(b.lastInbound?.date) - ts(a.lastInbound?.date)) return { status: 200, days, since, scanned: leads.length, checked: work.length, withoutEmail, expiredSkipped, includeExpired, inboundLeads, errors, rows, ms: Date.now() - started } } catch (err: any) { console.warn('[lead-followups] failed:', err?.data ?? err?.message ?? err) return { status: Number(err?.statusCode || err?.status) || 502, message: err?.message || 'Lookup failed', rows: [] } } })