MCP server providing Microsoft Outlook integration via Graph API: - Email: list, search, read, send, thread reconstruction - Calendar: list, create, decline, cancel, delete events - Folders: list, create, move emails - Inbox rules: list, create, reorder - MSAL device code flow auth with persistent token cache - Test mode with mock data - Comprehensive README with setup, config, and tool reference 20 MCP tools across 5 modules. Node.js >= 14. MIT license.
160 lines
5.1 KiB
JavaScript
160 lines
5.1 KiB
JavaScript
'use strict';
|
|
|
|
const { cleanBody } = require('./bodyParser');
|
|
|
|
/**
|
|
* 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
|
|
? new Date(msg.receivedDateTime).toLocaleString('en-US', {
|
|
month: 'numeric', day: 'numeric', year: 'numeric',
|
|
hour: 'numeric', minute: '2-digit', hour12: true
|
|
})
|
|
: '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 };
|