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:
parent
11241a4f3e
commit
6d0436d679
12 changed files with 1019 additions and 120 deletions
|
|
@ -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: []
|
||||
|
|
|
|||
107
email/search.js
107
email/search.js
|
|
@ -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}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -163,6 +182,12 @@ function formatFolderList(folders, includeItemCounts) {
|
|||
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})`;
|
||||
|
|
@ -233,6 +258,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) {
|
||||
const unreadCount = folder.unreadItemCount || 0;
|
||||
|
|
@ -261,4 +292,8 @@ function formatFolderHierarchy(folders, includeItemCounts) {
|
|||
return `Folder Hierarchy:\n\n${formattedHierarchy}`;
|
||||
}
|
||||
|
||||
module.exports = handleListFolders;
|
||||
module.exports = {
|
||||
handleListFolders,
|
||||
formatFolderList,
|
||||
formatFolderHierarchy
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,8 +19,12 @@ 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
|
||||
};
|
||||
|
|
|
|||
255
tests/date-filter-timezone.test.js
Normal file
255
tests/date-filter-timezone.test.js
Normal 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
38
tests/folder-list.test.js
Normal 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
58
tests/rules-list.test.js
Normal 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.');
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -34,28 +178,6 @@ function buildODataFilter(conditions) {
|
|||
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
|
||||
|
|
@ -90,78 +212,109 @@ function parseDate(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)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -170,34 +323,78 @@ function processDateRange(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()}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -217,5 +414,7 @@ module.exports = {
|
|||
processDateRange,
|
||||
buildDateFilter,
|
||||
startOfDay,
|
||||
endOfDay
|
||||
endOfDay,
|
||||
localDateStringToInstant,
|
||||
getEffectiveTimeZone
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
136
utils/timezone-mapper.js
Normal 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
|
||||
};
|
||||
Loading…
Reference in a new issue