/** * Gmail Scanner — extrait vols, colis, abonnements → notes + rappels */ import prisma from '@/lib/prisma' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens' const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me' const SCAN_QUERIES: { category: string; q: string }[] = [ { category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' }, { category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' }, { category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' }, ] function decodeBase64Url(data: string): string { const normalized = data.replace(/-/g, '+').replace(/_/g, '/') return Buffer.from(normalized, 'base64').toString('utf-8') } function extractBody(payload: any): string { if (!payload) return '' if (payload.body?.data) { const raw = decodeBase64Url(payload.body.data) return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000) } if (Array.isArray(payload.parts)) { for (const part of payload.parts) { if (part.mimeType === 'text/plain' && part.body?.data) { return decodeBase64Url(part.body.data).slice(0, 4000) } } for (const part of payload.parts) { const nested = extractBody(part) if (nested) return nested } } return '' } async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) { let token = accessToken let res = await fetch(`${GMAIL_API}${path}`, { headers: { Authorization: `Bearer ${token}` }, }) if (res.status === 401 && refreshToken) { const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken) if (refreshed) { token = refreshed res = await fetch(`${GMAIL_API}${path}`, { headers: { Authorization: `Bearer ${token}` }, }) } } return res } async function extractStructured( category: string, subject: string, body: string, ): Promise | null> { const config = await getSystemConfig() const provider = getChatProvider(config) if (!provider) return null const schemas: Record = { flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}', package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}', subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}', } const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'} Sujet: ${subject} Corps: ${body.slice(0, 2500)}` try { const raw = await provider.generateText(prompt) const match = raw.match(/\{[\s\S]*\}/) if (!match) return null return JSON.parse(match[0]) } catch { return null } } function buildNoteFromExtraction(category: string, subject: string, data: Record) { if (category === 'flight') { const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80) const dep = data.departureDate ? new Date(String(data.departureDate)) : null const reminder = dep && !Number.isNaN(dep.getTime()) ? new Date(dep.getTime() - 3 * 60 * 60 * 1000) : null const html = `

${title}

${data.departureAirport || ''} → ${data.arrivalAirport || ''}

Confirmation: ${data.confirmationCode || '—'}

` return { title, content: html, reminder } } if (category === 'package') { const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80) const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null const reminder = del && !Number.isNaN(del.getTime()) ? del : null const html = `

${title}

Transporteur: ${data.carrier || '—'}

Suivi: ${data.trackingNumber || '—'}

Statut: ${data.status || '—'}

` return { title, content: html, reminder } } const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80) const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null const reminder = ren && !Number.isNaN(ren.getTime()) ? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000) : null const html = `

${title}

Renouvellement: ${data.renewalDate || '—'}

Montant: ${data.amount ?? '—'} ${data.currency || ''}

` return { title, content: html, reminder } } export class GmailScannerService { async scanUser(userId: string): Promise<{ processed: number; created: number }> { const tokens = await getGoogleTokens(userId, 'gmail') if (!tokens) return { processed: 0, created: 0 } let processed = 0 let created = 0 for (const { category, q } of SCAN_QUERIES) { const listRes = await gmailFetch( userId, `/messages?maxResults=5&q=${encodeURIComponent(q)}`, tokens.accessToken, tokens.refreshToken, ) if (!listRes.ok) continue const listJson = await listRes.json() const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id) for (const messageId of ids) { const exists = await prisma.gmailScanHistory.findUnique({ where: { userId_messageId: { userId, messageId } }, }) if (exists) continue const msgRes = await gmailFetch( userId, `/messages/${messageId}?format=full`, tokens.accessToken, tokens.refreshToken, ) if (!msgRes.ok) continue processed++ const msg = await msgRes.json() const headers: { name: string; value: string }[] = msg.payload?.headers || [] const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)' const body = extractBody(msg.payload) const extracted = await extractStructured(category, subject, body) if (!extracted) { await prisma.gmailScanHistory.create({ data: { userId, messageId, category, data: { subject, skipped: true } }, }) continue } const built = buildNoteFromExtraction(category, subject, extracted) const note = await prisma.note.create({ data: { userId, title: built.title, content: built.content, type: 'richtext', labels: JSON.stringify(['gmail', category]), reminder: built.reminder, autoGenerated: true, }, }) await prisma.gmailScanHistory.create({ data: { userId, messageId, category, data: extracted as object, noteId: note.id, }, }) created++ } } return { processed, created } } async getStatus(userId: string) { const tokens = await getGoogleTokens(userId, 'gmail') const recent = await prisma.gmailScanHistory.count({ where: { userId, scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, noteId: { not: null }, }, }) return { connected: !!tokens, recentCaptures: recent } } } export const gmailScannerService = new GmailScannerService()