Files
accounted/extensions/general/invoice-inbox/lib/gmail-scanner.ts
T
Jakob WennbergandClaude Opus 4.6 c4a6d16e94 feat: document inbox + fix MCP pending operations user_id (#172)
* fix: always use business PSU type for bank connections

EF (sole trader) users connecting to Nordea got personal accounts
because psu_type was set to 'personal' based on entity_type. Since
gnubok is accounting software, all bank connections should use
'business' PSU type regardless of entity type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: document inbox extension with AI classification

Add invoice-inbox extension for email-based document processing with
AI-powered classification, supplier matching, and inbox management.
Includes MCP tools for document upload/listing, migration, and
supporting changes across document service, API keys, and banking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable invoice-inbox extension, use dynamic import in MCP server

Keep invoice-inbox out of extensions.config.json until ready for
production. MCP server now dynamically imports classifyDocument to
avoid breaking when the extension is disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct file size error message in invoice-inbox upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:45:17 +02:00

337 lines
10 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { uploadDocument, computeSHA256 } from '@/lib/core/documents/document-service'
import { classifyDocument } from './classify-document'
import { decryptToken, refreshAccessToken } from './gmail-helpers'
import type { InvoiceExtractionResult } from '@/types'
const MIN_ATTACHMENT_SIZE = 3_000
const MAX_MESSAGES = 30
const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'application/octet-stream',
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'image/webp',
])
const SKIP_EXTENSIONS = new Set([
'ics', 'vcf', 'html', 'htm', 'zip', 'rar', 'gz',
'csv', 'json', 'xml', 'txt', 'eml', 'msg',
'mp3', 'mp4', 'mov', 'avi', 'wav',
])
interface GmailMessage {
id: string
payload: {
headers: { name: string; value: string }[]
parts?: GmailPart[]
mimeType: string
body?: { attachmentId?: string; size?: number; data?: string }
}
internalDate: string
}
interface GmailPart {
mimeType: string
filename: string
body: { attachmentId?: string; size?: number; data?: string }
parts?: GmailPart[]
}
function getHeader(message: GmailMessage, name: string): string | null {
return message.payload.headers.find(
(h) => h.name.toLowerCase() === name.toLowerCase()
)?.value ?? null
}
function collectAttachments(parts: GmailPart[] | undefined): GmailPart[] {
if (!parts) return []
const result: GmailPart[] = []
for (const part of parts) {
if (part.filename && part.body?.attachmentId) {
result.push(part)
}
if (part.parts) {
result.push(...collectAttachments(part.parts))
}
}
return result
}
function resolveActualMimeType(mimeType: string, filename: string): string | null {
const ext = filename.split('.').pop()?.toLowerCase()
if (ext && SKIP_EXTENSIONS.has(ext)) return null
if (mimeType === 'application/octet-stream') {
const extMap: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
}
return ext && extMap[ext] ? extMap[ext] : null
}
return mimeType
}
export interface ScanResult {
scanned: number
classified: number
skipped: number
errors: number
}
interface EmailConnection {
id: string
company_id: string
encrypted_token: string
last_sync_at: string | null
gmail_label_id: string | null
}
export async function scanGmailConnection(
supabase: SupabaseClient,
connection: EmailConnection,
userId: string,
companyId: string
): Promise<ScanResult> {
const result: ScanResult = { scanned: 0, classified: 0, skipped: 0, errors: 0 }
const seenFileHashes = new Set<string>()
const refreshToken = decryptToken(connection.encrypted_token)
if (!refreshToken) {
await supabase
.from('email_connections')
.update({ status: 'error', error_message: 'Failed to decrypt refresh token' })
.eq('id', connection.id)
result.errors++
return result
}
const accessToken = await refreshAccessToken(refreshToken)
if (!accessToken) {
await supabase
.from('email_connections')
.update({ status: 'revoked', error_message: 'Token refresh failed — user may have revoked access' })
.eq('id', connection.id)
result.errors++
return result
}
// Build Gmail search query
let afterDate: string
if (connection.last_sync_at) {
const d = new Date(connection.last_sync_at)
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
} else {
const d = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
}
let query = `has:attachment after:${afterDate}`
if (connection.gmail_label_id) {
query += ' -label:gnubok-processed'
}
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${MAX_MESSAGES}`
const listResponse = await fetch(listUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!listResponse.ok) {
console.error('[gmail/scan] Failed to list messages:', await listResponse.text())
result.errors++
return result
}
const listData = await listResponse.json() as { messages?: { id: string }[] }
const messageIds = listData.messages || []
for (const { id: messageId } of messageIds) {
try {
const msgResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!msgResponse.ok) continue
const message = await msgResponse.json() as GmailMessage
const emailFrom = getHeader(message, 'From')
const emailSubject = getHeader(message, 'Subject')
const emailDate = message.internalDate
? new Date(parseInt(message.internalDate)).toISOString()
: null
const attachments = collectAttachments(message.payload.parts)
for (const attachment of attachments) {
if (!attachment.body.attachmentId) continue
if ((attachment.body.size ?? 0) < MIN_ATTACHMENT_SIZE) continue
if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) continue
const resolvedMimeType = resolveActualMimeType(attachment.mimeType, attachment.filename)
if (!resolvedMimeType) continue
// Deduplicate by message ID + filename
const { data: existing } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', companyId)
.eq('source', 'email')
.filter('raw_email_payload->>messageId', 'eq', messageId)
.filter('raw_email_payload->>filename', 'eq', attachment.filename)
.limit(1)
.maybeSingle()
if (existing) {
result.skipped++
continue
}
// Download attachment
const attResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachment.body.attachmentId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!attResponse.ok) {
result.errors++
continue
}
const attData = await attResponse.json() as { data: string }
const fileBuffer = Buffer.from(attData.data, 'base64url')
// Deduplicate by file content hash
const fileHash = await computeSHA256(fileBuffer.buffer.slice(
fileBuffer.byteOffset,
fileBuffer.byteOffset + fileBuffer.byteLength
))
if (seenFileHashes.has(fileHash)) {
result.skipped++
continue
}
const { data: existingByHash } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('sha256_hash', fileHash)
.limit(1)
.maybeSingle()
if (existingByHash) {
result.skipped++
seenFileHashes.add(fileHash)
continue
}
seenFileHashes.add(fileHash)
// Store in WORM archive
const doc = await uploadDocument(supabase, userId, companyId, {
name: attachment.filename,
buffer: fileBuffer.buffer.slice(
fileBuffer.byteOffset,
fileBuffer.byteOffset + fileBuffer.byteLength
),
type: resolvedMimeType,
}, {
upload_source: 'email',
})
// Classify
let classificationResult
let classificationError: string | null = null
try {
classificationResult = await classifyDocument({
fileBuffer,
mimeType: resolvedMimeType,
fileName: attachment.filename,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
console.error('[gmail/scan] Classification failed:', err)
}
// Find matching supplier
let matchedSupplierId: string | null = null
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
const extractedData = classificationResult.extractedData as InvoiceExtractionResult
const orgNumber = extractedData.supplier?.orgNumber
if (orgNumber) {
const normalized = orgNumber.replace(/\D/g, '')
const { data: supplierByOrg } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', normalized)
.limit(1)
.maybeSingle()
if (supplierByOrg) matchedSupplierId = supplierByOrg.id
}
}
// Create inbox item
await supabase.from('invoice_inbox_items').insert({
company_id: companyId,
user_id: userId,
status: classificationError ? 'error' : 'ready',
source: 'email',
document_id: doc.id,
document_type: classificationResult?.documentType || 'unknown',
extracted_data: classificationResult?.extractedData || null,
raw_llm_response: classificationResult?.rawResponse || null,
confidence: classificationResult?.confidence
? classificationResult.confidence / 100
: null,
matched_supplier_id: matchedSupplierId,
email_from: emailFrom,
email_subject: emailSubject,
email_received_at: emailDate,
raw_email_payload: { messageId, filename: attachment.filename },
error_message: classificationError,
})
if (classificationError) {
result.errors++
} else {
result.classified++
}
result.scanned++
}
// Label message as processed
if (connection.gmail_label_id) {
try {
await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ addLabelIds: [connection.gmail_label_id] }),
}
)
} catch {
// Non-blocking
}
}
} catch (err) {
console.error('[gmail/scan] Error processing message:', err)
result.errors++
}
}
// Update last_sync_at
await supabase
.from('email_connections')
.update({ last_sync_at: new Date().toISOString(), error_message: null })
.eq('id', connection.id)
return result
}