'use strict'; const { cleanBody } = require('./bodyParser'); const { formatDateTime } = require('../utils/time-formatter'); /** * threadBuilder.js * Reconstructs a clean, deduplicated email thread from a set of Graph API * message objects. Each message is stripped down to only its unique new * content — quoted prior messages are removed. */ // --------------------------------------------------------------------------- // Quote-stripping patterns // Ordered from most specific to most general. // --------------------------------------------------------------------------- const QUOTE_BOUNDARIES = [ // Outlook-style attribution: "From: Name \nSent: ..." /^From\s*:\s*.+\n(?:Sent|Date)\s*:/im, // Outlook-style with leading whitespace/non-breaking spaces /^\s*From\s*:\s*.+\n\s*(?:Sent|Date)\s*:/im, // Gmail/web client: "On Mon, Mar 13, 2026 at 8:32 AM, Name wrote:" /^On\s+.{5,80}wrote\s*:\s*$/im, // Simple "wrote:" attribution line /^.{0,100}<.+@.+>\s+wrote\s*:/im, // Forwarded message header block /^-{3,}\s*(?:Forwarded|Original)\s+[Mm]essage\s*-{3,}/im, // Lines starting with > (standard quote marker) /^>+\s/m, ]; // --------------------------------------------------------------------------- // Core functions // --------------------------------------------------------------------------- /** * Extract only the new/unique content from a single message body, * stripping all quoted prior messages. * @param {string} body - Cleaned body text * @returns {string} Unique message content only */ function extractUniqueContent(body) { if (!body) return ''; let earliestBoundary = body.length; for (const pattern of QUOTE_BOUNDARIES) { const match = body.search(pattern); if (match !== -1 && match < earliestBoundary) { earliestBoundary = match; } } const unique = body.slice(0, earliestBoundary).trim(); return unique; } /** * Format a single message as a clean thread entry. * @param {object} msg - Graph API message object (already body-cleaned) * @param {number} index - 1-based position in thread * @returns {string} */ function formatThreadEntry(msg, index) { const from = msg.from?.emailAddress ? `${msg.from.emailAddress.name || ''} <${msg.from.emailAddress.address}>`.trim() : 'Unknown'; const to = (msg.toRecipients || []) .map(r => r.emailAddress?.name || r.emailAddress?.address || '') .filter(Boolean) .join(', ') || 'Unknown'; const cc = (msg.ccRecipients || []) .map(r => r.emailAddress?.name || r.emailAddress?.address || '') .filter(Boolean) .join(', '); const date = msg.receivedDateTime ? formatDateTime(msg.receivedDateTime) : 'Unknown date'; const bodyText = msg.body?.content || msg.bodyPreview || ''; const unique = extractUniqueContent(bodyText); const lines = [ `[${index}] ${date}`, `From: ${from}`, `To: ${to}`, ]; if (cc) lines.push(`CC: ${cc}`); if (msg.hasAttachments) lines.push('Attachments: Yes'); lines.push(''); lines.push(unique || '(no unique content)'); return lines.join('\n'); } /** * Build a clean deduplicated thread from an array of Graph API message objects. * Messages are sorted chronologically and each is stripped to unique content only. * * @param {object[]} messages - Array of Graph API message objects with body.content populated * @param {string} [subject] - Thread subject (inferred from first message if omitted) * @returns {string} Formatted thread as a single string */ function buildThread(messages, subject) { if (!Array.isArray(messages) || messages.length === 0) { return '(no messages)'; } // Sort chronologically const sorted = [...messages].sort((a, b) => { const ta = a.receivedDateTime ? new Date(a.receivedDateTime).getTime() : 0; const tb = b.receivedDateTime ? new Date(b.receivedDateTime).getTime() : 0; return ta - tb; }); // Infer subject if not provided const threadSubject = subject || sorted[0]?.subject || '(no subject)'; // Strip Re:/Fw: prefix for display const displaySubject = threadSubject.replace(/^(Re|Fwd?)\s*:\s*/i, '').trim(); const header = [ `Thread: ${displaySubject}`, `Messages: ${sorted.length}`, '='.repeat(60), '', ].join('\n'); // Track which senders have already had their signature included. // First message from each sender keeps the signature; repeats get it stripped. const seenSenders = new Set(); const entries = sorted .map((msg, i) => { const senderEmail = msg.from?.emailAddress?.address?.toLowerCase() || ''; const isRepeat = seenSenders.has(senderEmail); if (senderEmail) seenSenders.add(senderEmail); const cleanedMsg = { ...msg }; if (cleanedMsg.body?.content) { cleanedMsg.body = { ...cleanedMsg.body, content: cleanBody(cleanedMsg.body.content, { stripSignature: isRepeat }), }; } return formatThreadEntry(cleanedMsg, i + 1); }) .join('\n\n' + '-'.repeat(40) + '\n\n'); return header + entries; } module.exports = { buildThread, extractUniqueContent };