|
|
@@ -0,0 +1,534 @@
|
|
|
+import crypto from 'node:crypto';
|
|
|
+import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
|
+import { readFile } from 'node:fs/promises';
|
|
|
+import http from 'node:http';
|
|
|
+import path from 'node:path';
|
|
|
+import { fileURLToPath, domainToASCII } from 'node:url';
|
|
|
+import {
|
|
|
+ createDomain,
|
|
|
+ deleteDomain,
|
|
|
+ getDomain,
|
|
|
+ getDomainByName,
|
|
|
+ getSmtpCredential,
|
|
|
+ initDatabase,
|
|
|
+ listDomains,
|
|
|
+ listSendEvents,
|
|
|
+ logSendEvent,
|
|
|
+ saveSmtpCredential,
|
|
|
+ saveDomainStatus,
|
|
|
+ seedSmtpCredential,
|
|
|
+ updateDkim,
|
|
|
+ updateDomain
|
|
|
+} from './db.js';
|
|
|
+import { buildDnsGuide } from './dns-guide.js';
|
|
|
+import { createDkimKeyPair } from './dkim.js';
|
|
|
+import {
|
|
|
+ buildMessage,
|
|
|
+ domainFromAddress,
|
|
|
+ extractAddress,
|
|
|
+ parseAddressList,
|
|
|
+ sendViaSmtp,
|
|
|
+ signMessageForDomain
|
|
|
+} from './mailer.js';
|
|
|
+import {
|
|
|
+ parseSubmissionListeners,
|
|
|
+ publicSubmissionListeners,
|
|
|
+ startSubmissionServer
|
|
|
+} from './submission.js';
|
|
|
+
|
|
|
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
+loadDotEnv();
|
|
|
+
|
|
|
+const config = {
|
|
|
+ port: Number(process.env.PORT || 3000),
|
|
|
+ dataDir: process.env.DATA_DIR || path.join(process.cwd(), 'data'),
|
|
|
+ appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
|
|
|
+ adminUser: process.env.ADMIN_USER || 'admin',
|
|
|
+ adminPassword: process.env.ADMIN_PASSWORD || 'change-this-admin-password',
|
|
|
+ apiToken: process.env.API_TOKEN || '',
|
|
|
+ mailHostname: process.env.MAIL_HOSTNAME || 'ali.ss5.xyz',
|
|
|
+ sendingIp: process.env.SENDING_IP || '',
|
|
|
+ defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
|
|
|
+ smtpHost: process.env.SMTP_HOST || '',
|
|
|
+ smtpPort: Number(process.env.SMTP_PORT || 25),
|
|
|
+ smtpSecure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
|
|
|
+ smtpUser: process.env.SMTP_USERNAME || '',
|
|
|
+ smtpPassword: process.env.SMTP_PASSWORD || '',
|
|
|
+ smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
|
|
|
+ sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true',
|
|
|
+ submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
|
|
|
+ submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
|
|
|
+ submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
|
|
|
+ submissionUsername: process.env.SUBMISSION_USERNAME || '',
|
|
|
+ submissionPassword: process.env.SUBMISSION_PASSWORD || '',
|
|
|
+ submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
|
|
|
+ submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
|
|
|
+ submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
|
|
|
+ dmarcPolicy: process.env.DMARC_POLICY || 'none',
|
|
|
+ dmarcRua: process.env.DMARC_RUA || '',
|
|
|
+ sessionSecret: process.env.SESSION_SECRET || crypto
|
|
|
+ .createHash('sha256')
|
|
|
+ .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
|
|
|
+ .digest('hex')
|
|
|
+};
|
|
|
+
|
|
|
+initDatabase(config.dataDir, config.sessionSecret);
|
|
|
+seedSmtpCredential(config.submissionUsername, config.submissionPassword);
|
|
|
+
|
|
|
+const server = http.createServer(async (req, res) => {
|
|
|
+ try {
|
|
|
+ setSecurityHeaders(res);
|
|
|
+ if (req.method === 'OPTIONS') return handleOptions(res);
|
|
|
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
|
+ if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
|
|
|
+ if (req.method === 'POST' && url.pathname === '/api/login') return await handleLogin(req, res);
|
|
|
+ if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
|
|
|
+ if (isLoginAsset(url.pathname)) {
|
|
|
+ if (url.pathname === '/login' && isAuthorized(req, url.pathname)) return redirect(res, '/');
|
|
|
+ return await serveStatic(req, res, url, { loginPage: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isAuthorized(req, url.pathname)) {
|
|
|
+ if (url.pathname.startsWith('/api/')) {
|
|
|
+ return sendJson(res, 401, { error: 'Authentication required.' });
|
|
|
+ }
|
|
|
+ return redirect(res, '/login');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (url.pathname.startsWith('/api/')) {
|
|
|
+ return await handleApi(req, res, url);
|
|
|
+ }
|
|
|
+ return await serveStatic(req, res, url);
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ return sendJson(res, 500, { error: error.message || 'Internal server error.' });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+server.listen(config.port, '0.0.0.0', () => {
|
|
|
+ console.log(`MailHub listening on 0.0.0.0:${config.port}`);
|
|
|
+});
|
|
|
+
|
|
|
+startSubmissionServer({
|
|
|
+ enabled: config.submissionEnabled && Boolean(getSmtpCredential()),
|
|
|
+ listeners: config.submissionListeners,
|
|
|
+ hostname: config.submissionHost,
|
|
|
+ allowInsecureAuth: config.submissionAllowInsecureAuth,
|
|
|
+ tlsCertPath: config.submissionTlsCert,
|
|
|
+ tlsKeyPath: config.submissionTlsKey,
|
|
|
+ relayHost: config.smtpHost,
|
|
|
+ relayPort: config.smtpPort,
|
|
|
+ relaySecure: config.smtpSecure,
|
|
|
+ relayUsername: config.smtpUser,
|
|
|
+ relayPassword: config.smtpPassword,
|
|
|
+ relayHelo: config.smtpHelo
|
|
|
+});
|
|
|
+
|
|
|
+async function handleApi(req, res, url) {
|
|
|
+ const method = req.method || 'GET';
|
|
|
+ const pathname = url.pathname;
|
|
|
+
|
|
|
+ if (method === 'GET' && pathname === '/api/config') {
|
|
|
+ return sendJson(res, 200, publicConfig());
|
|
|
+ }
|
|
|
+ if (method === 'GET' && pathname === '/api/domains') {
|
|
|
+ return sendJson(res, 200, { domains: listDomains() });
|
|
|
+ }
|
|
|
+ if (method === 'POST' && pathname === '/api/domains') {
|
|
|
+ const body = await readJson(req);
|
|
|
+ const domain = normalizeDomain(body.domain);
|
|
|
+ if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
|
|
|
+ const selector = normalizeSelector(body.selector || defaultSelector());
|
|
|
+ if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
|
|
|
+ const keys = createDkimKeyPair();
|
|
|
+ const row = createDomain({
|
|
|
+ domain,
|
|
|
+ selector,
|
|
|
+ verificationToken: crypto.randomBytes(18).toString('hex'),
|
|
|
+ dkimPublic: keys.publicKey,
|
|
|
+ dkimPrivate: keys.privateKey,
|
|
|
+ senderHost: normalizeHostname(body.senderHost || config.mailHostname),
|
|
|
+ sendingIp: String(body.sendingIp || config.sendingIp).trim(),
|
|
|
+ spfExtra: String(body.spfExtra ?? config.defaultSpfMechanisms).trim(),
|
|
|
+ dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || config.dmarcPolicy),
|
|
|
+ dmarcRua: String(body.dmarcRua ?? config.dmarcRua).trim()
|
|
|
+ });
|
|
|
+ return sendJson(res, 201, { domain: row });
|
|
|
+ }
|
|
|
+ if (method === 'GET' && pathname === '/api/events') {
|
|
|
+ return sendJson(res, 200, { events: listSendEvents() });
|
|
|
+ }
|
|
|
+ if (method === 'GET' && pathname === '/api/smtp-credential') {
|
|
|
+ return sendJson(res, 200, { credential: getSmtpCredential({ includePassword: true }) });
|
|
|
+ }
|
|
|
+ if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
|
|
|
+ const body = await readJson(req);
|
|
|
+ saveSmtpCredential({
|
|
|
+ username: String(body.username || '').trim(),
|
|
|
+ password: String(body.password || '')
|
|
|
+ });
|
|
|
+ const credential = getSmtpCredential({ includePassword: true });
|
|
|
+ return sendJson(res, 200, { credential });
|
|
|
+ }
|
|
|
+ if (method === 'POST' && pathname === '/api/send') {
|
|
|
+ const body = await readJson(req);
|
|
|
+ const result = await sendMailFromBody(body);
|
|
|
+ return sendJson(res, 202, result);
|
|
|
+ }
|
|
|
+
|
|
|
+ const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
|
|
|
+ if (domainMatch) {
|
|
|
+ const id = Number(domainMatch[1]);
|
|
|
+ const action = domainMatch[2] || '';
|
|
|
+ if (method === 'GET' && !action) {
|
|
|
+ const domain = getDomain(id);
|
|
|
+ if (!domain) return sendJson(res, 404, { error: '域名不存在。' });
|
|
|
+ return sendJson(res, 200, { domain });
|
|
|
+ }
|
|
|
+ if (method === 'PATCH' && !action) {
|
|
|
+ const body = await readJson(req);
|
|
|
+ const row = updateDomain(id, {
|
|
|
+ selector: body.selector ? normalizeSelector(body.selector) : undefined,
|
|
|
+ senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
|
|
|
+ sendingIp: body.sendingIp ? String(body.sendingIp).trim() : undefined,
|
|
|
+ spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
|
|
|
+ dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
|
|
|
+ dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
|
|
|
+ });
|
|
|
+ if (!row) return sendJson(res, 404, { error: '域名不存在。' });
|
|
|
+ return sendJson(res, 200, { domain: row });
|
|
|
+ }
|
|
|
+ if (method === 'DELETE' && !action) {
|
|
|
+ const deleted = deleteDomain(id);
|
|
|
+ return sendJson(res, deleted ? 200 : 404, { deleted });
|
|
|
+ }
|
|
|
+ if (method === 'POST' && action === 'check') {
|
|
|
+ const row = getDomain(id);
|
|
|
+ if (!row) return sendJson(res, 404, { error: '域名不存在。' });
|
|
|
+ const guide = await buildDnsGuide(row);
|
|
|
+ saveDomainStatus(id, guide);
|
|
|
+ return sendJson(res, 200, { guide, domain: getDomain(id) });
|
|
|
+ }
|
|
|
+ if (method === 'POST' && action === 'rotate-dkim') {
|
|
|
+ const row = getDomain(id);
|
|
|
+ if (!row) return sendJson(res, 404, { error: '域名不存在。' });
|
|
|
+ const body = await readJson(req).catch(() => ({}));
|
|
|
+ const selector = normalizeSelector(body.selector || defaultSelector());
|
|
|
+ const next = updateDkim(id, createDkimKeyPair(), selector);
|
|
|
+ return sendJson(res, 200, { domain: next });
|
|
|
+ }
|
|
|
+ if (method === 'POST' && action === 'test-send') {
|
|
|
+ const row = getDomain(id);
|
|
|
+ if (!row) return sendJson(res, 404, { error: '域名不存在。' });
|
|
|
+ const body = await readJson(req);
|
|
|
+ const from = body.from || `noreply@${row.domain}`;
|
|
|
+ const result = await sendMailFromBody({
|
|
|
+ from,
|
|
|
+ to: body.to,
|
|
|
+ subject: body.subject || `MailHub test for ${row.domain}`,
|
|
|
+ text: body.text || `This is a MailHub test message from ${row.domain}.`
|
|
|
+ });
|
|
|
+ return sendJson(res, 202, result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return sendJson(res, 404, { error: 'Not found.' });
|
|
|
+}
|
|
|
+
|
|
|
+async function sendMailFromBody(body) {
|
|
|
+ const from = extractAddress(body.from);
|
|
|
+ if (!from) throw new Error('发件人地址格式不正确。');
|
|
|
+ const recipients = parseAddressList(body.to);
|
|
|
+ if (!recipients.length) throw new Error('收件人地址格式不正确。');
|
|
|
+ const fromDomain = domainFromAddress(from);
|
|
|
+ const domain = getDomainByName(fromDomain, { includePrivate: true });
|
|
|
+ if (!domain) throw new Error(`发件域名 ${fromDomain} 尚未添加。`);
|
|
|
+ if (config.sendRequiresVerified && !domain.status?.verified) {
|
|
|
+ throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const rawMessage = buildMessage({
|
|
|
+ from,
|
|
|
+ to: recipients,
|
|
|
+ subject: body.subject || '(no subject)',
|
|
|
+ text: body.text || '',
|
|
|
+ html: body.html || '',
|
|
|
+ baseUrl: config.appBaseUrl
|
|
|
+ });
|
|
|
+ const signed = signMessageForDomain(rawMessage, domain);
|
|
|
+ try {
|
|
|
+ const smtpResult = await sendViaSmtp({
|
|
|
+ host: config.smtpHost,
|
|
|
+ port: config.smtpPort,
|
|
|
+ secure: config.smtpSecure,
|
|
|
+ username: config.smtpUser,
|
|
|
+ password: config.smtpPassword,
|
|
|
+ helo: config.smtpHelo,
|
|
|
+ mailFrom: from,
|
|
|
+ recipients,
|
|
|
+ rawMessage: signed
|
|
|
+ });
|
|
|
+ logSendEvent({
|
|
|
+ domainId: domain.id,
|
|
|
+ sender: from,
|
|
|
+ recipients,
|
|
|
+ subject: body.subject || '(no subject)',
|
|
|
+ status: 'queued',
|
|
|
+ detail: smtpResult.message
|
|
|
+ });
|
|
|
+ return {
|
|
|
+ queued: true,
|
|
|
+ domain: domain.domain,
|
|
|
+ recipients,
|
|
|
+ smtp: smtpResult.message
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logSendEvent({
|
|
|
+ domainId: domain.id,
|
|
|
+ sender: from,
|
|
|
+ recipients,
|
|
|
+ subject: body.subject || '(no subject)',
|
|
|
+ status: 'failed',
|
|
|
+ detail: error.message
|
|
|
+ });
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function serveStatic(req, res, url) {
|
|
|
+ const publicDir = path.join(__dirname, '..', 'public');
|
|
|
+ const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
|
|
|
+ const filePath = path.normalize(path.join(publicDir, pathname));
|
|
|
+ if (!filePath.startsWith(publicDir) || !existsSync(filePath) || statSync(filePath).isDirectory()) {
|
|
|
+ return sendStaticFile(res, path.join(publicDir, 'index.html'));
|
|
|
+ }
|
|
|
+ return sendStaticFile(res, filePath);
|
|
|
+}
|
|
|
+
|
|
|
+async function sendStaticFile(res, filePath) {
|
|
|
+ const ext = path.extname(filePath);
|
|
|
+ const contentType = {
|
|
|
+ '.html': 'text/html; charset=utf-8',
|
|
|
+ '.css': 'text/css; charset=utf-8',
|
|
|
+ '.js': 'application/javascript; charset=utf-8',
|
|
|
+ '.json': 'application/json; charset=utf-8',
|
|
|
+ '.svg': 'image/svg+xml'
|
|
|
+ }[ext] || 'application/octet-stream';
|
|
|
+ res.writeHead(200, { 'Content-Type': contentType });
|
|
|
+ res.end(await readFile(filePath));
|
|
|
+}
|
|
|
+
|
|
|
+async function readJson(req) {
|
|
|
+ const chunks = [];
|
|
|
+ for await (const chunk of req) chunks.push(chunk);
|
|
|
+ if (!chunks.length) return {};
|
|
|
+ const raw = Buffer.concat(chunks).toString('utf8');
|
|
|
+ return JSON.parse(raw);
|
|
|
+}
|
|
|
+
|
|
|
+function sendJson(res, status, payload) {
|
|
|
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
|
+ res.end(JSON.stringify(payload));
|
|
|
+}
|
|
|
+
|
|
|
+function redirect(res, location) {
|
|
|
+ res.writeHead(302, { Location: location });
|
|
|
+ res.end();
|
|
|
+}
|
|
|
+
|
|
|
+function setSecurityHeaders(res) {
|
|
|
+ res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
|
+ res.setHeader('X-Frame-Options', 'DENY');
|
|
|
+ res.setHeader('Referrer-Policy', 'same-origin');
|
|
|
+}
|
|
|
+
|
|
|
+function handleOptions(res) {
|
|
|
+ res.writeHead(204, {
|
|
|
+ 'Access-Control-Allow-Origin': '*',
|
|
|
+ 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS',
|
|
|
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
|
|
+ });
|
|
|
+ res.end();
|
|
|
+}
|
|
|
+
|
|
|
+function isAuthorized(req, pathname) {
|
|
|
+ const auth = req.headers.authorization || '';
|
|
|
+ if (hasValidSession(req)) return true;
|
|
|
+ if (auth.startsWith('Basic ')) {
|
|
|
+ const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
|
|
+ const index = decoded.indexOf(':');
|
|
|
+ const user = decoded.slice(0, index);
|
|
|
+ const password = decoded.slice(index + 1);
|
|
|
+ return safeEqual(user, config.adminUser) && safeEqual(password, config.adminPassword);
|
|
|
+ }
|
|
|
+ if (pathname === '/api/send' && config.apiToken && auth.startsWith('Bearer ')) {
|
|
|
+ return safeEqual(auth.slice(7), config.apiToken);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+async function handleLogin(req, res) {
|
|
|
+ const body = await readJson(req);
|
|
|
+ const user = String(body.username || '');
|
|
|
+ const password = String(body.password || '');
|
|
|
+ if (!safeEqual(user, config.adminUser) || !safeEqual(password, config.adminPassword)) {
|
|
|
+ return sendJson(res, 401, { error: '账号或密码不正确。' });
|
|
|
+ }
|
|
|
+ const token = createSessionToken(user);
|
|
|
+ res.writeHead(200, {
|
|
|
+ 'Content-Type': 'application/json; charset=utf-8',
|
|
|
+ 'Set-Cookie': sessionCookie(token)
|
|
|
+ });
|
|
|
+ res.end(JSON.stringify({ ok: true }));
|
|
|
+}
|
|
|
+
|
|
|
+function handleLogout(res) {
|
|
|
+ res.writeHead(200, {
|
|
|
+ 'Content-Type': 'application/json; charset=utf-8',
|
|
|
+ 'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
|
|
|
+ });
|
|
|
+ res.end(JSON.stringify({ ok: true }));
|
|
|
+}
|
|
|
+
|
|
|
+function createSessionToken(user) {
|
|
|
+ const payload = Buffer.from(JSON.stringify({
|
|
|
+ user,
|
|
|
+ exp: Date.now() + 12 * 60 * 60 * 1000,
|
|
|
+ nonce: crypto.randomBytes(10).toString('hex')
|
|
|
+ })).toString('base64url');
|
|
|
+ const signature = signSessionPayload(payload);
|
|
|
+ return `${payload}.${signature}`;
|
|
|
+}
|
|
|
+
|
|
|
+function hasValidSession(req) {
|
|
|
+ const token = parseCookies(req.headers.cookie || '').mailhub_session;
|
|
|
+ if (!token || !token.includes('.')) return false;
|
|
|
+ const [payload, signature] = token.split('.');
|
|
|
+ if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return false;
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
|
+ return data.user === config.adminUser && Number(data.exp) > Date.now();
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function signSessionPayload(payload) {
|
|
|
+ return crypto
|
|
|
+ .createHmac('sha256', config.sessionSecret)
|
|
|
+ .update(payload)
|
|
|
+ .digest('base64url');
|
|
|
+}
|
|
|
+
|
|
|
+function sessionCookie(token) {
|
|
|
+ return [
|
|
|
+ `mailhub_session=${token}`,
|
|
|
+ 'Path=/',
|
|
|
+ 'HttpOnly',
|
|
|
+ 'SameSite=Lax',
|
|
|
+ 'Max-Age=43200'
|
|
|
+ ].join('; ');
|
|
|
+}
|
|
|
+
|
|
|
+function parseCookies(header) {
|
|
|
+ const cookies = {};
|
|
|
+ for (const part of String(header || '').split(';')) {
|
|
|
+ const index = part.indexOf('=');
|
|
|
+ if (index === -1) continue;
|
|
|
+ const key = part.slice(0, index).trim();
|
|
|
+ const value = part.slice(index + 1).trim();
|
|
|
+ cookies[key] = value;
|
|
|
+ }
|
|
|
+ return cookies;
|
|
|
+}
|
|
|
+
|
|
|
+function safeEqual(actual, expected) {
|
|
|
+ const a = Buffer.from(String(actual || ''));
|
|
|
+ const b = Buffer.from(String(expected || ''));
|
|
|
+ if (a.length !== b.length) return false;
|
|
|
+ return crypto.timingSafeEqual(a, b);
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeDomain(input) {
|
|
|
+ const raw = String(input || '')
|
|
|
+ .trim()
|
|
|
+ .toLowerCase()
|
|
|
+ .replace(/^https?:\/\//, '')
|
|
|
+ .replace(/\/.*$/, '')
|
|
|
+ .replace(/\.$/, '');
|
|
|
+ const ascii = domainToASCII(raw);
|
|
|
+ if (!ascii || ascii.length > 253) return '';
|
|
|
+ if (!/^(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(ascii)) return '';
|
|
|
+ return ascii;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeHostname(input) {
|
|
|
+ return normalizeDomain(input) || String(input || '').trim().toLowerCase();
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeSelector(input) {
|
|
|
+ const value = String(input || '').trim().toLowerCase();
|
|
|
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(value)) return '';
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeDmarcPolicy(input) {
|
|
|
+ const value = String(input || '').trim().toLowerCase();
|
|
|
+ return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
|
|
|
+}
|
|
|
+
|
|
|
+function defaultSelector() {
|
|
|
+ const d = new Date();
|
|
|
+ return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
|
+}
|
|
|
+
|
|
|
+function publicConfig() {
|
|
|
+ const smtpCredential = getSmtpCredential();
|
|
|
+ return {
|
|
|
+ appBaseUrl: config.appBaseUrl,
|
|
|
+ mailHostname: config.mailHostname,
|
|
|
+ sendingIp: config.sendingIp,
|
|
|
+ defaultSpfMechanisms: config.defaultSpfMechanisms,
|
|
|
+ smtpHost: config.smtpHost ? 'configured' : '',
|
|
|
+ submission: {
|
|
|
+ enabled: config.submissionEnabled && Boolean(smtpCredential),
|
|
|
+ host: config.submissionHost,
|
|
|
+ ports: publicSubmissionListeners(config.submissionListeners),
|
|
|
+ username: smtpCredential?.username || '',
|
|
|
+ passwordSet: Boolean(smtpCredential?.passwordSet),
|
|
|
+ tls: Boolean(config.submissionTlsCert && config.submissionTlsKey),
|
|
|
+ requireTlsForAuth: !config.submissionAllowInsecureAuth
|
|
|
+ },
|
|
|
+ sendRequiresVerified: config.sendRequiresVerified,
|
|
|
+ apiTokenSet: Boolean(config.apiToken),
|
|
|
+ usingDefaultAdminPassword: config.adminPassword === 'change-this-admin-password'
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function isLoginAsset(pathname) {
|
|
|
+ return ['/login', '/login.html', '/login.css', '/login.js'].includes(pathname);
|
|
|
+}
|
|
|
+
|
|
|
+function resolveStaticPathname(pathname) {
|
|
|
+ if (pathname === '/') return '/index.html';
|
|
|
+ if (pathname === '/login') return '/login.html';
|
|
|
+ return pathname;
|
|
|
+}
|
|
|
+
|
|
|
+function loadDotEnv() {
|
|
|
+ const file = path.join(process.cwd(), '.env');
|
|
|
+ if (!existsSync(file)) return;
|
|
|
+ const lines = readFileSync(file, 'utf8').split(/\r?\n/);
|
|
|
+ for (const line of lines) {
|
|
|
+ const trimmed = line.trim();
|
|
|
+ if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
+ const index = trimmed.indexOf('=');
|
|
|
+ if (index === -1) continue;
|
|
|
+ const key = trimmed.slice(0, index).trim();
|
|
|
+ let value = trimmed.slice(index + 1).trim();
|
|
|
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
|
+ value = value.slice(1, -1);
|
|
|
+ }
|
|
|
+ if (!(key in process.env)) process.env[key] = value;
|
|
|
+ }
|
|
|
+}
|