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:
Johannes Millan 2026-07-10 17:05:50 +02:00 committed by GitHub
parent 76f2371a56
commit d5db11f329
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 607 additions and 69 deletions

View file

@ -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.

View file

@ -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
View file

@ -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",

View file

@ -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": {

View file

@ -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({

View file

@ -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',
'',
'![pixel](https://attacker.example/pixel)',
'\\![already escaped](https://attacker.example/pixel)',
'![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' +
'![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' +
'<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,

View file

@ -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;
};

View file

@ -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');
});
});

View file

@ -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);
};

View 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();
});
});

View file

@ -0,0 +1,3 @@
export const isFileEml = (file: File): boolean => {
return file.name.toLowerCase().endsWith('.eml') || file.type === 'message/rfc822';
};