From 304b3219412be21b78a6aa32e4583d48f96fa6b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:27:45 +0000 Subject: [PATCH 1/2] Strip attachment bodies from the "View original email" raw source view The raw .eml modal rendered base64 attachment/inline-image data inline, bloating the view with content nobody reads there. Add stripAttachmentsFromRawEmail(), which walks MIME part boundaries and replaces attachment/inline-file bodies with a placeholder while leaving headers and the visible text/html body intact. Applied only to the modal's display text; Copy and Download still use the untouched raw source so the .eml stays a faithful reproduction. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V85rptvcLi67TUmaqU1s53 --- src/components/EmailSignalCard.vue | 7 +- src/lib/stripAttachments.ts | 84 ++++++++++++++++++ tests/unit/stripAttachments.test.ts | 128 ++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/lib/stripAttachments.ts create mode 100644 tests/unit/stripAttachments.test.ts diff --git a/src/components/EmailSignalCard.vue b/src/components/EmailSignalCard.vue index 47b5e6a..799bc53 100644 --- a/src/components/EmailSignalCard.vue +++ b/src/components/EmailSignalCard.vue @@ -8,6 +8,7 @@ import { isAdminUser } from '@/stores/admin' import { useRulesQuery } from '@/composables/useRulesQueries' import { useSignalStoreMutator } from '@/composables/useSignalQueries' import { api } from '@/lib/api' +import { stripAttachmentsFromRawEmail } from '@/lib/stripAttachments' import ActionBadge from '@/components/ActionBadge.vue' import CopyMenuItem from '@/components/CopyMenuItem.vue' import OverflowMenu from '@/components/ui/OverflowMenu.vue' @@ -143,6 +144,10 @@ const hasOriginalEmail = computed(() => { return s !== 'report_violation' }) +// Attachment bodies (base64) are stripped for display — Copy and Download still +// use the untouched originalEmailSource so the .eml stays a faithful reproduction. +const displayedOriginalEmail = computed(() => stripAttachmentsFromRawEmail(originalEmailSource.value)) + function viewSignalObject() { if (!accountStore.accountId) return showSignalObjectModal.value = true @@ -587,7 +592,7 @@ const iframeStyle = {
{{ originalError }}
-
{{ originalEmailSource }}
+
{{ displayedOriginalEmail }}
diff --git a/src/lib/stripAttachments.ts b/src/lib/stripAttachments.ts new file mode 100644 index 0000000..981cf9a --- /dev/null +++ b/src/lib/stripAttachments.ts @@ -0,0 +1,84 @@ +/** + * Strips attachment/inline-file bodies out of a raw .eml source so the + * "View original email" modal doesn't have to render megabytes of base64 + * attachment data inline. Headers for each part are left intact — only the + * base64 body is replaced with a placeholder — so the MIME structure is + * still visible, just without the payload. + */ + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function stripAttachmentsFromRawEmail(raw: string): string { + const boundaries = new Set() + const boundaryRegex = /boundary\s*=\s*"?([^";\r\n]+)"?/gi + let boundaryMatch: RegExpExecArray | null + while ((boundaryMatch = boundaryRegex.exec(raw))) { + boundaries.add(boundaryMatch[1]) + } + if (boundaries.size === 0) return raw // not multipart — nothing to strip + + const boundaryLineRegex = new RegExp( + `^--(${[...boundaries].map(escapeRegExp).join('|')})(--)?\\s*$`, + ) + + const lines = raw.split(/\r\n|\n/) + const output: string[] = [] + let i = 0 + + // Preamble / top-level headers before the first boundary — unchanged + while (i < lines.length && !boundaryLineRegex.test(lines[i])) { + output.push(lines[i]) + i++ + } + + while (i < lines.length) { + const boundaryLine = lines[i] + output.push(boundaryLine) + i++ + if (/--\s*$/.test(boundaryLine)) break // closing boundary — no part follows + + const headerLines: string[] = [] + while (i < lines.length && lines[i] !== '' && !boundaryLineRegex.test(lines[i])) { + headerLines.push(lines[i]) + output.push(lines[i]) + i++ + } + if (i < lines.length && lines[i] === '') { + output.push(lines[i]) + i++ + } + + const bodyStart = i + while (i < lines.length && !boundaryLineRegex.test(lines[i])) { + i++ + } + const bodyLines = lines.slice(bodyStart, i) + + const headerText = headerLines.join('\n') + const isFilePart = + /Content-Disposition:\s*attachment/i.test(headerText) || + /Content-Disposition:[^\r\n]*\bfilename\*?=/i.test(headerText) || + /Content-Type:[^\r\n]*\bname\s*=/i.test(headerText) + + if (isFilePart && bodyLines.some(line => line.trim() !== '')) { + const filenameMatch = + headerText.match(/filename\*?=\s*"?([^";\r\n]+)"?/i) ?? headerText.match(/name\s*=\s*"?([^";\r\n]+)"?/i) + const filename = filenameMatch ? filenameMatch[1] : null + const approxBytes = Math.floor(bodyLines.join('').length * 0.75) // base64 -> raw bytes estimate + output.push(`[attachment content omitted${filename ? `: ${filename}` : ''} (~${formatBytes(approxBytes)})]`) + output.push('') + } else { + output.push(...bodyLines) + } + } + + return output.join('\r\n') +} diff --git a/tests/unit/stripAttachments.test.ts b/tests/unit/stripAttachments.test.ts new file mode 100644 index 0000000..5720108 --- /dev/null +++ b/tests/unit/stripAttachments.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { stripAttachmentsFromRawEmail } from '@/lib/stripAttachments' + +function buildRawEmail(attachmentBase64: string): string { + const boundary = '----=_Part_test_boundary' + return [ + 'From: sender@example.com', + 'To: recipient@example.com', + 'Subject: Test email with attachment', + 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + '', + 'This is the visible body text.', + '', + `--${boundary}`, + 'Content-Type: application/pdf', + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="document.pdf"', + '', + attachmentBase64, + '', + `--${boundary}--`, + ].join('\r\n') +} + +describe('stripAttachmentsFromRawEmail', () => { + it('replaces an attachment body with a placeholder, keeping its headers', () => { + const raw = buildRawEmail('SGVsbG8gd29ybGQh'.repeat(50)) + const result = stripAttachmentsFromRawEmail(raw) + + expect(result).toContain('Content-Disposition: attachment; filename="document.pdf"') + expect(result).toContain('[attachment content omitted: document.pdf') + expect(result).not.toContain('SGVsbG8gd29ybGQh') + }) + + it('leaves the visible text body untouched', () => { + const raw = buildRawEmail('SGVsbG8gd29ybGQh'.repeat(50)) + const result = stripAttachmentsFromRawEmail(raw) + + expect(result).toContain('This is the visible body text.') + }) + + it('leaves non-multipart plain-text emails unchanged', () => { + const raw = [ + 'From: sender@example.com', + 'To: recipient@example.com', + 'Subject: Plain text email', + 'Content-Type: text/plain; charset="UTF-8"', + 'MIME-Version: 1.0', + '', + 'Just a plain message, no attachments.', + ].join('\r\n') + + expect(stripAttachmentsFromRawEmail(raw)).toBe(raw) + }) + + it('strips an inline image body identified by a filename= parameter without Content-Disposition: attachment', () => { + const boundary = '----=_Part_related_boundary' + const raw = [ + 'From: sender@example.com', + 'To: recipient@example.com', + 'Subject: Test email with inline image', + 'MIME-Version: 1.0', + `Content-Type: multipart/related; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + '', + 'Logo: ', + '', + `--${boundary}`, + 'Content-Type: image/png; name="logo.png"', + 'Content-Transfer-Encoding: base64', + 'Content-ID: ', + 'Content-Disposition: inline; filename="logo.png"', + '', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'.repeat(20), + '', + `--${boundary}--`, + ].join('\r\n') + + const result = stripAttachmentsFromRawEmail(raw) + expect(result).toContain('') + expect(result).toContain('[attachment content omitted: logo.png') + expect(result).not.toContain('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB') + }) + + it('leaves multiple attachments in a multi-part message each replaced independently', () => { + const boundary = '----=_Part_multi_boundary' + const raw = [ + 'From: sender@example.com', + 'To: recipient@example.com', + 'Subject: Two attachments', + 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + '', + 'Body text.', + '', + `--${boundary}`, + 'Content-Type: application/pdf', + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="a.pdf"', + '', + 'QQ=='.repeat(50), + '', + `--${boundary}`, + 'Content-Type: image/png', + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="b.png"', + '', + 'Qg=='.repeat(50), + '', + `--${boundary}--`, + ].join('\r\n') + + const result = stripAttachmentsFromRawEmail(raw) + expect(result).toContain('[attachment content omitted: a.pdf') + expect(result).toContain('[attachment content omitted: b.png') + expect(result).not.toContain('QQ==QQ==') + expect(result).not.toContain('Qg==Qg==') + }) +}) From 50afe62d5241632c55eb8065cd31eac6502426a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:46:06 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Revert=20frontend=20attachment=20stripping?= =?UTF-8?q?=20=E2=80=94=20now=20handled=20by=20the=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw email the frontend fetches is now already a display-safe copy with attachments stripped server-side (see the SES-Email-Adapter backend change to the content sanitizer and the /raw endpoint). Copy and Download previously still shipped the full original bytes since only the display text was stripped client-side; stripping in the backend means every consumer of this endpoint gets the stripped copy, so the client-side transform is no longer needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V85rptvcLi67TUmaqU1s53 --- src/components/EmailSignalCard.vue | 7 +- src/lib/stripAttachments.ts | 84 ------------------ tests/unit/stripAttachments.test.ts | 128 ---------------------------- 3 files changed, 1 insertion(+), 218 deletions(-) delete mode 100644 src/lib/stripAttachments.ts delete mode 100644 tests/unit/stripAttachments.test.ts diff --git a/src/components/EmailSignalCard.vue b/src/components/EmailSignalCard.vue index 799bc53..47b5e6a 100644 --- a/src/components/EmailSignalCard.vue +++ b/src/components/EmailSignalCard.vue @@ -8,7 +8,6 @@ import { isAdminUser } from '@/stores/admin' import { useRulesQuery } from '@/composables/useRulesQueries' import { useSignalStoreMutator } from '@/composables/useSignalQueries' import { api } from '@/lib/api' -import { stripAttachmentsFromRawEmail } from '@/lib/stripAttachments' import ActionBadge from '@/components/ActionBadge.vue' import CopyMenuItem from '@/components/CopyMenuItem.vue' import OverflowMenu from '@/components/ui/OverflowMenu.vue' @@ -144,10 +143,6 @@ const hasOriginalEmail = computed(() => { return s !== 'report_violation' }) -// Attachment bodies (base64) are stripped for display — Copy and Download still -// use the untouched originalEmailSource so the .eml stays a faithful reproduction. -const displayedOriginalEmail = computed(() => stripAttachmentsFromRawEmail(originalEmailSource.value)) - function viewSignalObject() { if (!accountStore.accountId) return showSignalObjectModal.value = true @@ -592,7 +587,7 @@ const iframeStyle = {
{{ originalError }}
-
{{ displayedOriginalEmail }}
+
{{ originalEmailSource }}
diff --git a/src/lib/stripAttachments.ts b/src/lib/stripAttachments.ts deleted file mode 100644 index 981cf9a..0000000 --- a/src/lib/stripAttachments.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Strips attachment/inline-file bodies out of a raw .eml source so the - * "View original email" modal doesn't have to render megabytes of base64 - * attachment data inline. Headers for each part are left intact — only the - * base64 body is replaced with a placeholder — so the MIME structure is - * still visible, just without the payload. - */ - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` -} - -export function stripAttachmentsFromRawEmail(raw: string): string { - const boundaries = new Set() - const boundaryRegex = /boundary\s*=\s*"?([^";\r\n]+)"?/gi - let boundaryMatch: RegExpExecArray | null - while ((boundaryMatch = boundaryRegex.exec(raw))) { - boundaries.add(boundaryMatch[1]) - } - if (boundaries.size === 0) return raw // not multipart — nothing to strip - - const boundaryLineRegex = new RegExp( - `^--(${[...boundaries].map(escapeRegExp).join('|')})(--)?\\s*$`, - ) - - const lines = raw.split(/\r\n|\n/) - const output: string[] = [] - let i = 0 - - // Preamble / top-level headers before the first boundary — unchanged - while (i < lines.length && !boundaryLineRegex.test(lines[i])) { - output.push(lines[i]) - i++ - } - - while (i < lines.length) { - const boundaryLine = lines[i] - output.push(boundaryLine) - i++ - if (/--\s*$/.test(boundaryLine)) break // closing boundary — no part follows - - const headerLines: string[] = [] - while (i < lines.length && lines[i] !== '' && !boundaryLineRegex.test(lines[i])) { - headerLines.push(lines[i]) - output.push(lines[i]) - i++ - } - if (i < lines.length && lines[i] === '') { - output.push(lines[i]) - i++ - } - - const bodyStart = i - while (i < lines.length && !boundaryLineRegex.test(lines[i])) { - i++ - } - const bodyLines = lines.slice(bodyStart, i) - - const headerText = headerLines.join('\n') - const isFilePart = - /Content-Disposition:\s*attachment/i.test(headerText) || - /Content-Disposition:[^\r\n]*\bfilename\*?=/i.test(headerText) || - /Content-Type:[^\r\n]*\bname\s*=/i.test(headerText) - - if (isFilePart && bodyLines.some(line => line.trim() !== '')) { - const filenameMatch = - headerText.match(/filename\*?=\s*"?([^";\r\n]+)"?/i) ?? headerText.match(/name\s*=\s*"?([^";\r\n]+)"?/i) - const filename = filenameMatch ? filenameMatch[1] : null - const approxBytes = Math.floor(bodyLines.join('').length * 0.75) // base64 -> raw bytes estimate - output.push(`[attachment content omitted${filename ? `: ${filename}` : ''} (~${formatBytes(approxBytes)})]`) - output.push('') - } else { - output.push(...bodyLines) - } - } - - return output.join('\r\n') -} diff --git a/tests/unit/stripAttachments.test.ts b/tests/unit/stripAttachments.test.ts deleted file mode 100644 index 5720108..0000000 --- a/tests/unit/stripAttachments.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { stripAttachmentsFromRawEmail } from '@/lib/stripAttachments' - -function buildRawEmail(attachmentBase64: string): string { - const boundary = '----=_Part_test_boundary' - return [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Test email with attachment', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - '', - 'This is the visible body text.', - '', - `--${boundary}`, - 'Content-Type: application/pdf', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="document.pdf"', - '', - attachmentBase64, - '', - `--${boundary}--`, - ].join('\r\n') -} - -describe('stripAttachmentsFromRawEmail', () => { - it('replaces an attachment body with a placeholder, keeping its headers', () => { - const raw = buildRawEmail('SGVsbG8gd29ybGQh'.repeat(50)) - const result = stripAttachmentsFromRawEmail(raw) - - expect(result).toContain('Content-Disposition: attachment; filename="document.pdf"') - expect(result).toContain('[attachment content omitted: document.pdf') - expect(result).not.toContain('SGVsbG8gd29ybGQh') - }) - - it('leaves the visible text body untouched', () => { - const raw = buildRawEmail('SGVsbG8gd29ybGQh'.repeat(50)) - const result = stripAttachmentsFromRawEmail(raw) - - expect(result).toContain('This is the visible body text.') - }) - - it('leaves non-multipart plain-text emails unchanged', () => { - const raw = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Plain text email', - 'Content-Type: text/plain; charset="UTF-8"', - 'MIME-Version: 1.0', - '', - 'Just a plain message, no attachments.', - ].join('\r\n') - - expect(stripAttachmentsFromRawEmail(raw)).toBe(raw) - }) - - it('strips an inline image body identified by a filename= parameter without Content-Disposition: attachment', () => { - const boundary = '----=_Part_related_boundary' - const raw = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Test email with inline image', - 'MIME-Version: 1.0', - `Content-Type: multipart/related; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - '', - 'Logo: ', - '', - `--${boundary}`, - 'Content-Type: image/png; name="logo.png"', - 'Content-Transfer-Encoding: base64', - 'Content-ID: ', - 'Content-Disposition: inline; filename="logo.png"', - '', - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'.repeat(20), - '', - `--${boundary}--`, - ].join('\r\n') - - const result = stripAttachmentsFromRawEmail(raw) - expect(result).toContain('') - expect(result).toContain('[attachment content omitted: logo.png') - expect(result).not.toContain('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB') - }) - - it('leaves multiple attachments in a multi-part message each replaced independently', () => { - const boundary = '----=_Part_multi_boundary' - const raw = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Two attachments', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - '', - 'Body text.', - '', - `--${boundary}`, - 'Content-Type: application/pdf', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="a.pdf"', - '', - 'QQ=='.repeat(50), - '', - `--${boundary}`, - 'Content-Type: image/png', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="b.png"', - '', - 'Qg=='.repeat(50), - '', - `--${boundary}--`, - ].join('\r\n') - - const result = stripAttachmentsFromRawEmail(raw) - expect(result).toContain('[attachment content omitted: a.pdf') - expect(result).toContain('[attachment content omitted: b.png') - expect(result).not.toContain('QQ==QQ==') - expect(result).not.toContain('Qg==Qg==') - }) -})