fix(outlook-mcp): P0 audit follow-up - folder hierarchy, recursive rule folder names, timezone date filters, search fallback, thread/read body parsing

This commit is contained in:
Seton Carmichael 2026-06-21 22:02:24 -04:00
parent 11241a4f3e
commit 6d0436d679
12 changed files with 1019 additions and 120 deletions

View file

@ -66,6 +66,18 @@ const emailTools = [
type: "string",
description: "Filter by subject line keywords (e.g. 'invoice', 'meeting notes')"
},
dateFrom: {
type: "string",
description: "Start date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'yesterday', 'last7days')"
},
dateTo: {
type: "string",
description: "End date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'today', 'tomorrow')"
},
dateRange: {
type: "string",
description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')"
},
hasAttachments: {
anyOf: [{ type: "boolean" }, { type: "string" }],
description: "Set true to return only emails that have attachments"
@ -77,6 +89,10 @@ const emailTools = [
count: {
anyOf: [{ type: "number" }, { type: "string" }],
description: "Number of results to return (default: 10, max: 500). WARNING: Large counts may consume significant context tokens."
},
strict: {
anyOf: [{ type: "boolean" }, { type: "string" }],
description: "Set true to disable the fallback to recent emails when no exact search matches are found"
}
},
required: []

View file

@ -6,6 +6,7 @@ const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth');
const { resolveFolderPath } = require('./folder-utils');
const { formatDateTime } = require('../utils/time-formatter');
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
/**
* Search emails handler
@ -20,9 +21,15 @@ async function handleSearchEmails(args) {
const from = args.from || '';
const to = args.to || '';
const subject = args.subject || '';
// Coerce booleans — MCP hosts may send "true"/"false" as strings
// Coerce booleans — MCP hosts may send numbers/booleans as strings
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
const strict = args.strict === true || args.strict === 'true';
// Date filtering uses the same timezone-aware helpers as list-emails.
const dateFrom = args.dateFrom || '';
const dateTo = args.dateTo || '';
const dateRange = args.dateRange || '';
try {
// Get access token
@ -38,10 +45,12 @@ async function handleSearchEmails(args) {
accessToken,
{ query, from, to, subject },
{ hasAttachments, unreadOnly },
count
count,
strict,
{ dateFrom, dateTo, dateRange }
);
return formatSearchResults(response);
return formatSearchResults(response, { dateFrom, dateTo, dateRange });
} catch (error) {
// Handle authentication errors
if (error.message === 'Authentication required') {
@ -76,11 +85,40 @@ async function handleSearchEmails(args) {
* then apply boolean filters client-side
* 2. Text terms present retry with each term individually (same approach)
* 3. Only boolean filters $filter + $orderby (fully supported)
* 4. Fallback recent emails (labeled in response)
* 4. Fallback recent emails (only when not in strict mode; marked clearly)
*/
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count) {
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}) {
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange);
// Build timezone-aware date filter once. It will be applied server-side
// when we can, or client-side after $search results come back.
const dateConditions = hasDateFilters
? buildDateFilter(dateOpts.dateFrom, dateOpts.dateTo, dateOpts.dateRange)
: [];
const dateFilterString = buildODataFilter(dateConditions);
// Parse date bounds for client-side filtering.
let dateFromMs = null;
let dateToMs = null;
if (hasDateFilters && dateConditions.length > 0) {
for (const cond of dateConditions) {
const geMatch = cond.match(/receivedDateTime ge ([^)]+)/);
const leMatch = cond.match(/receivedDateTime le ([^)]+)/);
if (geMatch) dateFromMs = new Date(geMatch[1]).getTime();
if (leMatch) dateToMs = new Date(leMatch[1]).getTime();
}
}
const applyDateFilter = (emails) => {
if (!hasDateFilters || dateConditions.length === 0) return emails;
return emails.filter(email => {
const receivedMs = email.receivedDateTime ? new Date(email.receivedDateTime).getTime() : null;
if (receivedMs == null) return false;
if (dateFromMs != null && receivedMs < dateFromMs) return false;
if (dateToMs != null && receivedMs > dateToMs) return false;
return true;
});
};
// 1. Try combined KQL search (text terms only — boolean filters applied client-side)
if (hasTextTerms) {
@ -98,7 +136,8 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
if (response.value && response.value.length > 0) {
const filtered = applyClientSideFilters(response.value, filterTerms);
let filtered = applyClientSideFilters(response.value, filterTerms);
filtered = applyDateFilter(filtered);
console.error(`Combined search found ${response.value.length} results, ${filtered.length} after filtering`);
if (filtered.length > 0) {
return { value: filtered };
@ -126,7 +165,8 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
if (response.value && response.value.length > 0) {
const filtered = applyClientSideFilters(response.value, filterTerms);
let filtered = applyClientSideFilters(response.value, filterTerms);
filtered = applyDateFilter(filtered);
console.error(`Search on ${term} found ${response.value.length} results, ${filtered.length} after filtering`);
if (filtered.length > 0) {
return { value: filtered };
@ -138,12 +178,13 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
}
}
// 3. Boolean filters only (no text search) — $filter + $orderby is supported
if (hasBooleanFilters) {
// 3. Boolean filters (and/or date filters) — $filter + $orderby is supported
if (hasBooleanFilters || hasDateFilters) {
try {
const filterConditions = [];
if (filterTerms.hasAttachments === true) filterConditions.push('hasAttachments eq true');
if (filterTerms.unreadOnly === true) filterConditions.push('isRead eq false');
if (dateFilterString) filterConditions.push(dateFilterString);
const params = {
$top: count,
@ -152,26 +193,39 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
$filter: filterConditions.join(' and ')
};
console.error(`Attempting boolean-filter-only search: ${params.$filter}`);
console.error(`Attempting filter-only search: ${params.$filter}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
console.error(`Boolean filter search found ${response.value?.length || 0} results`);
console.error(`Filter-only search found ${response.value?.length || 0} results`);
return response;
} catch (error) {
console.error(`Boolean filter search failed: ${error.message}`);
console.error(`Filter-only search failed: ${error.message}`);
}
}
// 4. Final fallback: recent emails
// 4. Final fallback: recent emails (disabled in strict mode)
console.error("All search strategies exhausted, falling back to recent emails");
if (strict) {
console.error('Strict mode enabled: returning empty results instead of fallback');
return { value: [], _searchFallback: false, _strict: true, _originalTerms: searchTerms };
}
const basicParams = {
$top: count,
$select: config.EMAIL_SELECT_FIELDS,
$orderby: 'receivedDateTime desc'
};
if (dateFilterString) {
basicParams.$filter = dateFilterString;
}
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams);
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
if (dateFilterString) {
response.value = applyDateFilter(response.value || []);
}
response._searchFallback = true;
response._originalTerms = searchTerms;
return response;
@ -219,9 +273,10 @@ function applyClientSideFilters(emails, filterTerms) {
/**
* Format search results into a readable text format
* @param {object} response - The API response object
* @param {object} [dateOpts] - Optional date filter metadata for the result message
* @returns {object} - MCP response object
*/
function formatSearchResults(response) {
function formatSearchResults(response, dateOpts = {}) {
if (!response.value || response.value.length === 0) {
return {
content: [{
@ -232,7 +287,7 @@ function formatSearchResults(response) {
}
// Format results
const emailList = response.value.map((email, index) => {
let emailList = response.value.map((email, index) => {
const sender = email.from?.emailAddress || { name: 'Unknown', address: 'unknown' };
const date = formatDateTime(email.receivedDateTime);
const readStatus = email.isRead ? '' : '[UNREAD] ';
@ -241,16 +296,34 @@ function formatSearchResults(response) {
return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}${threadNote}\n`;
}).join("\n");
// Strict mode: no results path
if (response._strict) {
return {
content: [{
type: "text",
text: `No emails found matching your search criteria. Strict mode is enabled; no fallback to recent emails was performed.`
}]
};
}
// Add fallback warning if search had to give up
let additionalInfo = '';
if (response._searchFallback) {
additionalInfo = `\n⚠️ Search could not find matches for the specified criteria — showing recent emails instead.`;
additionalInfo = `\n\n⚠️ FALLBACK: No messages matched the exact search terms. The ${response.value.length} result(s) below are the most recent emails from the folder, not search hits.`;
// Tag each listing so an automated parser can tell these are fallback results
emailList = emailList.replace(/^(\d+\.)\s*/gm, '$1 [FALLBACK] ');
}
const dateParts = [];
if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`);
if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`);
if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`);
const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : '';
return {
content: [{
type: "text",
text: `Found ${response.value.length} emails:${additionalInfo}\n\n${emailList}`
text: `Found ${response.value.length} emails${dateInfo}:${additionalInfo}\n\n${emailList}`
}]
};
}

View file

@ -1,7 +1,7 @@
/**
* Folder management module for Outlook MCP server
*/
const handleListFolders = require('./list');
const { handleListFolders } = require('./list');
const handleCreateFolder = require('./create');
const handleMoveEmails = require('./move');

View file

@ -120,8 +120,27 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
isTopLevel: true
}));
// Combine all folders
return [...topLevelFolders, ...allChildFolders];
// Combine all folders and deduplicate by ID. Some folders can appear both as
// top-level entries and as children of other folders (e.g. Junk Email under
// RecoverableItems), causing duplicate output.
const combined = [...topLevelFolders, ...allChildFolders];
const deduped = Array.from(
new Map(combined.map(folder => [folder.id, folder])).values()
);
// Mark folders whose displayName is shared by multiple distinct IDs so the
// formatter can include the ID to tell them apart.
const displayNameCounts = new Map();
for (const folder of deduped) {
displayNameCounts.set(folder.displayName, (displayNameCounts.get(folder.displayName) || 0) + 1);
}
for (const folder of deduped) {
if (displayNameCounts.get(folder.displayName) > 1) {
folder.hasDuplicateName = true;
}
}
return deduped;
} catch (error) {
console.error(`Error getting all folders: ${error.message}`);
throw error;
@ -162,7 +181,13 @@ function formatFolderList(folders, includeItemCounts) {
// Format each folder
const folderLines = sortedFolders.map(folder => {
let folderInfo = folder.displayName;
// Include the raw folder id when multiple distinct folders share the
// same displayName (e.g. two Junk Email folders).
if (folder.hasDuplicateName) {
folderInfo += ` [id: ${folder.id}]`;
}
// Add parent folder info if available
if (folder.parentFolder) {
folderInfo += ` (in ${folder.parentFolder})`;
@ -232,6 +257,12 @@ function formatFolderHierarchy(folders, includeItemCounts) {
const indent = ' '.repeat(level);
let line = `${indent}${folder.displayName}`;
// Include the raw folder id when multiple distinct folders share the
// same displayName (e.g. two Junk Email folders).
if (folder.hasDuplicateName) {
line += ` [id: ${folder.id}]`;
}
// Add item counts if requested
if (includeItemCounts) {
@ -261,4 +292,8 @@ function formatFolderHierarchy(folders, includeItemCounts) {
return `Folder Hierarchy:\n\n${formattedHierarchy}`;
}
module.exports = handleListFolders;
module.exports = {
handleListFolders,
formatFolderList,
formatFolderHierarchy
};

View file

@ -18,9 +18,13 @@ async function handleListRules(args) {
// Get all inbox rules
const rules = await getInboxRules(accessToken);
// Build an id -> displayName map from all mail folders so rule actions can
// show friendly folder names instead of raw Graph folder IDs.
const folderMap = await getFolderDisplayNameMap(accessToken);
// Format the rules based on detail level
const formattedRules = formatRulesList(rules, includeDetails);
const formattedRules = formatRulesList(rules, includeDetails, folderMap);
return {
content: [{
@ -68,13 +72,67 @@ async function getInboxRules(accessToken) {
}
}
/**
* Get a mapping from folder ID to displayName for all mail folders.
* Recurses into child folders so folders nested under Recoverable Items
* (and other parents) are resolved.
* @param {string} accessToken - Access token
* @returns {Promise<Map<string, string>>} - Map of folder id -> displayName
*/
async function getFolderDisplayNameMap(accessToken) {
const map = new Map();
try {
await collectFolders(accessToken, map, 'me/mailFolders');
} catch (error) {
console.error(`Error building folder display name map: ${error.message}`);
}
return map;
}
/**
* Recursively collect id -> displayName mappings for a folder endpoint.
* @param {string} accessToken - Access token
* @param {Map<string, string>} map - Accumulator map
* @param {string} endpoint - Graph API folder endpoint
*/
async function collectFolders(accessToken, map, endpoint) {
const response = await callGraphAPI(
accessToken,
'GET',
endpoint,
null,
{
$top: 100,
$select: 'id,displayName,childFolderCount'
}
);
const folders = response.value || [];
const childPromises = [];
for (const folder of folders) {
if (folder.id && folder.displayName) {
map.set(folder.id, folder.displayName);
}
if (folder.childFolderCount > 0) {
childPromises.push(collectFolders(accessToken, map, `me/mailFolders/${folder.id}/childFolders`));
}
}
await Promise.all(childPromises);
}
/**
* Format rules list for display
* @param {Array} rules - Array of rule objects
* @param {boolean} includeDetails - Whether to include detailed conditions and actions
* @param {Map<string, string>} folderMap - Optional folder id -> displayName map
* @returns {string} - Formatted rules list
*/
function formatRulesList(rules, includeDetails) {
function formatRulesList(rules, includeDetails, folderMap = new Map()) {
if (!rules || rules.length === 0) {
return "No inbox rules found.\n\nTip: You can create rules using the 'create-rule' tool. Rules are processed in order of their sequence number (lower numbers are processed first).";
}
@ -98,7 +156,7 @@ function formatRulesList(rules, includeDetails) {
}
// Format actions
const actions = formatRuleActions(rule);
const actions = formatRuleActions(rule, folderMap);
if (actions) {
ruleText += `\n Actions: ${actions}`;
}
@ -157,19 +215,26 @@ function formatRuleConditions(rule) {
/**
* Format rule actions for display
* @param {object} rule - Rule object
* @param {Map<string, string>} folderMap - Optional folder id -> displayName map
* @returns {string} - Formatted actions
*/
function formatRuleActions(rule) {
function formatRuleActions(rule, folderMap = new Map()) {
const actions = [];
// Resolve a folder ID to a displayName; fall back to the raw ID if unknown.
const resolveFolder = (folderId) => {
if (!folderId) return folderId;
return folderMap.get(folderId) || folderId;
};
// Move to folder
if (rule.actions?.moveToFolder) {
actions.push(`Move to folder: ${rule.actions.moveToFolder}`);
actions.push(`Move to folder: ${resolveFolder(rule.actions.moveToFolder)}`);
}
// Copy to folder
if (rule.actions?.copyToFolder) {
actions.push(`Copy to folder: ${rule.actions.copyToFolder}`);
actions.push(`Copy to folder: ${resolveFolder(rule.actions.copyToFolder)}`);
}
// Mark as read
@ -198,5 +263,7 @@ function formatRuleActions(rule) {
module.exports = {
handleListRules,
getInboxRules
getInboxRules,
formatRulesList,
formatRuleActions
};

View file

@ -0,0 +1,255 @@
/**
* Static Node tests for timezone-aware date filtering in odata-helpers.js.
*
* These tests exercise buildDateFilter / localDateStringToInstant / processDateRange
* with known local dates and assert that the produced UTC ISO boundaries are correct.
*
* Run with:
* node tests/date-filter-timezone.test.js
*/
const assert = require('assert');
const {
buildDateFilter,
processDateRange,
startOfDay,
endOfDay,
localDateStringToInstant,
getEffectiveTimeZone
} = require('../utils/odata-helpers');
function assertBoundary(actualISO, expectedISO, label) {
if (actualISO !== expectedISO) {
throw new Error(`${label}: expected ${expectedISO}, got ${actualISO}`);
}
}
// We control the timezone via MS_TIMEZONE env var before requiring config.
// For these tests we set America/New_York explicitly.
assert.strictEqual(getEffectiveTimeZone('America/New_York'), 'America/New_York', 'IANA timezone should pass through');
assert.strictEqual(getEffectiveTimeZone('Eastern Standard Time'), 'America/New_York', 'Windows timezone should map to IANA');
// Test 1: dateFrom/dateTo as plain date strings in America/New_York (EDT).
// 2024-06-21 EDT is UTC-4, so local midnight is 2024-06-21T04:00:00.000Z,
// and end-of-day is 2024-06-22T03:59:59.999Z.
{
const conditions = buildDateFilter('2024-06-21', '2024-06-21', null, 'America/New_York');
console.log('Test 1 conditions (NY EDT):', conditions);
assert.strictEqual(conditions.length, 2, 'should produce two conditions');
assertBoundary(
conditions[0],
'receivedDateTime ge 2024-06-21T04:00:00.000Z',
'Test 1 start of local day in EDT'
);
assertBoundary(
conditions[1],
'receivedDateTime le 2024-06-22T03:59:59.999Z',
'Test 1 end of local day in EDT'
);
}
// Test 2: Plain date strings in America/New_York during EST (UTC-5).
{
const conditions = buildDateFilter('2024-01-15', '2024-01-15', null, 'America/New_York');
console.log('Test 2 conditions (NY EST):', conditions);
assertBoundary(
conditions[0],
'receivedDateTime ge 2024-01-15T05:00:00.000Z',
'Test 2 start of local day in EST'
);
assertBoundary(
conditions[1],
'receivedDateTime le 2024-01-16T04:59:59.999Z',
'Test 2 end of local day in EST'
);
}
// Test 3: Plain date strings in Pacific Time during PDT (UTC-7).
{
const conditions = buildDateFilter('2024-06-21', '2024-06-21', null, 'America/Los_Angeles');
console.log('Test 3 conditions (LA PDT):', conditions);
assertBoundary(
conditions[0],
'receivedDateTime ge 2024-06-21T07:00:00.000Z',
'Test 3 start of local day in PDT'
);
assertBoundary(
conditions[1],
'receivedDateTime le 2024-06-22T06:59:59.999Z',
'Test 3 end of local day in PDT'
);
}
// Test 4: ISO strings with explicit offsets are respected as-is.
{
const conditions = buildDateFilter('2024-06-21T08:00:00-04:00', '2024-06-21T18:00:00-04:00', null, 'America/New_York');
console.log('Test 4 conditions (offset provided):', conditions);
assertBoundary(
conditions[0],
'receivedDateTime ge 2024-06-21T12:00:00.000Z',
'Test 4 explicit offset fromDate'
);
assertBoundary(
conditions[1],
'receivedDateTime le 2024-06-21T22:00:00.000Z',
'Test 4 explicit offset toDate'
);
}
// Test 5: Windows timezone name should map correctly.
{
const conditions = buildDateFilter('2024-06-21', '2024-06-21', null, 'Pacific Standard Time');
console.log('Test 5 conditions (Windows PST in summer -> PDT):', conditions);
assertBoundary(
conditions[0],
'receivedDateTime ge 2024-06-21T07:00:00.000Z',
'Test 5 Windows Pacific start'
);
}
// Test 6: dateRange='today' boundary for a known UTC instant.
// Force "now" by passing a specific date into processDateRange indirectly via startOfDay.
// We instead test processDateRange by stubbing Date in a narrow scope.
{
const savedDate = Date;
const fixedNow = new Date('2024-06-21T02:30:00.000Z'); // 2024-06-20 22:30 EDT
global.Date = class extends savedDate {
constructor(...args) {
if (args.length === 0) {
super(fixedNow);
} else {
super(...args);
}
}
static now() { return fixedNow.getTime(); }
};
// Reload helpers so the new Date constructor is used.
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
const helpers = require('../utils/odata-helpers');
const range = helpers.processDateRange('today', 'America/New_York');
console.log('Test 6 today range:', range);
assertBoundary(
range.from.toISOString(),
'2024-06-20T04:00:00.000Z',
'Test 6 today start in EDT (local date is 2024-06-20)'
);
assertBoundary(
range.to.toISOString(),
'2024-06-21T03:59:59.999Z',
'Test 6 today end in EDT'
);
global.Date = savedDate;
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
}
// Test 7: dateRange='yesterday' for the same fixed EDT instant.
{
const savedDate = Date;
const fixedNow = new Date('2024-06-21T02:30:00.000Z');
global.Date = class extends savedDate {
constructor(...args) {
if (args.length === 0) {
super(fixedNow);
} else {
super(...args);
}
}
static now() { return fixedNow.getTime(); }
};
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
const helpers = require('../utils/odata-helpers');
const range = helpers.processDateRange('yesterday', 'America/New_York');
console.log('Test 7 yesterday range:', range);
assertBoundary(
range.from.toISOString(),
'2024-06-19T04:00:00.000Z',
'Test 7 yesterday start in EDT'
);
assertBoundary(
range.to.toISOString(),
'2024-06-20T03:59:59.999Z',
'Test 7 yesterday end in EDT'
);
global.Date = savedDate;
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
}
// Test 8: dateRange='thisweek' (Sunday-based) in EDT around the same instant.
// Local date is Thursday 2024-06-20, so Sunday is 2024-06-16.
{
const savedDate = Date;
const fixedNow = new Date('2024-06-21T02:30:00.000Z');
global.Date = class extends savedDate {
constructor(...args) {
if (args.length === 0) {
super(fixedNow);
} else {
super(...args);
}
}
static now() { return fixedNow.getTime(); }
};
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
const helpers = require('../utils/odata-helpers');
const range = helpers.processDateRange('thisweek', 'America/New_York');
console.log('Test 8 thisweek range:', range);
assertBoundary(
range.from.toISOString(),
'2024-06-16T04:00:00.000Z',
'Test 8 thisweek start (Sunday 2024-06-16 EDT)'
);
global.Date = savedDate;
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
}
// Test 9: dateRange='thismonth' in EDT.
{
const savedDate = Date;
const fixedNow = new Date('2024-06-21T02:30:00.000Z');
global.Date = class extends savedDate {
constructor(...args) {
if (args.length === 0) {
super(fixedNow);
} else {
super(...args);
}
}
static now() { return fixedNow.getTime(); }
};
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
const helpers = require('../utils/odata-helpers');
const range = helpers.processDateRange('thismonth', 'America/New_York');
console.log('Test 9 thismonth range:', range);
assertBoundary(
range.from.toISOString(),
'2024-06-01T04:00:00.000Z',
'Test 9 thismonth start (June 1 EDT)'
);
global.Date = savedDate;
delete require.cache[require.resolve('../utils/odata-helpers')];
delete require.cache[require.resolve('../utils/timezone-mapper')];
delete require.cache[require.resolve('../config')];
}
console.log('\nAll timezone-aware date filter tests passed.');

38
tests/folder-list.test.js Normal file
View file

@ -0,0 +1,38 @@
/**
* Unit tests for folder/list formatters
* Run with: node tests/folder-list.test.js
*/
const assert = require('assert');
const { formatFolderList, formatFolderHierarchy } = require('../folder/list');
const mockFolders = [
{ id: 'inbox-id', displayName: 'Inbox', isTopLevel: true },
{ id: 'recoverable-id', displayName: 'Recoverable Items', isTopLevel: true },
{ id: 'junk-1', displayName: 'Junk Email', isTopLevel: true },
{ id: 'junk-2', displayName: 'Junk Email', parentFolderId: 'recoverable-id', parentFolder: 'Recoverable Items', isTopLevel: false },
{ id: 'archive-id', displayName: 'Archive', isTopLevel: true },
{ id: 'custom-id', displayName: 'Custom Folder', isTopLevel: true },
{ id: 'custom-id', displayName: 'Custom Folder', isTopLevel: true } // duplicate ID
];
// Simulate deduplication/duplicate marking done by getAllFoldersHierarchy
const folders = Array.from(new Map(mockFolders.map(f => [f.id, f])).values());
const displayNameCounts = new Map();
for (const f of folders) displayNameCounts.set(f.displayName, (displayNameCounts.get(f.displayName) || 0) + 1);
for (const f of folders) if (displayNameCounts.get(f.displayName) > 1) f.hasDuplicateName = true;
// Test flat list deduplication and duplicate ID display
const flat = formatFolderList(folders, false);
assert(!flat.includes('Custom Folder\nCustom Folder'), 'Duplicate IDs should be deduplicated');
assert(flat.includes('Junk Email [id: junk-1]'), 'Duplicate displayName should include id');
assert(flat.includes('Junk Email [id: junk-2]'), 'Duplicate displayName should include id');
assert(!flat.includes('[id: inbox-id]'), 'Unique displayName should not include id');
console.log('✅ formatFolderList duplicate/id assertions passed');
// Test hierarchy respects duplicate IDs
const hierarchy = formatFolderHierarchy(folders, false);
assert(hierarchy.includes('Junk Email [id: junk-1]'), 'Hierarchy should show duplicate id');
assert(hierarchy.includes('Junk Email [id: junk-2]'), 'Hierarchy should show duplicate id for child duplicate');
console.log('✅ formatFolderHierarchy duplicate/id assertions passed');
console.log('\nAll folder-list tests passed.');

58
tests/rules-list.test.js Normal file
View file

@ -0,0 +1,58 @@
/**
* Unit tests for rules/list formatter
* Run with: node tests/rules-list.test.js
*/
const assert = require('assert');
const { formatRulesList, formatRuleActions } = require('../rules/list');
const folderMap = new Map([
['AAMkAGI5...', 'Project A'],
['AAMkAGI6...', 'Newsletters']
]);
const rules = [
{
id: 'rule-1',
displayName: 'Project A emails',
sequence: 1,
isEnabled: true,
conditions: { fromAddresses: [{ emailAddress: { address: 'team@example.com' } }] },
actions: { moveToFolder: 'AAMkAGI5...', markAsRead: true }
},
{
id: 'rule-2',
displayName: 'Newsletter rule',
sequence: 2,
isEnabled: true,
conditions: { subjectContains: ['newsletter'] },
actions: { copyToFolder: 'AAMkAGI6...' }
},
{
id: 'rule-3',
displayName: 'Unknown folder rule',
sequence: 3,
isEnabled: true,
actions: { moveToFolder: 'AAMkUNKNOWN' }
}
];
// Test action formatter resolves known IDs and falls back to raw IDs
const actions1 = formatRuleActions(rules[0], folderMap);
assert(actions1.includes('Move to folder: Project A'), `Expected "Move to folder: Project A", got: ${actions1}`);
assert(actions1.includes('Mark as read'), `Expected "Mark as read", got: ${actions1}`);
const actions2 = formatRuleActions(rules[1], folderMap);
assert(actions2.includes('Copy to folder: Newsletters'), `Expected "Copy to folder: Newsletters", got: ${actions2}`);
const actions3 = formatRuleActions(rules[2], folderMap);
assert(actions3.includes('Move to folder: AAMkUNKNOWN'), `Expected raw ID fallback, got: ${actions3}`);
console.log('✅ formatRuleActions folder-name resolution assertions passed');
// Test full detailed list includes resolved folder names
const detailed = formatRulesList(rules, true, folderMap);
assert(detailed.includes('Move to folder: Project A'), `Detailed list should contain resolved folder name, got: ${detailed}`);
assert(detailed.includes('Copy to folder: Newsletters'), `Detailed list should contain resolved folder name, got: ${detailed}`);
assert(detailed.includes('Move to folder: AAMkUNKNOWN'), `Detailed list should fall back to raw ID, got: ${detailed}`);
console.log('✅ formatRulesList detailed output assertions passed');
console.log('\nAll rules-list tests passed.');

View file

@ -22,12 +22,16 @@ const HTML_ENTITIES = [
];
// 2. External email caution banners — appear at start of body or inline
// Covers variations with/without "This email originated..." sentence
// Covers known Proofpoint sentinel tokens and human-readable CAUTION blocks.
const CAUTION_BANNERS = [
// Proofpoint Essentials sentinel style: NkdkJdXPPEBannerStart ... NkdkJdXPPEBannerEnd
/[A-Za-z0-9]{8,}BannerStart[\s\S]*?[A-Za-z0-9]{8,}BannerEnd/g,
// Full two-sentence form with bold/marker text
/CAUTION[\s\-]*EXTERNAL\s+EMAIL\s*:.*?(?:content is safe\.?)/gis,
// Short form
/CAUTION\s*:?\s*This email originated from outside.*?(?:content is safe\.?)/gis,
// Generic external sender marker lines
/External Sender[\s\-]*:?[\s\S]*?This message came from outside[\s\S]*?Learn More/gi,
];
// 3. Legal boilerplate blocks — DISCLAIMER and CONFIDENTIALITY NOTICE
@ -37,6 +41,20 @@ const LEGAL_BLOCKS = [
/DISCLAIMER\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis,
// CONFIDENTIALITY NOTICE block (Prime HHCC, Farber & Lindley, others)
/CONFIDENTIALITY\s*NOTICE\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis,
// Generic confidentiality footer
/This message \(including any attachments\) may contain confidential,[\s\S]*?scanned for spam and viruses by Proofpoint Essentials[\s\S]*?$/gim,
];
// 3b. Non-delivery report and automated response noise
const AUTO_NOISE_BLOCKS = [
// Microsoft NDR "Original Message Details" and onward
/Original Message Details[\s\S]*$/gi,
// Generic delivery failure explanation blocks
/Action Required[\s\S]*How to Fix It[\s\S]*$/gi,
// Message hops / headers dump inside NDRs
/^Message Hops[\s\S]*$/gim,
// Auto-reply / out-of-office markers
/^\s*Auto-?generated by.*$/gim,
];
// 4. Signature block delimiters — everything from a recognized sig opener onward
@ -110,6 +128,15 @@ function cleanBody(body, opts = {}) {
text = text.replace(pattern, '');
}
// Step 3b: Strip automated noise (NDRs, hops, auto-replies)
for (const pattern of AUTO_NOISE_BLOCKS) {
text = text.replace(pattern, '');
}
// Step 3c: Remove all lines that look like raw SMTP/X-MS headers inside NDRs
// These are the long colon-heavy strings dumped by Microsoft delivery failures.
text = text.replace(/^([A-Z][a-zA-Z0-9\-]*|X-[A-Za-z\-]+):.*$/gm, '');
// Step 4: Handle [SIG] sentinel (injected by htmlToText for id="Signature" divs)
if (opts.stripSignature) {
// Remove from [SIG] marker onward

View file

@ -1,6 +1,150 @@
/**
* OData helper functions for Microsoft Graph API
*/
const config = require('../config');
const { resolveTimeZone } = require('./timezone-mapper');
/**
* Returns the effective IANA timezone for date math.
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {string}
*/
function getEffectiveTimeZone(timeZone = config.DEFAULT_TIMEZONE) {
return resolveTimeZone(timeZone);
}
/**
* Parses a formatted date string from Intl.DateTimeFormat into numeric components.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @param {string[]} partsNeeded - parts to include
* @returns {Object}
*/
function formatParts(date, timeZone, partsNeeded = ['year', 'month', 'day']) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
const allParts = fmt.formatToParts(date).reduce((acc, p) => {
acc[p.type] = p.value;
return acc;
}, {});
const result = {};
for (const key of partsNeeded) {
result[key] = parseInt(allParts[key], 10);
}
return result;
}
/**
* Returns the local (wall-clock) year/month/day for a UTC Date in the target timezone.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @returns {{year:number, month:number, day:number}}
*/
function getLocalDateComponents(date, timeZone) {
if (!timeZone || timeZone === 'UTC') {
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };
}
return formatParts(date, timeZone, ['year', 'month', 'day']);
}
/**
* Computes the offset in milliseconds between UTC and the target timezone at a given UTC instant.
* Positive offset means the timezone is ahead of UTC.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @returns {number} offset in ms
*/
function getTimeZoneOffsetMs(date, timeZone) {
if (!timeZone || timeZone === 'UTC') return 0;
const utcParts = {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds()
};
const localParts = formatParts(date, timeZone, ['year', 'month', 'day', 'hour', 'minute', 'second']);
const utcMs = Date.UTC(
utcParts.year, utcParts.month - 1, utcParts.day,
utcParts.hour, utcParts.minute, utcParts.second, date.getUTCMilliseconds()
);
const localMs = Date.UTC(
localParts.year, localParts.month - 1, localParts.day,
localParts.hour, localParts.minute, localParts.second, date.getUTCMilliseconds()
);
return localMs - utcMs;
}
/**
* Convert a local date/time expressed in the target timezone into a UTC Date.
*
* @param {number} year - local year
* @param {number} month - local month (1-12)
* @param {number} day - local day
* @param {number} [hours=0] - local hour
* @param {number} [minutes=0] - local minute
* @param {number} [seconds=0] - local second
* @param {number} [ms=0] - local millisecond
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} UTC instant
*/
function localToUtc(year, month, day, hours = 0, minutes = 0, seconds = 0, ms = 0, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
// Desired wall-clock timestamp in the target timezone, expressed as ms since Unix epoch
// *as if* that wall-clock time were UTC. This gives us a starting guess.
const targetMs = Date.UTC(year, month - 1, day, hours, minutes, seconds, ms);
let guess = new Date(targetMs);
// Converge on the real UTC instant for that wall-clock time (handles DST changes).
// residual = how far the guess's local time is ahead of (+) / behind (-) the target local time.
for (let i = 0; i < 5; i++) {
const offsetMs = getTimeZoneOffsetMs(guess, tz);
const actualLocalMs = guess.getTime() + offsetMs;
const residual = actualLocalMs - targetMs;
if (residual === 0) {
return guess;
}
guess = new Date(guess.getTime() - residual);
}
return guess;
}
/**
* Gets UTC instant for start of the local day containing `date` in the configured timezone.
* @param {Date} date - The date
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} - UTC instant of start of day in that timezone
*/
function startOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const { year, month, day } = getLocalDateComponents(date, tz);
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
}
/**
* Gets UTC instant for end of the local day containing `date` in the configured timezone.
* @param {Date} date - The date
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} - UTC instant of end of day in that timezone
*/
function endOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const { year, month, day } = getLocalDateComponents(date, tz);
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
}
/**
* Escapes a string for use in OData queries
@ -9,14 +153,14 @@
*/
function escapeODataString(str) {
if (!str) return str;
// Replace single quotes with double single quotes (OData escaping)
// And remove any special characters that could cause OData syntax errors
str = str.replace(/'/g, "''");
// Escape other potentially problematic characters
str = str.replace(/[\(\)\{\}\[\]\:\;\,\/\?\&\=\+\*\%\$\#\@\!\^]/g, '');
console.error(`Escaped OData string: '${str}'`);
return str;
}
@ -30,32 +174,10 @@ function buildODataFilter(conditions) {
if (!conditions || conditions.length === 0) {
return '';
}
return conditions.join(' and ');
}
/**
* Gets start of day for a given date
* @param {Date} date - The date
* @returns {Date} - Start of day
*/
function startOfDay(date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
/**
* Gets end of day for a given date
* @param {Date} date - The date
* @returns {Date} - End of day
*/
function endOfDay(date) {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return end;
}
/**
* Parses date input (ISO string or relative date)
* @param {string} dateInput - Date string
@ -63,10 +185,10 @@ function endOfDay(date) {
*/
function parseDate(dateInput) {
if (!dateInput) return null;
const now = new Date();
const today = new Date(now);
// Handle relative dates
const relativeMap = {
'today': today,
@ -76,128 +198,203 @@ function parseDate(dateInput) {
'last30days': new Date(now.getTime() - 30*24*60*60*1000),
'last90days': new Date(now.getTime() - 90*24*60*60*1000)
};
if (relativeMap[dateInput.toLowerCase()]) {
return relativeMap[dateInput.toLowerCase()];
}
// Handle ISO dates
const parsed = new Date(dateInput);
if (isNaN(parsed.getTime())) {
throw new Error(`Invalid date format: ${dateInput}`);
}
return parsed;
}
/**
* Returns the UTC instant for a date-only local string (YYYY-MM-DD).
* Treats the date as midnight (or end-of-day) in the configured timezone.
* @param {string} dateInput - Date-only string
* @param {boolean} endOfDayFlag - If true, return end of that local day
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date}
*/
function localDateStringToInstant(dateInput, endOfDayFlag = false, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const [year, month, day] = dateInput.split('-').map(Number);
if (endOfDayFlag) {
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
}
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
}
/**
* Processes predefined date ranges
* @param {string} dateRange - Predefined range
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Object} - Object with from and to dates
*/
function processDateRange(dateRange) {
function processDateRange(dateRange, timeZone = config.DEFAULT_TIMEZONE) {
if (!dateRange) return null;
const tz = getEffectiveTimeZone(timeZone);
const now = new Date();
const today = new Date(now);
switch (dateRange.toLowerCase()) {
case 'today':
return {
from: startOfDay(today),
to: endOfDay(today)
from: startOfDay(today, tz),
to: endOfDay(today, tz)
};
case 'yesterday': {
const yesterday = new Date(now.getTime() - 24*60*60*1000);
const { year, month, day } = getLocalDateComponents(today, tz);
const yesterday = subtractLocalDays(year, month, day, 1);
return {
from: startOfDay(yesterday),
to: endOfDay(yesterday)
from: localToUtc(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0, 0, tz),
to: localToUtc(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59, 999, tz)
};
}
case 'last7days':
return {
from: new Date(now.getTime() - 7*24*60*60*1000),
to: now
from: startOfDay(new Date(now.getTime() - 7*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'last30days':
return {
from: new Date(now.getTime() - 30*24*60*60*1000),
to: now
from: startOfDay(new Date(now.getTime() - 30*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'last90days':
return {
from: startOfDay(new Date(now.getTime() - 90*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'thisweek': {
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - today.getDay());
const { year, month, day } = getLocalDateComponents(today, tz);
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
const dayOfWeek = localMidnight.getUTCDay();
const startOfWeek = subtractLocalDays(year, month, day, dayOfWeek);
return {
from: startOfDay(startOfWeek),
to: endOfDay(today)
from: localToUtc(startOfWeek.year, startOfWeek.month, startOfWeek.day, 0, 0, 0, 0, tz),
to: endOfDay(today, tz)
};
}
case 'lastweek': {
const startOfLastWeek = new Date(today);
startOfLastWeek.setDate(today.getDate() - today.getDay() - 7);
const endOfLastWeek = new Date(startOfLastWeek);
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
const { year, month, day } = getLocalDateComponents(today, tz);
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
const dayOfWeek = localMidnight.getUTCDay();
const startOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 7);
const endOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 1);
return {
from: startOfDay(startOfLastWeek),
to: endOfDay(endOfLastWeek)
from: localToUtc(startOfLastWeek.year, startOfLastWeek.month, startOfLastWeek.day, 0, 0, 0, 0, tz),
to: localToUtc(endOfLastWeek.year, endOfLastWeek.month, endOfLastWeek.day, 23, 59, 59, 999, tz)
};
}
case 'thismonth': {
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const { year, month } = getLocalDateComponents(today, tz);
return {
from: startOfDay(startOfMonth),
to: endOfDay(today)
from: localToUtc(year, month, 1, 0, 0, 0, 0, tz),
to: endOfDay(today, tz)
};
}
case 'lastmonth': {
const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
const { year, month } = getLocalDateComponents(today, tz);
const startOfLastMonth = subtractLocalMonths(year, month, 1);
// Last day of the previous month (in UTC calendar arithmetic, which mirrors local Y/M).
const lastDay = new Date(Date.UTC(startOfLastMonth.year, startOfLastMonth.month, 0)).getUTCDate();
return {
from: startOfDay(startOfLastMonth),
to: endOfDay(endOfLastMonth)
from: localToUtc(startOfLastMonth.year, startOfLastMonth.month, 1, 0, 0, 0, 0, tz),
to: localToUtc(startOfLastMonth.year, startOfLastMonth.month, lastDay, 23, 59, 59, 999, tz)
};
}
default:
throw new Error(`Unknown date range: ${dateRange}`);
}
}
/**
* Subtract a number of days from a local calendar date.
* @param {number} year
* @param {number} month
* @param {number} day
* @param {number} days
* @returns {{year:number, month:number, day:number}}
*/
function subtractLocalDays(year, month, day, days) {
const d = new Date(Date.UTC(year, month - 1, day));
d.setUTCDate(d.getUTCDate() - days);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate()
};
}
/**
* Subtract a number of months from a local calendar date.
* @param {number} year
* @param {number} month
* @param {number} months
* @returns {{year:number, month:number}}
*/
function subtractLocalMonths(year, month, months) {
let newMonth = month - months;
let newYear = year;
while (newMonth < 1) {
newMonth += 12;
newYear -= 1;
}
while (newMonth > 12) {
newMonth -= 12;
newYear += 1;
}
return { year: newYear, month: newMonth };
}
/**
* Builds date filter conditions for OData queries
* @param {string} dateFrom - Start date
* @param {string} dateTo - End date
* @param {string} dateRange - Predefined range
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Array<string>} - Array of filter conditions
*/
function buildDateFilter(dateFrom, dateTo, dateRange) {
function buildDateFilter(dateFrom, dateTo, dateRange, timeZone = config.DEFAULT_TIMEZONE) {
const conditions = [];
try {
if (dateRange) {
const range = processDateRange(dateRange);
const range = processDateRange(dateRange, timeZone);
if (range) {
conditions.push(`receivedDateTime ge ${range.from.toISOString()}`);
conditions.push(`receivedDateTime le ${range.to.toISOString()}`);
}
} else {
if (dateFrom) {
const fromDate = parseDate(dateFrom);
conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`);
// Date-only strings are local-day boundaries in the configured timezone
if (dateFrom.length === 10) {
conditions.push(`receivedDateTime ge ${localDateStringToInstant(dateFrom, false, timeZone).toISOString()}`);
} else {
const fromDate = parseDate(dateFrom);
conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`);
}
}
if (dateTo) {
const toDate = parseDate(dateTo);
// If only date provided (no time), set to end of day
if (dateTo.length === 10) { // YYYY-MM-DD format
conditions.push(`receivedDateTime le ${endOfDay(toDate).toISOString()}`);
if (dateTo.length === 10) {
conditions.push(`receivedDateTime le ${localDateStringToInstant(dateTo, true, timeZone).toISOString()}`);
} else {
const toDate = parseDate(dateTo);
conditions.push(`receivedDateTime le ${toDate.toISOString()}`);
}
}
@ -206,7 +403,7 @@ function buildDateFilter(dateFrom, dateTo, dateRange) {
console.error(`Date filter error: ${error.message}`);
// Return empty conditions on error to avoid breaking the query
}
return conditions;
}
@ -217,5 +414,7 @@ module.exports = {
processDateRange,
buildDateFilter,
startOfDay,
endOfDay
endOfDay,
localDateStringToInstant,
getEffectiveTimeZone
};

View file

@ -133,20 +133,15 @@ function buildThread(messages, subject) {
'',
].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);
// Strip signatures from every message; they rarely add useful content and
// the sender names are already in the thread header.
const cleanedMsg = { ...msg };
if (cleanedMsg.body?.content) {
cleanedMsg.body = {
...cleanedMsg.body,
content: cleanBody(cleanedMsg.body.content, { stripSignature: isRepeat }),
content: cleanBody(cleanedMsg.body.content, { stripSignature: true }),
};
}
return formatThreadEntry(cleanedMsg, i + 1);

136
utils/timezone-mapper.js Normal file
View file

@ -0,0 +1,136 @@
/**
* Small Windows timezone name -> IANA timezone name mapper.
*
* MS_TIMEZONE is documented to accept either IANA names (e.g. "America/New_York")
* or Windows names (e.g. "Eastern Standard Time"). Intl.DateTimeFormat only
* understands IANA identifiers, so we normalize Windows names before use.
*/
const WINDOWS_TO_IANA = {
'dateline standard time': 'Etc/GMT+12',
'utc-11': 'Etc/GMT+11',
'aleutian standard time': 'America/Adak',
'hawaiian standard time': 'Pacific/Honolulu',
'marquesas standard time': 'Pacific/Marquesas',
'alaskan standard time': 'America/Anchorage',
'utc-09': 'Etc/GMT+9',
'pacific standard time (mexico)': 'America/Tijuana',
'utc-08': 'Etc/GMT+8',
'pacific standard time': 'America/Los_Angeles',
'us mountain standard time': 'America/Phoenix',
'mountain standard time (mexico)': 'America/Chihuahua',
'mountain standard time': 'America/Denver',
'central america standard time': 'America/Guatemala',
'central standard time': 'America/Chicago',
'central standard time (mexico)': 'America/Mexico_City',
'canada central standard time': 'America/Regina',
'sa pacific standard time': 'America/Bogota',
'eastern standard time': 'America/New_York',
'us eastern standard time': 'America/Indianapolis',
'venezuela standard time': 'America/Caracas',
'paraguay standard time': 'America/Asuncion',
'atlantic standard time': 'America/Halifax',
'central brazilian standard time': 'America/Cuiaba',
'sa western standard time': 'America/La_Paz',
'pacific sa standard time': 'America/Santiago',
'newfoundland standard time': 'America/St_Johns',
'e. south america standard time': 'America/Sao_Paulo',
'argentina standard time': 'America/Buenos_Aires',
'greenland standard time': 'America/Godthab',
'montevideo standard time': 'America/Montevideo',
'bahia standard time': 'America/Bahia',
'utc-02': 'Etc/GMT+2',
'mid-atlantic standard time': 'Etc/GMT+2',
'azores standard time': 'Atlantic/Azores',
'cape verde standard time': 'Atlantic/Cape_Verde',
'utc': 'UTC',
'gmt standard time': 'Europe/London',
'greenwich standard time': 'Etc/GMT',
'w. europe standard time': 'Europe/Berlin',
'central europe standard time': 'Europe/Budapest',
'romance standard time': 'Europe/Paris',
'w. central africa standard time': 'Africa/Lagos',
'jordan standard time': 'Asia/Amman',
'gtb standard time': 'Europe/Athens',
'middle east standard time': 'Asia/Beirut',
'egypt standard time': 'Africa/Cairo',
'e. europe standard time': 'Europe/Chisinau',
'syria standard time': 'Asia/Damascus',
'west bank standard time': 'Asia/Hebron',
'south africa standard time': 'Africa/Johannesburg',
'fle standard time': 'Europe/Kiev',
'israel standard time': 'Asia/Jerusalem',
'kaliningrad standard time': 'Europe/Kaliningrad',
'libya standard time': 'Africa/Tripoli',
'arabic standard time': 'Asia/Baghdad',
'turkey standard time': 'Europe/Istanbul',
'arab standard time': 'Asia/Riyadh',
'belarus standard time': 'Europe/Minsk',
'russian standard time': 'Europe/Moscow',
'e. africa standard time': 'Africa/Nairobi',
'iran standard time': 'Asia/Tehran',
'arabian standard time': 'Asia/Dubai',
'azerbaijan standard time': 'Asia/Baku',
'caucasus standard time': 'Asia/Yerevan',
'mauritius standard time': 'Indian/Mauritius',
'georgian standard time': 'Asia/Tbilisi',
'caucasus standard time': 'Asia/Yerevan',
'afghanistan standard time': 'Asia/Kabul',
'west asia standard time': 'Asia/Tashkent',
'ekaterinburg standard time': 'Asia/Yekaterinburg',
'pakistan standard time': 'Asia/Karachi',
'india standard time': 'Asia/Kolkata',
'sri lanka standard time': 'Asia/Colombo',
'nepal standard time': 'Asia/Kathmandu',
'central asia standard time': 'Asia/Almaty',
'bangladesh standard time': 'Asia/Dhaka',
'myanmar standard time': 'Asia/Yangon',
'se asia standard time': 'Asia/Bangkok',
'north asia standard time': 'Asia/Krasnoyarsk',
'china standard time': 'Asia/Shanghai',
'north asia east standard time': 'Asia/Irkutsk',
'singapore standard time': 'Asia/Singapore',
'w. australia standard time': 'Australia/Perth',
'taipei standard time': 'Asia/Taipei',
'ulaanbaatar standard time': 'Asia/Ulaanbaatar',
'tokyo standard time': 'Asia/Tokyo',
'korea standard time': 'Asia/Seoul',
'yakutsk standard time': 'Asia/Yakutsk',
'cen. australia standard time': 'Australia/Adelaide',
'aus central standard time': 'Australia/Darwin',
'e. australia standard time': 'Australia/Brisbane',
'aus eastern standard time': 'Australia/Sydney',
'west pacific standard time': 'Pacific/Port_Moresby',
'tasmania standard time': 'Australia/Hobart',
'vladivostok standard time': 'Asia/Vladivostok',
'russia time zone 10': 'Asia/Srednekolymsk',
'central pacific standard time': 'Pacific/Guadalcanal',
'russia time zone 11': 'Asia/Kamchatka',
'new zealand standard time': 'Pacific/Auckland',
'utc+12': 'Etc/GMT-12',
'fiji standard time': 'Pacific/Fiji',
'tonga standard time': 'Pacific/Tongatapu',
'samoa standard time': 'Pacific/Apia',
'line islands standard time': 'Pacific/Kiritimati'
};
function windowsToIana(windowsName) {
if (!windowsName) return windowsName;
const normalized = String(windowsName).trim().toLowerCase();
return WINDOWS_TO_IANA[normalized] || windowsName;
}
/**
* Normalize a configured timezone name for use with Intl.DateTimeFormat.
* MS_TIMEZONE may be an IANA name (returned as-is) or a Windows name (mapped).
* @param {string} [timeZone] - Timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {string}
*/
function resolveTimeZone(timeZone = require('../config').DEFAULT_TIMEZONE) {
return windowsToIana(timeZone);
}
module.exports = {
WINDOWS_TO_IANA,
windowsToIana,
resolveTimeZone
};