Fixes and cleanup script

This commit is contained in:
Ryann 2025-03-22 02:50:26 +00:00
parent 21f2636c3e
commit f2e1414c61
10 changed files with 281 additions and 36 deletions

View File

@ -102,6 +102,20 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: 'Something went wrong!' }); res.status(500).json({ error: 'Something went wrong!' });
}); });
const cron = require('node-cron');
const { cleanupOrphanedMailboxes, cleanupUnmatchedAndExpired, cleanupInactiveMailboxes } = require('./scripts/cleanup');
cron.schedule('0 0 * * *', async () => {
console.log('Running mailbox cleanup job');
try {
await cleanupOrphanedMailboxes();
await cleanupUnmatchedAndExpired();
await cleanupInactiveMailboxes();
console.log('Mailbox cleanup completed successfully');
} catch (error) {
console.error('Mailbox cleanup failed:', error);
}
});
// Start server // Start server
app.listen(port, ip, () => { app.listen(port, ip, () => {
console.log(`API server listening on port ${port}`); console.log(`API server listening on port ${port}`);

View File

@ -0,0 +1,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.alterTable('mailbox', function (table) {
table.tinyint('ignore').defaultTo(0);
}).alterTable('alias', function (table) {
table.tinyint('ignore').defaultTo(0);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.alterTable('mailbox', function (table) {
table.dropColumn('ignore');
}).alterTable('alias', function (table) {
table.dropColumn('ignore');
});
};

View File

@ -22,7 +22,8 @@ class Alias extends BaseModel {
domain: { type: 'string', minLength: 1, maxLength: 255 }, domain: { type: 'string', minLength: 1, maxLength: 255 },
created: { type: 'string', format: 'date-time' }, created: { type: 'string', format: 'date-time' },
modified: { type: 'string', format: 'date-time' }, modified: { type: 'string', format: 'date-time' },
active: { type: 'integer', default: 1 } active: { type: 'integer', default: 1 },
ignore: { type: 'integer', default: 0 }
} }
}; };
} }

View File

@ -33,7 +33,8 @@ class Mailbox extends BaseModel {
token: { type: 'string', maxLength: 255 }, token: { type: 'string', maxLength: 255 },
token_validity: { type: 'string', format: 'date-time' }, token_validity: { type: 'string', format: 'date-time' },
password_expiry: { type: 'string', format: 'date-time' }, password_expiry: { type: 'string', format: 'date-time' },
expires: { type: 'string', format: 'date-time' } expires: { type: 'string', format: 'date-time' },
ignore: { type: 'integer', default: 0 }
} }
}; };
} }

200
api/scripts/cleanup.js Normal file
View File

@ -0,0 +1,200 @@
const fs = require('fs').promises;
const path = require('path');
const { models } = require('../db/db');
const { Mailbox, Alias } = models;
const { format, isBefore } = require('date-fns');
async function scanMailboxDirectories(callback) {
const mailPath = '/var/mail';
// Verify mail directory exists
try {
await fs.access(mailPath);
} catch (err) {
console.error(`Mail directory ${mailPath} does not exist or is not accessible`);
return;
}
const domains = await fs.readdir(mailPath);
console.log(`Found ${domains.length} domains in ${mailPath}`);
for (const domain of domains) {
const domainPath = path.join(mailPath, domain);
const stat = await fs.stat(domainPath);
if (stat.isDirectory()) {
const users = await fs.readdir(domainPath);
console.log(`Processing domain ${domain} with ${users.length} users`);
for (const user of users) {
const userPath = path.join(domainPath, user);
const userStat = await fs.stat(userPath);
if (userStat.isDirectory()) {
await callback({ user, domain, userPath });
}
}
}
}
}
async function cleanupOrphanedMailboxes() {
try {
// Get all valid mailboxes from database - include ignore flag
const validMailboxes = await Mailbox.query()
.select('username', 'ignore'); // Add ignore to selection
// Create a lookup map for quick checking
const validMailboxMap = {};
validMailboxes.forEach(mailbox => {
const [user, domain] = mailbox.username.split('@');
if (!validMailboxMap[domain]) validMailboxMap[domain] = new Map();
validMailboxMap[domain].set(user, mailbox.ignore); // Store ignore status
});
await scanMailboxDirectories(async ({ user, domain, userPath }) => {
// Check if mailbox exists and is not ignored
const hasMailbox = validMailboxMap[domain] && validMailboxMap[domain].has(user);
const isIgnored = hasMailbox && validMailboxMap[domain].get(user) === 1;
// Only remove if mailbox doesn't exist or is not ignored
if (!hasMailbox || !isIgnored) {
console.log(`Checking mailbox ${user}@${domain} - exists: ${hasMailbox}, ignored: ${isIgnored}`);
console.log(`Removing orphaned mailbox: ${user}@${domain}`);
await fs.rm(userPath, { recursive: true, force: true });
}
});
console.log('Mailbox cleanup completed');
} catch (error) {
console.error('Error during mailbox cleanup:', error);
}
}
async function cleanupUnmatchedAndExpired() {
try {
let expired = 0;
let removed = 0;
const mailboxes = await Mailbox.query()
.select('username', 'domain', 'expires', 'ignore')
.where('ignore', 0);
const aliases = await Alias.query()
.select('address', 'domain', 'ignore')
.where('ignore', 0);
// Create lookup maps for efficient matching
const mailboxMap = new Map(
mailboxes.map(m => [m.username, m])
);
const aliasMap = new Map(
aliases.map(a => [a.address, a])
);
// Check mailboxes without matching aliases
for (const [mailboxAddress, mailbox] of mailboxMap) {
if (!aliasMap.has(mailboxAddress)) {
console.log(`Removing mailbox without alias: ${mailboxAddress}`);
await Mailbox.query()
.where('username', mailbox.username)
.patch({ active: 0 });
removed++;
}
}
// Check aliases without matching mailboxes
for (const [aliasAddress, alias] of aliasMap) {
if (!mailboxMap.has(aliasAddress)) {
console.log(`Removing alias without mailbox: ${aliasAddress}`);
await Alias.query()
.where('address', alias.address)
.patch({ active: 0 });
removed++;
}
}
// Check expiration for matched pairs
const currentTime = format(new Date(), 'yyyy-MM-dd HH:mm:ss');
for (const [mailboxAddress, mailbox] of mailboxMap) {
console.log(`Checking ${mailboxAddress}: expires ${mailbox.expires} vs current ${currentTime}`);
if (aliasMap.has(mailboxAddress) && isBefore(new Date(mailbox.expires), new Date())) {
console.log(`Removing expired mailbox and alias: ${mailboxAddress}`);
await Mailbox.query()
.where('username', mailbox.username)
.where('ignore', 0)
.delete();
await Alias.query()
.where('address', mailboxAddress)
.where('ignore', 0)
.delete();
expired++;
}
}
await scanMailboxDirectories(async ({ user, domain, userPath }) => {
const mailboxAddress = `${user}@${domain}`;
const mailbox = mailboxMap.get(mailboxAddress);
const alias = aliasMap.get(mailboxAddress);
// Check if this mailbox exists in database with ignore=1
const ignoredMailbox = await Mailbox.query()
.where('username', mailboxAddress)
.where('ignore', 1)
.first();
// Only remove if mailbox doesn't exist OR (exists, not ignored, and expired)
if (!ignoredMailbox && (!mailbox || !alias || (mailbox && mailbox.expires < currentTime))) {
console.log(`Removing directory for invalid/expired mailbox: ${mailboxAddress}`);
await fs.rm(userPath, { recursive: true, force: true });
}
});
console.log('Cleanup of unmatched and expired items completed');
} catch (error) {
console.error('Error during cleanup:', error);
}
}
async function cleanupInactiveMailboxes() {
try {
const mailboxes = await Mailbox.query()
.select('username', 'domain', 'active')
.where('active', 0)
.where('ignore', 0);
const aliases = await Alias.query()
.select('address', 'domain', 'active')
.where('active', 0)
.where('ignore', 0);
for (const mailbox of mailboxes) {
await Mailbox.query()
.where('username', mailbox.username)
.where('ignore', 0)
.delete();
}
for (const alias of aliases) {
await Alias.query()
.where('address', alias.address)
.where('ignore', 0)
.delete();
}
console.log('Cleanup of inactive mailboxes completed');
} catch (error) {
console.error('Error during cleanup:', error);
}
}
module.exports = {
cleanupOrphanedMailboxes,
cleanupUnmatchedAndExpired,
cleanupInactiveMailboxes
};

View File

@ -125,7 +125,7 @@ async function configureDNS(domain) {
await cf.post(`/zones/${zoneId}/dns_records`, { await cf.post(`/zones/${zoneId}/dns_records`, {
type: 'MX', type: 'MX',
name: '@', name: '@',
content: `mail.${domain}`, content: `mail.2weekmail.fyi`,
priority: 10, priority: 10,
ttl: 3600 ttl: 3600
}); });
@ -161,18 +161,18 @@ async function configureDNS(domain) {
await cf.post(`/zones/${zoneId}/dns_records`, { await cf.post(`/zones/${zoneId}/dns_records`, {
type: 'TXT', type: 'TXT',
name: '_dmarc', name: '_dmarc',
content: `"v=DMARC1; p=reject; rua=mailto:postmaster@${domain}"`, content: `"v=DMARC1; p=reject; rua=mailto:postmaster@2weekmail.fyi"`,
ttl: 3600 ttl: 3600
}); });
// Step 8: Add A record for mail subdomain // // Step 8: Add A record for mail subdomain
console.log(`Adding A record for mail.${domain}`); // console.log(`Adding A record for mail.${domain}`);
await cf.post(`/zones/${zoneId}/dns_records`, { // await cf.post(`/zones/${zoneId}/dns_records`, {
type: 'A', // type: 'A',
name: 'mail', // name: 'mail',
content: process.env.SERVER_IP, // content: process.env.SERVER_IP,
ttl: 3600 // ttl: 3600
}); // });
console.log(`DNS configuration completed for ${domain}`); console.log(`DNS configuration completed for ${domain}`);
return true; return true;

View File

@ -1,3 +1,3 @@
const uuid = require('uuid'); const { format } = require('date-fns');
console.log(uuid.v4()); console.log(format(new Date(), 'yyyy-MM-dd HH:mm:ss'));

View File

@ -73,21 +73,21 @@ app.get('/terms', (req, res) => {
const setupSwagger = require('./swagger'); const setupSwagger = require('./swagger');
setupSwagger(app); setupSwagger(app);
const { deleteExpiredMailboxes } = require('./utils/mailbox_cleanup'); // const { deleteExpiredMailboxes } = require('./utils/mailbox_cleanup');
const { cleanupOrphanedMailboxes } = require('./utils/helpers'); // const { cleanupOrphanedMailboxes } = require('./utils/helpers');
const cron = require('node-cron'); // const cron = require('node-cron');
cron.schedule('0 0 * * *', async () => { // cron.schedule('0 0 * * *', async () => {
console.log('Running mailbox cleanup job'); // console.log('Running mailbox cleanup job');
try { // try {
await deleteExpiredMailboxes(); // await deleteExpiredMailboxes();
await cleanupOrphanedMailboxes(); // await cleanupOrphanedMailboxes();
console.log('Mailbox cleanup completed successfully'); // console.log('Mailbox cleanup completed successfully');
} catch (error) { // } catch (error) {
console.error('Mailbox cleanup failed:', error); // console.error('Mailbox cleanup failed:', error);
} // }
}); // });
// Start server // Start server
app.listen(PORT, () => { app.listen(PORT, () => {

View File

@ -2,7 +2,8 @@
# Variables - replace these with your desired values # Variables - replace these with your desired values
ADMIN_USER="admin" ADMIN_USER="admin"
ADMIN_PASSWORD="CHANGEME" ADMIN_PASSWORD=$(openssl rand -base64 32)
DB_PASS="ZSI8R1LFJVOqX65EmBsBA1OQPO2arA=="
CONTAINER_NAME="mailserver_db" CONTAINER_NAME="mailserver_db"
echo "Creating MySQL admin user with root-like privileges..." echo "Creating MySQL admin user with root-like privileges..."

View File

@ -1,5 +1,3 @@
version: '3.8'
services: services:
# Database # Database
db: db:
@ -45,9 +43,12 @@ services:
- ./certs:/tmp/docker-mailserver/certs - ./certs:/tmp/docker-mailserver/certs
- /etc/letsencrypt:/etc/letsencrypt - /etc/letsencrypt:/etc/letsencrypt
environment: environment:
- OVERRIDE_HOSTNAME=mail.2weekmail.fyi
- SPOOF_PROTECTION=1
- LOGROTATE_INTERVAL=daily
- ENABLE_SPAMASSASSIN=0 - ENABLE_SPAMASSASSIN=0
- ENABLE_CLAMAV=0 - ENABLE_CLAMAV=0
- ENABLE_POSTGREY=1 - ENABLE_POSTGREY=0
- POSTMASTER_ADDRESS=admin@2weekmail.fyi - POSTMASTER_ADDRESS=admin@2weekmail.fyi
- POSTFIX_MYSQL_HOST=db - POSTFIX_MYSQL_HOST=db
- POSTFIX_MYSQL_USER=${DB_USER} - POSTFIX_MYSQL_USER=${DB_USER}
@ -60,9 +61,12 @@ services:
- ENABLE_POSTFIX_VIRTUAL_TRANSPORT=1 - ENABLE_POSTFIX_VIRTUAL_TRANSPORT=1
- POSTFIX_DAGENT=lmtp:unix:/var/run/dovecot/lmtp - POSTFIX_DAGENT=lmtp:unix:/var/run/dovecot/lmtp
- SSL_TYPE=letsencrypt - SSL_TYPE=letsencrypt
- SSL_CERT_PATH=/tmp/docker-mailserver/certs/2weekmail.test-cert.pem # - SSL_CERT_PATH=/tmp/docker-mailserver/certs/2weekmail.test-cert.pem
- SSL_KEY_PATH=/tmp/docker-mailserver/certs/2weekmail.test-key.pem # - SSL_KEY_PATH=/tmp/docker-mailserver/certs/2weekmail.test-key.pem
- TZ=UTC - TZ=UTC
cap_add:
- NET_ADMIN
- SYS_PTRACE
networks: networks:
- mail_network - mail_network
depends_on: depends_on:
@ -86,7 +90,7 @@ services:
- POSTFIXADMIN_SMTP_PORT=25 - POSTFIXADMIN_SMTP_PORT=25
- POSTFIXADMIN_CONFIGURED=true - POSTFIXADMIN_CONFIGURED=true
volumes: volumes:
- postfixadmin_data:/var/www/html/templates_c - postfixadmin_data:/var/www/html
- ./config/postfixadmin:/var/www/html - ./config/postfixadmin:/var/www/html
- ./logs/postfixadmin:/var/log/apache2 - ./logs/postfixadmin:/var/log/apache2
networks: networks:
@ -110,9 +114,9 @@ services:
- ROUNDCUBEMAIL_DB_USER=${DB_USER} - ROUNDCUBEMAIL_DB_USER=${DB_USER}
- ROUNDCUBEMAIL_DB_PASSWORD=${DB_PASS} - ROUNDCUBEMAIL_DB_PASSWORD=${DB_PASS}
- ROUNDCUBEMAIL_DB_NAME=${DB_NAME} - ROUNDCUBEMAIL_DB_NAME=${DB_NAME}
- ROUNDCUBEMAIL_DEFAULT_HOST=ssl://mailserver - ROUNDCUBEMAIL_DEFAULT_HOST=ssl://mail.2weekmail.fyi
- ROUNDCUBEMAIL_DEFAULT_PORT=993 - ROUNDCUBEMAIL_DEFAULT_PORT=993
- ROUNDCUBEMAIL_SMTP_SERVER=tls://mailserver - ROUNDCUBEMAIL_SMTP_SERVER=tls://mail.2weekmail.fyi
- ROUNDCUBEMAIL_SMTP_PORT=587 - ROUNDCUBEMAIL_SMTP_PORT=587
volumes: volumes:
- roundcube_data:/var/www/html - roundcube_data:/var/www/html
@ -155,6 +159,7 @@ services:
- ./api:/app - ./api:/app
- /app/node_modules - /app/node_modules
- opendkim_data:/etc/opendkim - opendkim_data:/etc/opendkim
- mailserver_data:/var/mail:rw
environment: environment:
- PORT=${PORT} - PORT=${PORT}
- WEB_PORT=${WEB_PORT} - WEB_PORT=${WEB_PORT}