Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5, suggestions agents, intégration Gmail et navigation sidebar alignée sur /home. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown> | null> {
|
|
const config = await getSystemConfig()
|
|
const provider = getChatProvider(config)
|
|
if (!provider) return null
|
|
|
|
const schemas: Record<string, string> = {
|
|
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<string, unknown>) {
|
|
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 = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''} → ${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
|
|
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 = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
|
|
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 = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
|
|
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()
|