diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 5ae0836..876c734 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -1314,10 +1314,33 @@ export async function getEmailContent( const { fetchGraphEmail } = await import('./microsoft-graph-mail'); const graphMessage = await fetchGraphEmail(graphCheck.mailCredentialId, emailId); + logger.debug('[EMAIL] Graph message received', { + userId, + emailId, + hasAttachments: graphMessage.hasAttachments, + attachmentsCount: graphMessage.attachments?.length || 0, + attachmentsPresent: !!graphMessage.attachments, + }); + // Convert Graph attachments to EmailAttachment format const attachments: EmailAttachment[] = []; if (graphMessage.attachments && Array.isArray(graphMessage.attachments)) { + logger.debug('[EMAIL] Processing attachments from Graph API', { + userId, + emailId, + count: graphMessage.attachments.length, + }); + for (const graphAtt of graphMessage.attachments) { + logger.debug('[EMAIL] Processing attachment', { + userId, + emailId, + attachmentName: graphAtt.name, + hasContentBytes: !!graphAtt.contentBytes, + attachmentId: graphAtt.id, + size: graphAtt.size, + }); + // Graph API attachments have contentBytes in base64 if (graphAtt.contentBytes) { attachments.push({ @@ -1327,17 +1350,34 @@ export async function getEmailContent( content: graphAtt.contentBytes, // Already base64 from Graph API }); } else { - // If no contentBytes, it might be a reference attachment - store metadata only + // If no contentBytes, store metadata anyway - content will be fetched on demand attachments.push({ filename: graphAtt.name || 'attachment', contentType: graphAtt.contentType || 'application/octet-stream', size: graphAtt.size || 0, + // Store attachment ID if available for later fetching + attachmentId: graphAtt.id, // No content - will need to fetch separately via attachment ID }); } } + } else if (graphMessage.hasAttachments) { + // Email has attachments but they weren't included in the response + // This can happen if the $select doesn't include attachments or if they need to be fetched separately + logger.warn('[EMAIL] Email has attachments but attachments array is missing', { + userId, + emailId, + hasAttachments: graphMessage.hasAttachments, + }); } + logger.debug('[EMAIL] Converted attachments', { + userId, + emailId, + attachmentsCount: attachments.length, + attachmentsWithContent: attachments.filter(a => a.content).length, + }); + // Convert Graph message to EmailMessage format const email: EmailMessage = { id: graphMessage.id, diff --git a/lib/services/microsoft-graph-mail.ts b/lib/services/microsoft-graph-mail.ts index 49ff436..b87800e 100644 --- a/lib/services/microsoft-graph-mail.ts +++ b/lib/services/microsoft-graph-mail.ts @@ -194,48 +194,97 @@ export async function fetchGraphEmail( try { const client = await getMicrosoftGraphClient(mailCredentialId); + // First, get the message without attachments to check if it has attachments const response = await client.get(`/me/messages/${messageId}`, { params: { - '$select': 'id,subject,from,toRecipients,ccRecipients,bccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag,attachments', + '$select': 'id,subject,from,toRecipients,ccRecipients,bccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag', }, }); const message = response.data; - // If email has attachments but they don't have contentBytes, fetch them individually - if (message.hasAttachments && message.attachments && Array.isArray(message.attachments)) { - const attachmentsWithContent = await Promise.all( - message.attachments.map(async (attachment: any) => { - // If contentBytes is missing, fetch the attachment content - if (!attachment.contentBytes && attachment.id) { - try { - logger.debug('Fetching attachment content from Graph API', { - messageId, - attachmentId: attachment.id, - attachmentName: attachment.name, - }); - - const attachmentData = await fetchGraphAttachment(mailCredentialId, messageId, attachment.id); - return { - ...attachment, - contentBytes: attachmentData.contentBytes, - }; - } catch (error) { - logger.error('Error fetching attachment content', { - messageId, - attachmentId: attachment.id, - error: error instanceof Error ? error.message : String(error), - }); - // Return attachment without content if fetch fails + logger.debug('Fetched email from Graph API', { + messageId, + hasAttachments: message.hasAttachments, + mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), + }); + + // If email has attachments, fetch them separately + // Microsoft Graph API sometimes doesn't include attachments in the initial response + if (message.hasAttachments) { + try { + logger.debug('Fetching attachments list from Graph API', { + messageId, + mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), + }); + + // Fetch attachments separately + const attachmentsResponse = await client.get(`/me/messages/${messageId}/attachments`, { + params: { + '$select': 'id,name,contentType,size,contentBytes,isInline', + }, + }); + + const attachments = attachmentsResponse.data.value || []; + + logger.debug('Fetched attachments from Graph API', { + messageId, + attachmentsCount: attachments.length, + mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), + }); + + // Process attachments - fetch content if not included + if (attachments.length > 0) { + const attachmentsWithContent = await Promise.all( + attachments.map(async (attachment: any) => { + // If contentBytes is missing, fetch the attachment content + if (!attachment.contentBytes && attachment.id) { + try { + logger.debug('Fetching attachment content from Graph API', { + messageId, + attachmentId: attachment.id, + attachmentName: attachment.name, + }); + + const attachmentData = await fetchGraphAttachment(mailCredentialId, messageId, attachment.id); + return { + ...attachment, + contentBytes: attachmentData.contentBytes, + }; + } catch (error) { + logger.error('Error fetching attachment content', { + messageId, + attachmentId: attachment.id, + error: error instanceof Error ? error.message : String(error), + }); + // Return attachment without content if fetch fails + return attachment; + } + } + // Already has contentBytes, return as-is return attachment; - } - } - // Already has contentBytes, return as-is - return attachment; - }) - ); - - message.attachments = attachmentsWithContent; + }) + ); + + message.attachments = attachmentsWithContent; + } else { + logger.warn('Email has hasAttachments=true but no attachments returned', { + messageId, + mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), + }); + message.attachments = []; + } + } catch (attachmentsError: any) { + logger.error('Error fetching attachments from Graph API', { + messageId, + error: attachmentsError instanceof Error ? attachmentsError.message : String(attachmentsError), + status: attachmentsError.response?.status, + }); + // Continue without attachments if fetch fails + message.attachments = []; + } + } else { + message.attachments = []; } return message;