mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(tasks): remove postal-mime dep and harden eml import (#8901)
* 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.
This commit is contained in:
parent
76f2371a56
commit
d5db11f329
11 changed files with 607 additions and 69 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'Subject: Remote image',
|
||||
'',
|
||||
'',
|
||||
'\\',
|
||||
'![reference image][pixel]',
|
||||
'[pixel]: https://attacker.example/pixel',
|
||||
'`C:\\tmp`',
|
||||
'<strong>ordinary markup</strong>',
|
||||
'<img src="https://attacker.example/pixel">',
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await service.createTaskFromEml(makeFile(remoteImageEml));
|
||||
|
||||
expect(taskService.add).toHaveBeenCalledWith(
|
||||
'Alice: Remote image',
|
||||
false,
|
||||
{
|
||||
notes:
|
||||
'~~~\n' +
|
||||
'\n' +
|
||||
'\\\n' +
|
||||
'![reference image][pixel]\n' +
|
||||
'[pixel]: https://attacker.example/pixel\n' +
|
||||
'`C:\\tmp`\n' +
|
||||
'<strong>ordinary markup</strong>\n' +
|
||||
'<img src="https://attacker.example/pixel">\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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'Subject: HTML',
|
||||
'Content-Type: text/html',
|
||||
'',
|
||||
'<p>HTML body</p>',
|
||||
'',
|
||||
].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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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==?= <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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 <alice@example.com>',
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ParsedEml> => {
|
||||
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<Email> => {
|
||||
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<string, string> => {
|
||||
const headers = new Map<string, string>();
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
22
src/app/util/is-file-eml.spec.ts
Normal file
22
src/app/util/is-file-eml.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
3
src/app/util/is-file-eml.ts
Normal file
3
src/app/util/is-file-eml.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const isFileEml = (file: File): boolean => {
|
||||
return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822';
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue