outlook-mcp/utils/threadBuilder.js
Seton Carmichael 68a64461eb fix(outlook-mcp): P0 audit fixes
- Handle empty 2xx Graph API responses without JSON parse errors
- Fix list-rules handler import (handleListRules named export)
- Add timezone-aware display formatting using MS_TIMEZONE / DEFAULT_TIMEZONE
- Return created event ID in create-event success message
- Change default timezone from Windows name to IANA (America/New_York)
2026-06-21 20:28:11 -04:00

159 lines
5 KiB
JavaScript

'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 <email>\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 <email> 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 };