Files
Momento/memento-note/lib/integrations/gmail-scanner.service.ts
Antigravity 30da592ba2
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
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>
2026-07-14 16:50:53 +00:00

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()