diff --git a/packages/communication/src/__tests__/agentmail-format.test.ts b/packages/communication/src/__tests__/agentmail-format.test.ts index d65ecfdfc..db2f7f597 100644 --- a/packages/communication/src/__tests__/agentmail-format.test.ts +++ b/packages/communication/src/__tests__/agentmail-format.test.ts @@ -102,6 +102,24 @@ describe('renderAgentMailPlainText', () => { it('strips blockquote markers', () => { expect(renderAgentMailPlainText('> quoted line')).toBe('quoted line'); }); + + it('preserves malformed repeated links without excessive backtracking', () => { + const markdown = '[label]('.repeat(12_500); + + expect(renderAgentMailPlainText(markdown)).toBe(markdown); + }); + + it('parses a heading with a long whitespace prefix in one pass', () => { + const markdown = `######${' '.repeat(99_980)}heading`; + + expect(renderAgentMailPlainText(markdown)).toBe('heading'); + }); + + it('keeps unsafe link protocols readable in plain text', () => { + expect(renderAgentMailPlainText('[click](javascript:alert)')).toBe( + 'click (javascript:alert)', + ); + }); }); describe('buildAgentMailEmailBody', () => { diff --git a/packages/communication/src/agentmail-format.ts b/packages/communication/src/agentmail-format.ts index 462568576..0d95f8979 100644 --- a/packages/communication/src/agentmail-format.ts +++ b/packages/communication/src/agentmail-format.ts @@ -138,11 +138,33 @@ type MarkdownBlock = | { kind: 'footer'; text: string } | { kind: 'paragraph'; lines: string[] }; -const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/; const UNORDERED_ITEM_PATTERN = /^[-*+]\s+(.*)$/; const ORDERED_ITEM_PATTERN = /^\d+[.)]\s+(.*)$/; const BLOCKQUOTE_PATTERN = /^>\s?(.*)$/; +function parseHeading(line: string): { level: number; text: string } | null { + let level = 0; + + while (line[level] === '#') { + level += 1; + } + + if (level === 0 || level > 6 || !/\s/.test(line[level] ?? '')) { + return null; + } + + let textStart = level + 1; + + while (/\s/.test(line[textStart] ?? '')) { + textStart += 1; + } + + return { + level, + text: line.slice(textStart).replace(/\r$/, ''), + }; +} + function splitBlocks( text: string, recognizeFinalFooter = false, @@ -164,7 +186,7 @@ function splitBlocks( continue; } - const heading = HEADING_PATTERN.exec(line); + const heading = parseHeading(line); if ( recognizeFinalFooter && @@ -179,12 +201,12 @@ function splitBlocks( continue; } - if (heading?.[1] && heading[2] !== undefined) { + if (heading) { flush(); blocks.push({ kind: 'heading', - level: heading[1].length, - text: heading[2], + level: heading.level, + text: heading.text, }); continue; } @@ -299,8 +321,7 @@ export function renderAgentMailHtml(markdown: string): string { } function stripInlineMarkdown(text: string): string { - return text - .replace(/\[([^\]\n]+)\]\(([^\s)]+)\)/g, '$1 ($2)') + return replaceMarkdownLinksWithText(text) .replace(/\*\*([^*\n]+)\*\*/g, '$1') .replace(/(? labelStart + 1 && + text[index + 1] === '(' + ) { + const urlStart = index + 2; + let urlEnd = urlStart; + + while ( + urlEnd < text.length && + text[urlEnd] !== ')' && + !/\s/.test(text[urlEnd] ?? '') + ) { + urlEnd += 1; + } + + if (urlEnd > urlStart && text[urlEnd] === ')') { + parts.push( + text.slice(unchangedStart, labelStart), + text.slice(labelStart + 1, index), + ' (', + text.slice(urlStart, urlEnd), + ')', + ); + index = urlEnd + 1; + unchangedStart = index; + labelStart = -1; + continue; + } + + if (urlEnd === text.length) { + break; + } + + if (/\s/.test(text[urlEnd] ?? '')) { + index = urlEnd; + labelStart = -1; + continue; + } + } + + labelStart = -1; + } + + index += 1; + } + + if (parts.length === 0) { + return text; + } + + parts.push(text.slice(unchangedStart)); + return parts.join(''); +} + /** * Strip markdown down to readable plain text for the email's text/plain * alternative. Links render as "label (url)"; list markers and blockquote @@ -333,9 +423,9 @@ export function renderAgentMailPlainText(markdown: string): string { return `--\n${stripInlineMarkdown(line.slice(AGENTMAIL_FOOTER_PREFIX.length))}`; } - const heading = HEADING_PATTERN.exec(line); + const heading = parseHeading(line); const blockquote = BLOCKQUOTE_PATTERN.exec(line); - const source = heading?.[2] ?? blockquote?.[1] ?? line; + const source = heading?.text ?? blockquote?.[1] ?? line; return stripInlineMarkdown(source); })