From bb8d8e0757fce2f6567f04d627021933c6da2b66 Mon Sep 17 00:00:00 2001
From: Daniel Winter
Date: Tue, 10 Mar 2026 15:52:19 +0100
Subject: [PATCH 1/5] Add domain overview module with DKIM, SPF, and DMARC
configuration support
---
.gitignore | 2 +
backend/dockerMailserver.js | 218 +++++++++++++++++++
backend/index.js | 44 ++++
frontend/src/App.js | 2 +
frontend/src/components/Sidebar.js | 3 +
frontend/src/locales/en/translation.json | 33 ++-
frontend/src/locales/pl/translation.json | 33 ++-
frontend/src/pages/Domains.js | 258 +++++++++++++++++++++++
frontend/src/services/api.js | 20 ++
9 files changed, 611 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/pages/Domains.js
diff --git a/.gitignore b/.gitignore
index dbf54d7..022841d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,5 @@ coverage
# OS generated files
.DS_Store
Thumbs.db
+
+.idea
\ No newline at end of file
diff --git a/backend/dockerMailserver.js b/backend/dockerMailserver.js
index 834bdfe..545bf66 100644
--- a/backend/dockerMailserver.js
+++ b/backend/dockerMailserver.js
@@ -3,6 +3,8 @@ const docker = new Docker({ socketPath: '/var/run/docker.sock' });
// Docker container name for docker-mailserver
const DOCKER_CONTAINER = process.env.DOCKER_CONTAINER || 'mailserver';
+const OPENDKIM_KEYS_PATH =
+ process.env.OPENDKIM_KEYS_PATH || '/tmp/docker-mailserver/opendkim/keys';
// Debug flag
const DEBUG = process.env.DEBUG_DOCKER === 'true';
@@ -34,6 +36,42 @@ function escapeShellArg(arg) {
return `'${arg.replace(/'/g, "'\\''")}'`;
}
+/**
+ * Normalizes a domain to lowercase and trims surrounding whitespace.
+ * @param {string} domain - Domain candidate
+ * @return {string|null} Normalized domain or null if invalid
+ */
+function normalizeDomain(domain) {
+ if (!domain || typeof domain !== 'string') {
+ return null;
+ }
+
+ const normalized = domain.trim().toLowerCase();
+ if (!normalized || !/^[a-z0-9.-]+$/.test(normalized)) {
+ return null;
+ }
+
+ return normalized;
+}
+
+/**
+ * Extracts a domain from an email address.
+ * @param {string} email - Email address
+ * @return {string|null} Extracted domain or null when invalid
+ */
+function extractDomainFromEmail(email) {
+ if (!email || typeof email !== 'string') {
+ return null;
+ }
+
+ const parts = email.trim().split('@');
+ if (parts.length !== 2 || !parts[1]) {
+ return null;
+ }
+
+ return normalizeDomain(parts[1]);
+}
+
/**
* Executes a command in the docker-mailserver container
* @param {string} command Command to execute
@@ -95,6 +133,184 @@ async function execSetup(setupCommand) {
return execInContainer(`/usr/local/bin/setup ${setupCommand}`);
}
+/**
+ * Reads immediate child directories in OPENDKIM keys path (domain folders).
+ * @return {Promise} Domain names discovered from folder structure
+ */
+async function getDomainsFromOpendkimKeysPath() {
+ const escapedPath = escapeShellArg(OPENDKIM_KEYS_PATH);
+ const stdout = await execInContainer(
+ `if [ -d ${escapedPath} ]; then ls -1 ${escapedPath}; fi`
+ );
+
+ return stdout
+ .split('\n')
+ .map((line) => line.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim())
+ .filter((line) => line.length > 0)
+ .map((line) => normalizeDomain(line))
+ .filter(Boolean);
+}
+
+/**
+ * Parses OpenDKIM TXT file content and extracts DNS-ready fields.
+ * @param {string} rawDkimTxt - Raw content of mail.txt
+ * @return {{recordName: string, recordType: string, recordValue: string, raw: string}}
+ */
+function parseDkimTxt(rawDkimTxt) {
+ const cleanedRaw = rawDkimTxt
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ const nameMatch = cleanedRaw.match(/^([^\s]+)\s+IN\s+TXT/i);
+ const recordName = nameMatch ? nameMatch[1] : 'mail._domainkey';
+
+ const quotedParts = [...cleanedRaw.matchAll(/"([^"]+)"/g)].map(
+ (match) => match[1]
+ );
+ const recordValue = quotedParts.join('').replace(/\s+/g, ' ').trim();
+
+ return {
+ recordName,
+ recordType: 'TXT',
+ recordValue,
+ raw: cleanedRaw,
+ };
+}
+
+/**
+ * Reads DKIM TXT record from mail.txt for a specific domain.
+ * @param {string} domain - Domain name
+ * @return {Promise<{configured: boolean, selector: string, recordName: string|null, recordType: string, recordValue: string|null, raw: string|null}>}
+ */
+async function getDomainDkim(domain) {
+ const normalizedDomain = normalizeDomain(domain);
+ if (!normalizedDomain) {
+ return {
+ configured: false,
+ selector: 'mail',
+ recordName: null,
+ recordType: 'TXT',
+ recordValue: null,
+ raw: null,
+ };
+ }
+
+ const dkimFilePath = `${OPENDKIM_KEYS_PATH}/${normalizedDomain}/mail.txt`;
+ const escapedFilePath = escapeShellArg(dkimFilePath);
+ const stdout = await execInContainer(
+ `if [ -f ${escapedFilePath} ]; then cat ${escapedFilePath}; fi`
+ );
+
+ const rawDkimTxt = stdout.trim();
+ if (!rawDkimTxt) {
+ return {
+ configured: false,
+ selector: 'mail',
+ recordName: null,
+ recordType: 'TXT',
+ recordValue: null,
+ raw: null,
+ };
+ }
+
+ const parsed = parseDkimTxt(rawDkimTxt);
+ return {
+ configured: true,
+ selector: 'mail',
+ recordName: parsed.recordName,
+ recordType: parsed.recordType,
+ recordValue: parsed.recordValue,
+ raw: parsed.raw,
+ };
+}
+
+/**
+ * Returns domain overview with DKIM, SPF, and DMARC DNS data.
+ * @return {Promise} Domain overview list
+ */
+async function getDomainsOverview() {
+ try {
+ const [accounts, aliases, keyPathDomains] = await Promise.all([
+ getAccounts(),
+ getAliases(),
+ getDomainsFromOpendkimKeysPath(),
+ ]);
+
+ const domainsSet = new Set();
+
+ accounts.forEach((account) => {
+ const domain = extractDomainFromEmail(account.email);
+ if (domain) {
+ domainsSet.add(domain);
+ }
+ });
+
+ aliases.forEach((alias) => {
+ const sourceDomain = extractDomainFromEmail(alias.source);
+ const destinationDomain = extractDomainFromEmail(alias.destination);
+ if (sourceDomain) {
+ domainsSet.add(sourceDomain);
+ }
+ if (destinationDomain) {
+ domainsSet.add(destinationDomain);
+ }
+ });
+
+ keyPathDomains.forEach((domain) => domainsSet.add(domain));
+
+ const domains = Array.from(domainsSet).sort((a, b) => a.localeCompare(b));
+ const domainsWithDns = await Promise.all(
+ domains.map(async (domain) => {
+ const dkim = await getDomainDkim(domain);
+
+ return {
+ domain,
+ dkim,
+ spf: {
+ recordName: '@',
+ recordType: 'TXT',
+ recordValue: 'v=spf1 mx -all',
+ explanation:
+ 'Allow mail delivery from this domain hosts (mx) and reject other senders.',
+ },
+ dmarc: {
+ recordName: `_dmarc.${domain}`,
+ recordType: 'TXT',
+ recordValue: `v=DMARC1; p=none; rua=mailto:postmaster@${domain}; fo=1; adkim=s; aspf=s`,
+ explanation:
+ 'Start with p=none for monitoring, then tighten policy to quarantine or reject after validation.',
+ },
+ };
+ })
+ );
+
+ return domainsWithDns;
+ } catch (error) {
+ console.error('Error retrieving domains overview:', error);
+ debugLog('Domains overview error:', error);
+ throw new Error('Unable to retrieve domains overview');
+ }
+}
+
+/**
+ * Runs docker-mailserver DKIM configuration command.
+ * @return {Promise<{success: boolean, command: string}>}
+ */
+async function configureDkim() {
+ try {
+ await execSetup('config dkim');
+ return {
+ success: true,
+ command: 'setup config dkim',
+ };
+ } catch (error) {
+ console.error('Error configuring DKIM:', error);
+ debugLog('DKIM configuration error:', error);
+ throw new Error('Unable to configure DKIM');
+ }
+}
+
// Function to retrieve email accounts
async function getAccounts() {
try {
@@ -356,5 +572,7 @@ module.exports = {
getAliases,
addAlias,
deleteAlias,
+ getDomainsOverview,
+ configureDkim,
getServerStatus,
};
diff --git a/backend/index.js b/backend/index.js
index c0df5c5..984f598 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -312,6 +312,50 @@ app.delete('/api/aliases/:source/:destination', async (req, res) => {
}
});
+// Endpoint for retrieving domains overview
+/**
+ * @swagger
+ * /api/domains:
+ * get:
+ * summary: Get domains overview
+ * description: Retrieve all domains with DKIM/SPF/DMARC DNS guidance
+ * responses:
+ * 200:
+ * description: List of domains and DNS records
+ * 500:
+ * description: Unable to retrieve domains overview
+ */
+app.get('/api/domains', async (req, res) => {
+ try {
+ const domains = await dockerMailserver.getDomainsOverview();
+ res.json(domains);
+ } catch (error) {
+ res.status(500).json({ error: 'Unable to retrieve domains overview' });
+ }
+});
+
+// Endpoint for generating DKIM keys
+/**
+ * @swagger
+ * /api/domains/dkim:
+ * post:
+ * summary: Configure DKIM
+ * description: Run `setup config dkim` inside docker-mailserver container
+ * responses:
+ * 200:
+ * description: DKIM configuration command executed
+ * 500:
+ * description: Unable to configure DKIM
+ */
+app.post('/api/domains/dkim', async (req, res) => {
+ try {
+ await dockerMailserver.configureDkim();
+ res.json({ message: 'DKIM configuration command executed successfully' });
+ } catch (error) {
+ res.status(500).json({ error: 'Unable to configure DKIM' });
+ }
+});
+
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 832481e..947a40b 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -5,6 +5,7 @@ import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard';
import Accounts from './pages/Accounts';
import Aliases from './pages/Aliases';
+import Domains from './pages/Domains';
import Settings from './pages/Settings';
import Container from 'react-bootstrap/Container'; // Import Container
import Row from 'react-bootstrap/Row'; // Import Row
@@ -28,6 +29,7 @@ function App() {
} />
} />
} />
+ } />
} />
{' '}
diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js
index 0aea99b..320687b 100644
--- a/frontend/src/components/Sidebar.js
+++ b/frontend/src/components/Sidebar.js
@@ -31,6 +31,9 @@ const Sidebar = () => {
{t('sidebar.aliases')}
+
+ {t('sidebar.domains')}
+
{t('sidebar.settings')}
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index 85208cd..f1b724b 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -9,6 +9,7 @@
"dashboard": "Dashboard",
"emailAccounts": "Email Accounts",
"aliases": "Aliases",
+ "domains": "Domains",
"settings": "Settings"
},
"dashboard": {
@@ -99,6 +100,33 @@
"aboutDescription": "Docker Mailserver GUI is a graphical interface for managing your Docker Mailserver. The application allows easy management of email accounts, aliases, and monitoring server status.",
"githubLink": "View on GitHub"
},
+ "domains": {
+ "title": "Domain Overview",
+ "overview": "Domains and DNS Records",
+ "overviewInfo": "Use this page to copy DKIM records and configure SPF/DMARC per domain.",
+ "domain": "Domain",
+ "dkim": "DKIM",
+ "dmarc": "DMARC",
+ "spf": "SPF",
+ "configured": "Configured",
+ "notConfigured": "Missing",
+ "dkimRecord": "DKIM TXT Record",
+ "noDkimYet": "DKIM key not generated yet",
+ "noDkimHelp": "No DKIM key found for this domain. Run DKIM configuration to generate it.",
+ "generateDkim": "Generate DKIM",
+ "generateMissingDkim": "Run setup config dkim",
+ "dkimConfigured": "DKIM configuration command executed. Refresh if key generation takes a moment.",
+ "copyDkim": "Copy DKIM",
+ "copied": "Copied",
+ "refresh": "Refresh",
+ "noDomains": "No domains found yet.",
+ "dnsGuideCard": "DNS Setup Guide",
+ "spfHelp": "Create this TXT record at your root domain (@).",
+ "dmarcHelp": "Create this TXT record to monitor DMARC alignment and reporting.",
+ "dkimHelp": "Create this TXT record to publish the DKIM public key.",
+ "recordName": "Record name",
+ "recordType": "Type"
+ },
"common": {
"loading": "Loading...",
"error": "Error",
@@ -114,7 +142,10 @@
"updatePassword": "Error updating password",
"fetchAliases": "Error fetching aliases",
"addAlias": "Error adding alias",
- "deleteAlias": "Error deleting alias"
+ "deleteAlias": "Error deleting alias",
+ "fetchDomains": "Error fetching domain overview",
+ "configureDkim": "Error running DKIM configuration",
+ "copyDkim": "Error copying DKIM record"
}
},
"language": {
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index 8f17818..7f0b0ca 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -9,6 +9,7 @@
"dashboard": "Dashboard",
"emailAccounts": "Konta Email",
"aliases": "Aliasy",
+ "domains": "Domeny",
"settings": "Ustawienia"
},
"dashboard": {
@@ -97,6 +98,33 @@
"version": "Wersja",
"githubLink": "Zobacz na GitHub"
},
+ "domains": {
+ "title": "Przegląd Domen",
+ "overview": "Domeny i Rekordy DNS",
+ "overviewInfo": "Użyj tej strony, aby skopiować rekordy DKIM i skonfigurować SPF/DMARC dla każdej domeny.",
+ "domain": "Domena",
+ "dkim": "DKIM",
+ "dmarc": "DMARC",
+ "spf": "SPF",
+ "configured": "Skonfigurowany",
+ "notConfigured": "Brak",
+ "dkimRecord": "Rekord TXT DKIM",
+ "noDkimYet": "Klucz DKIM nie został jeszcze wygenerowany",
+ "noDkimHelp": "Nie znaleziono klucza DKIM dla tej domeny. Uruchom konfigurację DKIM, aby go wygenerować.",
+ "generateDkim": "Wygeneruj DKIM",
+ "generateMissingDkim": "Uruchom setup config dkim",
+ "dkimConfigured": "Polecenie konfiguracji DKIM zostało uruchomione. Odśwież dane, jeśli generowanie kluczy potrwa chwilę.",
+ "copyDkim": "Kopiuj DKIM",
+ "copied": "Skopiowano",
+ "refresh": "Odśwież",
+ "noDomains": "Nie znaleziono jeszcze żadnych domen.",
+ "dnsGuideCard": "Przewodnik Konfiguracji DNS",
+ "spfHelp": "Utwórz ten rekord TXT w głównej domenie (@).",
+ "dmarcHelp": "Utwórz ten rekord TXT do monitorowania zgodności DMARC i raportowania.",
+ "dkimHelp": "Utwórz ten rekord TXT, aby opublikować klucz publiczny DKIM.",
+ "recordName": "Nazwa rekordu",
+ "recordType": "Typ"
+ },
"common": {
"loading": "Ładowanie...",
"error": "Błąd",
@@ -112,7 +140,10 @@
"updatePassword": "Błąd podczas aktualizacji hasła",
"fetchAliases": "Błąd podczas pobierania aliasów",
"addAlias": "Błąd podczas dodawania aliasu",
- "deleteAlias": "Błąd podczas usuwania aliasu"
+ "deleteAlias": "Błąd podczas usuwania aliasu",
+ "fetchDomains": "Błąd podczas pobierania przeglądu domen",
+ "configureDkim": "Błąd podczas uruchamiania konfiguracji DKIM",
+ "copyDkim": "Błąd podczas kopiowania rekordu DKIM"
}
},
"language": {
diff --git a/frontend/src/pages/Domains.js b/frontend/src/pages/Domains.js
new file mode 100644
index 0000000..cf8c134
--- /dev/null
+++ b/frontend/src/pages/Domains.js
@@ -0,0 +1,258 @@
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { configureDkim, getDomains } from '../services/api';
+import {
+ AlertMessage,
+ Button,
+ Card,
+ DataTable,
+ LoadingSpinner,
+} from '../components';
+import Row from 'react-bootstrap/Row';
+import Col from 'react-bootstrap/Col';
+import Badge from 'react-bootstrap/Badge';
+
+const Domains = () => {
+ const { t } = useTranslation();
+ const [domains, setDomains] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [successMessage, setSuccessMessage] = useState('');
+ const [dkimGenerating, setDkimGenerating] = useState(false);
+ const [copiedDomain, setCopiedDomain] = useState(null);
+
+ useEffect(() => {
+ fetchDomains();
+ }, []);
+
+ const fetchDomains = async () => {
+ try {
+ setLoading(true);
+ const data = await getDomains();
+ setDomains(data);
+ setError(null);
+ } catch (err) {
+ console.error(t('api.errors.fetchDomains'), err);
+ setError('api.errors.fetchDomains');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfigureDkim = async () => {
+ try {
+ setError(null);
+ setSuccessMessage('');
+ setDkimGenerating(true);
+ await configureDkim();
+ setSuccessMessage('domains.dkimConfigured');
+ await fetchDomains();
+ } catch (err) {
+ console.error(t('api.errors.configureDkim'), err);
+ setError('api.errors.configureDkim');
+ } finally {
+ setDkimGenerating(false);
+ }
+ };
+
+ const handleCopyDkim = async (domain, recordValue) => {
+ if (!recordValue) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(recordValue);
+ setCopiedDomain(domain);
+ setTimeout(() => {
+ setCopiedDomain(null);
+ }, 2000);
+ } catch (err) {
+ console.error(t('api.errors.copyDkim'), err);
+ setError('api.errors.copyDkim');
+ }
+ };
+
+ const columns = [
+ {
+ key: 'domain',
+ label: 'domains.domain',
+ render: (item) => {item.domain} ,
+ },
+ {
+ key: 'dkim',
+ label: 'domains.dkim',
+ render: (item) =>
+ item.dkim.configured ? (
+ {t('domains.configured')}
+ ) : (
+
+ {t('domains.notConfigured')}
+
+ ),
+ },
+ {
+ key: 'dkimRecord',
+ label: 'domains.dkimRecord',
+ render: (item) =>
+ item.dkim.configured ? (
+
+
+ {item.dkim.recordName} {item.dkim.recordType}
+
+
+ {item.dkim.recordValue}
+
+
+ ) : (
+ {t('domains.noDkimYet')}
+ ),
+ },
+ {
+ key: 'actions',
+ label: 'accounts.actions',
+ render: (item) => (
+
+
+ handleCopyDkim(item.domain, item.dkim.recordValue)}
+ disabled={!item.dkim.configured}
+ />
+
+ ),
+ },
+ ];
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
{t('domains.title')}
+
+
+
+
+
+
+
+
+
+ }
+ >
+ {t('domains.overviewInfo')}
+ item.domain}
+ emptyMessage="domains.noDomains"
+ loading={loading}
+ />
+
+
+
+
+
+ {domains.map((item) => (
+
+
+ {item.domain}
+
+ }
+ >
+
+ {t('domains.spf')} {t('domains.spfHelp')}
+
+
+ {t('domains.recordName')}: {' '}
+ {item.spf.recordName}
+
+
+ {t('domains.recordType')}: {' '}
+ {item.spf.recordType}
+
+
+ {item.spf.recordValue}
+
+
+
+ {t('domains.dmarc')} {t('domains.dmarcHelp')}
+
+
+ {t('domains.recordName')}: {' '}
+ {item.dmarc.recordName}
+
+
+ {t('domains.recordType')}: {' '}
+ {item.dmarc.recordType}
+
+
+ {item.dmarc.recordValue}
+
+
+
+ {t('domains.dkim')} {t('domains.dkimHelp')}
+
+ {item.dkim.configured ? (
+ <>
+
+ {t('domains.recordName')}: {' '}
+ {item.dkim.recordName}
+
+
+ {t('domains.recordType')}: {' '}
+ {item.dkim.recordType}
+
+
+ {item.dkim.recordValue}
+
+ >
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+ );
+};
+
+export default Domains;
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index eb07bbb..6a4ab63 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -97,4 +97,24 @@ export const deleteAlias = async (source, destination) => {
}
};
+export const getDomains = async () => {
+ try {
+ const response = await api.get('/domains');
+ return response.data;
+ } catch (error) {
+ console.error('Error fetching domains overview:', error);
+ throw error;
+ }
+};
+
+export const configureDkim = async () => {
+ try {
+ const response = await api.post('/domains/dkim');
+ return response.data;
+ } catch (error) {
+ console.error('Error configuring DKIM:', error);
+ throw error;
+ }
+};
+
export default api;
From 7a5a1c8a98ffa33b292051483653b99cca0dc97e Mon Sep 17 00:00:00 2001
From: Daniel Winter
Date: Tue, 10 Mar 2026 16:02:41 +0100
Subject: [PATCH 2/5] Add DNS guide modal for detailed domain record
instructions
---
frontend/src/locales/en/translation.json | 1 +
frontend/src/locales/pl/translation.json | 1 +
frontend/src/pages/Domains.js | 90 +++++++++++++++++-------
3 files changed, 65 insertions(+), 27 deletions(-)
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index f1b724b..77d2e83 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -117,6 +117,7 @@
"generateMissingDkim": "Run setup config dkim",
"dkimConfigured": "DKIM configuration command executed. Refresh if key generation takes a moment.",
"copyDkim": "Copy DKIM",
+ "openDnsGuide": "DNS Guide",
"copied": "Copied",
"refresh": "Refresh",
"noDomains": "No domains found yet.",
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index 7f0b0ca..74deac8 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -115,6 +115,7 @@
"generateMissingDkim": "Uruchom setup config dkim",
"dkimConfigured": "Polecenie konfiguracji DKIM zostało uruchomione. Odśwież dane, jeśli generowanie kluczy potrwa chwilę.",
"copyDkim": "Kopiuj DKIM",
+ "openDnsGuide": "Przewodnik DNS",
"copied": "Skopiowano",
"refresh": "Odśwież",
"noDomains": "Nie znaleziono jeszcze żadnych domen.",
diff --git a/frontend/src/pages/Domains.js b/frontend/src/pages/Domains.js
index cf8c134..a22cb18 100644
--- a/frontend/src/pages/Domains.js
+++ b/frontend/src/pages/Domains.js
@@ -11,6 +11,7 @@ import {
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Badge from 'react-bootstrap/Badge';
+import Modal from 'react-bootstrap/Modal';
const Domains = () => {
const { t } = useTranslation();
@@ -20,6 +21,8 @@ const Domains = () => {
const [successMessage, setSuccessMessage] = useState('');
const [dkimGenerating, setDkimGenerating] = useState(false);
const [copiedDomain, setCopiedDomain] = useState(null);
+ const [showGuideModal, setShowGuideModal] = useState(false);
+ const [selectedDomain, setSelectedDomain] = useState(null);
useEffect(() => {
fetchDomains();
@@ -61,7 +64,27 @@ const Domains = () => {
}
try {
- await navigator.clipboard.writeText(recordValue);
+ // Clipboard API requires a secure context (HTTPS/localhost).
+ // Fallback for HTTP/self-hosted deployments.
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(recordValue);
+ } else {
+ const textArea = document.createElement('textarea');
+ textArea.value = recordValue;
+ textArea.setAttribute('readonly', '');
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-9999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+
+ const copied = document.execCommand('copy');
+ document.body.removeChild(textArea);
+
+ if (!copied) {
+ throw new Error('Fallback copy command failed');
+ }
+ }
+
setCopiedDomain(domain);
setTimeout(() => {
setCopiedDomain(null);
@@ -112,6 +135,16 @@ const Domains = () => {
label: 'accounts.actions',
render: (item) => (
+
{
+ setSelectedDomain(item);
+ setShowGuideModal(true);
+ }}
+ />
{
-
- {domains.map((item) => (
-
-
- {item.domain}
-
- }
- >
+ setShowGuideModal(false)}
+ size="lg"
+ centered
+ >
+
+
+ {t('domains.dnsGuideCard')}
+ {selectedDomain ? ` - ${selectedDomain.domain}` : ''}
+
+
+
+ {selectedDomain && (
+ <>
{t('domains.spf')} {t('domains.spfHelp')}
{t('domains.recordName')}: {' '}
- {item.spf.recordName}
+ {selectedDomain.spf.recordName}
{t('domains.recordType')}: {' '}
- {item.spf.recordType}
+ {selectedDomain.spf.recordType}
- {item.spf.recordValue}
+ {selectedDomain.spf.recordValue}
@@ -217,40 +253,40 @@ const Domains = () => {
{t('domains.recordName')}: {' '}
- {item.dmarc.recordName}
+ {selectedDomain.dmarc.recordName}
{t('domains.recordType')}: {' '}
- {item.dmarc.recordType}
+ {selectedDomain.dmarc.recordType}
- {item.dmarc.recordValue}
+ {selectedDomain.dmarc.recordValue}
{t('domains.dkim')} {t('domains.dkimHelp')}
- {item.dkim.configured ? (
+ {selectedDomain.dkim.configured ? (
<>
{t('domains.recordName')}: {' '}
- {item.dkim.recordName}
+ {selectedDomain.dkim.recordName}
{t('domains.recordType')}: {' '}
- {item.dkim.recordType}
+ {selectedDomain.dkim.recordType}
- {item.dkim.recordValue}
+ {selectedDomain.dkim.recordValue}
>
) : (
)}
-
-
- ))}
-
+ >
+ )}
+
+
);
};
From 3b73a2999dc6029f3d05185644b55850c7fa3dc2 Mon Sep 17 00:00:00 2001
From: Daniel Winter
Date: Wed, 11 Mar 2026 11:52:16 +0100
Subject: [PATCH 3/5] Add DNS validation check for domain configuration
---
frontend/src/locales/en/translation.json | 1 +
frontend/src/locales/pl/translation.json | 1 +
frontend/src/pages/Domains.js | 16 +++++++++++++++-
3 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index 77d2e83..fd54b4f 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -122,6 +122,7 @@
"refresh": "Refresh",
"noDomains": "No domains found yet.",
"dnsGuideCard": "DNS Setup Guide",
+ "testingInfo": "You can easily test DKIM, SPF, and DMARC using tools like",
"spfHelp": "Create this TXT record at your root domain (@).",
"dmarcHelp": "Create this TXT record to monitor DMARC alignment and reporting.",
"dkimHelp": "Create this TXT record to publish the DKIM public key.",
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index 74deac8..8b4f940 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -120,6 +120,7 @@
"refresh": "Odśwież",
"noDomains": "Nie znaleziono jeszcze żadnych domen.",
"dnsGuideCard": "Przewodnik Konfiguracji DNS",
+ "testingInfo": "DKIM, SPF i DMARC możesz łatwo przetestować narzędziami takimi jak",
"spfHelp": "Utwórz ten rekord TXT w głównej domenie (@).",
"dmarcHelp": "Utwórz ten rekord TXT do monitorowania zgodności DMARC i raportowania.",
"dkimHelp": "Utwórz ten rekord TXT, aby opublikować klucz publiczny DKIM.",
diff --git a/frontend/src/pages/Domains.js b/frontend/src/pages/Domains.js
index a22cb18..89203a8 100644
--- a/frontend/src/pages/Domains.js
+++ b/frontend/src/pages/Domains.js
@@ -276,13 +276,27 @@ const Domains = () => {
{t('domains.recordType')}: {' '}
{selectedDomain.dkim.recordType}
-
+
{selectedDomain.dkim.recordValue}
>
) : (
)}
+
+
+ {t('domains.testingInfo')}{' '}
+
+ mailtested.com
+
+
>
)}
From d77b6815af366a6d3663fb15d4167730433deaec Mon Sep 17 00:00:00 2001
From: Daniel Winter
Date: Wed, 11 Mar 2026 12:00:20 +0100
Subject: [PATCH 4/5] Add email account quota management features
Integrate quota update functionality, including backend API, frontend support, and translation updates.
---
backend/dockerMailserver.js | 17 +++
backend/index.js | 51 +++++++
frontend/src/locales/en/translation.json | 9 ++
frontend/src/locales/pl/translation.json | 9 ++
frontend/src/pages/Accounts.js | 168 ++++++++++++++++++++++-
frontend/src/services/api.js | 10 ++
6 files changed, 260 insertions(+), 4 deletions(-)
diff --git a/backend/dockerMailserver.js b/backend/dockerMailserver.js
index 545bf66..6f184a3 100644
--- a/backend/dockerMailserver.js
+++ b/backend/dockerMailserver.js
@@ -399,6 +399,22 @@ async function updateAccountPassword(email, password) {
}
}
+// Function to update an email account quota
+async function updateAccountQuota(email, quota) {
+ try {
+ debugLog(`Updating quota for account: ${email} -> ${quota}`);
+ await execSetup(
+ `email update ${escapeShellArg(email)} --quota ${escapeShellArg(quota)}`
+ );
+ debugLog(`Quota updated for account: ${email}`);
+ return { success: true, email, quota };
+ } catch (error) {
+ console.error('Error updating account quota:', error);
+ debugLog('Account quota update error:', error);
+ throw new Error('Unable to update email account quota');
+ }
+}
+
// Function to delete an email account
async function deleteAccount(email) {
try {
@@ -568,6 +584,7 @@ module.exports = {
getAccounts,
addAccount,
updateAccountPassword,
+ updateAccountQuota,
deleteAccount,
getAliases,
addAlias,
diff --git a/backend/index.js b/backend/index.js
index 984f598..77140f7 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -200,6 +200,57 @@ app.put('/api/accounts/:email/password', async (req, res) => {
}
});
+// Endpoint for updating an email account quota
+/**
+ * @swagger
+ * /api/accounts/{email}/quota:
+ * put:
+ * summary: Update an email account quota
+ * description: Update the storage quota for an existing email account
+ * parameters:
+ * - in: path
+ * name: email
+ * required: true
+ * schema:
+ * type: string
+ * description: Email address of the account to update
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * quota:
+ * type: string
+ * description: New quota value, for example 1024M or 2G
+ * responses:
+ * 200:
+ * description: Quota updated successfully
+ * 400:
+ * description: Email and quota are required
+ * 500:
+ * description: Unable to update quota
+ */
+app.put('/api/accounts/:email/quota', async (req, res) => {
+ try {
+ const { email } = req.params;
+ const { quota } = req.body;
+
+ if (!email) {
+ return res.status(400).json({ error: 'Email is required' });
+ }
+ if (!quota) {
+ return res.status(400).json({ error: 'Quota is required' });
+ }
+
+ await dockerMailserver.updateAccountQuota(email, quota);
+ res.json({ message: 'Quota updated successfully', email, quota });
+ } catch (error) {
+ res.status(500).json({ error: 'Unable to update quota' });
+ }
+});
+
// Endpoint for retrieving aliases
/**
* @swagger
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index fd54b4f..bae0cd1 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -42,6 +42,10 @@
"addAccount": "Add Account",
"changePassword": "Change Password",
"updatePassword": "Update Password",
+ "editQuota": "Edit Quota",
+ "quotaValue": "Quota Value",
+ "quotaUnit": "Quota Unit",
+ "updateQuota": "Update Quota",
"noAccounts": "No email accounts. Add your first account.",
"emailRequired": "Email is required",
"invalidEmail": "Invalid email format",
@@ -51,6 +55,10 @@
"accountCreated": "Account created successfully!",
"accountDeleted": "Account deleted successfully!",
"passwordUpdated": "Password updated successfully!",
+ "quotaUpdated": "Quota updated successfully!",
+ "quotaRequired": "Quota value is required",
+ "invalidQuota": "Quota value must be a positive number",
+ "invalidQuotaUnit": "Please select a valid quota unit",
"cannotCreateAccount": "Unable to create account. Please try again.",
"cannotDeleteAccount": "Unable to delete account. Please try again.",
"cannotUpdatePassword": "Unable to update password. Please try again.",
@@ -142,6 +150,7 @@
"addAccount": "Error adding account",
"deleteAccount": "Error deleting account",
"updatePassword": "Error updating password",
+ "updateQuota": "Error updating quota",
"fetchAliases": "Error fetching aliases",
"addAlias": "Error adding alias",
"deleteAlias": "Error deleting alias",
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index 8b4f940..a222f73 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -42,6 +42,10 @@
"addAccount": "Dodaj Konto",
"changePassword": "Zmień Hasło",
"updatePassword": "Aktualizuj Hasło",
+ "editQuota": "Edytuj Limit",
+ "quotaValue": "Wartość Limitu",
+ "quotaUnit": "Jednostka Limitu",
+ "updateQuota": "Aktualizuj Limit",
"noAccounts": "Brak kont email. Dodaj pierwsze konto.",
"emailRequired": "Email jest wymagany",
"invalidEmail": "Nieprawidłowy format email",
@@ -51,6 +55,10 @@
"accountCreated": "Konto zostało pomyślnie utworzone!",
"accountDeleted": "Konto zostało pomyślnie usunięte!",
"passwordUpdated": "Hasło zostało pomyślnie zaktualizowane!",
+ "quotaUpdated": "Limit został pomyślnie zaktualizowany!",
+ "quotaRequired": "Wartość limitu jest wymagana",
+ "invalidQuota": "Wartość limitu musi być dodatnią liczbą",
+ "invalidQuotaUnit": "Wybierz poprawną jednostkę limitu",
"cannotCreateAccount": "Nie można utworzyć konta. Spróbuj ponownie.",
"cannotDeleteAccount": "Nie można usunąć konta. Spróbuj ponownie.",
"cannotUpdatePassword": "Nie można zaktualizować hasła. Spróbuj ponownie.",
@@ -140,6 +148,7 @@
"addAccount": "Błąd podczas dodawania konta",
"deleteAccount": "Błąd podczas usuwania konta",
"updatePassword": "Błąd podczas aktualizacji hasła",
+ "updateQuota": "Błąd podczas aktualizacji limitu",
"fetchAliases": "Błąd podczas pobierania aliasów",
"addAlias": "Błąd podczas dodawania aliasu",
"deleteAlias": "Błąd podczas usuwania aliasu",
diff --git a/frontend/src/pages/Accounts.js b/frontend/src/pages/Accounts.js
index 3dea2d0..ec674a5 100644
--- a/frontend/src/pages/Accounts.js
+++ b/frontend/src/pages/Accounts.js
@@ -5,6 +5,7 @@ import {
addAccount,
deleteAccount,
updateAccountPassword,
+ updateAccountQuota,
} from '../services/api';
import {
AlertMessage,
@@ -19,6 +20,24 @@ import Row from 'react-bootstrap/Row'; // Import Row
import Col from 'react-bootstrap/Col'; // Import Col
import Modal from 'react-bootstrap/Modal'; // Import Modal
import ProgressBar from 'react-bootstrap/ProgressBar'; // Import ProgressBar
+import Form from 'react-bootstrap/Form';
+
+const parseQuotaToForm = (quotaValue) => {
+ if (!quotaValue || quotaValue === 'unlimited') {
+ return { value: '', unit: 'GB' };
+ }
+
+ const match = quotaValue.trim().match(/^(\d+)\s*(m|mb|g|gb)$/i);
+ if (!match) {
+ return { value: '', unit: 'GB' };
+ }
+
+ const rawUnit = match[2].toUpperCase();
+ return {
+ value: match[1],
+ unit: rawUnit.startsWith('M') ? 'MB' : 'GB',
+ };
+};
const Accounts = () => {
const passwordFormRef = useRef(null);
@@ -42,6 +61,13 @@ const Accounts = () => {
confirmPassword: '',
});
const [passwordFormErrors, setPasswordFormErrors] = useState({});
+ const [showQuotaModal, setShowQuotaModal] = useState(false);
+ const [quotaFormData, setQuotaFormData] = useState({
+ value: '',
+ unit: 'GB',
+ });
+ const [quotaFormErrors, setQuotaFormErrors] = useState({});
+ const [quotaUpdating, setQuotaUpdating] = useState(false);
useEffect(() => {
fetchAccounts();
@@ -154,6 +180,19 @@ const Accounts = () => {
setSelectedAccount(null);
};
+ const handleChangeQuota = (account) => {
+ setSelectedAccount(account);
+ setQuotaFormData(parseQuotaToForm(account?.storage?.total));
+ setQuotaFormErrors({});
+ setShowQuotaModal(true);
+ };
+
+ const handleCloseQuotaModal = () => {
+ setShowQuotaModal(false);
+ setSelectedAccount(null);
+ setQuotaFormErrors({});
+ };
+
// Handle input changes for password change form
const handlePasswordInputChange = (e) => {
const { name, value } = e.target;
@@ -212,6 +251,65 @@ const Accounts = () => {
}
};
+ const handleQuotaInputChange = (e) => {
+ const { name, value } = e.target;
+ setQuotaFormData((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+
+ if (quotaFormErrors[name]) {
+ setQuotaFormErrors((prev) => ({
+ ...prev,
+ [name]: null,
+ }));
+ }
+ };
+
+ const validateQuotaForm = () => {
+ const errors = {};
+ const quotaValue = Number(quotaFormData.value);
+
+ if (!quotaFormData.value) {
+ errors.value = 'accounts.quotaRequired';
+ } else if (!Number.isFinite(quotaValue) || quotaValue <= 0) {
+ errors.value = 'accounts.invalidQuota';
+ }
+
+ if (!quotaFormData.unit || !['MB', 'GB'].includes(quotaFormData.unit)) {
+ errors.unit = 'accounts.invalidQuotaUnit';
+ }
+
+ setQuotaFormErrors(errors);
+ return Object.keys(errors).length === 0;
+ };
+
+ const handleSubmitQuotaChange = async (e) => {
+ e.preventDefault();
+ setError(null);
+ setSuccessMessage('');
+
+ if (!selectedAccount || !validateQuotaForm()) {
+ return;
+ }
+
+ const unitSuffix = quotaFormData.unit === 'MB' ? 'M' : 'G';
+ const quotaValue = `${parseInt(quotaFormData.value, 10)}${unitSuffix}`;
+
+ try {
+ setQuotaUpdating(true);
+ await updateAccountQuota(selectedAccount.email, quotaValue);
+ setSuccessMessage('accounts.quotaUpdated');
+ handleCloseQuotaModal();
+ fetchAccounts();
+ } catch (err) {
+ console.error(t('api.errors.updateQuota'), err);
+ setError('api.errors.updateQuota');
+ } finally {
+ setQuotaUpdating(false);
+ }
+ };
+
// Column definitions for accounts table
const columns = [
{ key: 'email', label: 'accounts.email' },
@@ -220,17 +318,21 @@ const Accounts = () => {
label: 'accounts.storage',
render: (account) =>
account.storage ? (
-
+
handleChangeQuota(account)}
+ >
{account.storage.used} / {account.storage.total}
- {/* Use ProgressBar component */}
-
+
) : (
'N/A'
),
@@ -383,6 +485,64 @@ const Accounts = () => {
/>
+
+
+
+
+ {t('accounts.editQuota')} - {selectedAccount?.email}
+
+
+
+ {selectedAccount && (
+
+ {t('accounts.quotaUnit')}
+
+ MB
+ GB
+
+ {quotaFormErrors.unit && (
+
+ {t(quotaFormErrors.unit)}
+
+ )}
+
+
+ )}
+
+
+
+
+
+
);
};
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 6a4ab63..fc7853d 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -66,6 +66,16 @@ export const updateAccountPassword = async (email, password) => {
}
};
+export const updateAccountQuota = async (email, quota) => {
+ try {
+ const response = await api.put(`/accounts/${email}/quota`, { quota });
+ return response.data;
+ } catch (error) {
+ console.error('Error updating account quota:', error);
+ throw error;
+ }
+};
+
// API dla aliasów
export const getAliases = async () => {
try {
From 98d721543b926731a6bbcd4aa3cd9b37a5d80ff1 Mon Sep 17 00:00:00 2001
From: Daniel Winter
Date: Wed, 11 Mar 2026 12:12:38 +0100
Subject: [PATCH 5/5] Refactor quota update command and validate quota format
in API
---
backend/dockerMailserver.js | 2 +-
backend/index.js | 5 +++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/backend/dockerMailserver.js b/backend/dockerMailserver.js
index 6f184a3..e7b30c5 100644
--- a/backend/dockerMailserver.js
+++ b/backend/dockerMailserver.js
@@ -404,7 +404,7 @@ async function updateAccountQuota(email, quota) {
try {
debugLog(`Updating quota for account: ${email} -> ${quota}`);
await execSetup(
- `email update ${escapeShellArg(email)} --quota ${escapeShellArg(quota)}`
+ `quota set ${escapeShellArg(email)} ${escapeShellArg(quota)}`
);
debugLog(`Quota updated for account: ${email}`);
return { success: true, email, quota };
diff --git a/backend/index.js b/backend/index.js
index 77140f7..311e387 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -243,6 +243,11 @@ app.put('/api/accounts/:email/quota', async (req, res) => {
if (!quota) {
return res.status(400).json({ error: 'Quota is required' });
}
+ if (!/^\d+\s*[mMgG]$/.test(String(quota).trim())) {
+ return res
+ .status(400)
+ .json({ error: 'Quota must use MB or GB units, for example 500M or 2G' });
+ }
await dockerMailserver.updateAccountQuota(email, quota);
res.json({ message: 'Quota updated successfully', email, quota });