From 362283b11e78645b968559c6878cf968178e75aa Mon Sep 17 00:00:00 2001 From: Markil 3 Date: Mon, 31 Aug 2026 17:58:02 -0600 Subject: [PATCH] Adds the mailing list setup. --- jest.config.js | 11 ++ package.json | 16 +- src/config.ts | 15 +- src/mailer_framework.ts | 339 ++++++++++++++++++++++++++++++++- tests/mailer_framework.test.ts | 32 ++++ tsconfig.json | 4 +- 6 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 jest.config.js create mode 100644 tests/mailer_framework.test.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..f6f9a6d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ +import { createDefaultPreset } from "ts-jest"; + +const tsJestTransformCfg = createDefaultPreset().transform; + +/** @type {import("jest").Config} **/ +export default { + testEnvironment: "node", + transform: { + ...tsJestTransformCfg, + }, +}; \ No newline at end of file diff --git a/package.json b/package.json index 04aa951..0dd3c63 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "WebBox", + "name": "web_box", "version": "1.0.0", "description": "Web Box is a server-side application that exposes mailing list services over a REST API.", "keywords": [ @@ -15,28 +15,34 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "tsc" + "build": "tsc", + "test": "jest" }, "devDependencies": { - "@types/nodemailer": "^9.0.6", "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.19", "@types/escape-html": "^1.0.4", "@types/express": "^5.0.6", + "@types/jest": "^30.0.0", "@types/mime": "^3.0.4", "@types/multer": "^2.2.0", "@types/node": "^26.4.0", + "@types/nodemailer": "^8.0.1", + "jest": "^30.5.0", + "ts-jest": "^29.4.12", "typescript": "^6.0.3" - }, + }, "dependencies": { - "nodemailer": "^9.0.6", "bcrypt": "^6.0.0", "cors": "^2.8.6", "escape-html": "^1.0.3", "express": "^5.2.1", + "imapflow": "^1.7.7", "mime": "^4.1.0", "multer": "^2.3.0", "node-html-parser": "^9.0.2", + "nodemailer": "^9.0.6", + "proper-lockfile": "^4.1.2", "xmlbuilder2": "^4.0.3" } } diff --git a/src/config.ts b/src/config.ts index fcd11aa..7c5a51d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ import {IncomingMessage} from "node:http"; const CONFIG_PATH = "config_path"; const BASE_URL = "base_url"; +const MAIL_LIST_PATH = "mail_list_path"; const UPLOAD_PATH = "upload_path"; const CACHE_PATH = "cache_path"; const INDEX_PATH = "index_path"; @@ -17,13 +18,14 @@ const AUTH_KEY = "auth_key"; const AUTH_KEY_HASH = "auth_key_hash"; let config: {[key: string]: any} = {}; -config[CONFIG_PATH] = "/etc/node_mailer/config"; +config[CONFIG_PATH] = "/etc/web_box/config"; config[BASE_URL] = ""; -config[UPLOAD_PATH] = "/var/lib/node_mailer/uploads"; -config[CACHE_PATH] = "/var/cache/node_mailer/output"; -config[INDEX_PATH] = "/var/cache/node_mailer/index.json"; -config[TMP_PATH] = "/var/cache/node_mailer/tmp"; -config[TMP_UPLOAD_PATH] = "/var/cache/node_mailer/upload"; +config[MAIL_LIST_PATH] = "/var/lib/web_box/mailing_lists.json"; +config[UPLOAD_PATH] = "/var/lib/web_box/uploads"; +config[CACHE_PATH] = "/var/cache/web_box/output"; +config[INDEX_PATH] = "/var/cache/web_box/index.json"; +config[TMP_PATH] = "/var/cache/web_box/tmp"; +config[TMP_UPLOAD_PATH] = "/var/cache/web_box/upload"; config[MAX_UPLOAD_SIZE] = 10485760; /** @@ -133,6 +135,7 @@ async function readConfiguration():Promise<{[key: string]: any}> export { CONFIG_PATH, BASE_URL, + MAIL_LIST_PATH, UPLOAD_PATH, CACHE_PATH, INDEX_PATH, diff --git a/src/mailer_framework.ts b/src/mailer_framework.ts index 4165542..7e60da8 100644 --- a/src/mailer_framework.ts +++ b/src/mailer_framework.ts @@ -2,7 +2,22 @@ * Copyright (c) 2026 Markil 3. All rights reserved. */ -import nodemailer from 'nodemailer'; +import path from "node:path"; +import nodemailer, {Transporter, TransportOptions} from 'nodemailer'; +import {ExpungeEvent, FetchMessageObject, IdInfoObject, ImapFlow, ImapFlowOptions, MailboxLockObject} from "imapflow"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; +import {ConnectionOptions} from "node:tls"; +import SMTPPool from "nodemailer/lib/smtp-pool"; +import fs from "node:fs/promises"; +import {PathLike, Stats} from "node:fs"; +import {config, MAIL_LIST_PATH} from "./config"; +import SendmailTransport from "nodemailer/lib/sendmail-transport"; +import StreamTransport from "nodemailer/lib/stream-transport"; +import JSONTransport from "nodemailer/lib/json-transport"; +import SESTransport from "nodemailer/lib/ses-transport"; +import SMTPConnection from "nodemailer/lib/smtp-connection"; + +const INBOX_FOLDER = "INBOX"; /** * A Mail object represents a single email message. @@ -11,18 +26,324 @@ class Mail { } -class Mailbox +/** + * Contains a list of emails that are subscribed to a certain mailing list. + */ +export class MailingList { - constructor() - { - } + /** + * The name of the mailing list. + */ + readonly name: string; + /** + * A list of emails that are subscribed to the list. + */ + emails: string[]; - async verify_mail_connection(): boolean + public constructor(name: string) { - return true; + this.name = name; + this.emails = []; } } -export { +/** + * A unified interface for imap/smtp handling. + */ +export class Mailbox +{ + /** + * The IMAP service used to collect mail. + * @private + */ + private readonly imap: ImapFlow; + /** + * The SMTP service used to send out mail. + * @private + */ + private readonly smtp: Transporter; + /** + * The directory all mail is stored in. + * @private + */ + private readonly mailPath: PathLike; -} \ No newline at end of file + /** + * Contains the mailing lists. + * @private + */ + private mailingLists: Map = new Map(); + + private mailFileMap: Map = new Map(); + + /** + * A flag that keeps track of the imap connection status. + * @private + */ + private imapConnected: boolean = false; + /** + * A flag that keeps track of the smtp connection status. + * @private + */ + private smtpConnected: boolean = false; + /** + * A flag that keeps track of the validity of the mail directory. + * @private + */ + private directoryValid: boolean = false; + + constructor(imapOptions: ImapFlowOptions, smtpOptions: SMTPConnection.Options, mailPath: PathLike) + { + this.imap = new ImapFlow(imapOptions); + this.imap.on("close", () => { + console.log("Dropped IMAP connection."); + this.imapConnected = false; + }); + this.smtp = nodemailer.createTransport(smtpOptions); + this.mailPath = mailPath; + } + + /** + * Establishes connections to the remote server, resolving when all connections succeed. + * @return True when all connections succeed. + */ + verify_mail_connection(): Promise + { + return Promise.all([ + this.smtp.verify().then( + () => this.smtpConnected = true + ).catch((reason) => { + this.smtpConnected = false; + throw reason; + }), + this.imap.connect().then( + () => this.imapConnected = true + ).catch((reason) => { + this.imapConnected = false; + throw reason; + }) + ]).then(() => true); + } + + /** + * Checks to see if the provided mail directory is valid. + * @return True if the directory may be used to store mail. + */ + async verify_mail_directory(): Promise + { + let directoryStats: Stats | undefined; + try + { + directoryStats = await fs.stat(this.mailPath); + } + catch (e) + { + directoryStats = undefined; + } + if (!directoryStats) + { + await fs.mkdir(this.mailPath); + try + { + directoryStats = await fs.stat(this.mailPath); + } + catch (e) + { + directoryStats = undefined; + } + if (!directoryStats) + { + this.directoryValid = false; + return false; + } + } + if (!directoryStats.isDirectory()) + { + this.directoryValid = false; + return false; + } + await fs.access(this.mailPath, fs.constants.O_DIRECTORY | fs.constants.O_RDWR); + this.directoryValid = true; + return true; + } + + /** + * Loads saved data from the disk (mailing lists, etc.) + */ + async load_data(): Promise + { + let mailingLists = fs.readFile(config[MAIL_LIST_PATH], {encoding: "utf8"}).then((mailingListJson: string) => { + let mailingListsRaw: MailingList[] = JSON.parse(mailingListJson); + this.mailingLists.clear(); + for (let listing of mailingListsRaw) + { + this.mailingLists.set(listing.name, listing); + } + }); + await mailingLists; + } + + /** + * Saves application data to the disk (mailing lists, etc.) + */ + async save_data(): Promise + { + let mailingListRaw = []; + for (let listing of this.mailingLists.values()) + { + mailingListRaw.push({ + "name": listing.name, + "emails": listing.emails + }); + } + let mailingLists = fs.writeFile(config[MAIL_LIST_PATH], JSON.stringify(mailingListRaw), {encoding: "utf8"}); + await mailingLists; + } + + /** + * Adds a new mailing list. + * @param listing - The name of the mailing list. + * @param emails - A list of email addresses for the mailing list. + * @param listing - The mailing list to add. + */ + addMailingList(listing: string, emails: string[]): void; + addMailingList(listing: MailingList): void; + addMailingList(listing: MailingList | string, emails?: string[]): void + { + if (typeof listing == "string") + { + listing = new MailingList(listing); + if (emails instanceof Array) + { + listing.emails = emails; + } + } + if (this.mailingLists.has(listing.name)) + { + throw new Error("Mail list is already defined"); + } + this.mailingLists.set(listing.name, listing); + } + + /** + * Registers a new email to a mailing list. + * @param email - The email to register. + * @param listing - The email list to add the email to. + */ + registerEmail(email: string, listing: string | MailingList) + { + if (typeof listing == "string") + { + listing = (this.mailingLists.get(listing) as MailingList); + } + if (listing.emails.includes(email)) + { + throw new Error("Email \"" + email + "\" is already registered to \"" + listing.name + "\""); + } + listing.emails.push(email); + } + + /** + * Removes an email from a mailing list. + * @param email - The email to unregister. + * @param listing - The email list to remove the email from. + */ + unregisterEmail(email: string, listing: string | MailingList) + { + if (typeof listing == "string") + { + listing = (this.mailingLists.get(listing) as MailingList); + } + let index = listing.emails.indexOf(email); + if (index == -1) + { + throw new Error("Email \"" + email + "\" is not registered to \"" + listing.name + "\""); + } + listing.emails.splice(index,1); + } + + /** + * Removes an email from all mailing lists + * @param email - The email to un register. + */ + unregisterEmailFromAll(email: string) + { + for (let listing of this.mailingLists.values()) + { + this.unregisterEmail(email, listing); + } + } + + /** + * Obtains all emails for an email list. + * @param listing - The mailing list to search. + */ + getEmails(listing: string): string[] | undefined + { + return this.mailingLists.get(listing)?.emails; + } + + async verify(): Promise + { + if (!this.imapConnected || !this.smtpConnected) + { + await this.verify_mail_connection(); + } + if (!this.directoryValid) + { + await this.verify_mail_directory(); + } + return true; + } + + /** + * Downloads new mail from the IMAP inbox and sorts it. + */ + async readMail(): Promise + { + let lock: MailboxLockObject | undefined; + let messages: FetchMessageObject[]; + let messageUids: false | number[]; + + await this.verify(); + try + { + lock = await this.imap.getMailboxLock(INBOX_FOLDER); + messageUids = await this.imap.search({ + seen: false + }, { + uid: true + }); + if (messageUids && messageUids.length > 0) + { + messages = await this.imap.fetchAll(messageUids, { + envelope: true, + bodyStructure: true + }, { uid: true }); + for (let message of messages) + { + + } + } + } + finally + { + if (lock) + { + lock.release(); + } + } + } + + + + /** + * Shuts down all resources. + */ + async close(): Promise + { + this.smtp.close(); + this.smtpConnected = false; + await this.imap.logout().finally(() => this.imapConnected = false); + } +} diff --git a/tests/mailer_framework.test.ts b/tests/mailer_framework.test.ts new file mode 100644 index 0000000..e9d8f6e --- /dev/null +++ b/tests/mailer_framework.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "@jest/globals"; +import { Mailbox } from '../src/mailer_framework'; + +describe('Mailer Framework Test', () => +{ + let mailbox: Mailbox = new Mailbox({host: "", port: 0}, {host: "", port: 0}, ""); + test('Should only have one mailing list', () => { + mailbox.addMailingList("test1", ["sample@gmail.com"]); + expect(mailbox.getEmails("test1")).toMatchObject(["sample@gmail.com"]); + }); + test("Mailing list corruption", () => + { + // Make sure the first one does not change. + mailbox.addMailingList("test2", ["sample@gmail.com", "sample2@gmail.com"]); + expect(mailbox.getEmails("test1")).toMatchObject(["sample@gmail.com"]); + expect(mailbox.getEmails("test2")).toMatchObject(["sample@gmail.com", "sample2@gmail.com"]); + }); + test("Deregistration test", () => + { + // Make sure the first one does not change. + mailbox.unregisterEmailFromAll("sample@gmail.com"); + expect(mailbox.getEmails("test1")).toMatchObject([]); + expect(mailbox.getEmails("test2")).toMatchObject(["sample2@gmail.com"]); + }); + test("Existing registration test", () => + { + // Make sure the first one does not change. + mailbox.registerEmail("sample3@gmail.com", "test1"); + expect(mailbox.getEmails("test1")).toMatchObject(["sample3@gmail.com"]); + expect(mailbox.getEmails("test2")).toMatchObject(["sample2@gmail.com"]); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 42c1e49..e71e8da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ { "compilerOptions": { "target": "es2016", - "module": "commonjs", + "module": "ES2020", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "outDir": "dist", - "types": ["node", "express", "cors", "multer", "nodemailer", "bcrypt", "escape-html", "mime"] + "types": ["node", "express", "cors", "multer", "nodemailer", "bcrypt", "escape-html", "mime"] }, "include": ["src"], }