diff --git a/email/index.js b/email/index.js index a9779db..1033637 100644 --- a/email/index.js +++ b/email/index.js @@ -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: [] diff --git a/email/search.js b/email/search.js index 1272fe2..2c382d9 100644 --- a/email/search.js +++ b/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}` }] }; } diff --git a/folder/index.js b/folder/index.js index 1dc73dc..b4f3cda 100644 --- a/folder/index.js +++ b/folder/index.js @@ -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'); diff --git a/folder/list.js b/folder/list.js index 5cca24f..f5ee498 100644 --- a/folder/list.js +++ b/folder/list.js @@ -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 +}; diff --git a/rules/list.js b/rules/list.js index 4d27bff..84794b2 100644 --- a/rules/list.js +++ b/rules/list.js @@ -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 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} 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} 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} 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 }; diff --git a/tests/date-filter-timezone.test.js b/tests/date-filter-timezone.test.js new file mode 100644 index 0000000..e0f09db --- /dev/null +++ b/tests/date-filter-timezone.test.js @@ -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.'); diff --git a/tests/folder-list.test.js b/tests/folder-list.test.js new file mode 100644 index 0000000..7fb5e5a --- /dev/null +++ b/tests/folder-list.test.js @@ -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.'); diff --git a/tests/rules-list.test.js b/tests/rules-list.test.js new file mode 100644 index 0000000..ab766e5 --- /dev/null +++ b/tests/rules-list.test.js @@ -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.'); diff --git a/utils/bodyParser.js b/utils/bodyParser.js index 2f677a9..3ebc871 100644 --- a/utils/bodyParser.js +++ b/utils/bodyParser.js @@ -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 diff --git a/utils/odata-helpers.js b/utils/odata-helpers.js index 0a9122f..9571ee5 100644 --- a/utils/odata-helpers.js +++ b/utils/odata-helpers.js @@ -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} - 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 }; diff --git a/utils/threadBuilder.js b/utils/threadBuilder.js index a817404..76a4f25 100644 --- a/utils/threadBuilder.js +++ b/utils/threadBuilder.js @@ -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); diff --git a/utils/timezone-mapper.js b/utils/timezone-mapper.js new file mode 100644 index 0000000..90ccbec --- /dev/null +++ b/utils/timezone-mapper.js @@ -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 +};