[WIP] Start tinkering with IMAP

This commit is contained in:
2024-12-30 21:36:12 -05:00
parent 7791baff20
commit 063809e446
15 changed files with 3651 additions and 2 deletions

0
src/mail/read.ts Normal file
View File

133
src/mail/replies.ts Normal file
View File

@@ -0,0 +1,133 @@
// EmailReplyParser is a library to parse plain text email content.
// The goal is to identify quoted text, signatures, or original content.
export class ReplyParser {
static VERSION = "0.5.11";
// Splits an email body into a list of Fragments.
static read(text: string): Email {
return new Email().read(text);
}
// Get the text of the visible portions of the given email body.
static parseReply(text: string): string {
return this.read(text).visibleText();
}
}
export class Email {
private fragments: Fragment[] = [];
private foundVisible: boolean = false;
// Gets the combined text of the visible fragments of the email body.
visibleText(): string {
return this.fragments
.filter((fragment) => !fragment.hidden)
.map((fragment) => fragment.toString())
.join("\n")
.trimEnd();
}
// Splits the given text into a list of Fragments.
read(text: string): this {
let modifiedText = text.slice();
// Normalize line endings.
modifiedText = modifiedText.replace(/\r\n/g, "\n");
// Handle multi-line reply headers.
const multiLineHeaderRegex = /^(?!On.*On\s.+?wrote:)(On\s(.+?)wrote:)$/m;
modifiedText = modifiedText.replace(multiLineHeaderRegex, (match) =>
match.replace(/\n/g, " ")
);
// Ensure proper splitting for lines of underscores.
modifiedText = modifiedText.replace(/([^\n])(?=\n_{7}_+)$/m, "$1\n");
// Reverse the text for parsing.
modifiedText = modifiedText.split("").reverse().join("");
this.foundVisible = false;
let fragment: Fragment | null = null;
const lines = modifiedText.split("\n");
for (const line of lines) {
const processedLine = line.trimEnd();
const isQuoted = processedLine.endsWith(">");
if (fragment && processedLine === "") {
if (Fragment.isSignature(fragment.lines[fragment.lines.length - 1])) {
fragment.signature = true;
this.finishFragment(fragment);
fragment = null;
}
}
if (
fragment &&
(fragment.quoted === isQuoted ||
(fragment.quoted &&
(Fragment.isQuoteHeader(processedLine) || processedLine === "")))
) {
fragment.lines.push(processedLine);
} else {
if (fragment) {
this.finishFragment(fragment);
}
fragment = new Fragment(isQuoted, processedLine);
}
}
if (fragment) {
this.finishFragment(fragment);
}
this.fragments.reverse();
return this;
}
private finishFragment(fragment: Fragment): void {
fragment.finish();
if (!this.foundVisible) {
if (fragment.quoted || fragment.signature || fragment.toString().trim() === "") {
fragment.hidden = true;
} else {
this.foundVisible = true;
}
}
this.fragments.push(fragment);
}
}
class Fragment {
static SIGNATURE_REGEX = /(--\s*$|__\s*$|\w-$)|(^(\w+\s+){1,3}ym morf tneS$)/m;
static QUOTE_HEADER_REGEX = /^:etorw.*nO$|^.*:(morF|tneS|oT|tcejbuS)$/;
quoted: boolean;
signature: boolean = false;
hidden: boolean = false;
lines: string[];
private content: string | null = null;
constructor(quoted: boolean, firstLine: string) {
this.quoted = quoted;
this.lines = [firstLine];
}
static isSignature(line: string): boolean {
return this.SIGNATURE_REGEX.test(line);
}
static isQuoteHeader(line: string): boolean {
return this.QUOTE_HEADER_REGEX.test(line);
}
finish(): void {
this.content = this.lines.join("\n").split("").reverse().join("");
this.lines = [];
}
toString(): string {
return this.content || "";
}
}