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) => ( +
+
+ ), + }, + ]; + + 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) => (
+
); }; 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 ? ( -
+
+ ) : ( 'N/A' ), @@ -383,6 +485,64 @@ const Accounts = () => { /> + + + + + {t('accounts.editQuota')} - {selectedAccount?.email} + + + + {selectedAccount && ( +
+ + + + {t('accounts.quotaUnit')} + + + + + {quotaFormErrors.unit && ( + + {t(quotaFormErrors.unit)} + + )} + + + )} +
+ +