Adds the mailing list setup.
This commit is contained in:
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createDefaultPreset } from "ts-jest";
|
||||
|
||||
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||
|
||||
/** @type {import("jest").Config} **/
|
||||
export default {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
...tsJestTransformCfg,
|
||||
},
|
||||
};
|
||||
14
package.json
14
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
public constructor(name: string)
|
||||
{
|
||||
this.name = name;
|
||||
this.emails = [];
|
||||
}
|
||||
}
|
||||
|
||||
async verify_mail_connection(): boolean
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
32
tests/mailer_framework.test.ts
Normal file
32
tests/mailer_framework.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"module": "ES2020",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user