import { v4 as uuidv4 } from 'uuid'; import type { EmailMessageDetails, EmailProviderConfig, } from '../../interfaces/inbound-email.interfaces'; import { MAX_ATTACHMENT_BYTES, ORIGINAL_EMAIL_ATTACHMENT_ID, buildDeterministicRfc822Message, buildOriginalEmailFileName, extractEmbeddedImageAttachments, hasRawMimeOverCapSkipReason, maybeExtractRawMimeFromEmailData, sanitizeGeneratedFileName, } from './inboundEmailArtifactHelpers'; const STALE_PROCESSING_MS = 30 * 60 * 1000; // 30 minutes interface PersistAttachmentInput { tenantId: string; providerId: string; emailId: string; ticketId: string; attachmentId: string; attachmentData: { id: string; name: string; contentType: string; size: number; contentId?: string; isInline?: boolean; content?: string; providerAttachmentId?: string; allowInlineProcessing?: boolean; }; consumedInlineCids?: Set; clientVisibleAttachments?: boolean; } interface PersistOriginalEmailInput { tenantId: string; providerId: string; emailId: string; ticketId: string; emailData: EmailMessageDetails; } export interface ProcessInboundEmailArtifactsInput { tenantId: string; providerId: string; ticketId: string; emailData: EmailMessageDetails; scopeLabel: 'new-ticket' | 'reply'; maxAttachmentConcurrency?: number; clientVisibleAttachments?: boolean; } export interface EmbeddedImageUrlMapping { source: 'data-url' | 'cid'; reference: string; fileId: string; documentId: string; url: string; } export interface ProcessInboundEmailArtifactsResult { embeddedImageUrlMappings: EmbeddedImageUrlMapping[]; } function isUniqueViolation(error: any): boolean { return error?.code === '23505' || String(error?.message || '').toLowerCase().includes('duplicate'); } function isBase64(value: string): boolean { return /^[A-Za-z0-9+/=\s]+$/.test(value); } function normalizeContentId(value: string | undefined | null): string { if (!value) return ''; return String(value).trim().replace(/^cid:/i, '').replace(/^<|>$/g, '').toLowerCase(); } function resolveAttachmentConcurrency( explicitLimit?: number ): number { if (Number.isFinite(explicitLimit) && (explicitLimit as number) > 0) { return Math.max(1, Math.min(8, Math.floor(explicitLimit as number))); } const raw = process.env.IMAP_INBOUND_EMAIL_IN_APP_ARTIFACT_CONCURRENCY || process.env.INBOUND_EMAIL_IN_APP_ARTIFACT_CONCURRENCY; if (!raw) return 1; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return 1; return Math.max(1, Math.min(8, Math.floor(parsed))); } async function runWithConcurrency( items: T[], limit: number, worker: (item: T) => Promise ): Promise { if (items.length === 0) return; const concurrency = Math.max(1, Math.min(limit, items.length)); let index = 0; const runners = Array.from({ length: concurrency }, async () => { while (true) { const current = index; index += 1; if (current >= items.length) { break; } await worker(items[current]); } }); await Promise.all(runners); } async function getAdminKnex(): Promise { const { getAdminConnection } = await import('@alga-psa/db/admin'); return getAdminConnection(); } async function loadProviderRow(knex: any, tenant: string, providerId: string): Promise { return knex('email_providers') .where({ tenant, id: providerId }) .first(); } async function buildMicrosoftProviderConfig( knex: any, tenant: string, providerRow: any ): Promise { const config = await knex('microsoft_email_provider_config') .where({ tenant, email_provider_id: providerRow.id }) .first(); if (!config) { throw new Error(`Microsoft provider config not found for provider ${providerRow.id}`); } return { id: providerRow.id, tenant, name: providerRow.provider_name || providerRow.mailbox, provider_type: 'microsoft', mailbox: providerRow.mailbox, folder_to_monitor: 'Inbox', active: !!providerRow.is_active, webhook_notification_url: providerRow.webhook_notification_url || '', webhook_subscription_id: config.webhook_subscription_id || undefined, webhook_verification_token: config.webhook_verification_token || undefined, webhook_expires_at: config.webhook_expires_at || undefined, connection_status: (providerRow.status as any) || 'connected', created_at: providerRow.created_at?.toISOString?.() ?? String(providerRow.created_at ?? new Date().toISOString()), updated_at: providerRow.updated_at?.toISOString?.() ?? String(providerRow.updated_at ?? new Date().toISOString()), provider_config: { client_id: config.client_id, client_secret: config.client_secret, tenant_id: config.tenant_id, access_token: config.access_token, refresh_token: config.refresh_token, token_expires_at: config.token_expires_at, }, } as any; } async function buildGoogleProviderConfig( knex: any, tenant: string, providerRow: any ): Promise { const config = await knex('google_email_provider_config') .where({ tenant, email_provider_id: providerRow.id }) .first(); if (!config) { throw new Error(`Google provider config not found for provider ${providerRow.id}`); } return { id: providerRow.id, tenant, name: providerRow.provider_name || providerRow.mailbox, provider_type: 'google', mailbox: providerRow.mailbox, folder_to_monitor: 'Inbox', active: !!providerRow.is_active, webhook_notification_url: providerRow.webhook_notification_url || '', connection_status: (providerRow.status as any) || 'connected', created_at: providerRow.created_at?.toISOString?.() ?? String(providerRow.created_at ?? new Date().toISOString()), updated_at: providerRow.updated_at?.toISOString?.() ?? String(providerRow.updated_at ?? new Date().toISOString()), provider_config: { project_id: config.project_id, pubsub_topic_name: config.pubsub_topic_name, pubsub_subscription_name: config.pubsub_subscription_name, client_id: config.client_id, client_secret: config.client_secret, access_token: config.access_token, refresh_token: config.refresh_token, token_expires_at: config.token_expires_at, history_id: config.history_id, watch_expiration: config.watch_expiration, label_filters: config.label_filters, auto_process_emails: config.auto_process_emails, max_emails_per_sync: config.max_emails_per_sync, }, } as any; } async function resolveSystemUserId(knex: any, tenant: string): Promise { const inboundDefaults = await knex('inbound_ticket_defaults') .select('entered_by') .where({ tenant, is_active: true }) .whereNotNull('entered_by') .orderBy('updated_at', 'desc') .first(); if (inboundDefaults?.entered_by) return inboundDefaults.entered_by; const user = await knex('users') .select('user_id') .where({ tenant }) .orderBy('created_at', 'asc') .first(); return user?.user_id || null; } async function claimAttachmentRow( knex: any, args: { tenantId: string; providerId: string; emailId: string; attachmentId: string; fileName: string; contentType: string; fileSize: number | null; contentId: string | null; } ): Promise<{ claimed: true } | { claimed: false; result: Record }> { const now = new Date(); try { await knex('email_processed_attachments').insert({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, processing_status: 'processing', file_name: args.fileName || null, content_type: args.contentType || null, file_size: args.fileSize, content_id: args.contentId, created_at: now, updated_at: now, }); return { claimed: true }; } catch (error: any) { if (!isUniqueViolation(error)) { throw error; } } const existing = await knex('email_processed_attachments') .where({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, }) .first(); const status = String(existing?.processing_status || '').toLowerCase(); if (status === 'failed') { const updated = await knex('email_processed_attachments') .where({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, }) .andWhere('processing_status', 'failed') .update({ processing_status: 'processing', error_message: null, file_name: args.fileName || existing?.file_name || null, content_type: args.contentType || existing?.content_type || null, file_size: args.fileSize ?? existing?.file_size ?? null, content_id: args.contentId ?? existing?.content_id ?? null, updated_at: new Date(), }); if (updated === 1) { return { claimed: true }; } } if (status === 'processing') { const updatedAt = existing?.updated_at ? new Date(existing.updated_at) : null; const isStale = updatedAt instanceof Date && Number.isFinite(updatedAt.getTime()) && Date.now() - updatedAt.getTime() > STALE_PROCESSING_MS; if (isStale) { const takeover = await knex('email_processed_attachments') .where({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, }) .andWhere('processing_status', 'processing') .andWhere('updated_at', '<', new Date(Date.now() - STALE_PROCESSING_MS)) .update({ error_message: null, file_name: args.fileName || existing?.file_name || null, content_type: args.contentType || existing?.content_type || null, file_size: args.fileSize ?? existing?.file_size ?? null, content_id: args.contentId ?? existing?.content_id ?? null, updated_at: new Date(), }); if (takeover === 1) { return { claimed: true }; } } } return { claimed: false, result: { success: true, duplicate: true, processing_status: existing?.processing_status, documentId: existing?.document_id ?? null, fileId: existing?.file_id ?? null, }, }; } async function markProcessedAttachment( knex: any, args: { tenantId: string; providerId: string; emailId: string; attachmentId: string; status: 'skipped' | 'failed'; errorMessage: string; } ): Promise { await knex('email_processed_attachments') .where({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, }) .update({ processing_status: args.status, error_message: args.errorMessage, updated_at: new Date(), }); } async function resolveTicketAttachmentFolder( trx: any, args: { tenantId: string; ticketId: string; createdByUserId: string; } ): Promise<{ folderPath: string | null; isClientVisible: boolean }> { const existingFolders = await trx('document_folders') .where({ tenant: args.tenantId, entity_id: args.ticketId, entity_type: 'ticket', }) .select('folder_id', 'folder_path', 'is_client_visible'); const existingAttachmentFolder = existingFolders.find( (folder: { folder_path?: string }) => folder.folder_path === '/Tickets/Attachments' ); if (existingAttachmentFolder) { return { folderPath: existingAttachmentFolder.folder_path, isClientVisible: Boolean(existingAttachmentFolder.is_client_visible), }; } const defaultFolders = await trx('document_default_folders') .where({ tenant: args.tenantId, entity_type: 'ticket', }) .select('folder_name', 'folder_path', 'is_client_visible', 'sort_order') .orderBy('sort_order', 'asc') .orderBy('folder_path', 'asc'); if (defaultFolders.length > 0) { const existingPaths = new Set(existingFolders.map((folder: { folder_path: string }) => folder.folder_path)); const pathToFolderId = new Map(); for (const folder of existingFolders as Array<{ folder_id: string; folder_path: string }>) { pathToFolderId.set(folder.folder_path, folder.folder_id); } const foldersToInsert = defaultFolders .filter((folder: { folder_path: string }) => !existingPaths.has(folder.folder_path)) .map((folder: { folder_name: string; folder_path: string; is_client_visible: boolean }) => { const folderId = uuidv4(); pathToFolderId.set(folder.folder_path, folderId); const segments = folder.folder_path.split('/').filter(Boolean); const parentPath = segments.length > 1 ? `/${segments.slice(0, -1).join('/')}` : null; return { tenant: args.tenantId, folder_id: folderId, folder_path: folder.folder_path, folder_name: folder.folder_name, parent_folder_id: parentPath ? pathToFolderId.get(parentPath) ?? null : null, entity_id: args.ticketId, entity_type: 'ticket', is_client_visible: folder.is_client_visible, created_by: args.createdByUserId, }; }); if (foldersToInsert.length > 0) { try { await trx('document_folders').insert(foldersToInsert); } catch (error) { if (!isUniqueViolation(error)) { throw error; } } } const attachmentDefault = defaultFolders.find( (folder: { folder_path?: string }) => folder.folder_path === '/Tickets/Attachments' ); if (attachmentDefault) { return { folderPath: attachmentDefault.folder_path, isClientVisible: Boolean(attachmentDefault.is_client_visible), }; } } const refreshedAttachmentFolder = await trx('document_folders') .where({ tenant: args.tenantId, entity_id: args.ticketId, entity_type: 'ticket', folder_path: '/Tickets/Attachments', }) .select('folder_path', 'is_client_visible') .first(); if (refreshedAttachmentFolder) { return { folderPath: refreshedAttachmentFolder.folder_path, isClientVisible: Boolean(refreshedAttachmentFolder.is_client_visible), }; } return { folderPath: null, isClientVisible: false, }; } async function persistDocumentForBuffer(args: { knex: any; tenantId: string; providerId: string; emailId: string; attachmentId: string; ticketId: string; systemUserId: string; fileName: string; mimeType: string; buffer: Buffer; clientVisibleOverride?: boolean; }): Promise<{ success: boolean; message?: string; documentId?: string; fileId?: string }> { const storageModule: any = await import('@alga-psa/storage/StorageProviderFactory'); const StorageProviderFactory = storageModule.StorageProviderFactory; const generateStoragePath = storageModule.generateStoragePath; const safeFileName = sanitizeGeneratedFileName(args.fileName, 'attachment.bin'); const storageProvider = await StorageProviderFactory.createProvider(); const storagePath = generateStoragePath(args.tenantId, '', safeFileName); let uploadResult: any; try { uploadResult = await storageProvider.upload(args.buffer, storagePath, { mime_type: args.mimeType, }); } catch (uploadErr: any) { await markProcessedAttachment(args.knex, { tenantId: args.tenantId, providerId: args.providerId, emailId: args.emailId, attachmentId: args.attachmentId, status: 'failed', errorMessage: uploadErr?.message || String(uploadErr), }); return { success: false, message: uploadErr?.message || String(uploadErr) }; } const now = new Date(); const fileId = uuidv4(); const documentId = uuidv4(); try { await args.knex.transaction(async (trx: any) => { const ticketFolder = await resolveTicketAttachmentFolder(trx, { tenantId: args.tenantId, ticketId: args.ticketId, createdByUserId: args.systemUserId, }); const isClientVisible = args.clientVisibleOverride ?? ticketFolder.isClientVisible; await trx('external_files').insert({ tenant: args.tenantId, file_id: fileId, file_name: String(storagePath).split('/').pop(), original_name: safeFileName, mime_type: args.mimeType, file_size: args.buffer.length, storage_path: uploadResult.path, uploaded_by_id: args.systemUserId, created_at: now, updated_at: now, }); await trx('documents').insert({ tenant: args.tenantId, document_id: documentId, document_name: safeFileName, type_id: null, shared_type_id: null, user_id: args.systemUserId, created_by: args.systemUserId, entered_at: now, updated_at: now, file_id: fileId, storage_path: uploadResult.path, mime_type: args.mimeType, file_size: args.buffer.length, folder_path: ticketFolder.folderPath, is_client_visible: isClientVisible, }); await trx('document_associations').insert({ tenant: args.tenantId, association_id: uuidv4(), document_id: documentId, entity_id: args.ticketId, entity_type: 'ticket', created_at: now, }); await trx('email_processed_attachments') .where({ tenant: args.tenantId, provider_id: args.providerId, email_id: args.emailId, attachment_id: args.attachmentId, }) .update({ processing_status: 'success', file_id: fileId, document_id: documentId, file_name: safeFileName, content_type: args.mimeType, file_size: args.buffer.length, updated_at: now, }); }); return { success: true, documentId, fileId }; } catch (dbErr: any) { await markProcessedAttachment(args.knex, { tenantId: args.tenantId, providerId: args.providerId, emailId: args.emailId, attachmentId: args.attachmentId, status: 'failed', errorMessage: dbErr?.message || String(dbErr), }); return { success: false, message: dbErr?.message || String(dbErr) }; } } async function downloadAttachmentBuffer(args: { knex: any; tenantId: string; providerId: string; providerAttachmentId: string; emailId: string; contentType: string; fileName: string; }): Promise<{ buffer: Buffer; contentType: string; fileName: string }> { const providerRow = await loadProviderRow(args.knex, args.tenantId, args.providerId); if (!providerRow) { throw new Error('Email provider not found'); } if (providerRow.provider_type === 'microsoft') { const { MicrosoftGraphAdapter } = await import( '@alga-psa/shared/services/email/providers/MicrosoftGraphAdapter' ); const providerConfig = await buildMicrosoftProviderConfig(args.knex, args.tenantId, providerRow); const adapter = new MicrosoftGraphAdapter(providerConfig); await adapter.connect(); const downloaded = await adapter.downloadAttachmentBytes(args.emailId, args.providerAttachmentId); return { buffer: downloaded.buffer, contentType: downloaded.contentType || args.contentType, fileName: downloaded.fileName || args.fileName, }; } if (providerRow.provider_type === 'google') { const { GmailAdapter } = await import('@alga-psa/shared/services/email/providers/GmailAdapter'); const providerConfig = await buildGoogleProviderConfig(args.knex, args.tenantId, providerRow); const adapter = new GmailAdapter(providerConfig); await adapter.connect(); const buffer = await adapter.downloadAttachmentBytes(args.emailId, args.providerAttachmentId); return { buffer, contentType: args.contentType, fileName: args.fileName, }; } throw new Error(`Unsupported provider_type: ${providerRow.provider_type}`); } async function downloadOriginalMime(args: { knex: any; tenantId: string; providerId: string; emailId: string; emailData: EmailMessageDetails; }): Promise { const rawFromPayload = maybeExtractRawMimeFromEmailData(args.emailData); if (rawFromPayload) { return rawFromPayload; } const providerRow = await loadProviderRow(args.knex, args.tenantId, args.providerId); if (!providerRow) { return buildDeterministicRfc822Message(args.emailData || { id: args.emailId }); } if (providerRow.provider_type === 'microsoft') { const { MicrosoftGraphAdapter } = await import( '@alga-psa/shared/services/email/providers/MicrosoftGraphAdapter' ); const providerConfig = await buildMicrosoftProviderConfig(args.knex, args.tenantId, providerRow); const adapter = new MicrosoftGraphAdapter(providerConfig); await adapter.connect(); return adapter.downloadMessageSource(args.emailId); } if (providerRow.provider_type === 'google') { const { GmailAdapter } = await import('@alga-psa/shared/services/email/providers/GmailAdapter'); const providerConfig = await buildGoogleProviderConfig(args.knex, args.tenantId, providerRow); const adapter = new GmailAdapter(providerConfig); await adapter.connect(); return adapter.downloadMessageSource(args.emailId); } return buildDeterministicRfc822Message(args.emailData || { id: args.emailId }); } async function persistInboundEmailAttachment(input: PersistAttachmentInput): Promise> { const attachment = input.attachmentData; const requestedFileName = String(attachment.name || ''); const requestedContentType = String(attachment.contentType || 'application/octet-stream').toLowerCase(); const declaredSize: number | null = typeof attachment.size === 'number' ? attachment.size : null; const contentId: string | null = attachment.contentId ? String(attachment.contentId) : null; const isInline: boolean = Boolean(attachment.isInline); const allowInlineProcessing = Boolean(attachment.allowInlineProcessing); const providerAttachmentId: string = String(attachment.providerAttachmentId || input.attachmentId); const providedContentBase64: string | null = typeof attachment.content === 'string' && attachment.content.trim().length > 0 ? attachment.content.trim() : null; const knex = await getAdminKnex(); const claim = await claimAttachmentRow(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, fileName: requestedFileName, contentType: requestedContentType, fileSize: declaredSize, contentId, }); if (!claim.claimed) { return (claim as { claimed: false; result: Record }).result; } const normalizedCid = normalizeContentId(contentId); const consumedInlineCids = input.consumedInlineCids; const isConsumedInline = Boolean( normalizedCid && consumedInlineCids && consumedInlineCids.has(normalizedCid) ); if (isConsumedInline && !allowInlineProcessing) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: 'Inline/CID attachments referenced in body are handled by embedded extraction', }); return { success: true, skipped: true, reason: 'inline' }; } if (!requestedFileName) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: 'Attachment missing filename', }); return { success: true, skipped: true, reason: 'missing_filename' }; } if (declaredSize !== null && declaredSize > MAX_ATTACHMENT_BYTES) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: `Attachment exceeds max size (${MAX_ATTACHMENT_BYTES} bytes)`, }); return { success: true, skipped: true, reason: 'too_large' }; } if (allowInlineProcessing && !requestedContentType.startsWith('image/')) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: 'Embedded extraction only supports image MIME types', }); return { success: true, skipped: true, reason: 'non_image_embedded' }; } const systemUserId = await resolveSystemUserId(knex, input.tenantId); if (!systemUserId) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'failed', errorMessage: 'No system user id available for attachment attribution', }); return { success: false, message: 'No system user id available for attachment attribution' }; } let buffer: Buffer; let resolvedMimeType = requestedContentType; let resolvedFileName = requestedFileName; try { if (providedContentBase64) { if (!isBase64(providedContentBase64)) { throw new Error('Invalid base64 attachment payload'); } buffer = Buffer.from(providedContentBase64.replace(/\s+/g, ''), 'base64'); } else { const downloaded = await downloadAttachmentBuffer({ knex, tenantId: input.tenantId, providerId: input.providerId, providerAttachmentId, emailId: input.emailId, contentType: requestedContentType, fileName: requestedFileName, }); buffer = downloaded.buffer; resolvedMimeType = String(downloaded.contentType || requestedContentType).toLowerCase(); resolvedFileName = downloaded.fileName || requestedFileName; } } catch (downloadErr: any) { const message = downloadErr?.message || String(downloadErr); const lower = String(message).toLowerCase(); const isUnsupported = lower.includes('unsupported attachment type') || lower.includes('contentbytes missing') || lower.includes('invalid base64') || lower.includes('unsupported provider_type'); await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: isUnsupported ? 'skipped' : 'failed', errorMessage: message, }); return isUnsupported ? { success: true, skipped: true, reason: 'unsupported_attachment' } : { success: false, message }; } if (buffer.length > MAX_ATTACHMENT_BYTES) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: `Attachment exceeds max size (${MAX_ATTACHMENT_BYTES} bytes)`, }); return { success: true, skipped: true, reason: 'too_large_downloaded' }; } if (allowInlineProcessing && !resolvedMimeType.startsWith('image/')) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, status: 'skipped', errorMessage: 'Embedded extraction only supports image MIME types', }); return { success: true, skipped: true, reason: 'non_image_embedded' }; } const persistResult = await persistDocumentForBuffer({ knex, tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId: input.attachmentId, ticketId: input.ticketId, systemUserId, fileName: resolvedFileName, mimeType: resolvedMimeType, buffer, clientVisibleOverride: input.clientVisibleAttachments ? true : undefined, }); if (!persistResult.success) { return { success: false, message: persistResult.message || 'Failed to persist attachment' }; } return { success: true, documentId: persistResult.documentId, fileId: persistResult.fileId, fileName: sanitizeGeneratedFileName(resolvedFileName), fileSize: buffer.length, contentType: resolvedMimeType, }; } async function persistInboundOriginalEmail(input: PersistOriginalEmailInput): Promise> { const attachmentId = ORIGINAL_EMAIL_ATTACHMENT_ID; const fileName = buildOriginalEmailFileName(input.emailId); const contentType = 'message/rfc822'; const knex = await getAdminKnex(); const claim = await claimAttachmentRow(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, fileName, contentType, fileSize: null, contentId: null, }); if (!claim.claimed) { return (claim as { claimed: false; result: Record }).result; } if (hasRawMimeOverCapSkipReason(input.emailData)) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, status: 'skipped', errorMessage: 'Raw MIME source exceeds ingress cap', }); return { success: true, skipped: true, reason: 'raw_mime_over_max_bytes' }; } const systemUserId = await resolveSystemUserId(knex, input.tenantId); if (!systemUserId) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, status: 'failed', errorMessage: 'No system user id available for attachment attribution', }); return { success: false, message: 'No system user id available for attachment attribution' }; } let buffer: Buffer; try { buffer = await downloadOriginalMime({ knex, tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, emailData: input.emailData, }); } catch (error: any) { const message = error?.message || String(error); await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, status: 'failed', errorMessage: message, }); return { success: false, message }; } if (buffer.length > MAX_ATTACHMENT_BYTES) { await markProcessedAttachment(knex, { tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, status: 'skipped', errorMessage: `Attachment exceeds max size (${MAX_ATTACHMENT_BYTES} bytes)`, }); return { success: true, skipped: true, reason: 'too_large' }; } const persistResult = await persistDocumentForBuffer({ knex, tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailId, attachmentId, ticketId: input.ticketId, systemUserId, fileName, mimeType: contentType, buffer, }); if (!persistResult.success) { return { success: false, message: persistResult.message || 'Failed to persist original email attachment', }; } return { success: true, documentId: persistResult.documentId, fileId: persistResult.fileId, fileName, fileSize: buffer.length, contentType, }; } export async function processInboundEmailArtifactsBestEffort( input: ProcessInboundEmailArtifactsInput ): Promise { const result: ProcessInboundEmailArtifactsResult = { embeddedImageUrlMappings: [], }; const baseAttachments = Array.isArray(input.emailData.attachments) ? input.emailData.attachments : []; const ingressSkipReasons = Array.isArray(input.emailData.ingressSkipReasons) ? input.emailData.ingressSkipReasons : []; if (ingressSkipReasons.length > 0) { console.warn(`processInboundEmailInApp:[${input.scopeLabel}] ingress skipped artifacts`, { emailId: input.emailData.id, reasons: ingressSkipReasons, }); } let embeddedAttachments: Array<{ id: string; name: string; contentType: string; size: number; contentId?: string; content?: string; providerAttachmentId?: string; source?: 'data-url' | 'cid'; allowInlineProcessing?: boolean; }> = []; if (input.emailData.body?.html) { try { const extraction = extractEmbeddedImageAttachments({ emailId: input.emailData.id, html: input.emailData.body.html, attachments: baseAttachments as any[], maxBytes: MAX_ATTACHMENT_BYTES, }); embeddedAttachments = extraction.attachments; if (extraction.warnings.length > 0) { console.warn(`processInboundEmailInApp:[${input.scopeLabel}] embedded image extraction warnings`, { emailId: input.emailData.id, warnings: extraction.warnings, }); } } catch (error) { console.warn( `processInboundEmailInApp:[${input.scopeLabel}] embedded image extraction failed (continuing)`, { emailId: input.emailData.id, error: error instanceof Error ? error.message : String(error), } ); } } const allAttachments = [...baseAttachments, ...embeddedAttachments]; const baseAttachmentById = new Map(); for (const attachment of baseAttachments) { if (attachment?.id) { baseAttachmentById.set(String(attachment.id), attachment); } } const consumedInlineCids = new Set(); for (const embedded of embeddedAttachments) { if (embedded.source !== 'cid') continue; const matchedBase = embedded.providerAttachmentId ? baseAttachmentById.get(String(embedded.providerAttachmentId)) : undefined; const cid = normalizeContentId((embedded as any).contentId || matchedBase?.contentId); if (cid) { consumedInlineCids.add(cid); } } const attachmentConcurrency = resolveAttachmentConcurrency(input.maxAttachmentConcurrency); await runWithConcurrency(allAttachments, attachmentConcurrency, async (attachment) => { try { const persistResult = await persistInboundEmailAttachment({ tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailData.id, ticketId: input.ticketId, attachmentId: String(attachment.id), attachmentData: { id: String(attachment.id), name: String(attachment.name), contentType: String(attachment.contentType || 'application/octet-stream'), size: Number(attachment.size || 0), contentId: (attachment as any).contentId ? String((attachment as any).contentId) : undefined, isInline: typeof (attachment as any).isInline === 'boolean' ? (attachment as any).isInline : undefined, content: typeof (attachment as any).content === 'string' ? (attachment as any).content : undefined, providerAttachmentId: typeof (attachment as any).providerAttachmentId === 'string' ? (attachment as any).providerAttachmentId : undefined, allowInlineProcessing: (attachment as any).allowInlineProcessing ? true : undefined, }, consumedInlineCids, clientVisibleAttachments: input.clientVisibleAttachments, }); const isEmbedded = Boolean((attachment as any).allowInlineProcessing); const source = (attachment as any).source as 'data-url' | 'cid' | undefined; const fileId = typeof persistResult?.fileId === 'string' && persistResult.fileId.trim().length > 0 ? persistResult.fileId : ''; const documentId = typeof persistResult?.documentId === 'string' && persistResult.documentId.trim().length > 0 ? persistResult.documentId : ''; if (!isEmbedded || !source || !fileId || !documentId) { return; } let reference = ''; if (source === 'data-url') { const contentType = String((attachment as any).contentType || '').toLowerCase(); const base64 = typeof (attachment as any).content === 'string' ? (attachment as any).content : ''; if (contentType.startsWith('image/') && base64.trim().length > 0) { reference = `data:${contentType};base64,${base64.replace(/\s+/g, '')}`; } } else if (source === 'cid') { const directContentId = normalizeContentId((attachment as any).contentId); const providerAttachmentId = String((attachment as any).providerAttachmentId || ''); const providerContentId = normalizeContentId(baseAttachmentById.get(providerAttachmentId)?.contentId); reference = directContentId || providerContentId; } if (!reference) { return; } result.embeddedImageUrlMappings.push({ source, reference, fileId, documentId, url: `/api/documents/view/${fileId}`, }); } catch (error) { console.warn(`processInboundEmailInApp:[${input.scopeLabel}] attachment processing failed (continuing)`, { emailId: input.emailData.id, attachmentId: attachment?.id, error: error instanceof Error ? error.message : String(error), }); } }); try { const originalResult = await persistInboundOriginalEmail({ tenantId: input.tenantId, providerId: input.providerId, emailId: input.emailData.id, ticketId: input.ticketId, emailData: input.emailData, }); if (!originalResult?.success) { console.warn(`processInboundEmailInApp:[${input.scopeLabel}] original-email persistence failed`, { emailId: input.emailData.id, reason: originalResult?.message || originalResult?.reason || 'unknown', }); } } catch (error) { console.warn( `processInboundEmailInApp:[${input.scopeLabel}] original-email persistence errored (continuing)`, { emailId: input.emailData.id, error: error instanceof Error ? error.message : String(error), } ); } return result; }