mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
/**
|
|
* A streaming mbox splitter that works on bytes, never on decoded text.
|
|
*
|
|
* Messages are separated by a line starting with `From ` that follows an
|
|
* empty line (or opens the file). That one rule reads mboxo, mboxrd, mboxcl
|
|
* and mboxcl2 files:
|
|
*
|
|
* - `>From ` lines lose one `>` (the mboxrd rule; on an mboxo file this also
|
|
* unquotes a line that was `>From ` originally, which is rare and harmless).
|
|
* - A `Content-Length` header (mboxcl, mboxcl2) is used only when the message
|
|
* it measures ends exactly where another message starts. Until then, `From `
|
|
* lines inside the body are held as possible separators; if the length turns
|
|
* out wrong, they split the message after all (Dovecot does the same).
|
|
* - CRLF files work: an empty line is LF or CRLF, line endings are kept.
|
|
*
|
|
* Bytes are passed through untouched (8-bit bodies, signatures): only the
|
|
* envelope line and one `>` of quoted lines are removed, and the empty line
|
|
* in front of each separator, which belongs to the mbox format.
|
|
*/
|
|
|
|
const LF = 0x0a;
|
|
const CR = 0x0d;
|
|
const GT = 0x3e;
|
|
const FROM = [0x46, 0x72, 0x6f, 0x6d, 0x20]; // "From "
|
|
|
|
export interface MboxMessage {
|
|
/** The message bytes, in order. Pass them to `new Blob(parts)`. */
|
|
parts: Uint8Array[];
|
|
/** Total bytes in `parts`. */
|
|
size: number;
|
|
/** The envelope line without its line ending, decoded as Latin-1. */
|
|
fromLine: string;
|
|
/** Byte offset of the envelope line in the file. */
|
|
offset: number;
|
|
/** Larger than `maxMessageBytes`: `parts` is empty, the message was skipped. */
|
|
tooLarge: boolean;
|
|
}
|
|
|
|
export interface MboxSplitterOptions {
|
|
/** Stop keeping the bytes of a message past this size (it is reported `tooLarge`). */
|
|
maxMessageBytes?: number;
|
|
}
|
|
|
|
interface Segment {
|
|
bytes: Uint8Array;
|
|
/** This segment is the empty line in front of a held separator. */
|
|
heldEmpty?: boolean;
|
|
/** This segment is a held separator line (its text in `fromLine`). */
|
|
heldFrom?: { line: string; offset: number };
|
|
}
|
|
|
|
function startsWithFrom(line: Uint8Array, at: number): boolean {
|
|
if (line.length - at < FROM.length) return false;
|
|
for (let i = 0; i < FROM.length; i++) if (line[at + i] !== FROM[i]) return false;
|
|
return true;
|
|
}
|
|
|
|
function isEmptyLine(line: Uint8Array): boolean {
|
|
return line.length === 1 ? line[0] === LF : line.length === 2 && line[0] === CR && line[1] === LF;
|
|
}
|
|
|
|
function latin1(bytes: Uint8Array): string {
|
|
let out = "";
|
|
for (let i = 0; i < bytes.length; i += 8192) {
|
|
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stripEol(line: Uint8Array): Uint8Array {
|
|
let end = line.length;
|
|
if (end > 0 && line[end - 1] === LF) end--;
|
|
if (end > 0 && line[end - 1] === CR) end--;
|
|
return line.subarray(0, end);
|
|
}
|
|
|
|
/**
|
|
* Whether a `From ` line looks like an envelope line ("From sender date"),
|
|
* which carries a time. Guards against a body line "From now on..." after an
|
|
* empty line in a file whose writer didn't quote it.
|
|
*/
|
|
export function isEnvelopeLine(text: string): boolean {
|
|
// Sender, then a date that ends in the year: "... Tue May 5 10:11:12 2020",
|
|
// optionally with a zone before the year or "remote from host" after it.
|
|
return /^From \S+\s.*\b\d{1,2}:\d{2}(?::\d{2})?\s+(?:[+-]\d{4}\s+|[A-Z]{2,5}\s+)?\d{4}(?:\s+remote from \S+)?\s*$/.test(text);
|
|
}
|
|
|
|
const CONTENT_LENGTH = /^content-length:[ \t]*(\d+)[ \t]*\r?$/im;
|
|
|
|
export class MboxSplitter {
|
|
private readonly maxBytes: number;
|
|
/** Bytes of the line not finished yet (the chunk ended inside it). */
|
|
private carry: Uint8Array[] = [];
|
|
private carryOffset = 0;
|
|
/** Offset of the next byte pushed. */
|
|
private offset = 0;
|
|
|
|
private current: {
|
|
fromLine: string;
|
|
offset: number;
|
|
segments: Segment[];
|
|
size: number;
|
|
tooLarge: boolean;
|
|
/** Header block finished (first empty line seen). */
|
|
inBody: boolean;
|
|
headerBytes: Uint8Array[];
|
|
/** End offset Content-Length says the body has, while it is unconfirmed. */
|
|
expectedEnd?: number;
|
|
holding: boolean;
|
|
} | null = null;
|
|
/** An empty line held back: it is dropped if a separator follows it. */
|
|
private pendingEmpty: { bytes: Uint8Array; offset: number } | null = null;
|
|
private out: MboxMessage[] = [];
|
|
|
|
constructor(options: MboxSplitterOptions = {}) {
|
|
this.maxBytes = options.maxMessageBytes ?? Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
/** Feed the next chunk of the file. Returns the messages it completed. */
|
|
push(chunk: Uint8Array): MboxMessage[] {
|
|
let start = 0;
|
|
for (let i = 0; i < chunk.length; i++) {
|
|
if (chunk[i] !== LF) continue;
|
|
const piece = chunk.subarray(start, i + 1);
|
|
if (this.carry.length > 0) {
|
|
this.carry.push(piece);
|
|
const line = concat(this.carry);
|
|
this.carry = [];
|
|
this.line(line, this.carryOffset);
|
|
} else {
|
|
this.line(piece, this.offset + start);
|
|
}
|
|
start = i + 1;
|
|
}
|
|
if (start < chunk.length) {
|
|
if (this.carry.length === 0) this.carryOffset = this.offset + start;
|
|
this.carry.push(chunk.subarray(start));
|
|
}
|
|
this.offset += chunk.length;
|
|
return this.take();
|
|
}
|
|
|
|
/** The file ended. Returns the last messages. */
|
|
end(): MboxMessage[] {
|
|
if (this.carry.length > 0) {
|
|
const line = concat(this.carry);
|
|
this.carry = [];
|
|
this.line(line, this.carryOffset);
|
|
}
|
|
if (this.current?.expectedEnd !== undefined) {
|
|
this.settleLength(this.offset === this.current.expectedEnd || this.pendingEmptyEndsAt(this.current.expectedEnd));
|
|
}
|
|
// The empty line closing the last message belongs to the format.
|
|
this.pendingEmpty = null;
|
|
this.finish();
|
|
return this.take();
|
|
}
|
|
|
|
private take(): MboxMessage[] {
|
|
const out = this.out;
|
|
this.out = [];
|
|
return out;
|
|
}
|
|
|
|
private pendingEmptyEndsAt(offset: number): boolean {
|
|
return !!this.pendingEmpty && this.pendingEmpty.offset === offset;
|
|
}
|
|
|
|
private line(line: Uint8Array, offset: number): void {
|
|
const cur = this.current;
|
|
// Content-Length said the body ends here: is there a separator?
|
|
if (cur?.expectedEnd !== undefined && offset >= cur.expectedEnd) {
|
|
const atEnd = offset === cur.expectedEnd;
|
|
const afterEmpty = this.pendingEmptyEndsAt(cur.expectedEnd);
|
|
if (atEnd && isEmptyLine(line)) {
|
|
// An empty line right at the end: decide on the line after it.
|
|
} else {
|
|
const confirmed = (atEnd || afterEmpty) && startsWithFrom(line, 0) && isEnvelopeLine(latin1(stripEol(line)));
|
|
this.settleLength(confirmed);
|
|
}
|
|
}
|
|
|
|
const empty = isEmptyLine(line);
|
|
if (startsWithFrom(line, 0) && (this.current === null || this.pendingEmpty !== null)) {
|
|
const text = latin1(stripEol(line));
|
|
if (this.current === null || isEnvelopeLine(text)) {
|
|
if (this.current?.holding) {
|
|
// Inside the length Content-Length claims: keep it as a possible separator.
|
|
this.flushPendingEmpty(true);
|
|
this.addSegment({ bytes: line, heldFrom: { line: text, offset } });
|
|
return;
|
|
}
|
|
this.pendingEmpty = null;
|
|
this.finish();
|
|
this.start(text, offset);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.current === null) return; // Bytes before the first envelope line.
|
|
|
|
if (empty) {
|
|
this.flushPendingEmpty(false);
|
|
this.pendingEmpty = { bytes: line, offset };
|
|
if (!this.current.inBody) this.endHeaders(offset + line.length);
|
|
return;
|
|
}
|
|
this.flushPendingEmpty(false);
|
|
// mboxrd: one ">" less on ">From ", ">>From ", ...
|
|
let at = 0;
|
|
while (at < line.length && line[at] === GT) at++;
|
|
const bytes = at > 0 && startsWithFrom(line, at) ? line.subarray(1) : line;
|
|
if (!this.current.inBody) this.current.headerBytes.push(bytes);
|
|
this.addSegment({ bytes });
|
|
}
|
|
|
|
private flushPendingEmpty(held: boolean): void {
|
|
if (!this.pendingEmpty) return;
|
|
this.addSegment({ bytes: this.pendingEmpty.bytes, heldEmpty: held || undefined });
|
|
this.pendingEmpty = null;
|
|
}
|
|
|
|
private addSegment(segment: Segment): void {
|
|
const cur = this.current!;
|
|
cur.size += segment.bytes.length;
|
|
if (cur.size > this.maxBytes) {
|
|
cur.tooLarge = true;
|
|
cur.segments = [];
|
|
return;
|
|
}
|
|
if (cur.tooLarge) return;
|
|
// Plain lines next to each other in one chunk become one segment.
|
|
const last = cur.segments[cur.segments.length - 1];
|
|
const b = segment.bytes;
|
|
if (last && !last.heldEmpty && !last.heldFrom && !segment.heldEmpty && !segment.heldFrom
|
|
&& last.bytes.buffer === b.buffer && last.bytes.byteOffset + last.bytes.length === b.byteOffset) {
|
|
last.bytes = new Uint8Array(b.buffer, last.bytes.byteOffset, last.bytes.length + b.length);
|
|
return;
|
|
}
|
|
cur.segments.push({ ...segment });
|
|
}
|
|
|
|
private start(fromLine: string, offset: number): void {
|
|
this.current = { fromLine, offset, segments: [], size: 0, tooLarge: false, inBody: false, headerBytes: [], holding: false };
|
|
}
|
|
|
|
private endHeaders(bodyStart: number): void {
|
|
const cur = this.current!;
|
|
cur.inBody = true;
|
|
const match = CONTENT_LENGTH.exec(latin1(concat(cur.headerBytes)));
|
|
cur.headerBytes = [];
|
|
if (match) {
|
|
cur.expectedEnd = bodyStart + Number(match[1]);
|
|
cur.holding = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Content-Length was checked: when it was right, the held separators were
|
|
* body lines; when it was wrong, they split the message.
|
|
*/
|
|
private settleLength(confirmed: boolean): void {
|
|
const cur = this.current!;
|
|
cur.expectedEnd = undefined;
|
|
cur.holding = false;
|
|
if (confirmed || cur.tooLarge || !cur.segments.some((s) => s.heldFrom)) {
|
|
for (const s of cur.segments) { s.heldEmpty = undefined; s.heldFrom = undefined; }
|
|
return;
|
|
}
|
|
// Split at each held separator. The last part becomes the current message.
|
|
const parts: { fromLine: string; offset: number; segments: Segment[] }[] = [
|
|
{ fromLine: cur.fromLine, offset: cur.offset, segments: [] },
|
|
];
|
|
for (const s of cur.segments) {
|
|
if (s.heldEmpty) continue;
|
|
if (s.heldFrom) {
|
|
parts.push({ fromLine: s.heldFrom.line, offset: s.heldFrom.offset, segments: [] });
|
|
continue;
|
|
}
|
|
parts[parts.length - 1].segments.push(s);
|
|
}
|
|
for (const part of parts.slice(0, -1)) {
|
|
this.out.push(toMessage(part.fromLine, part.offset, part.segments, false));
|
|
}
|
|
const last = parts[parts.length - 1];
|
|
this.current = {
|
|
fromLine: last.fromLine,
|
|
offset: last.offset,
|
|
segments: last.segments,
|
|
size: last.segments.reduce((n, s) => n + s.bytes.length, 0),
|
|
tooLarge: false,
|
|
inBody: true,
|
|
headerBytes: [],
|
|
holding: false,
|
|
};
|
|
}
|
|
|
|
private finish(): void {
|
|
const cur = this.current;
|
|
if (!cur) return;
|
|
this.current = null;
|
|
this.out.push(toMessage(cur.fromLine, cur.offset, cur.segments, cur.tooLarge, cur.size));
|
|
}
|
|
}
|
|
|
|
function toMessage(fromLine: string, offset: number, segments: Segment[], tooLarge: boolean, size?: number): MboxMessage {
|
|
const parts = tooLarge ? [] : mergeAdjacent(segments.map((s) => s.bytes));
|
|
return {
|
|
parts,
|
|
size: size ?? parts.reduce((n, p) => n + p.length, 0),
|
|
fromLine,
|
|
offset,
|
|
tooLarge,
|
|
};
|
|
}
|
|
|
|
/** Joins subarrays that sit next to each other in the same buffer, so a Blob gets few parts. */
|
|
function mergeAdjacent(parts: Uint8Array[]): Uint8Array[] {
|
|
const out: Uint8Array[] = [];
|
|
for (const p of parts) {
|
|
const last = out[out.length - 1];
|
|
if (last && last.buffer === p.buffer && last.byteOffset + last.length === p.byteOffset) {
|
|
out[out.length - 1] = new Uint8Array(p.buffer, last.byteOffset, last.length + p.length);
|
|
} else {
|
|
out.push(p);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function concat(parts: Uint8Array[]): Uint8Array {
|
|
if (parts.length === 1) return parts[0];
|
|
let n = 0;
|
|
for (const p of parts) n += p.length;
|
|
const out = new Uint8Array(n);
|
|
let at = 0;
|
|
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
return out;
|
|
}
|
|
|
|
/** The header block of a message (up to the first empty line, at most 64 KB), as Latin-1 text. */
|
|
export function readHeaderBlock(parts: Uint8Array[], limit = 64 * 1024): string {
|
|
const bytes: Uint8Array[] = [];
|
|
let n = 0;
|
|
for (const p of parts) {
|
|
bytes.push(p.subarray(0, Math.max(0, limit - n)));
|
|
n += p.length;
|
|
if (n >= limit) break;
|
|
}
|
|
const text = latin1(concat(bytes));
|
|
const end = text.search(/\r?\n\r?\n/);
|
|
return end === -1 ? text : text.slice(0, end);
|
|
}
|
|
|
|
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
|
|
/**
|
|
* The date of an envelope line, "From sender Tue May 5 10:11:12 2020" (asctime,
|
|
* read as UTC, optionally with a zone before the year), as a JMAP UTCDate.
|
|
*/
|
|
export function envelopeDate(fromLine: string): string | undefined {
|
|
const m = /(?:^|\s)(?:mon|tue|wed|thu|fri|sat|sun)\w*\s+([a-z]{3})\w*\s+(\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s+(?:([+-]\d{4})\s+)?(\d{4})/i.exec(fromLine);
|
|
if (!m) return undefined;
|
|
const month = MONTHS.indexOf(m[1].toLowerCase());
|
|
if (month < 0) return undefined;
|
|
let time = Date.UTC(Number(m[7]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5] ?? 0));
|
|
if (m[6]) {
|
|
const sign = m[6][0] === "-" ? -1 : 1;
|
|
time -= sign * (Number(m[6].slice(1, 3)) * 60 + Number(m[6].slice(3))) * 60_000;
|
|
}
|
|
if (Number.isNaN(time)) return undefined;
|
|
return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
}
|