import { Router } from 'express'; import { asyncHandler, NotFoundError } from '../middleware/errorHandler.js'; import { authMiddleware } from '../middleware/auth.js'; import { apiRateLimit } from '../middleware/rateLimit.js'; import { getGmailService } from '../services/gmail.js'; import { getMicrosoftService } from '../services/microsoft.js'; import { getImapService } from '../services/imap-service.js'; import { getIntelligenceService } from '../services/intelligence.js'; import { getStorageService } from '../services/storage.js'; import { getAttachmentStorageService, AttachmentMetadata } from '../services/attachment-storage.js'; import { createLogger } from '../utils/logger.js'; const router = Router(); const logger = createLogger('DraftsRoutes'); // List drafts router.get('/', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => { const { limit = '100', offset = '0', status = 'pending', account_id } = req.query; // Resolve the user's account IDs first — avoids unreliable joined-table // filters (.eq on embedded resource) that silently return empty when no // direct filter narrows the base table. const { data: userAccounts, error: accError } = await req.supabase! .from('email_accounts') .select('id') .eq('user_id', req.user!.id); if (accError) throw accError; const accountIds = (userAccounts || []).map((a: any) => a.id as string); // If a specific account is requested, narrow further (and verify ownership) const filterIds = account_id ? accountIds.filter((id: string) => id === (account_id as string)) : accountIds; let query = req.supabase! .from('emails') .select(` id, subject, sender, recipient, date, draft_status, draft_created_at, draft_content, ai_analysis, account_id, external_id, draft_id, email_accounts(id, email_address, provider) `, { count: 'exact' }) .in('account_id', filterIds) .eq('draft_status', status) .order('draft_created_at', { ascending: false }) .range( parseInt(offset as string, 10), parseInt(offset as string, 10) + parseInt(limit as string, 10) - 1 ); const { data, error, count } = await query; if (error) throw error; // Filter out drafts without content (these can't be sent) const validDrafts = (data || []).filter(email => { const aiAnalysis = email.ai_analysis as any; return email.draft_content || aiAnalysis?.draft_response || aiAnalysis?.draft_content; }); res.json({ drafts: validDrafts, total: validDrafts.length }); }) ); // Send draft router.post('/:emailId/send', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => { const { emailId } = req.params; const userId = req.user!.id; // Optional compose fields from request body (UI customization) const { to: customTo, cc: customCc, bcc: customBcc, subject: customSubject } = req.body; // Fetch email with account info and SECURITY CHECK in query const { data: email, error } = await req.supabase! .from('emails') .select('*, email_accounts!inner(*)') .eq('id', emailId) .eq('email_accounts.user_id', userId) // Security: Ensure user owns the account .single(); if (error || !email) { throw new NotFoundError('Email'); } const account = email.email_accounts; // CRITICAL: Priority Definition // 1. draft_content (User edits / Persisted draft) overrides everything // 2. ai_analysis.draft_response (EmailAnalysisSchema) // 3. ai_analysis.draft_content (ContextAwareAnalysisSchema) const aiAnalysis = email.ai_analysis as any; const draftContent = email.draft_content ?? aiAnalysis?.draft_response ?? aiAnalysis?.draft_content; logger.debug('Sending draft', { emailId, hasDraftContent: !!email.draft_content, hasAiDraftResponse: !!aiAnalysis?.draft_response, hasAiDraftContent: !!aiAnalysis?.draft_content, contentLength: draftContent?.length || 0, analysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : [] }); if (!draftContent) { logger.warn('Draft content missing', { emailId, draft_content: email.draft_content, ai_draft_response: aiAnalysis?.draft_response, ai_draft_content: aiAnalysis?.draft_content, analysisKeys: email.ai_analysis ? Object.keys(email.ai_analysis) : [] }); return res.status(400).json({ error: 'No draft content found for this email', details: 'No draft found in draft_content, ai_analysis.draft_response, or ai_analysis.draft_content fields' }); } let sentMessageId: string | null = null; const replyToId = email.external_id; // Prepare compose fields with fallbacks to original email values const extractEmail = (sender: string): string => { const match = sender?.match(/<([^>]+)>/) || sender?.match(/([^\s,<>]+@[^\s,<>]+)/); return match?.[1] || sender || ''; }; const toAddress = customTo || extractEmail(email.sender || ''); const subject = customSubject || email.subject || ''; const cc = customCc || ''; const bcc = customBcc || ''; logger.debug('Sending with compose fields', { to: toAddress, cc, bcc, subject }); // Fetch attachments from storage if present const emailAttachments = (Array.isArray(email.attachments) ? email.attachments : []) as AttachmentMetadata[]; const attachmentFiles: Array<{ filename: string; content: Buffer; contentType: string }> = []; if (emailAttachments.length > 0) { const attachmentStorage = getAttachmentStorageService(); for (const attachment of emailAttachments) { try { const fileBuffer = await attachmentStorage.downloadAttachment(req.supabase!, attachment.path); attachmentFiles.push({ filename: attachment.name, content: fileBuffer, contentType: attachment.type }); } catch (error) { logger.warn('Failed to download attachment, skipping', { attachmentId: attachment.id, error }); } } logger.info('Loaded attachments for sending', { count: attachmentFiles.length }); } try { // Priority: Send existing draft if ID exists (cleaner, preserves original draft object) // Note: Existing draft objects (Gmail/Outlook) cannot be customized for to/cc/bcc/subject or attachments if (email.draft_id && !customTo && !customCc && !customBcc && !customSubject && attachmentFiles.length === 0) { if (account.provider === 'gmail') { const gmailService = getGmailService(); sentMessageId = await gmailService.sendDraft(account, email.draft_id); } else if (account.provider === 'outlook') { const microsoftService = getMicrosoftService(); sentMessageId = await microsoftService.sendDraft(account, email.draft_id); } logger.info('Sent existing draft', { draftId: email.draft_id, sentMessageId }); } // Fallback: Create new reply using content (if draft object was deleted, not saved, custom compose fields, or attachments provided) else { if (account.provider === 'gmail') { const gmailService = getGmailService(); sentMessageId = await gmailService.sendReply( account, replyToId, draftContent, subject, toAddress, cc, bcc, attachmentFiles ); } else if (account.provider === 'outlook') { const microsoftService = getMicrosoftService(); sentMessageId = await microsoftService.sendReply( account, replyToId, draftContent, subject, toAddress, cc, bcc, attachmentFiles ); } else if (account.provider === 'imap') { const imapService = getImapService(); // Best-effort: read Message-ID from stored .eml for proper threading let inReplyTo: string | undefined; if (email.file_path) { try { const raw = await getStorageService().readEmail(email.file_path); const msgIdMatch = raw.match(/^Message-ID:\s*(<[^>]+>)/im); if (msgIdMatch) inReplyTo = msgIdMatch[1]; } catch { logger.warn('Could not read .eml for Message-ID threading', { file_path: email.file_path }); } } sentMessageId = await imapService.sendReply( account, toAddress, draftContent, subject, inReplyTo, cc, bcc, attachmentFiles ); } logger.info('Sent draft via reply', { sentMessageId, to: toAddress, cc, bcc, subject }); } // Update email status await req.supabase! .from('emails') .update({ draft_status: 'sent', draft_sent_at: new Date().toISOString(), action_taken: 'reply', // Legacy compatibility actions_taken: ['reply'] }) .eq('id', emailId); res.json({ success: true, messageId: sentMessageId || email.draft_id }); } catch (error: any) { logger.error('Failed to send draft', error, { emailId }); res.status(500).json({ error: 'Failed to send draft', details: error.message }); } }) ); // Update draft content (inline edit) router.patch('/:emailId', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => { const { emailId } = req.params; const { draft_content } = req.body; if (!draft_content || typeof draft_content !== 'string') { return res.status(400).json({ error: 'draft_content is required' }); } // Verify ownership const { data: email, error } = await req.supabase! .from('emails') .select('id, email_accounts!inner(user_id)') .eq('id', emailId) .eq('email_accounts.user_id', req.user!.id) .single(); if (error || !email) { throw new NotFoundError('Email'); } const { error: updateError } = await req.supabase! .from('emails') .update({ draft_content }) .eq('id', emailId); if (updateError) throw updateError; res.json({ success: true }); }) ); // Regenerate draft with new instructions router.post('/:emailId/regenerate', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => { const { emailId } = req.params; const { instructions } = req.body; if (!instructions?.trim()) { return res.status(400).json({ error: 'Instructions are required' }); } // Fetch email with ownership check const { data: email, error } = await req.supabase! .from('emails') .select('*, email_accounts!inner(id, user_id)') .eq('id', emailId) .eq('email_accounts.user_id', req.user!.id) .single(); if (error || !email) { throw new NotFoundError('Email'); } // Fetch user settings for LLM config const { data: settings } = await req.supabase! .from('user_settings') .select('*') .eq('user_id', req.user!.id) .single(); const intelligenceService = getIntelligenceService(); const newDraft = await intelligenceService.generateDraftReply( { subject: email.subject || '', sender: email.sender || '', body: email.body_snippet || '' }, instructions, { llm_provider: settings?.llm_provider, llm_model: settings?.llm_model } ); if (!newDraft) { return res.status(500).json({ error: 'Failed to regenerate draft' }); } // Persist regenerated content await req.supabase! .from('emails') .update({ draft_content: newDraft }) .eq('id', emailId); res.json({ success: true, draft_content: newDraft }); }) ); // Dismiss draft router.post('/:emailId/dismiss', apiRateLimit, authMiddleware, asyncHandler(async (req, res) => { const { emailId } = req.params; // Verify ownership const { data: email, error } = await req.supabase! .from('emails') .select('id, email_accounts!inner(user_id)') .eq('id', emailId) .eq('email_accounts.user_id', req.user!.id) .single(); if (error || !email) { throw new NotFoundError('Email'); } // Update status to dismissed const { error: updateError } = await req.supabase! .from('emails') .update({ draft_status: 'dismissed', draft_dismissed_at: new Date().toISOString() }) .eq('id', emailId); if (updateError) throw updateError; res.json({ success: true }); }) ); export default router;