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..e7b30c5 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 { @@ -183,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( + `quota set ${escapeShellArg(email)} ${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 { @@ -352,9 +584,12 @@ module.exports = { getAccounts, addAccount, updateAccountPassword, + updateAccountQuota, deleteAccount, getAliases, addAlias, deleteAlias, + getDomainsOverview, + configureDkim, getServerStatus, }; diff --git a/backend/index.js b/backend/index.js index c0df5c5..311e387 100644 --- a/backend/index.js +++ b/backend/index.js @@ -200,6 +200,62 @@ 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' }); + } + 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 }); + } catch (error) { + res.status(500).json({ error: 'Unable to update quota' }); + } +}); + // Endpoint for retrieving aliases /** * @swagger @@ -312,6 +368,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..bae0cd1 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": { @@ -41,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", @@ -50,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.", @@ -99,6 +108,35 @@ "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", + "openDnsGuide": "DNS Guide", + "copied": "Copied", + "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.", + "recordName": "Record name", + "recordType": "Type" + }, "common": { "loading": "Loading...", "error": "Error", @@ -112,9 +150,13 @@ "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" + "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..a222f73 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": { @@ -41,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", @@ -50,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.", @@ -97,6 +106,35 @@ "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", + "openDnsGuide": "Przewodnik DNS", + "copied": "Skopiowano", + "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.", + "recordName": "Nazwa rekordu", + "recordType": "Typ" + }, "common": { "loading": "Ładowanie...", "error": "Błąd", @@ -110,9 +148,13 @@ "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" + "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/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)} + + )} + + + )} +
+ +