Adds the mailing list setup.

This commit is contained in:
2026-08-31 17:58:02 -06:00
parent da23c9fc66
commit 362283b11e
6 changed files with 395 additions and 22 deletions

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { createDefaultPreset } from "ts-jest";
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
export default {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
};

View File

@@ -1,5 +1,5 @@
{ {
"name": "WebBox", "name": "web_box",
"version": "1.0.0", "version": "1.0.0",
"description": "Web Box is a server-side application that exposes mailing list services over a REST API.", "description": "Web Box is a server-side application that exposes mailing list services over a REST API.",
"keywords": [ "keywords": [
@@ -15,28 +15,34 @@
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc" "build": "tsc",
"test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@types/nodemailer": "^9.0.6",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/escape-html": "^1.0.4", "@types/escape-html": "^1.0.4",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/mime": "^3.0.4", "@types/mime": "^3.0.4",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
"@types/nodemailer": "^8.0.1",
"jest": "^30.5.0",
"ts-jest": "^29.4.12",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"nodemailer": "^9.0.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^5.2.1", "express": "^5.2.1",
"imapflow": "^1.7.7",
"mime": "^4.1.0", "mime": "^4.1.0",
"multer": "^2.3.0", "multer": "^2.3.0",
"node-html-parser": "^9.0.2", "node-html-parser": "^9.0.2",
"nodemailer": "^9.0.6",
"proper-lockfile": "^4.1.2",
"xmlbuilder2": "^4.0.3" "xmlbuilder2": "^4.0.3"
} }
} }

View File

@@ -7,6 +7,7 @@ import {IncomingMessage} from "node:http";
const CONFIG_PATH = "config_path"; const CONFIG_PATH = "config_path";
const BASE_URL = "base_url"; const BASE_URL = "base_url";
const MAIL_LIST_PATH = "mail_list_path";
const UPLOAD_PATH = "upload_path"; const UPLOAD_PATH = "upload_path";
const CACHE_PATH = "cache_path"; const CACHE_PATH = "cache_path";
const INDEX_PATH = "index_path"; const INDEX_PATH = "index_path";
@@ -17,13 +18,14 @@ const AUTH_KEY = "auth_key";
const AUTH_KEY_HASH = "auth_key_hash"; const AUTH_KEY_HASH = "auth_key_hash";
let config: {[key: string]: any} = {}; let config: {[key: string]: any} = {};
config[CONFIG_PATH] = "/etc/node_mailer/config"; config[CONFIG_PATH] = "/etc/web_box/config";
config[BASE_URL] = ""; config[BASE_URL] = "";
config[UPLOAD_PATH] = "/var/lib/node_mailer/uploads"; config[MAIL_LIST_PATH] = "/var/lib/web_box/mailing_lists.json";
config[CACHE_PATH] = "/var/cache/node_mailer/output"; config[UPLOAD_PATH] = "/var/lib/web_box/uploads";
config[INDEX_PATH] = "/var/cache/node_mailer/index.json"; config[CACHE_PATH] = "/var/cache/web_box/output";
config[TMP_PATH] = "/var/cache/node_mailer/tmp"; config[INDEX_PATH] = "/var/cache/web_box/index.json";
config[TMP_UPLOAD_PATH] = "/var/cache/node_mailer/upload"; config[TMP_PATH] = "/var/cache/web_box/tmp";
config[TMP_UPLOAD_PATH] = "/var/cache/web_box/upload";
config[MAX_UPLOAD_SIZE] = 10485760; config[MAX_UPLOAD_SIZE] = 10485760;
/** /**
@@ -133,6 +135,7 @@ async function readConfiguration():Promise<{[key: string]: any}>
export { export {
CONFIG_PATH, CONFIG_PATH,
BASE_URL, BASE_URL,
MAIL_LIST_PATH,
UPLOAD_PATH, UPLOAD_PATH,
CACHE_PATH, CACHE_PATH,
INDEX_PATH, INDEX_PATH,

View File

@@ -2,7 +2,22 @@
* Copyright (c) 2026 Markil 3. All rights reserved. * 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. * 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[];
public constructor(name: string)
{ {
this.name = name;
this.emails = [];
}
}
/**
* 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;
/**
* Contains the mailing lists.
* @private
*/
private mailingLists: Map<string, MailingList> = new Map();
private mailFileMap: Map<string, PathLike> = 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;
} }
async verify_mail_connection(): boolean /**
* Establishes connections to the remote server, resolving when all connections succeed.
* @return True when all connections succeed.
*/
verify_mail_connection(): Promise<boolean>
{ {
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<boolean>
{
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; return true;
} }
}
export { /**
* Loads saved data from the disk (mailing lists, etc.)
*/
async load_data(): Promise<void>
{
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<void>
{
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<boolean>
{
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<void>
{
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<void>
{
this.smtp.close();
this.smtpConnected = false;
await this.imap.logout().finally(() => this.imapConnected = false);
}
} }

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2016", "target": "es2016",
"module": "commonjs", "module": "ES2020",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,