From d5db11f329631fb5fb0683a9319dff434f6356ec Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 17:05:50 +0200 Subject: [PATCH] fix(tasks): remove postal-mime dep and harden eml import (#8901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(tasks): elevate add-subtask input * fix(tasks): parse eml files without external dependency Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy. * fix(tasks): isolate and harden eml import Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage. * fix(tasks): decode eml headers and harden import parsing - Decode RFC 2047 encoded-words in the subject and sender name so international titles show "Grüße" instead of raw "=?UTF-8?...?=". - Replace the charset regex with a quote-aware Content-Type parser so a charset inside another quoted parameter can't be mistaken for the real one, and ignore RFC 822 header comments (e.g. "7bit (comment)"). - Cap the synced title (300) and body (100k) so a crafted .eml can't amplify into an oversized op (the literal fence can double the body). - Document parseEml's intentional MIME omissions to prevent a later "fix" that reopens the untrusted-body attack surface. --- AGENTS.md | 1 + docs/wiki/2.03-Add-Tasks.md | 2 +- package-lock.json | 7 - package.json | 1 - .../drop-paste-input/eml-drop.directive.ts | 2 +- .../drop-paste-input/eml-drop.service.spec.ts | 105 ++++++- .../core/drop-paste-input/eml-drop.service.ts | 61 ++-- src/app/util/eml-parser.spec.ts | 281 ++++++++++++++++-- src/app/util/eml-parser.ts | 191 +++++++++++- src/app/util/is-file-eml.spec.ts | 22 ++ src/app/util/is-file-eml.ts | 3 + 11 files changed, 607 insertions(+), 69 deletions(-) create mode 100644 src/app/util/is-file-eml.spec.ts create mode 100644 src/app/util/is-file-eml.ts diff --git a/AGENTS.md b/AGENTS.md index 760ab568c5..475885b9c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ For local SuperSync E2E (docker-compose) and the full E2E reference, see [`e2e/C - **Translations:** UI strings go through `T` / `TranslateService`. Edit only `en.json`; never other locales. - **Privacy:** no analytics or tracking — user data stays local unless explicitly synced. +- **Dependencies:** PRs must not add new packages to the root project's `dependencies` or `devDependencies`; use platform APIs, existing packages, or a small in-repo implementation instead. Dependencies scoped to an individual plugin are allowed when they are necessary and remain isolated to that plugin. - **Electron:** check `IS_ELECTRON` before using Electron-specific APIs. - **Templates:** plain HTML, minimal CSS/classes, Angular Material sparingly. See [`docs/styling-guide.md`](docs/styling-guide.md). - **Styling review:** do not locally restyle Angular Material or shared `src/app/ui/` components for one-off context needs. This includes overriding button styles via `.mat-*`, `.mdc-*`, `button[mat-*]`, or component internals in local SCSS. Prefer existing inputs/classes/tokens; if a variant must exist, make it reusable or add it to the shared style layer. diff --git a/docs/wiki/2.03-Add-Tasks.md b/docs/wiki/2.03-Add-Tasks.md index 24c8633f1b..59fed0f00c 100755 --- a/docs/wiki/2.03-Add-Tasks.md +++ b/docs/wiki/2.03-Add-Tasks.md @@ -13,7 +13,7 @@ This how-to covers adding new tasks to your list. For subtasks, scheduling, repe 1. With the add task bar open, type the task title in the input. The placeholder shows an example: **A task title #tag @16:00 t25m** (you can use **#tag**, **@time**, **@date**, and **[t]time** in the title; see below). 2. Press **Enter** or click the **Add task** button (plus icon next to the input). The task is added to the current project or context (Today / tag). -> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject` and the email body is saved to the task's notes. +> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject`. Simple, unencoded plain-text bodies are saved to the task's notes as literal text; unsupported body formats create a title-only task. The new task appears at the **top** or **bottom** of the list depending on the add bar setting (see “Add to top or bottom” below). diff --git a/package-lock.json b/package-lock.json index 65a1857cb7..62455fd9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { @@ -22711,12 +22710,6 @@ "node": ">= 0.4" } }, - "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", - "license": "MIT-0" - }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/package.json b/package.json index 62db9fe4f6..c52ee87f70 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { diff --git a/src/app/core/drop-paste-input/eml-drop.directive.ts b/src/app/core/drop-paste-input/eml-drop.directive.ts index d9a588ba14..ed6f5e36c3 100644 --- a/src/app/core/drop-paste-input/eml-drop.directive.ts +++ b/src/app/core/drop-paste-input/eml-drop.directive.ts @@ -1,5 +1,5 @@ import { Directive, HostListener, inject } from '@angular/core'; -import { isFileEml } from '../../util/eml-parser'; +import { isFileEml } from '../../util/is-file-eml'; import { EmlDropService } from './eml-drop.service'; @Directive({ diff --git a/src/app/core/drop-paste-input/eml-drop.service.spec.ts b/src/app/core/drop-paste-input/eml-drop.service.spec.ts index 7ab0625ff9..d566f8d620 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.spec.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.spec.ts @@ -52,7 +52,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example: Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -65,7 +65,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -77,7 +77,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -99,6 +99,67 @@ describe('EmlDropService', () => { ); }); + it('should create a title-only task when the body encoding is unsupported', async () => { + const encodedEml = [ + 'From: Alice ', + 'Subject: Encoded', + 'Content-Type: text/plain', + 'Content-Transfer-Encoding: base64', + '', + 'Ym9keQ==', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(encodedEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Encoded', + false, + { notes: undefined }, + false, + true, + ); + }); + + it('should store imported plain text literally so remote content stays inert', async () => { + const remoteImageEml = [ + 'From: Alice ', + 'Subject: Remote image', + '', + '![pixel](https://attacker.example/pixel)', + '\\![already escaped](https://attacker.example/pixel)', + '![reference image][pixel]', + '[pixel]: https://attacker.example/pixel', + '`C:\\tmp`', + 'ordinary markup', + '', + '```', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(remoteImageEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Remote image', + false, + { + notes: + '~~~\n' + + '![pixel](https://attacker.example/pixel)\n' + + '\\![already escaped](https://attacker.example/pixel)\n' + + '![reference image][pixel]\n' + + '[pixel]: https://attacker.example/pixel\n' + + '`C:\\tmp`\n' + + 'ordinary markup\n' + + '\n' + + '```\n' + + '~~~', + }, + false, + true, + ); + }); + it('should not parse a valid eml or add a task when the file exceeds the size limit', async () => { const bigFile = makeFile(VALID_EML); // Report an oversized file without allocating 10MB. @@ -113,6 +174,39 @@ describe('EmlDropService', () => { }); }); + it('should cap an oversized body so the synced note stays bounded', async () => { + const maxBodyLength = 100_000; + const longBody = 'a'.repeat(maxBodyLength + 50); + const eml = [ + 'From: Alice ', + 'Subject: Long', + '', + longBody, + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const notes = taskService.add.calls.mostRecent().args[2]?.notes; + expect(notes).toBe('```\n' + 'a'.repeat(maxBodyLength) + '…\n```'); + }); + + it('should cap an oversized title', async () => { + const eml = [ + 'From: Alice ', + 'Subject: ' + 'S'.repeat(350), + '', + 'body', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const title = taskService.add.calls.mostRecent().args[0]; + expect(title).toBe('Alice: ' + 'S'.repeat(293) + '…'); + expect(title?.length).toBe(301); + }); + it('should show a warning snack and not add a task when both sender and subject are empty', async () => { await service.createTaskFromEml(makeFile(EMPTY_EML)); @@ -126,13 +220,12 @@ describe('EmlDropService', () => { it('should log and show an error snack without adding a task when parsing fails', async () => { const logErrSpy = spyOn(Log, 'err'); const file = makeFile(VALID_EML); - // postal-mime reads a Blob via arrayBuffer(), so that's the read to fail. - spyOn(file, 'arrayBuffer').and.rejectWith(new Error('read failed')); + spyOn(file, 'text').and.rejectWith(new Error('read failed')); await service.createTaskFromEml(file); expect(taskService.add).not.toHaveBeenCalled(); - expect(logErrSpy).toHaveBeenCalled(); + expect(logErrSpy).toHaveBeenCalledOnceWith('Failed to parse EML file'); expect(snackService.open).toHaveBeenCalledWith({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR, diff --git a/src/app/core/drop-paste-input/eml-drop.service.ts b/src/app/core/drop-paste-input/eml-drop.service.ts index 6d53aefc96..8c55fb3de7 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.ts @@ -2,13 +2,18 @@ import { inject, Injectable } from '@angular/core'; import { TaskService } from '../../features/tasks/task.service'; import { SnackService } from '../snack/snack.service'; import { Log } from '../log'; -import { parseEml } from '../../util/eml-parser'; import { T } from '../../t.const'; -// postal-mime parses synchronously on the main thread, and the body becomes a -// note that syncs to every device. Bound the untrusted input so a pathological -// .eml can't freeze the UI or balloon the op-log. +// Parsing runs on the main thread, and the body becomes a note that syncs to every +// device. Bound the untrusted input so a pathological .eml can't freeze the UI or +// balloon the op-log. const MAX_EML_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +// The title and body sync to every device as ops. Bound them independently of the +// file size: the literal fence can up to double the body length, and a crafted +// `.eml` can carry a multi-MB Subject, either of which would bloat the op-log. +// shortcut: fixed caps — make configurable if real emails legitimately exceed them. +const MAX_EML_TITLE_LENGTH = 300; +const MAX_EML_BODY_LENGTH = 100_000; @Injectable({ providedIn: 'root', @@ -24,6 +29,7 @@ export class EmlDropService { } try { + const { parseEml } = await import('../../util/eml-parser'); const data = await parseEml(file); const sender = (data.from?.name || data.from?.address || '').trim(); @@ -35,23 +41,44 @@ export class EmlDropService { return; } - const title = [sender, subject].filter(Boolean).join(': '); - // Keep the email body as notes so the task retains context, not just a - // title. Use the plain-text part only (never data.html) — notes render as - // markdown, so injecting untrusted email HTML would be an XSS vector. - const notes = data.text?.trim() || undefined; + const title = _truncate( + [sender, subject].filter(Boolean).join(': '), + MAX_EML_TITLE_LENGTH, + ); + // A text/plain body is external text, not trusted Markdown. Store it in a + // fence that cannot occur in the body so rendering stays literal and inert. + const plainText = data.text?.trim(); + const notes = plainText + ? _asLiteralMarkdown(_truncate(plainText, MAX_EML_BODY_LENGTH)) + : undefined; // isIgnoreShortSyntax: the subject is untrusted external content — don't // let ShortSyntaxEffects parse #tag/@date/+project tokens out of it. this._taskService.add(title, false, { notes }, false, true); - } catch (e) { - // Log a bounded reason, not the raw error: the source is untrusted email - // content and log history is exportable (rule #9). postal-mime's throw - // messages are structural (no message content), so the reason is safe to keep. - Log.err( - 'Failed to parse EML file', - e instanceof Error ? e.message : 'Unknown error', - ); + } catch { + // Error details cross an untrusted file boundary and may contain user content. + Log.err('Failed to parse EML file'); this._snackService.open({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR }); } } } + +const _truncate = (text: string, max: number): string => + text.length > max ? `${text.slice(0, max)}…` : text; + +const _asLiteralMarkdown = (text: string): string => { + const backtickFence = '`'.repeat(_getFenceLength(text, /^ {0,3}(`{3,})/gm)); + const tildeFence = '~'.repeat(_getFenceLength(text, /^ {0,3}(~{3,})/gm)); + const fence = backtickFence.length <= tildeFence.length ? backtickFence : tildeFence; + + return `${fence}\n${text}\n${fence}`; +}; + +const _getFenceLength = (text: string, fencePattern: RegExp): number => { + let fenceLength = 3; + + for (const match of text.matchAll(fencePattern)) { + fenceLength = Math.max(fenceLength, match[1].length + 1); + } + + return fenceLength; +}; diff --git a/src/app/util/eml-parser.spec.ts b/src/app/util/eml-parser.spec.ts index 5092eb5c54..7c95d393d7 100644 --- a/src/app/util/eml-parser.spec.ts +++ b/src/app/util/eml-parser.spec.ts @@ -1,5 +1,4 @@ -import PostalMime from 'postal-mime'; -import { isFileEml, parseEml } from './eml-parser'; +import { parseEml } from './eml-parser'; const makeFile = (content: string, name: string, type = ''): File => new File([content], name, { type }); @@ -37,25 +36,6 @@ const NO_SUBJECT_EML = [ '', ].join('\n'); -describe('isFileEml', () => { - it('should return true if file ends with .eml', () => { - expect(isFileEml(makeFile('', 'mail.eml'))).toBeTrue(); - }); - - it('should be case-insensitive about the extension', () => { - expect(isFileEml(makeFile('', 'MAIL.EML'))).toBeTrue(); - }); - - it('should return true if file type is message/rfc822', () => { - expect(isFileEml(makeFile('', 'mail', 'message/rfc822'))).toBeTrue(); - }); - - it('should return false if file ending is not .eml and file type is not message/rfc822', () => { - expect(isFileEml(makeFile('', 'doc.pdf', 'application/pdf'))).toBeFalse(); - expect(isFileEml(makeFile('', 'notes.txt', 'text/plain'))).toBeFalse(); - }); -}); - describe('parseEml', () => { it('should parse sender and subject from a valid eml file', async () => { const data = await parseEml(makeFile(VALID_EML, 'mail.eml')); @@ -63,6 +43,23 @@ describe('parseEml', () => { expect(data.from?.address).toBe('alice@example.com'); expect(data.from?.name).toBe('Alice Example'); expect(data.subject).toBe('Hello World'); + expect(data.text).toBe('This is the body text.\n'); + }); + + it('should handle CRLF line endings and folded headers', async () => { + const foldedEml = [ + 'From: Alice ', + 'Subject: Hello', + ' World', + '', + 'body', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(foldedEml, 'mail.eml')); + + expect(data.subject).toBe('Hello World'); + expect(data.text).toBe('body\n'); }); it('should leave from undefined when there is no From header', async () => { @@ -86,18 +83,248 @@ describe('parseEml', () => { expect(data.from?.address).toBe('alice@example.com'); }); - it('should throw if parsing rejects', async () => { - // NOTE: This test should almost never happen, since postal-mime almost never fails, but its good to have. - spyOn(PostalMime, 'parse').and.rejectWith(new Error('boom')); - + it('should reject a file without a header/body separator', async () => { await expectAsync(parseEml(makeFile('whatever', 'bad.eml'))).toBeRejected(); }); it('should throw if the file cannot be read', async () => { const file = makeFile('', 'unreadable.eml'); - // postal-mime reads a Blob via arrayBuffer(), so that's the read to fail. - spyOn(file, 'arrayBuffer').and.rejectWith(new Error('read failed')); + spyOn(file, 'text').and.rejectWith(new Error('read failed')); await expectAsync(parseEml(file)).toBeRejected(); }); + + it('should omit multipart bodies instead of trying to parse MIME parts', async () => { + const multipartEml = [ + 'From: Alice ', + 'Subject: Multipart', + 'Content-Type: multipart/alternative; boundary="example"', + '', + '--example', + 'Content-Type: text/plain', + '', + 'plain body', + '--example--', + '', + ].join('\n'); + + const data = await parseEml(makeFile(multipartEml, 'multipart.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Multipart'); + expect(data.text).toBeUndefined(); + }); + + it('should omit transfer-encoded bodies instead of returning encoded content', async () => { + const encodedEml = [ + 'From: Alice ', + 'Subject: Encoded', + 'Content-Type: text/plain', + 'Content-Transfer-Encoding: base64', + '', + 'c2VjcmV0IGJvZHk=', + '', + ].join('\n'); + + const data = await parseEml(makeFile(encodedEml, 'encoded.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Encoded'); + expect(data.text).toBeUndefined(); + }); + + it('should omit HTML bodies while preserving sender and subject', async () => { + const htmlEml = [ + 'From: Alice ', + 'Subject: HTML', + 'Content-Type: text/html', + '', + '

HTML body

', + '', + ].join('\n'); + + const data = await parseEml(makeFile(htmlEml, 'html.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('HTML'); + expect(data.text).toBeUndefined(); + }); + + it('should omit quoted-printable bodies while preserving sender and subject', async () => { + const quotedPrintableEml = [ + 'From: Alice ', + 'Subject: Quoted printable', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + 'encoded=20body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(quotedPrintableEml, 'quoted-printable.eml')); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Quoted printable'); + expect(data.text).toBeUndefined(); + }); + + it('should omit bodies with a declared unsupported charset', async () => { + const file = new File( + [ + [ + 'From: Alice ', + 'Subject: Windows charset', + 'Content-Type: text/plain; charset=windows-1252', + '', + '', + ].join('\r\n'), + new Uint8Array([0xe9]), + ], + 'windows-1252.eml', + ); + + const data = await parseEml(file); + + expect(data.from?.address).toBe('alice@example.com'); + expect(data.subject).toBe('Windows charset'); + expect(data.text).toBeUndefined(); + }); + + it('should keep an explicitly UTF-8 plain-text body', async () => { + const utf8Eml = [ + 'From: Alice ', + 'Subject: UTF-8', + 'Content-Type: text/plain; charset="UTF-8"', + '', + 'Grüße', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(utf8Eml, 'utf8.eml')); + + expect(data.text).toBe('Grüße\n'); + }); + + it('should keep an explicitly US-ASCII plain-text body', async () => { + const asciiEml = [ + 'From: Alice ', + 'Subject: ASCII', + 'Content-Type: text/plain; charset=us-ascii', + '', + 'ASCII body', + '', + ].join('\r\n'); + + const data = await parseEml(makeFile(asciiEml, 'ascii.eml')); + + expect(data.text).toBe('ASCII body\n'); + }); + + it('should only treat the exact text/plain media type as plain text', async () => { + const nonPlainTextEml = [ + 'From: Alice ', + 'Subject: Other media type', + 'Content-Type: text/plain-script', + '', + 'not plain text', + '', + ].join('\n'); + + const data = await parseEml(makeFile(nonPlainTextEml, 'other.eml')); + + expect(data.text).toBeUndefined(); + }); + + it('should decode a Q-encoded (RFC 2047) subject', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'q.eml')); + + expect(data.subject).toBe('Grüße'); + }); + + it('should decode a B-encoded (RFC 2047) subject', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?UTF-8?B?5LiW55WM?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'b.eml')); + + expect(data.subject).toBe('世界'); + }); + + it('should join adjacent encoded-words and decode encoded-word display names', async () => { + const eml = [ + 'From: =?UTF-8?B?R3LDvMOfZQ==?= ', + 'Subject: =?UTF-8?Q?Hello?= =?UTF-8?Q?_World?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'adjacent.eml')); + + expect(data.from?.name).toBe('Grüße'); + expect(data.subject).toBe('Hello World'); + }); + + it('should leave encoded-words with an unsupported charset verbatim', async () => { + const eml = [ + 'From: Alice ', + 'Subject: =?ISO-8859-1?Q?caf=E9?=', + '', + 'body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'iso.eml')); + + expect(data.subject).toBe('=?ISO-8859-1?Q?caf=E9?='); + }); + + it('should not be fooled by a charset inside another quoted parameter', async () => { + const file = new File( + [ + [ + 'From: Alice ', + 'Subject: Quoted param', + 'Content-Type: text/plain; name="x; charset=utf-8; y"; charset=windows-1252', + '', + '', + ].join('\r\n'), + new Uint8Array([0xe9]), + ], + 'quoted-param.eml', + ); + + const data = await parseEml(file); + + expect(data.text).toBeUndefined(); + }); + + it('should ignore header comments on Content-Type and Content-Transfer-Encoding', async () => { + const eml = [ + 'From: Alice ', + 'Subject: Comments', + 'Content-Type: text/plain (the plain one); charset=utf-8', + 'Content-Transfer-Encoding: 7bit (identity)', + '', + 'kept body', + '', + ].join('\n'); + + const data = await parseEml(makeFile(eml, 'comments.eml')); + + expect(data.text).toBe('kept body\n'); + }); }); diff --git a/src/app/util/eml-parser.ts b/src/app/util/eml-parser.ts index 3761f97417..450794d21a 100644 --- a/src/app/util/eml-parser.ts +++ b/src/app/util/eml-parser.ts @@ -1,13 +1,186 @@ -import { type Email } from 'postal-mime'; +export interface ParsedEmlAddress { + address: string; + name?: string; +} -export const isFileEml = (file: File): boolean => { - return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822'; +export interface ParsedEml { + from?: ParsedEmlAddress; + subject?: string; + text?: string; +} + +/** + * Minimal, dependency-free RFC 822 / MIME reader for the drop-an-`.eml` feature. + * + * Intentionally NOT a full MIME parser. A body is only returned as `text` when it + * is a single, unencoded, UTF-8/US-ASCII `text/plain` part; multipart, HTML, + * transfer-encoded (base64/quoted-printable) and non-UTF-8/ASCII bodies are + * omitted by design (`text: undefined`) — never decoded. The caller stores the + * body as an untrusted, inert note, so we favour safety and simplicity over + * completeness; do not add body decoding here without revisiting that threat + * model (untrusted-HTML XSS, main-thread cost, op-log/sync size). + * + * Headers ARE decoded (RFC 2047 encoded-words), because the sender/subject become + * the always-visible task title where raw `=?UTF-8?...?=` would be unreadable. + */ +export const parseEml = async (file: File): Promise => { + const content = (await file.text()).replace(/\r\n?/g, '\n'); + const separatorIndex = content.startsWith('\n') ? 0 : content.indexOf('\n\n'); + + if (separatorIndex < 0) { + throw new Error('Invalid EML: missing header/body separator'); + } + + const headers = _parseHeaders(content.slice(0, separatorIndex)); + const bodyStart = separatorIndex === 0 ? 1 : separatorIndex + 2; + const body = content.slice(bodyStart); + + const { mediaType, charset } = _parseContentType(headers.get('content-type')); + // Take only the leading token so a value like `7bit (comment)` still matches. + const transferEncoding = headers + .get('content-transfer-encoding') + ?.trim() + .split(/[\s(;]/)[0] + .toLowerCase(); + const isPlainText = !mediaType || mediaType === 'text/plain'; + const isUnencoded = + !transferEncoding || transferEncoding === '7bit' || transferEncoding === '8bit'; + const isSupportedCharset = + charset === undefined || charset === 'us-ascii' || charset === 'utf-8'; + + return { + from: _parseAddress(headers.get('from')), + subject: _decodeEncodedWords(headers.get('subject') ?? '').trim() || undefined, + text: isPlainText && isUnencoded && isSupportedCharset ? body : undefined, + }; }; -export const parseEml = async (file: File): Promise => { - const { default: PostalMime } = await import('postal-mime'); - // Hand the File (a Blob) straight to postal-mime so it applies the message's - // own charset and transfer-encoding. file.text() would force UTF-8 and mangle - // non-UTF-8 emails. - return PostalMime.parse(file); +const _parseHeaders = (headerBlock: string): Map => { + const headers = new Map(); + const unfoldedHeaders = headerBlock.replace(/\n[ \t]+/g, ' '); + + for (const line of unfoldedHeaders.split('\n')) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) { + continue; + } + + const name = line.slice(0, separatorIndex).trim().toLowerCase(); + if (!headers.has(name)) { + headers.set(name, line.slice(separatorIndex + 1).trim()); + } + } + + return headers; +}; + +const _parseAddress = (fromHeader?: string): ParsedEmlAddress | undefined => { + if (!fromHeader) { + return undefined; + } + + const angleStart = fromHeader.indexOf('<'); + const angleEnd = fromHeader.indexOf('>', angleStart + 1); + + if (angleStart >= 0 && angleEnd > angleStart) { + const address = fromHeader.slice(angleStart + 1, angleEnd).trim(); + const rawName = fromHeader.slice(0, angleStart).trim(); + const name = _decodeEncodedWords(rawName.replace(/^"|"$/g, '')).trim(); + + return address ? { address, name: name || undefined } : undefined; + } + + const address = fromHeader.split(',', 1)[0].trim(); + return address ? { address } : undefined; +}; + +const _parseContentType = (value?: string): { mediaType?: string; charset?: string } => { + if (!value) { + return {}; + } + + // Split parameters on ';', but not inside a double-quoted value, so a `charset=` + // embedded in another quoted parameter (e.g. a filename) can't be mistaken for + // the real charset. + const parts: string[] = []; + let current = ''; + let inQuotes = false; + for (const char of value) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ';' && !inQuotes) { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + + // The media type is the first token; drop any trailing RFC 822 comment/whitespace. + const mediaType = parts[0].trim().split(/[\s(]/)[0].toLowerCase() || undefined; + + let charset: string | undefined; + for (const part of parts.slice(1)) { + const eq = part.indexOf('='); + if (eq < 0 || part.slice(0, eq).trim().toLowerCase() !== 'charset') { + continue; + } + charset = part + .slice(eq + 1) + .trim() + .replace(/^"|"$/g, '') + .toLowerCase(); + } + + return { mediaType, charset }; +}; + +// Decode RFC 2047 encoded-words (`=?charset?B|Q?text?=`) for the UTF-8/US-ASCII +// case so international subjects/names are readable. Unsupported charsets and +// malformed words are left verbatim rather than throwing. +const _decodeEncodedWords = (value: string): string => + // Whitespace separating two adjacent encoded-words is not significant (RFC 2047). + value + .replace(/(=\?[^?]*\?[BbQq]\?[^?]*\?=)\s+(?==\?[^?]*\?[BbQq]\?)/g, '$1') + .replace( + /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, + (match, charset: string, encoding: string, text: string) => { + const cs = charset.toLowerCase(); + if (cs !== 'utf-8' && cs !== 'us-ascii' && cs !== 'ascii') { + return match; + } + try { + const bytes = + encoding.toUpperCase() === 'B' ? _base64ToBytes(text) : _qToBytes(text); + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } catch { + return match; + } + }, + ); + +const _base64ToBytes = (input: string): Uint8Array => { + const binary = atob(input.replace(/\s+/g, '')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +const _qToBytes = (input: string): Uint8Array => { + const bytes: number[] = []; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (char === '_') { + bytes.push(0x20); + } else if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(input.slice(i + 1, i + 3))) { + bytes.push(parseInt(input.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(char.charCodeAt(0) & 0xff); + } + } + return new Uint8Array(bytes); }; diff --git a/src/app/util/is-file-eml.spec.ts b/src/app/util/is-file-eml.spec.ts new file mode 100644 index 0000000000..173135e19e --- /dev/null +++ b/src/app/util/is-file-eml.spec.ts @@ -0,0 +1,22 @@ +import { isFileEml } from './is-file-eml'; + +const makeFile = (name: string, type = ''): File => new File([], name, { type }); + +describe('isFileEml', () => { + it('should return true if file ends with .eml', () => { + expect(isFileEml(makeFile('mail.eml'))).toBeTrue(); + }); + + it('should be case-insensitive about the extension', () => { + expect(isFileEml(makeFile('MAIL.EML'))).toBeTrue(); + }); + + it('should return true if file type is message/rfc822', () => { + expect(isFileEml(makeFile('mail', 'message/rfc822'))).toBeTrue(); + }); + + it('should return false if file ending is not .eml and file type is not message/rfc822', () => { + expect(isFileEml(makeFile('doc.pdf', 'application/pdf'))).toBeFalse(); + expect(isFileEml(makeFile('notes.txt', 'text/plain'))).toBeFalse(); + }); +}); diff --git a/src/app/util/is-file-eml.ts b/src/app/util/is-file-eml.ts new file mode 100644 index 0000000000..ae384bd167 --- /dev/null +++ b/src/app/util/is-file-eml.ts @@ -0,0 +1,3 @@ +export const isFileEml = (file: File): boolean => { + return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822'; +};