initial commit

This commit is contained in:
2026-04-26 13:47:35 -05:00
commit 844c63dd85
27 changed files with 1241 additions and 0 deletions

25
backend/.env.example Normal file
View File

@@ -0,0 +1,25 @@
NODE_ENV=production
PORT=3000
PUBLIC_DIR=/app/frontend
DATABASE_URL=postgres://mailadmin:change-me@mailadmin-db:5432/mailadmin
JWT_SECRET=change-this-long-random-secret
COOKIE_SECURE=true
# Initial admin is created if it does not exist.
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=ChangeMe123!
# This node identity. Run the same app on node2 with NODE_NAME=node2.
NODE_NAME=node1
NODE_HOSTNAME=node1.email-srvr.com
# DMS integration
DMS_CONTAINER=mailserver
# Optional: if present on host/container, add/del/sync use this script instead of direct docker exec.
MANAGE_MAIL_USER_SCRIPT=/opt/email-amazon/basic_setup/manage_mail_user.sh
MAILDATA_PATH=/mail-data
# AWS / DynamoDB
AWS_REGION=us-east-2
DYNAMODB_RULES_TABLE=email-rules
DYNAMODB_BLOCKED_TABLE=email-blocked-senders

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-bookworm AS build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend ./
RUN npm run build
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends docker.io awscli jq bash coreutils ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/backend/node_modules ./backend/node_modules
COPY --from=build /app/backend/dist ./backend/dist
COPY backend/migrations ./backend/migrations
COPY frontend ./frontend
WORKDIR /app/backend
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
CMD ["node", "dist/server.js"]

View File

@@ -0,0 +1,63 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL,
hostname TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain TEXT UNIQUE NOT NULL,
current_node TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
last_synced_at TIMESTAMPTZ,
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mailboxes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_address TEXT UNIQUE NOT NULL,
local_part TEXT NOT NULL,
domain TEXT NOT NULL REFERENCES domains(domain) ON DELETE CASCADE,
node_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
used_bytes BIGINT NOT NULL DEFAULT 0,
usage_scanned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'super_admin',
allowed_domains TEXT[] NOT NULL DEFAULT '{}',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_email TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
details JSONB NOT NULL DEFAULT '{}',
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_domains_node ON domains(current_node);
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);

33
backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "mailadmin-backend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.731.1",
"@aws-sdk/lib-dynamodb": "^3.731.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.6",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

24
backend/src/config.ts Normal file
View File

@@ -0,0 +1,24 @@
import 'dotenv/config';
export const config = {
env: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3000', 10),
publicDir: process.env.PUBLIC_DIR ?? '../frontend',
databaseUrl: process.env.DATABASE_URL ?? 'postgres://mailadmin:mailadmin@localhost:5432/mailadmin',
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
cookieSecure: (process.env.COOKIE_SECURE ?? 'false').toLowerCase() === 'true',
adminEmail: process.env.ADMIN_EMAIL ?? 'admin@example.com',
adminPassword: process.env.ADMIN_PASSWORD ?? 'ChangeMe123!',
nodeName: process.env.NODE_NAME ?? 'node1',
nodeHostname: process.env.NODE_HOSTNAME ?? 'node1.email-srvr.com',
dmsContainer: process.env.DMS_CONTAINER ?? 'mailserver',
manageMailUserScript: process.env.MANAGE_MAIL_USER_SCRIPT ?? '',
mailDataPath: process.env.MAILDATA_PATH ?? '/mail-data',
awsRegion: process.env.AWS_REGION ?? 'us-east-2',
rulesTable: process.env.DYNAMODB_RULES_TABLE ?? 'email-rules',
blockedTable: process.env.DYNAMODB_BLOCKED_TABLE ?? 'email-blocked-senders',
};

32
backend/src/db.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Pool } from 'pg';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import bcrypt from 'bcryptjs';
import { config } from './config.js';
export const pool = new Pool({ connectionString: config.databaseUrl });
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function initDb(): Promise<void> {
const migration = await readFile(join(__dirname, '../migrations/001_init.sql'), 'utf8').catch(async () => {
return readFile(join(process.cwd(), 'migrations/001_init.sql'), 'utf8');
});
await pool.query(migration);
await pool.query(
`INSERT INTO nodes(name, hostname, is_current)
VALUES($1, $2, true)
ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, is_current = true, updated_at = now()`,
[config.nodeName, config.nodeHostname],
);
const hash = await bcrypt.hash(config.adminPassword, 12);
await pool.query(
`INSERT INTO admin_users(email, password_hash, role)
VALUES($1, $2, 'super_admin')
ON CONFLICT (email) DO NOTHING`,
[config.adminEmail.toLowerCase(), hash],
);
}

View File

@@ -0,0 +1,38 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export interface AuthUser {
id: string;
email: string;
role: string;
allowed_domains: string[];
}
declare global {
namespace Express {
interface Request { user?: AuthUser }
}
}
export function signUser(user: AuthUser): string {
return jwt.sign(user, config.jwtSecret, { expiresIn: '12h' });
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.mailadmin_token;
if (!token) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
try {
req.user = jwt.verify(token, config.jwtSecret) as AuthUser;
next();
} catch {
res.status(401).json({ error: 'Invalid session' });
}
}
export function canAccessDomain(user: AuthUser, domain: string): boolean {
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
}

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
export const auditRouter = Router();
auditRouter.use(requireAuth);
auditRouter.get('/', async (_req, res) => {
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
);
res.json(result.rows);
});

View File

@@ -0,0 +1,40 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, signUser } from '../middleware/auth.js';
export const authRouter = Router();
const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1) });
authRouter.post('/login', async (req, res) => {
const body = loginSchema.parse(req.body);
const result = await pool.query(
`SELECT id, email, password_hash, role, allowed_domains FROM admin_users WHERE email=$1 AND active=true`,
[body.email.toLowerCase()],
);
const user = result.rows[0];
if (!user || !(await bcrypt.compare(body.password, user.password_hash))) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const token = signUser({ id: user.id, email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
res.cookie('mailadmin_token', token, {
httpOnly: true,
sameSite: 'lax',
secure: config.cookieSecure,
maxAge: 12 * 60 * 60 * 1000,
});
res.json({ email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
});
authRouter.post('/logout', (_req, res) => {
res.clearCookie('mailadmin_token');
res.json({ ok: true });
});
authRouter.get('/me', requireAuth, (req, res) => {
res.json(req.user);
});

View File

@@ -0,0 +1,33 @@
import { Router } from 'express';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
import { SyncService } from '../services/sync.js';
import { audit } from '../services/audit.js';
export const domainsRouter = Router();
domainsRouter.use(requireAuth);
const sync = new SyncService();
domainsRouter.post('/resync', async (req, res) => {
const result = await sync.syncFromDms();
await audit(req.user!.email, 'domains.resync', 'node', config.nodeName, result, req.ip);
res.json(result);
});
domainsRouter.get('/', async (req, res) => {
if (req.query.resync === 'true') await sync.syncFromDms();
const result = await pool.query(
`SELECT d.*, COUNT(m.email_address) FILTER (WHERE m.status='active')::int AS active_mailboxes,
COALESCE(SUM(m.used_bytes) FILTER (WHERE m.status='active'),0)::bigint AS used_bytes
FROM domains d
LEFT JOIN mailboxes m ON m.domain=d.domain
WHERE d.current_node=$1
GROUP BY d.id
ORDER BY d.domain`,
[config.nodeName],
);
const rows = result.rows.filter((r) => canAccessDomain(req.user!, r.domain));
res.json(rows);
});

View File

@@ -0,0 +1,113 @@
import { Router } from 'express';
import { z } from 'zod';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
import { DmsService } from '../services/dms.js';
import { SyncService } from '../services/sync.js';
import { DynamoRulesService } from '../services/dynamodb.js';
import { audit } from '../services/audit.js';
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
export const mailboxesRouter = Router();
mailboxesRouter.use(requireAuth);
const dms = new DmsService();
const sync = new SyncService(dms);
const dynamo = new DynamoRulesService();
function ensureDomain(req: any, domain: string): void {
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
}
mailboxesRouter.get('/', async (req, res) => {
const domain = String(req.query.domain ?? '').toLowerCase();
if (domain) ensureDomain(req, domain);
const params: unknown[] = [config.nodeName];
let where = 'WHERE node_name=$1';
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
const result = await pool.query(
`SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`,
params,
);
res.json(result.rows.filter((r) => canAccessDomain(req.user!, r.domain)));
});
mailboxesRouter.post('/', async (req, res) => {
const body = z.object({ email: z.string().email(), password: z.string().min(8) }).parse(req.body);
const email = normalizeEmail(body.email);
const domain = domainFromEmail(email);
ensureDomain(req, domain);
await dms.addMailbox(email, body.password);
await sync.syncFromDms();
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
});
mailboxesRouter.delete('/:email', async (req, res) => {
const email = normalizeEmail(req.params.email);
const domain = domainFromEmail(email);
ensureDomain(req, domain);
await dms.deleteMailbox(email);
await dms.syncSesDomain(domain).catch(() => undefined);
await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
res.json({ ok: true });
});
mailboxesRouter.post('/:email/password', async (req, res) => {
const body = z.object({ password: z.string().min(8) }).parse(req.body);
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
await dms.updatePassword(email, body.password);
await audit(req.user!.email, 'mailbox.password_update', 'mailbox', email, {}, req.ip);
res.json({ ok: true });
});
mailboxesRouter.post('/usage/rescan', async (req, res) => {
const domain = String(req.body?.domain ?? '').toLowerCase();
if (domain) ensureDomain(req, domain);
const params: unknown[] = [config.nodeName];
let where = `WHERE node_name=$1 AND status='active'`;
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
const rows = (await pool.query(`SELECT email_address, domain FROM mailboxes ${where}`, params)).rows;
let count = 0;
for (const row of rows) {
if (!canAccessDomain(req.user!, row.domain)) continue;
const bytes = await dms.getMailboxUsageBytes(row.email_address);
await pool.query(`UPDATE mailboxes SET used_bytes=$2, usage_scanned_at=now(), updated_at=now() WHERE email_address=$1`, [row.email_address, bytes]);
count++;
}
await audit(req.user!.email, 'mailbox.usage_rescan', 'domain', domain || '*', { count }, req.ip);
res.json({ count });
});
mailboxesRouter.get('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
res.json(await dynamo.getRules(email));
});
mailboxesRouter.put('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
const body = z.object({ ooo_active: z.boolean().optional(), ooo_message: z.string().optional(), forwards: z.array(z.string().email()).optional() }).parse(req.body);
const saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards });
await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip);
res.json(saved);
});
mailboxesRouter.get('/:email/blocklist', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
res.json(await dynamo.getBlocklist(email));
});
mailboxesRouter.put('/:email/blocklist', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
const body = z.object({ blocked_patterns: z.array(z.string()) }).parse(req.body);
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
res.json(saved);
});

46
backend/src/server.ts Normal file
View File

@@ -0,0 +1,46 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import { initDb } from './db.js';
import { authRouter } from './routes/auth.js';
import { domainsRouter } from './routes/domains.js';
import { mailboxesRouter } from './routes/mailboxes.js';
import { auditRouter } from './routes/audit.js';
import { SyncService } from './services/sync.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.get('/api/health', (_req, res) => res.json({ ok: true, node: config.nodeName, hostname: config.nodeHostname }));
app.use('/api/auth', authRouter);
app.use('/api/domains', domainsRouter);
app.use('/api/mailboxes', mailboxesRouter);
app.use('/api/audit', auditRouter);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = err.status ?? 500;
console.error(err);
res.status(status).json({ error: err.message ?? 'Internal server error' });
});
const publicDir = config.publicDir.startsWith('/') ? config.publicDir : resolve(__dirname, config.publicDir);
app.use(express.static(publicDir));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
await initDb();
try {
await new SyncService().syncFromDms();
} catch (err) {
console.warn('Initial DMS sync failed. The app still starts:', err);
}
app.listen(config.port, () => {
console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`);
});

View File

@@ -0,0 +1,9 @@
import { pool } from '../db.js';
export async function audit(actorEmail: string | null, action: string, targetType: string, targetId: string, details: unknown, ip?: string): Promise<void> {
await pool.query(
`INSERT INTO audit_log(actor_email, action, target_type, target_id, details, ip_address)
VALUES($1,$2,$3,$4,$5,$6)`,
[actorEmail, action, targetType, targetId, details ?? {}, ip ?? null],
);
}

View File

@@ -0,0 +1,83 @@
import { existsSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { config } from '../config.js';
import { run } from '../utils/shell.js';
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
export interface DmsAccount {
email: string;
localPart: string;
domain: string;
}
function parseAccounts(output: string): DmsAccount[] {
const accounts: DmsAccount[] = [];
for (const line of output.split('\n')) {
const match = line.match(/([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/i);
if (!match) continue;
const email = normalizeEmail(match[1]);
accounts.push({ email, localPart: localPartFromEmail(email), domain: domainFromEmail(email) });
}
return [...new Map(accounts.map((a) => [a.email, a])).values()].sort((a, b) => a.email.localeCompare(b.email));
}
export class DmsService {
async listAccounts(): Promise<DmsAccount[]> {
const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']);
return parseAccounts(stdout);
}
async addMailbox(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email);
if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
await run(config.manageMailUserScript, ['add', normalized, password]);
return;
}
await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password]);
}
async deleteMailbox(email: string): Promise<void> {
const normalized = normalizeEmail(email);
if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
await run(config.manageMailUserScript, ['del', normalized]);
return;
}
await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized]);
}
async updatePassword(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email);
// docker-mailserver supports setup email update <email> <password> in current versions.
await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password]);
}
async syncSesDomain(domain: string): Promise<void> {
if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
await run(config.manageMailUserScript, ['sync', domain.toLowerCase()]);
}
}
async getMailboxUsageBytes(email: string): Promise<number> {
const domain = domainFromEmail(email);
const local = localPartFromEmail(email);
const candidates = [
join(config.mailDataPath, domain, local),
join(config.mailDataPath, domain, `${local}/`),
join(config.mailDataPath, domain, `${local}/Maildir`),
join(config.mailDataPath, email),
];
for (const p of candidates) {
try {
await stat(p);
const { stdout } = await run('du', ['-sb', p], 60000);
const value = parseInt(stdout.trim().split(/\s+/)[0] ?? '0', 10);
return Number.isFinite(value) ? value : 0;
} catch {
// try next path
}
}
return 0;
}
}

View File

@@ -0,0 +1,60 @@
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { config } from '../config.js';
import { normalizeEmail } from '../utils/email.js';
export interface EmailRule {
email_address: string;
ooo_active?: boolean;
ooo_message?: string;
ooo_content_type?: string;
forwards?: string[];
}
export interface BlockList {
email_address: string;
blocked_patterns: string[];
}
export class DynamoRulesService {
private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), {
marshallOptions: { removeUndefinedValues: true },
});
async getRules(email: string): Promise<EmailRule> {
const email_address = normalizeEmail(email);
const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } }));
return (resp.Item as EmailRule) ?? { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text/plain', forwards: [] };
}
async putRules(rule: EmailRule): Promise<EmailRule> {
const item: EmailRule = {
email_address: normalizeEmail(rule.email_address),
ooo_active: !!rule.ooo_active,
ooo_message: rule.ooo_message ?? '',
ooo_content_type: rule.ooo_content_type ?? 'text/plain',
forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean),
};
await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item }));
return item;
}
async getBlocklist(email: string): Promise<BlockList> {
const email_address = normalizeEmail(email);
const resp = await this.doc.send(new GetCommand({ TableName: config.blockedTable, Key: { email_address } }));
return (resp.Item as BlockList) ?? { email_address, blocked_patterns: [] };
}
async putBlocklist(email: string, patterns: string[]): Promise<BlockList> {
const item: BlockList = {
email_address: normalizeEmail(email),
blocked_patterns: patterns.map((p) => p.trim().toLowerCase()).filter(Boolean),
};
if (item.blocked_patterns.length === 0) {
await this.doc.send(new DeleteCommand({ TableName: config.blockedTable, Key: { email_address: item.email_address } }));
return item;
}
await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item }));
return item;
}
}

View File

@@ -0,0 +1,65 @@
import { pool } from '../db.js';
import { config } from '../config.js';
import { DmsService } from './dms.js';
export class SyncService {
constructor(private dms = new DmsService()) {}
async syncFromDms(): Promise<{ domains: number; mailboxes: number }> {
const accounts = await this.dms.listAccounts();
const domains = [...new Set(accounts.map((a) => a.domain))].sort();
await pool.query('BEGIN');
try {
for (const domain of domains) {
await pool.query(
`INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at)
VALUES($1,$2,'active',now(),now())
ON CONFLICT(domain) DO UPDATE SET
current_node = EXCLUDED.current_node,
status = 'active',
last_seen_at = now(),
last_synced_at = now(),
updated_at = now()`,
[domain, config.nodeName],
);
}
const localEmails = accounts.map((a) => a.email);
for (const account of accounts) {
await pool.query(
`INSERT INTO mailboxes(email_address, local_part, domain, node_name, status, deleted_at)
VALUES($1,$2,$3,$4,'active',NULL)
ON CONFLICT(email_address) DO UPDATE SET
local_part = EXCLUDED.local_part,
domain = EXCLUDED.domain,
node_name = EXCLUDED.node_name,
status = 'active',
deleted_at = NULL,
updated_at = now()`,
[account.email, account.localPart, account.domain, config.nodeName],
);
}
await pool.query(
`UPDATE domains
SET status='missing_on_node', last_synced_at=now(), updated_at=now()
WHERE current_node=$1 AND NOT (domain = ANY($2::text[]))`,
[config.nodeName, domains],
);
await pool.query(
`UPDATE mailboxes
SET status='missing_on_node', updated_at=now()
WHERE node_name=$1 AND status='active' AND NOT (email_address = ANY($2::text[]))`,
[config.nodeName, localEmails],
);
await pool.query('COMMIT');
return { domains: domains.length, mailboxes: accounts.length };
} catch (err) {
await pool.query('ROLLBACK');
throw err;
}
}
}

View File

@@ -0,0 +1,29 @@
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function domainFromEmail(email: string): string {
const normalized = normalizeEmail(email);
const at = normalized.lastIndexOf('@');
if (at < 1 || at === normalized.length - 1) throw new Error('Invalid email address');
return normalized.slice(at + 1);
}
export function localPartFromEmail(email: string): string {
const normalized = normalizeEmail(email);
const at = normalized.lastIndexOf('@');
if (at < 1) throw new Error('Invalid email address');
return normalized.slice(0, at);
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -0,0 +1,21 @@
import { execFile } from 'node:child_process';
export interface ShellResult {
stdout: string;
stderr: string;
}
export function run(cmd: string, args: string[], timeoutMs = 120000): Promise<ShellResult> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
const err = new Error(`Command failed: ${cmd} ${args.join(' ')}\n${stderr || error.message}`);
(err as any).stdout = stdout;
(err as any).stderr = stderr;
reject(err);
return;
}
resolve({ stdout, stderr });
});
});
}

13
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}