Merge branch 'v2.4.4' of github.com:usmannasir/cyberpanel into v2.4.4

This commit is contained in:
usmannasir 2025-12-25 20:22:45 +04:00
commit db35838f72
15 changed files with 2771 additions and 67 deletions

View File

@ -2,6 +2,7 @@
import os
import os.path
import sys
import re
from io import StringIO
import django
@ -784,9 +785,35 @@ class BackupManager:
except:
finalDic['user'] = "root"
# SECURITY: Validate all inputs to prevent command injection
if ACLManager.commandInjectionCheck(finalDic['ipAddress']) == 1:
final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in IP address'}
return HttpResponse(json.dumps(final_dic))
if ACLManager.commandInjectionCheck(finalDic['password']) == 1:
final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in password'}
return HttpResponse(json.dumps(final_dic))
if ACLManager.commandInjectionCheck(finalDic['port']) == 1:
final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in port'}
return HttpResponse(json.dumps(final_dic))
if ACLManager.commandInjectionCheck(finalDic['user']) == 1:
final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Invalid characters in username'}
return HttpResponse(json.dumps(final_dic))
# SECURITY: Validate port is numeric
try:
port_int = int(finalDic['port'])
if port_int < 1 or port_int > 65535:
raise ValueError("Port out of range")
except ValueError:
final_dic = {'status': 0, 'destStatus': 0, 'error_message': 'Port must be a valid number (1-65535)'}
return HttpResponse(json.dumps(final_dic))
execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/backupUtilities.py"
execPath = execPath + " submitDestinationCreation --ipAddress " + finalDic['ipAddress'] + " --password " \
+ finalDic['password'] + " --port " + finalDic['port'] + ' --user %s' % (finalDic['user'])
execPath = execPath + " submitDestinationCreation --ipAddress " + shlex.quote(finalDic['ipAddress']) + " --password " \
+ shlex.quote(finalDic['password']) + " --port " + shlex.quote(finalDic['port']) + ' --user %s' % (shlex.quote(finalDic['user']))
if os.path.exists(ProcessUtilities.debugPath):
logging.CyberCPLogFileWriter.writeToFile(execPath)
@ -880,8 +907,13 @@ class BackupManager:
ipAddress = data['IPAddress']
# SECURITY: Validate IP address to prevent command injection
if ACLManager.commandInjectionCheck(ipAddress) == 1:
final_dic = {'connStatus': 0, 'error_message': 'Invalid characters in IP address'}
return HttpResponse(json.dumps(final_dic))
execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/backupUtilities.py"
execPath = execPath + " getConnectionStatus --ipAddress " + ipAddress
execPath = execPath + " getConnectionStatus --ipAddress " + shlex.quote(ipAddress)
output = ProcessUtilities.executioner(execPath)
@ -1342,16 +1374,32 @@ class BackupManager:
if ACLManager.currentContextPermission(currentACL, 'remoteBackups') == 0:
return ACLManager.loadErrorJson('remoteTransferStatus', 0)
backupDir = data['backupDir']
backupDir = str(data['backupDir'])
backupDirComplete = "/home/backup/transfer-" + str(backupDir)
# adminEmail = admin.email
# SECURITY: Validate backupDir to prevent command injection and path traversal
if ACLManager.commandInjectionCheck(backupDir) == 1:
data = {'remoteRestoreStatus': 0, 'error_message': 'Invalid characters in backup directory name'}
return HttpResponse(json.dumps(data))
##
# SECURITY: Ensure backupDir is alphanumeric only (backup dirs are typically numeric IDs)
if not re.match(r'^[a-zA-Z0-9_-]+$', backupDir):
data = {'remoteRestoreStatus': 0, 'error_message': 'Backup directory name must be alphanumeric'}
return HttpResponse(json.dumps(data))
# SECURITY: Prevent path traversal
if '..' in backupDir or '/' in backupDir:
data = {'remoteRestoreStatus': 0, 'error_message': 'Invalid backup directory path'}
return HttpResponse(json.dumps(data))
backupDirComplete = "/home/backup/transfer-" + backupDir
# SECURITY: Verify the backup directory exists
if not os.path.exists(backupDirComplete):
data = {'remoteRestoreStatus': 0, 'error_message': 'Backup directory does not exist'}
return HttpResponse(json.dumps(data))
execPath = "/usr/local/CyberCP/bin/python " + virtualHostUtilities.cyberPanel + "/plogical/remoteTransferUtilities.py"
execPath = execPath + " remoteBackupRestore --backupDirComplete " + backupDirComplete + " --backupDir " + str(
backupDir)
execPath = execPath + " remoteBackupRestore --backupDirComplete " + shlex.quote(backupDirComplete) + " --backupDir " + shlex.quote(backupDir)
ProcessUtilities.popenExecutioner(execPath)
@ -1373,16 +1421,35 @@ class BackupManager:
if ACLManager.currentContextPermission(currentACL, 'remoteBackups') == 0:
return ACLManager.loadErrorJson('remoteTransferStatus', 0)
backupDir = data['backupDir']
backupDir = str(data['backupDir'])
# SECURITY: Validate backupDir to prevent command injection and path traversal
if ACLManager.commandInjectionCheck(backupDir) == 1:
data = {'remoteTransferStatus': 0, 'error_message': 'Invalid characters in backup directory name', "status": "None", "complete": 0}
return HttpResponse(json.dumps(data))
# SECURITY: Ensure backupDir is alphanumeric only
if not re.match(r'^[a-zA-Z0-9_-]+$', backupDir):
data = {'remoteTransferStatus': 0, 'error_message': 'Backup directory name must be alphanumeric', "status": "None", "complete": 0}
return HttpResponse(json.dumps(data))
# SECURITY: Prevent path traversal
if '..' in backupDir or '/' in backupDir:
data = {'remoteTransferStatus': 0, 'error_message': 'Invalid backup directory path', "status": "None", "complete": 0}
return HttpResponse(json.dumps(data))
# admin = Administrator.objects.get(userName=username)
backupLogPath = "/home/backup/transfer-" + backupDir + "/" + "backup_log"
removalPath = "/home/backup/transfer-" + backupDir
removalPath = "/home/backup/transfer-" + str(backupDir)
# SECURITY: Verify the backup directory exists before operating on it
if not os.path.exists(removalPath):
data = {'remoteTransferStatus': 0, 'error_message': 'Backup directory does not exist', "status": "None", "complete": 0}
return HttpResponse(json.dumps(data))
time.sleep(3)
command = "sudo cat " + backupLogPath
command = "sudo cat " + shlex.quote(backupLogPath)
status = ProcessUtilities.outputExecutioner(command)
@ -1393,14 +1460,14 @@ class BackupManager:
if status.find("completed[success]") > -1:
command = "rm -rf " + removalPath
command = "rm -rf " + shlex.quote(removalPath)
ProcessUtilities.executioner(command)
data_ret = {'remoteTransferStatus': 1, 'error_message': "None", "status": status, "complete": 1}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
elif status.find("[5010]") > -1:
command = "sudo rm -rf " + removalPath
command = "sudo rm -rf " + shlex.quote(removalPath)
ProcessUtilities.executioner(command)
data = {'remoteTransferStatus': 0, 'error_message': status,
"status": "None", "complete": 0}

View File

@ -1573,6 +1573,21 @@
<span>Email Forwarding</span>
</a>
{% endif %}
{% if admin or emailForwarding %}
<a href="{% url 'catchAllEmail' %}" class="menu-item">
<span>Catch-All Email</span>
</a>
{% endif %}
{% if admin or emailForwarding %}
<a href="{% url 'patternForwarding' %}" class="menu-item">
<span>Pattern Forwarding</span>
</a>
{% endif %}
{% if admin %}
<a href="{% url 'plusAddressingSettings' %}" class="menu-item">
<span>Plus-Addressing</span>
</a>
{% endif %}
{% if admin or changeEmailPassword %}
<a href="{% url 'changeEmailAccountPassword' %}" class="menu-item">
<span>Change Password</span>

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import os
from django.shortcuts import render,redirect
from loginSystem.models import Administrator
from loginSystem.views import loadLoginPage
@ -326,6 +327,23 @@ def downloadFile(request):
if fileToDownload.find('..') > -1 or fileToDownload.find(homePath) == -1:
return HttpResponse("Unauthorized access.")
# SECURITY: Check for symlink attacks - resolve the real path and verify it stays within homePath
try:
realPath = os.path.realpath(fileToDownload)
# Verify the resolved path is still within the user's home directory
if not realPath.startswith(homePath + '/') and realPath != homePath:
logging.CyberCPLogFileWriter.writeToFile(
f"Symlink attack blocked: {fileToDownload} -> {realPath} (outside {homePath})")
return HttpResponse("Unauthorized access: Symlink points outside allowed directory.")
# Verify it's a regular file
if not os.path.isfile(realPath):
return HttpResponse("Unauthorized access: Not a valid file.")
except OSError as e:
return HttpResponse("Unauthorized access: Cannot verify file path.")
response = HttpResponse(content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename=%s' % (fileToDownload.split('/')[-1])
response['X-LiteSpeed-Location'] = '%s' % (fileToDownload)
@ -351,6 +369,37 @@ def RootDownloadFile(request):
else:
return ACLManager.loadError()
# SECURITY: Prevent path traversal attacks
if fileToDownload.find('..') > -1:
return HttpResponse("Unauthorized access: Path traversal detected.")
# SECURITY: Check for symlink attacks - resolve the real path and verify it's safe
try:
# Get the real path (resolves symlinks)
realPath = os.path.realpath(fileToDownload)
# SECURITY: Prevent access to sensitive system files
sensitive_paths = ['/etc/shadow', '/etc/passwd', '/etc/sudoers', '/root/.ssh',
'/var/log', '/proc', '/sys', '/dev']
for sensitive in sensitive_paths:
if realPath.startswith(sensitive):
return HttpResponse("Unauthorized access: Access to system files denied.")
# SECURITY: Verify the file exists and is a regular file (not a directory or device)
if not os.path.isfile(realPath):
return HttpResponse("Unauthorized access: Not a valid file.")
# SECURITY: Check if the original path differs from real path (symlink detection)
# Allow the download only if the real path is within allowed directories
# For admin, we'll be more permissive but still block sensitive system files
if fileToDownload != realPath:
# This is a symlink - log it and verify destination is safe
logging.CyberCPLogFileWriter.writeToFile(
f"Symlink download detected: {fileToDownload} -> {realPath}")
except OSError as e:
return HttpResponse("Unauthorized access: Cannot verify file path.")
response = HttpResponse(content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename=%s' % (fileToDownload.split('/')[-1])
response['X-LiteSpeed-Location'] = '%s' % (fileToDownload)

View File

@ -1226,10 +1226,18 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
var comodoInstalled = false;
var counterOWASP = 0;
var counterComodo = 0;
var updatingOWASPStatus = false;
var updatingComodoStatus = false;
$('#owaspInstalled').change(function () {
// Prevent triggering installation when status check updates the toggle
if (updatingOWASPStatus) {
counterOWASP = counterOWASP + 1; // Still increment counter
return;
}
owaspInstalled = $(this).prop('checked');
$scope.ruleFiles = true;
@ -1246,6 +1254,12 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
$('#comodoInstalled').change(function () {
// Prevent triggering installation when status check updates the toggle
if (updatingComodoStatus) {
counterComodo = counterComodo + 1; // Still increment counter
return;
}
$scope.ruleFiles = true;
comodoInstalled = $(this).prop('checked');
@ -1264,9 +1278,12 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
getOWASPAndComodoStatus(true);
function getOWASPAndComodoStatus(updateToggle) {
function getOWASPAndComodoStatus(updateToggle, showLoader) {
$scope.modsecLoading = false;
// Only show loader if explicitly requested (during installations)
if (showLoader === true) {
$scope.modsecLoading = false;
}
url = "/firewall/getOWASPAndComodoStatus";
@ -1291,6 +1308,10 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
if (updateToggle === true) {
// Set flags to prevent change event from triggering installation
updatingOWASPStatus = true;
updatingComodoStatus = true;
if (response.data.owaspInstalled === 1) {
$('#owaspInstalled').prop('checked', true);
$scope.owaspDisable = false;
@ -1305,6 +1326,7 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
$('#comodoInstalled').prop('checked', false);
$scope.comodoDisable = true;
}
} else {
if (response.data.owaspInstalled === 1) {
@ -1321,10 +1343,19 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
}
// Always reset flags after status check completes
$timeout(function() {
updatingOWASPStatus = false;
updatingComodoStatus = false;
}, 100);
}
function cantLoadInitialDatas(response) {
$scope.modsecLoading = true;
// Reset flags even on error
updatingOWASPStatus = false;
updatingComodoStatus = false;
}
}
@ -1366,8 +1397,10 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
$scope.installationFailed = true;
$scope.installationSuccess = false;
// Update toggle state immediately to reflect installation result
getOWASPAndComodoStatus(true);
// Update toggle state after a short delay to reflect installation result
$timeout(function() {
getOWASPAndComodoStatus(true);
}, 500);
} else {
$scope.modsecLoading = true;
@ -1382,7 +1415,9 @@ app.controller('modSecRulesPack', function ($scope, $http, $timeout, $window) {
$scope.errorMessage = response.data.error_message;
// Update toggle to reflect failed installation (will show OFF)
getOWASPAndComodoStatus(true);
$timeout(function() {
getOWASPAndComodoStatus(true);
}, 500);
}
}

View File

@ -30,13 +30,14 @@ import _thread
try:
from dns.models import Domains as dnsDomains
from dns.models import Records as dnsRecords
from mailServer.models import Forwardings, Pipeprograms
from mailServer.models import Forwardings, Pipeprograms, CatchAllEmail, EmailServerSettings, PlusAddressingOverride, PatternForwarding
from plogical.acl import ACLManager
from plogical.dnsUtilities import DNS
from loginSystem.models import Administrator
from websiteFunctions.models import Websites
except:
pass
import re
import os
from plogical.processUtilities import ProcessUtilities
import bcrypt
@ -2001,6 +2002,559 @@ protocol sieve {
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
## Catch-All Email Methods
def catchAllEmail(self):
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if not os.path.exists('/home/cyberpanel/postfix'):
proc = httpProc(self.request, 'mailServer/catchAllEmail.html',
{"status": 0}, 'emailForwarding')
return proc.render()
websitesName = ACLManager.findAllSites(currentACL, userID)
websitesName = websitesName + ACLManager.findChildDomains(websitesName)
proc = httpProc(self.request, 'mailServer/catchAllEmail.html',
{'websiteList': websitesName, "status": 1}, 'emailForwarding')
return proc.render()
def fetchCatchAllConfig(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('fetchStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
try:
domainObj = Domains.objects.get(domain=domain)
catchAll = CatchAllEmail.objects.get(domain=domainObj)
data_ret = {
'status': 1,
'fetchStatus': 1,
'configured': 1,
'destination': catchAll.destination,
'enabled': catchAll.enabled
}
except CatchAllEmail.DoesNotExist:
data_ret = {
'status': 1,
'fetchStatus': 1,
'configured': 0
}
except Domains.DoesNotExist:
data_ret = {
'status': 0,
'fetchStatus': 0,
'error_message': 'Domain not found in email system'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def saveCatchAllConfig(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('saveStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
destination = data['destination']
enabled = data.get('enabled', True)
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
# Validate destination email
if '@' not in destination:
data_ret = {'status': 0, 'saveStatus': 0, 'error_message': 'Invalid destination email address'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
domainObj = Domains.objects.get(domain=domain)
# Create or update catch-all config
catchAll, created = CatchAllEmail.objects.update_or_create(
domain=domainObj,
defaults={'destination': destination, 'enabled': enabled}
)
# Also add/update entry in Forwardings table for Postfix
catchAllSource = '@' + domain
if enabled:
# Remove existing catch-all forwarding if any
Forwardings.objects.filter(source=catchAllSource).delete()
# Add new forwarding
forwarding = Forwardings(source=catchAllSource, destination=destination)
forwarding.save()
else:
# Remove catch-all forwarding when disabled
Forwardings.objects.filter(source=catchAllSource).delete()
data_ret = {
'status': 1,
'saveStatus': 1,
'message': 'Catch-all email configured successfully'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def deleteCatchAllConfig(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('deleteStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
domainObj = Domains.objects.get(domain=domain)
# Delete catch-all config
CatchAllEmail.objects.filter(domain=domainObj).delete()
# Remove from Forwardings table
catchAllSource = '@' + domain
Forwardings.objects.filter(source=catchAllSource).delete()
data_ret = {
'status': 1,
'deleteStatus': 1,
'message': 'Catch-all email removed successfully'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'deleteStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
## Plus-Addressing Methods
def plusAddressingSettings(self):
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if not os.path.exists('/home/cyberpanel/postfix'):
proc = httpProc(self.request, 'mailServer/plusAddressingSettings.html',
{"status": 0}, 'admin')
return proc.render()
websitesName = ACLManager.findAllSites(currentACL, userID)
websitesName = websitesName + ACLManager.findChildDomains(websitesName)
proc = httpProc(self.request, 'mailServer/plusAddressingSettings.html',
{'websiteList': websitesName, "status": 1, 'admin': currentACL['admin']}, 'admin')
return proc.render()
def fetchPlusAddressingConfig(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
# Get global settings
settings = EmailServerSettings.get_settings()
# Check if plus-addressing is enabled in Postfix
postfixEnabled = False
try:
mainCfPath = '/etc/postfix/main.cf'
if os.path.exists(mainCfPath):
with open(mainCfPath, 'r') as f:
content = f.read()
if 'recipient_delimiter' in content:
postfixEnabled = True
except:
pass
data_ret = {
'status': 1,
'fetchStatus': 1,
'globalEnabled': settings.plus_addressing_enabled,
'delimiter': settings.plus_addressing_delimiter,
'postfixEnabled': postfixEnabled
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def savePlusAddressingGlobal(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
# Admin only
if currentACL['admin'] != 1:
return ACLManager.loadErrorJson('saveStatus', 0)
data = json.loads(self.request.body)
enabled = data['enabled']
delimiter = data.get('delimiter', '+')
# Update database settings
settings = EmailServerSettings.get_settings()
settings.plus_addressing_enabled = enabled
settings.plus_addressing_delimiter = delimiter
settings.save()
# Update Postfix configuration
mainCfPath = '/etc/postfix/main.cf'
if os.path.exists(mainCfPath):
with open(mainCfPath, 'r') as f:
content = f.read()
# Remove existing recipient_delimiter line
lines = content.split('\n')
newLines = [line for line in lines if not line.strip().startswith('recipient_delimiter')]
content = '\n'.join(newLines)
if enabled:
# Add recipient_delimiter setting
content = content.rstrip() + f'\nrecipient_delimiter = {delimiter}\n'
with open(mainCfPath, 'w') as f:
f.write(content)
# Reload Postfix
ProcessUtilities.executioner('postfix reload')
data_ret = {
'status': 1,
'saveStatus': 1,
'message': 'Plus-addressing settings saved successfully'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def savePlusAddressingDomain(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('saveStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
enabled = data['enabled']
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
domainObj = Domains.objects.get(domain=domain)
# Create or update per-domain override
override, created = PlusAddressingOverride.objects.update_or_create(
domain=domainObj,
defaults={'enabled': enabled}
)
data_ret = {
'status': 1,
'saveStatus': 1,
'message': f'Plus-addressing {"enabled" if enabled else "disabled"} for {domain}'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
## Pattern Forwarding Methods
def patternForwarding(self):
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if not os.path.exists('/home/cyberpanel/postfix'):
proc = httpProc(self.request, 'mailServer/patternForwarding.html',
{"status": 0}, 'emailForwarding')
return proc.render()
websitesName = ACLManager.findAllSites(currentACL, userID)
websitesName = websitesName + ACLManager.findChildDomains(websitesName)
proc = httpProc(self.request, 'mailServer/patternForwarding.html',
{'websiteList': websitesName, "status": 1}, 'emailForwarding')
return proc.render()
def fetchPatternRules(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('fetchStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
domainObj = Domains.objects.get(domain=domain)
rules = PatternForwarding.objects.filter(domain=domainObj).order_by('priority')
rulesData = []
for rule in rules:
rulesData.append({
'id': rule.id,
'pattern': rule.pattern,
'destination': rule.destination,
'pattern_type': rule.pattern_type,
'priority': rule.priority,
'enabled': rule.enabled
})
data_ret = {
'status': 1,
'fetchStatus': 1,
'rules': rulesData
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def createPatternRule(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('createStatus', 0)
data = json.loads(self.request.body)
domain = data['domain']
pattern = data['pattern']
destination = data['destination']
pattern_type = data.get('pattern_type', 'wildcard')
priority = data.get('priority', 100)
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
# Validate destination email
if '@' not in destination:
data_ret = {'status': 0, 'createStatus': 0, 'error_message': 'Invalid destination email address'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
# Validate pattern
if pattern_type == 'regex':
# Validate regex pattern
valid, msg = self._validateRegexPattern(pattern)
if not valid:
data_ret = {'status': 0, 'createStatus': 0, 'error_message': f'Invalid regex pattern: {msg}'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
else:
# Validate wildcard pattern
if not pattern or len(pattern) > 200:
data_ret = {'status': 0, 'createStatus': 0, 'error_message': 'Invalid wildcard pattern'}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
domainObj = Domains.objects.get(domain=domain)
# Create pattern rule
rule = PatternForwarding(
domain=domainObj,
pattern=pattern,
destination=destination,
pattern_type=pattern_type,
priority=priority,
enabled=True
)
rule.save()
# Regenerate virtual_regexp file
self._regenerateVirtualRegexp()
data_ret = {
'status': 1,
'createStatus': 1,
'message': 'Pattern forwarding rule created successfully'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'createStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def deletePatternRule(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if ACLManager.currentContextPermission(currentACL, 'emailForwarding') == 0:
return ACLManager.loadErrorJson('deleteStatus', 0)
data = json.loads(self.request.body)
ruleId = data['ruleId']
# Get the rule and verify ownership
rule = PatternForwarding.objects.get(id=ruleId)
domain = rule.domain.domain
admin = Administrator.objects.get(pk=userID)
if ACLManager.checkOwnership(domain, admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
# Delete the rule
rule.delete()
# Regenerate virtual_regexp file
self._regenerateVirtualRegexp()
data_ret = {
'status': 1,
'deleteStatus': 1,
'message': 'Pattern forwarding rule deleted successfully'
}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
except BaseException as msg:
data_ret = {'status': 0, 'deleteStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def _validateRegexPattern(self, pattern):
"""Validate regex pattern for security and syntax"""
if len(pattern) > 200:
return False, "Pattern too long"
# Dangerous patterns that could cause ReDoS or security issues
dangerous = ['\\1', '\\2', '\\3', '(?P', '(?=', '(?!', '(?<', '(?:']
for d in dangerous:
if d in pattern:
return False, f"Disallowed construct: {d}"
try:
re.compile(pattern)
return True, "Valid"
except re.error as e:
return False, str(e)
def _wildcardToRegex(self, pattern, domain):
"""Convert wildcard pattern to Postfix regexp format"""
# Escape special regex characters except * and ?
escaped = re.escape(pattern.replace('*', '__STAR__').replace('?', '__QUESTION__'))
# Replace placeholders with regex equivalents
regex = escaped.replace('__STAR__', '.*').replace('__QUESTION__', '.')
# Return full Postfix regexp format
return f'/^{regex}@{re.escape(domain)}$/'
def _regenerateVirtualRegexp(self):
"""Regenerate /etc/postfix/virtual_regexp from database"""
try:
rules = PatternForwarding.objects.filter(enabled=True).order_by('priority')
content = "# Auto-generated by CyberPanel - DO NOT EDIT MANUALLY\n"
for rule in rules:
if rule.pattern_type == 'wildcard':
pattern = self._wildcardToRegex(rule.pattern, rule.domain.domain)
else:
pattern = f'/^{rule.pattern}@{re.escape(rule.domain.domain)}$/'
content += f"{pattern} {rule.destination}\n"
# Write the file
regexpPath = '/etc/postfix/virtual_regexp'
with open(regexpPath, 'w') as f:
f.write(content)
# Set permissions
os.chmod(regexpPath, 0o640)
ProcessUtilities.executioner('chown root:postfix /etc/postfix/virtual_regexp')
# Update main.cf to include regexp file if not already present
mainCfPath = '/etc/postfix/main.cf'
if os.path.exists(mainCfPath):
with open(mainCfPath, 'r') as f:
content = f.read()
if 'virtual_regexp' not in content:
# Add regexp file to virtual_alias_maps
if 'virtual_alias_maps' in content:
content = content.replace(
'virtual_alias_maps =',
'virtual_alias_maps = regexp:/etc/postfix/virtual_regexp,'
)
with open(mainCfPath, 'w') as f:
f.write(content)
# Reload Postfix
ProcessUtilities.executioner('postfix reload')
return True
except BaseException as msg:
logging.CyberCPLogFileWriter.writeToFile(str(msg) + ' [_regenerateVirtualRegexp]')
return False
def main():
parser = argparse.ArgumentParser(description='CyberPanel')

View File

@ -49,3 +49,62 @@ class Transport(models.Model):
class Pipeprograms(models.Model):
source = models.CharField(max_length=80)
destination = models.TextField()
class Meta:
db_table = 'e_pipeprograms'
class CatchAllEmail(models.Model):
"""Stores catch-all email configuration per domain"""
domain = models.OneToOneField(Domains, on_delete=models.CASCADE, primary_key=True, db_column='domain_id')
destination = models.CharField(max_length=255)
enabled = models.BooleanField(default=True)
class Meta:
db_table = 'e_catchall'
managed = False
class EmailServerSettings(models.Model):
"""Global email server settings (singleton)"""
plus_addressing_enabled = models.BooleanField(default=False)
plus_addressing_delimiter = models.CharField(max_length=1, default='+')
class Meta:
db_table = 'e_server_settings'
managed = False
@classmethod
def get_settings(cls):
settings, _ = cls.objects.get_or_create(pk=1)
return settings
class PlusAddressingOverride(models.Model):
"""Per-domain plus-addressing override"""
domain = models.OneToOneField(Domains, on_delete=models.CASCADE, primary_key=True, db_column='domain_id')
enabled = models.BooleanField(default=True)
class Meta:
db_table = 'e_plus_override'
managed = False
class PatternForwarding(models.Model):
"""Stores wildcard/regex forwarding rules"""
PATTERN_TYPES = [
('wildcard', 'Wildcard'),
('regex', 'Regular Expression'),
]
domain = models.ForeignKey(Domains, on_delete=models.CASCADE, db_column='domain_id')
pattern = models.CharField(max_length=255)
destination = models.CharField(max_length=255)
pattern_type = models.CharField(max_length=20, choices=PATTERN_TYPES, default='wildcard')
priority = models.IntegerField(default=100)
enabled = models.BooleanField(default=True)
class Meta:
db_table = 'e_pattern_forwarding'
managed = False
ordering = ['priority']

View File

@ -1556,3 +1556,341 @@ app.controller('EmailLimitsNew', function ($scope, $http) {
});
/* Java script for EmailLimitsNew */
/* Catch-All Email Controller */
app.controller('catchAllEmail', function ($scope, $http) {
$scope.configBox = true;
$scope.loading = false;
$scope.errorBox = true;
$scope.successBox = true;
$scope.couldNotConnect = true;
$scope.notifyBox = true;
$scope.currentConfigured = false;
$scope.enabled = true;
$scope.fetchConfig = function () {
if (!$scope.selectedDomain) {
$scope.configBox = true;
return;
}
$scope.loading = true;
$scope.configBox = true;
$scope.notifyBox = true;
var url = "/email/fetchCatchAllConfig";
var data = { domain: $scope.selectedDomain };
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.fetchStatus === 1) {
$scope.configBox = false;
if (response.data.configured === 1) {
$scope.currentConfigured = true;
$scope.currentDestination = response.data.destination;
$scope.currentEnabled = response.data.enabled;
$scope.destination = response.data.destination;
$scope.enabled = response.data.enabled;
} else {
$scope.currentConfigured = false;
$scope.destination = '';
$scope.enabled = true;
}
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
$scope.saveConfig = function () {
if (!$scope.destination) {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = 'Please enter a destination email address';
return;
}
$scope.loading = true;
$scope.notifyBox = true;
var url = "/email/saveCatchAllConfig";
var data = {
domain: $scope.selectedDomain,
destination: $scope.destination,
enabled: $scope.enabled
};
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.saveStatus === 1) {
$scope.successBox = false;
$scope.notifyBox = false;
$scope.successMessage = response.data.message;
$scope.currentConfigured = true;
$scope.currentDestination = $scope.destination;
$scope.currentEnabled = $scope.enabled;
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
$scope.deleteConfig = function () {
if (!confirm('Are you sure you want to remove the catch-all configuration?')) {
return;
}
$scope.loading = true;
$scope.notifyBox = true;
var url = "/email/deleteCatchAllConfig";
var data = { domain: $scope.selectedDomain };
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.deleteStatus === 1) {
$scope.successBox = false;
$scope.notifyBox = false;
$scope.successMessage = response.data.message;
$scope.currentConfigured = false;
$scope.destination = '';
$scope.enabled = true;
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
});
/* Plus-Addressing Controller */
app.controller('plusAddressing', function ($scope, $http) {
$scope.loading = true;
$scope.globalEnabled = false;
$scope.delimiter = '+';
$scope.domainEnabled = true;
$scope.globalNotifyBox = true;
$scope.globalErrorBox = true;
$scope.globalSuccessBox = true;
$scope.domainNotifyBox = true;
$scope.domainErrorBox = true;
$scope.domainSuccessBox = true;
// Fetch global settings on load
var url = "/email/fetchPlusAddressingConfig";
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, {}, config).then(function (response) {
$scope.loading = false;
if (response.data.fetchStatus === 1) {
$scope.globalEnabled = response.data.globalEnabled;
$scope.delimiter = response.data.delimiter || '+';
}
}, function (response) {
$scope.loading = false;
});
$scope.saveGlobalSettings = function () {
$scope.loading = true;
$scope.globalNotifyBox = true;
var url = "/email/savePlusAddressingGlobal";
var data = {
enabled: $scope.globalEnabled,
delimiter: $scope.delimiter
};
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.saveStatus === 1) {
$scope.globalSuccessBox = false;
$scope.globalNotifyBox = false;
$scope.globalSuccessMessage = response.data.message;
} else {
$scope.globalErrorBox = false;
$scope.globalNotifyBox = false;
$scope.globalErrorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.globalErrorBox = false;
$scope.globalNotifyBox = false;
$scope.globalErrorMessage = 'Could not connect to server';
});
};
$scope.saveDomainSettings = function () {
if (!$scope.selectedDomain) {
return;
}
$scope.domainNotifyBox = true;
var url = "/email/savePlusAddressingDomain";
var data = {
domain: $scope.selectedDomain,
enabled: $scope.domainEnabled
};
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
if (response.data.saveStatus === 1) {
$scope.domainSuccessBox = false;
$scope.domainNotifyBox = false;
$scope.domainSuccessMessage = response.data.message;
} else {
$scope.domainErrorBox = false;
$scope.domainNotifyBox = false;
$scope.domainErrorMessage = response.data.error_message;
}
}, function (response) {
$scope.domainErrorBox = false;
$scope.domainNotifyBox = false;
$scope.domainErrorMessage = 'Could not connect to server';
});
};
});
/* Pattern Forwarding Controller */
app.controller('patternForwarding', function ($scope, $http) {
$scope.configBox = true;
$scope.loading = false;
$scope.errorBox = true;
$scope.successBox = true;
$scope.couldNotConnect = true;
$scope.notifyBox = true;
$scope.rules = [];
$scope.patternType = 'wildcard';
$scope.priority = 100;
$scope.fetchRules = function () {
if (!$scope.selectedDomain) {
$scope.configBox = true;
return;
}
$scope.loading = true;
$scope.configBox = true;
$scope.notifyBox = true;
var url = "/email/fetchPatternRules";
var data = { domain: $scope.selectedDomain };
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.fetchStatus === 1) {
$scope.configBox = false;
$scope.rules = response.data.rules;
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
$scope.createRule = function () {
if (!$scope.pattern || !$scope.destination) {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = 'Please enter both pattern and destination';
return;
}
$scope.loading = true;
$scope.notifyBox = true;
var url = "/email/createPatternRule";
var data = {
domain: $scope.selectedDomain,
pattern: $scope.pattern,
destination: $scope.destination,
pattern_type: $scope.patternType,
priority: $scope.priority
};
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.createStatus === 1) {
$scope.successBox = false;
$scope.notifyBox = false;
$scope.successMessage = response.data.message;
$scope.pattern = '';
$scope.destination = '';
$scope.fetchRules();
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
$scope.deleteRule = function (ruleId) {
if (!confirm('Are you sure you want to delete this forwarding rule?')) {
return;
}
$scope.loading = true;
$scope.notifyBox = true;
var url = "/email/deletePatternRule";
var data = { ruleId: ruleId };
var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } };
$http.post(url, data, config).then(function (response) {
$scope.loading = false;
if (response.data.deleteStatus === 1) {
$scope.successBox = false;
$scope.notifyBox = false;
$scope.successMessage = response.data.message;
$scope.fetchRules();
} else {
$scope.errorBox = false;
$scope.notifyBox = false;
$scope.errorMessage = response.data.error_message;
}
}, function (response) {
$scope.loading = false;
$scope.couldNotConnect = false;
$scope.notifyBox = false;
});
};
});

View File

@ -0,0 +1,468 @@
{% extends "baseTemplate/index.html" %}
{% load i18n %}
{% block title %}{% trans "Catch-All Email - CyberPanel" %}{% endblock %}
{% block content %}
{% load static %}
{% get_current_language as LANGUAGE_CODE %}
<style>
.modern-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
text-align: center;
margin-bottom: 3rem;
animation: fadeInDown 0.5s ease-out;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary, #1e293b);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--text-secondary, #64748b);
margin-bottom: 0;
}
.main-card {
background: var(--bg-secondary, white);
border-radius: 16px;
box-shadow: 0 1px 3px var(--shadow-light, rgba(0,0,0,0.05)), 0 10px 40px var(--shadow-color, rgba(0,0,0,0.08));
border: 1px solid var(--border-color, #e8e9ff);
overflow: hidden;
animation: fadeInUp 0.5s ease-out;
}
.card-header {
background: linear-gradient(135deg, var(--bg-hover, #f8f9ff) 0%, var(--bg-gradient, #f0f1ff) 100%);
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color, #e8e9ff);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.card-body {
padding: 2rem;
}
.form-section {
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary, #1e293b);
font-size: 0.875rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 8px;
font-size: 0.875rem;
transition: all 0.3s ease;
background: var(--bg-secondary, #fff);
}
.form-control:focus {
outline: none;
border-color: var(--accent-color, #5b5fcf);
box-shadow: 0 0 0 3px var(--accent-focus, rgba(91, 95, 207, 0.1));
}
.btn-primary {
background: var(--accent-color, #5b5fcf);
color: var(--text-inverse, white);
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary:hover {
background: var(--accent-dark, #4547a9);
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--accent-shadow-hover, rgba(91, 95, 207, 0.4));
}
.btn-danger {
background: var(--bg-secondary, #fff);
color: var(--danger-color, #ef4444);
border: 1px solid var(--danger-bg-light, #fee2e2);
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-danger:hover {
background: var(--danger-color, #ef4444);
color: var(--text-inverse, white);
}
.alert {
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideInRight 0.3s ease-out;
}
.alert-success {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
border: 1px solid var(--success-border, #a7f3d0);
}
.alert-danger {
background: var(--danger-bg-light, #fee2e2);
color: var(--danger-text, #991b1b);
border: 1px solid var(--danger-border, #fecaca);
}
.alert-info {
background: var(--info-bg, #dbeafe);
color: var(--info-text, #1e40af);
border: 1px solid var(--info-border, #bfdbfe);
}
.disabled-notice {
background: var(--warning-bg, #fef3c7);
border: 1px solid var(--warning-border, #fde68a);
border-radius: 12px;
padding: 2rem;
text-align: center;
margin-bottom: 2rem;
}
.disabled-notice h3 {
color: var(--warning-text, #92400e);
margin-bottom: 1rem;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #e8e9ff);
border-top-color: var(--accent-color, #5b5fcf);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.config-box {
background: var(--bg-hover, #f8f9ff);
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 12px;
padding: 2rem;
margin-top: 1.5rem;
}
.config-box h3 {
color: var(--text-primary, #1e293b);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-config {
background: var(--bg-secondary, white);
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 8px;
padding: 1.5rem;
margin-top: 1.5rem;
}
.current-config h4 {
color: var(--text-primary, #1e293b);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light, #f3f4f6);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
color: var(--text-secondary, #64748b);
font-size: 0.875rem;
}
.config-value {
color: var(--text-primary, #1e293b);
font-weight: 500;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status-enabled {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
}
.status-disabled {
background: var(--danger-bg-light, #fee2e2);
color: var(--danger-text, #991b1b);
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-color, #5b5fcf);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.warning-icon {
color: var(--warning-color, #ffa000);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
<div class="modern-container" ng-controller="catchAllEmail">
<div class="page-header">
<h1 class="page-title">
<i class="fas fa-inbox"></i>
{% trans "Catch-All Email" %}
</h1>
<p class="page-subtitle">{% trans "Forward all unmatched emails for a domain to a single address" %}</p>
</div>
<div class="main-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-filter"></i>
{% trans "Catch-All Configuration" %}
<span ng-show="loading" class="loading-spinner"></span>
</h2>
</div>
<div class="card-body">
{% if not status %}
<div class="disabled-notice">
<i class="fas fa-exclamation-triangle fa-3x mb-3 warning-icon"></i>
<h3>{% trans "Postfix is disabled" %}</h3>
<p class="mb-3">{% trans "You need to enable Postfix to configure catch-all email" %}</p>
<a href="{% url 'managePostfix' %}" class="btn-primary">
<i class="fas fa-power-off"></i>
{% trans "Enable Postfix Now" %}
</a>
</div>
{% else %}
<form action="/" method="post">
<div class="form-section">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Select Domain" %}</label>
<select ng-change="fetchConfig()" ng-model="selectedDomain" class="form-control">
<option value="">{% trans "Choose a domain..." %}</option>
{% for items in websiteList %}
<option value="{{ items }}">{{ items }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div ng-hide="configBox" class="config-box">
<h3><i class="fas fa-cog"></i> {% trans "Configure Catch-All" %}</h3>
<div ng-show="currentConfigured" class="current-config">
<h4><i class="fas fa-info-circle"></i> {% trans "Current Configuration" %}</h4>
<div class="config-item">
<span class="config-label">{% trans "Status" %}</span>
<span class="status-badge" ng-class="currentEnabled ? 'status-enabled' : 'status-disabled'">
{$ currentEnabled ? 'Enabled' : 'Disabled' $}
</span>
</div>
<div class="config-item">
<span class="config-label">{% trans "Destination" %}</span>
<span class="config-value">{$ currentDestination $}</span>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Destination Email" %}</label>
<input type="email" class="form-control" ng-model="destination"
placeholder="{% trans 'catchall@example.com' %}" required>
<small class="text-muted">{% trans "All unmatched emails will be forwarded to this address" %}</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Enable Catch-All" %}</label>
<div class="mt-2">
<label class="toggle-switch">
<input type="checkbox" ng-model="enabled" ng-init="enabled=true">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<button type="button" ng-click="saveConfig()" class="btn-primary">
<i class="fas fa-save"></i>
{% trans "Save Configuration" %}
</button>
<button type="button" ng-show="currentConfigured" ng-click="deleteConfig()" class="btn-danger ml-2">
<i class="fas fa-trash"></i>
{% trans "Remove Catch-All" %}
</button>
</div>
</div>
</div>
<!-- Alert Messages -->
<div ng-hide="notifyBox" class="form-section mt-3">
<div ng-hide="errorBox" class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
{$ errorMessage $}
</div>
<div ng-hide="successBox" class="alert alert-success">
<i class="fas fa-check-circle"></i>
{$ successMessage $}
</div>
<div ng-hide="couldNotConnect" class="alert alert-danger">
<i class="fas fa-times-circle"></i>
{% trans "Could not connect to server. Please refresh this page." %}
</div>
</div>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,465 @@
{% extends "baseTemplate/index.html" %}
{% load i18n %}
{% block title %}{% trans "Pattern Forwarding - CyberPanel" %}{% endblock %}
{% block content %}
{% load static %}
{% get_current_language as LANGUAGE_CODE %}
<style>
.modern-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
text-align: center;
margin-bottom: 3rem;
animation: fadeInDown 0.5s ease-out;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary, #1e293b);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--text-secondary, #64748b);
margin-bottom: 0;
}
.main-card {
background: var(--bg-secondary, white);
border-radius: 16px;
box-shadow: 0 1px 3px var(--shadow-light, rgba(0,0,0,0.05)), 0 10px 40px var(--shadow-color, rgba(0,0,0,0.08));
border: 1px solid var(--border-color, #e8e9ff);
overflow: hidden;
animation: fadeInUp 0.5s ease-out;
}
.card-header {
background: linear-gradient(135deg, var(--bg-hover, #f8f9ff) 0%, var(--bg-gradient, #f0f1ff) 100%);
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color, #e8e9ff);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.card-body {
padding: 2rem;
}
.form-section {
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary, #1e293b);
font-size: 0.875rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 8px;
font-size: 0.875rem;
transition: all 0.3s ease;
background: var(--bg-secondary, #fff);
}
.form-control:focus {
outline: none;
border-color: var(--accent-color, #5b5fcf);
box-shadow: 0 0 0 3px var(--accent-focus, rgba(91, 95, 207, 0.1));
}
.btn-primary {
background: var(--accent-color, #5b5fcf);
color: var(--text-inverse, white);
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary:hover {
background: var(--accent-dark, #4547a9);
transform: translateY(-2px);
}
.btn-danger {
background: var(--bg-secondary, #fff);
color: var(--danger-color, #ef4444);
border: 1px solid var(--danger-bg-light, #fee2e2);
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.875rem;
}
.btn-danger:hover {
background: var(--danger-color, #ef4444);
color: var(--text-inverse, white);
}
.alert {
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-success {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
border: 1px solid var(--success-border, #a7f3d0);
}
.alert-danger {
background: var(--danger-bg-light, #fee2e2);
color: var(--danger-text, #991b1b);
border: 1px solid var(--danger-border, #fecaca);
}
.alert-info {
background: var(--info-bg, #dbeafe);
color: var(--info-text, #1e40af);
border: 1px solid var(--info-border, #bfdbfe);
}
.disabled-notice {
background: var(--warning-bg, #fef3c7);
border: 1px solid var(--warning-border, #fde68a);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #e8e9ff);
border-top-color: var(--accent-color, #5b5fcf);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.config-box {
background: var(--bg-hover, #f8f9ff);
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 12px;
padding: 2rem;
margin-top: 1.5rem;
}
.config-box h3 {
color: var(--text-primary, #1e293b);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rules-table {
width: 100%;
background: var(--bg-secondary, white);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color, #e8e9ff);
margin-top: 2rem;
}
.rules-table thead {
background: var(--bg-hover, #f8f9ff);
}
.rules-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary, #1e293b);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color, #e8e9ff);
}
.rules-table td {
padding: 1rem;
color: var(--text-secondary, #64748b);
font-size: 0.875rem;
border-bottom: 1px solid var(--border-light, #f3f4f6);
}
.rules-table tbody tr:hover {
background: var(--bg-hover, #f8f9ff);
}
.rules-table tbody tr:last-child td {
border-bottom: none;
}
.pattern-type-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.type-wildcard {
background: var(--accent-bg, #e0e7ff);
color: var(--accent-color, #5b5fcf);
}
.type-regex {
background: var(--warning-bg, #fef3c7);
color: var(--warning-text, #92400e);
}
.help-text {
background: var(--info-bg, #dbeafe);
border: 1px solid var(--info-border, #bfdbfe);
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
}
.help-text h4 {
color: var(--info-text, #1e40af);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.help-text ul {
color: var(--info-text, #1e40af);
margin: 0;
padding-left: 1.5rem;
font-size: 0.875rem;
}
.help-text li {
margin-bottom: 0.25rem;
}
.warning-icon {
color: var(--warning-color, #ffa000);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="modern-container" ng-controller="patternForwarding">
<div class="page-header">
<h1 class="page-title">
<i class="fas fa-asterisk"></i>
{% trans "Pattern Forwarding" %}
</h1>
<p class="page-subtitle">{% trans "Create wildcard and regex-based email forwarding rules" %}</p>
</div>
<div class="main-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-code-branch"></i>
{% trans "Pattern Forwarding Rules" %}
<span ng-show="loading" class="loading-spinner"></span>
</h2>
</div>
<div class="card-body">
{% if not status %}
<div class="disabled-notice">
<i class="fas fa-exclamation-triangle fa-3x mb-3 warning-icon"></i>
<h3>{% trans "Postfix is disabled" %}</h3>
<p class="mb-3">{% trans "You need to enable Postfix to configure pattern forwarding" %}</p>
<a href="{% url 'managePostfix' %}" class="btn-primary">
<i class="fas fa-power-off"></i>
{% trans "Enable Postfix Now" %}
</a>
</div>
{% else %}
<form action="/" method="post">
<div class="form-section">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Select Domain" %}</label>
<select ng-change="fetchRules()" ng-model="selectedDomain" class="form-control">
<option value="">{% trans "Choose a domain..." %}</option>
{% for items in websiteList %}
<option value="{{ items }}">{{ items }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div ng-hide="configBox" class="config-box">
<h3><i class="fas fa-plus-circle"></i> {% trans "Create New Rule" %}</h3>
<div class="help-text" ng-show="patternType === 'wildcard'">
<h4><i class="fas fa-lightbulb"></i> {% trans "Wildcard Pattern Examples" %}</h4>
<ul>
<li><code>user_*</code> - {% trans "Matches user_anything (e.g., user_sales, user_123)" %}</li>
<li><code>support-?</code> - {% trans "Matches support- followed by any single character" %}</li>
<li><code>team*</code> - {% trans "Matches anything starting with team" %}</li>
</ul>
</div>
<div class="help-text" ng-show="patternType === 'regex'">
<h4><i class="fas fa-exclamation-triangle"></i> {% trans "Regex Pattern (Advanced)" %}</h4>
<ul>
<li><code>user_[0-9]+</code> - {% trans "Matches user_ followed by digits" %}</li>
<li><code>support-(sales|billing)</code> - {% trans "Matches support-sales or support-billing" %}</li>
<li>{% trans "Note: Pattern is matched against the local part only (before @)" %}</li>
</ul>
</div>
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label class="form-label">{% trans "Pattern Type" %}</label>
<select class="form-control" ng-model="patternType" ng-init="patternType='wildcard'">
<option value="wildcard">{% trans "Wildcard" %}</option>
<option value="regex">{% trans "Regex (Advanced)" %}</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="form-label">{% trans "Pattern" %}</label>
<input type="text" class="form-control" ng-model="pattern"
placeholder="{% trans 'e.g., user_*' %}">
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label class="form-label">{% trans "Destination Email" %}</label>
<input type="email" class="form-control" ng-model="destination"
placeholder="{% trans 'forward@example.com' %}">
</div>
</div>
<div class="col-md-2">
<div class="form-group">
<label class="form-label">{% trans "Priority" %}</label>
<input type="number" class="form-control" ng-model="priority"
ng-init="priority=100" min="1" max="999">
</div>
</div>
<div class="col-md-1">
<div class="form-group">
<label class="form-label">&nbsp;</label>
<button type="button" ng-click="createRule()" class="btn-primary" style="width:100%">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
</div>
<table class="rules-table" ng-show="rules.length > 0">
<thead>
<tr>
<th style="width:10%">{% trans "Priority" %}</th>
<th style="width:15%">{% trans "Type" %}</th>
<th style="width:25%">{% trans "Pattern" %}</th>
<th style="width:35%">{% trans "Destination" %}</th>
<th style="width:15%">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="rule in rules track by $index">
<td><strong ng-bind="rule.priority"></strong></td>
<td>
<span class="pattern-type-badge" ng-class="rule.pattern_type === 'wildcard' ? 'type-wildcard' : 'type-regex'">
<i ng-class="rule.pattern_type === 'wildcard' ? 'fas fa-asterisk' : 'fas fa-code'"></i>
{$ rule.pattern_type $}
</span>
</td>
<td><code ng-bind="rule.pattern"></code>@{$ selectedDomain $}</td>
<td ng-bind="rule.destination"></td>
<td>
<button type="button" ng-click="deleteRule(rule.id)" class="btn-danger">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div ng-hide="rules.length > 0" class="alert alert-info mt-3">
<i class="fas fa-info-circle"></i>
{% trans "No pattern forwarding rules configured for this domain yet." %}
</div>
</div>
<!-- Alert Messages -->
<div ng-hide="notifyBox" class="form-section mt-3">
<div ng-hide="errorBox" class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
{$ errorMessage $}
</div>
<div ng-hide="successBox" class="alert alert-success">
<i class="fas fa-check-circle"></i>
{$ successMessage $}
</div>
<div ng-hide="couldNotConnect" class="alert alert-danger">
<i class="fas fa-times-circle"></i>
{% trans "Could not connect to server. Please refresh this page." %}
</div>
</div>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,406 @@
{% extends "baseTemplate/index.html" %}
{% load i18n %}
{% block title %}{% trans "Plus-Addressing Settings - CyberPanel" %}{% endblock %}
{% block content %}
{% load static %}
{% get_current_language as LANGUAGE_CODE %}
<style>
.modern-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
text-align: center;
margin-bottom: 3rem;
animation: fadeInDown 0.5s ease-out;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary, #1e293b);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--text-secondary, #64748b);
margin-bottom: 0;
}
.main-card {
background: var(--bg-secondary, white);
border-radius: 16px;
box-shadow: 0 1px 3px var(--shadow-light, rgba(0,0,0,0.05)), 0 10px 40px var(--shadow-color, rgba(0,0,0,0.08));
border: 1px solid var(--border-color, #e8e9ff);
overflow: hidden;
animation: fadeInUp 0.5s ease-out;
margin-bottom: 2rem;
}
.card-header {
background: linear-gradient(135deg, var(--bg-hover, #f8f9ff) 0%, var(--bg-gradient, #f0f1ff) 100%);
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color, #e8e9ff);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.card-body {
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary, #1e293b);
font-size: 0.875rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color, #e8e9ff);
border-radius: 8px;
font-size: 0.875rem;
transition: all 0.3s ease;
background: var(--bg-secondary, #fff);
}
.form-control:focus {
outline: none;
border-color: var(--accent-color, #5b5fcf);
box-shadow: 0 0 0 3px var(--accent-focus, rgba(91, 95, 207, 0.1));
}
.btn-primary {
background: var(--accent-color, #5b5fcf);
color: var(--text-inverse, white);
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary:hover {
background: var(--accent-dark, #4547a9);
transform: translateY(-2px);
}
.alert {
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-success {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
border: 1px solid var(--success-border, #a7f3d0);
}
.alert-danger {
background: var(--danger-bg-light, #fee2e2);
color: var(--danger-text, #991b1b);
border: 1px solid var(--danger-border, #fecaca);
}
.alert-info {
background: var(--info-bg, #dbeafe);
color: var(--info-text, #1e40af);
border: 1px solid var(--info-border, #bfdbfe);
}
.disabled-notice {
background: var(--warning-bg, #fef3c7);
border: 1px solid var(--warning-border, #fde68a);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #e8e9ff);
border-top-color: var(--accent-color, #5b5fcf);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-color, #5b5fcf);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.info-box {
background: var(--info-bg, #dbeafe);
border: 1px solid var(--info-border, #bfdbfe);
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
}
.info-box h4 {
color: var(--info-text, #1e40af);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.info-box p {
color: var(--info-text, #1e40af);
margin: 0;
font-size: 0.875rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status-enabled {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
}
.status-disabled {
background: var(--danger-bg-light, #fee2e2);
color: var(--danger-text, #991b1b);
}
.warning-icon {
color: var(--warning-color, #ffa000);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="modern-container" ng-controller="plusAddressing" ng-init="isAdmin={{ admin|yesno:'true,false' }}">
<div class="page-header">
<h1 class="page-title">
<i class="fas fa-plus-circle"></i>
{% trans "Plus-Addressing" %}
</h1>
<p class="page-subtitle">{% trans "Enable email sub-addressing (user+tag@domain.com)" %}</p>
</div>
{% if not status %}
<div class="main-card">
<div class="card-body">
<div class="disabled-notice">
<i class="fas fa-exclamation-triangle fa-3x mb-3 warning-icon"></i>
<h3>{% trans "Postfix is disabled" %}</h3>
<p class="mb-3">{% trans "You need to enable Postfix to configure plus-addressing" %}</p>
<a href="{% url 'managePostfix' %}" class="btn-primary">
<i class="fas fa-power-off"></i>
{% trans "Enable Postfix Now" %}
</a>
</div>
</div>
</div>
{% else %}
<!-- Global Settings (Admin Only) -->
<div class="main-card" ng-show="isAdmin">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-globe"></i>
{% trans "Global Settings" %}
<span ng-show="loading" class="loading-spinner"></span>
</h2>
</div>
<div class="card-body">
<div class="info-box">
<h4><i class="fas fa-info-circle"></i> {% trans "What is Plus-Addressing?" %}</h4>
<p>{% trans "Plus-addressing allows users to receive email at user+anything@domain.com which will be delivered to user@domain.com. This is useful for filtering and tracking email sources." %}</p>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Enable Plus-Addressing (Server-wide)" %}</label>
<div class="mt-2">
<label class="toggle-switch">
<input type="checkbox" ng-model="globalEnabled">
<span class="toggle-slider"></span>
</label>
<span class="ml-2 status-badge" ng-class="globalEnabled ? 'status-enabled' : 'status-disabled'">
{$ globalEnabled ? 'Enabled' : 'Disabled' $}
</span>
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Delimiter Character" %}</label>
<select class="form-control" ng-model="delimiter" style="width: auto;">
<option value="+">+ (Plus)</option>
<option value="-">- (Hyphen)</option>
<option value="_">_ (Underscore)</option>
</select>
</div>
</div>
</div>
<button type="button" ng-click="saveGlobalSettings()" class="btn-primary">
<i class="fas fa-save"></i>
{% trans "Save Global Settings" %}
</button>
<!-- Alert Messages -->
<div ng-hide="globalNotifyBox" class="mt-3">
<div ng-hide="globalErrorBox" class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
{$ globalErrorMessage $}
</div>
<div ng-hide="globalSuccessBox" class="alert alert-success">
<i class="fas fa-check-circle"></i>
{$ globalSuccessMessage $}
</div>
</div>
</div>
</div>
<!-- Per-Domain Override -->
<div class="main-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-sitemap"></i>
{% trans "Per-Domain Settings" %}
</h2>
</div>
<div class="card-body">
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle"></i>
{% trans "Per-domain settings allow you to track which domains should use plus-addressing. Note: Actual filtering is server-wide in Postfix." %}
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-label">{% trans "Select Domain" %}</label>
<select ng-model="selectedDomain" class="form-control">
<option value="">{% trans "Choose a domain..." %}</option>
{% for items in websiteList %}
<option value="{{ items }}">{{ items }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6" ng-show="selectedDomain">
<div class="form-group">
<label class="form-label">{% trans "Enable for this domain" %}</label>
<div class="mt-2">
<label class="toggle-switch">
<input type="checkbox" ng-model="domainEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<button type="button" ng-show="selectedDomain" ng-click="saveDomainSettings()" class="btn-primary">
<i class="fas fa-save"></i>
{% trans "Save Domain Settings" %}
</button>
<!-- Alert Messages -->
<div ng-hide="domainNotifyBox" class="mt-3">
<div ng-hide="domainErrorBox" class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
{$ domainErrorMessage $}
</div>
<div ng-hide="domainSuccessBox" class="alert alert-success">
<i class="fas fa-check-circle"></i>
{$ domainSuccessMessage $}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -35,4 +35,22 @@ urlpatterns = [
### email limits
re_path(r'^EmailLimits$', views.EmailLimits, name='EmailLimits'),
re_path(r'^SaveEmailLimitsNew$', views.SaveEmailLimitsNew, name='SaveEmailLimitsNew'),
## Catch-All Email
re_path(r'^catchAllEmail$', views.catchAllEmail, name='catchAllEmail'),
re_path(r'^fetchCatchAllConfig$', views.fetchCatchAllConfig, name='fetchCatchAllConfig'),
re_path(r'^saveCatchAllConfig$', views.saveCatchAllConfig, name='saveCatchAllConfig'),
re_path(r'^deleteCatchAllConfig$', views.deleteCatchAllConfig, name='deleteCatchAllConfig'),
## Plus-Addressing
re_path(r'^plusAddressingSettings$', views.plusAddressingSettings, name='plusAddressingSettings'),
re_path(r'^fetchPlusAddressingConfig$', views.fetchPlusAddressingConfig, name='fetchPlusAddressingConfig'),
re_path(r'^savePlusAddressingGlobal$', views.savePlusAddressingGlobal, name='savePlusAddressingGlobal'),
re_path(r'^savePlusAddressingDomain$', views.savePlusAddressingDomain, name='savePlusAddressingDomain'),
## Pattern Forwarding
re_path(r'^patternForwarding$', views.patternForwarding, name='patternForwarding'),
re_path(r'^fetchPatternRules$', views.fetchPatternRules, name='fetchPatternRules'),
re_path(r'^createPatternRule$', views.createPatternRule, name='createPatternRule'),
re_path(r'^deletePatternRule$', views.deletePatternRule, name='deletePatternRule'),
]

View File

@ -263,4 +263,113 @@ def SaveEmailLimitsNew(request):
return HttpResponse(json_data)
## Catch-All Email
def catchAllEmail(request):
try:
msM = MailServerManager(request)
return msM.catchAllEmail()
except KeyError:
return redirect(loadLoginPage)
def fetchCatchAllConfig(request):
try:
msM = MailServerManager(request)
return msM.fetchCatchAllConfig()
except KeyError as msg:
data_ret = {'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def saveCatchAllConfig(request):
try:
msM = MailServerManager(request)
return msM.saveCatchAllConfig()
except KeyError as msg:
data_ret = {'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def deleteCatchAllConfig(request):
try:
msM = MailServerManager(request)
return msM.deleteCatchAllConfig()
except KeyError as msg:
data_ret = {'deleteStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
## Plus-Addressing
def plusAddressingSettings(request):
try:
msM = MailServerManager(request)
return msM.plusAddressingSettings()
except KeyError:
return redirect(loadLoginPage)
def fetchPlusAddressingConfig(request):
try:
msM = MailServerManager(request)
return msM.fetchPlusAddressingConfig()
except KeyError as msg:
data_ret = {'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def savePlusAddressingGlobal(request):
try:
msM = MailServerManager(request)
return msM.savePlusAddressingGlobal()
except KeyError as msg:
data_ret = {'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def savePlusAddressingDomain(request):
try:
msM = MailServerManager(request)
return msM.savePlusAddressingDomain()
except KeyError as msg:
data_ret = {'saveStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
## Pattern Forwarding
def patternForwarding(request):
try:
msM = MailServerManager(request)
return msM.patternForwarding()
except KeyError:
return redirect(loadLoginPage)
def fetchPatternRules(request):
try:
msM = MailServerManager(request)
return msM.fetchPatternRules()
except KeyError as msg:
data_ret = {'fetchStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def createPatternRule(request):
try:
msM = MailServerManager(request)
return msM.createPatternRule()
except KeyError as msg:
data_ret = {'createStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)
def deletePatternRule(request):
try:
msM = MailServerManager(request)
return msM.deletePatternRule()
except KeyError as msg:
data_ret = {'deleteStatus': 0, 'error_message': str(msg)}
json_data = json.dumps(data_ret)
return HttpResponse(json_data)

View File

@ -291,24 +291,26 @@ extprocessor docker{port} {{
@staticmethod
def SetupN8NVhost(domain, port):
"""Setup n8n vhost with proper proxy configuration including Origin header"""
"""Setup n8n vhost with proper proxy configuration for OpenLiteSpeed"""
try:
vhost_path = f'/usr/local/lsws/conf/vhosts/{domain}/vhost.conf'
if not os.path.exists(vhost_path):
logging.writeToFile(f"Error: Vhost file not found at {vhost_path}")
return False
# Read existing vhost configuration
with open(vhost_path, 'r') as f:
content = f.read()
# Check if context already exists
if 'context / {' in content:
logging.writeToFile("Context already exists, skipping...")
return True
# Add proxy context with proper headers for n8n
# NOTE: Do NOT include "RequestHeader set Origin" - OpenLiteSpeed cannot override
# browser Origin headers, which is why NODE_ENV=development is required
proxy_context = f'''
# N8N Proxy Configuration
@ -322,7 +324,6 @@ context / {{
RequestHeader set X-Forwarded-For $ip
RequestHeader set X-Forwarded-Proto https
RequestHeader set X-Forwarded-Host "{domain}"
RequestHeader set Origin "{domain}, {domain}"
RequestHeader set Host "{domain}"
END_extraHeaders
}}
@ -910,41 +911,59 @@ services:
##### N8N Container
def check_container_health(self, container_name, max_retries=3, delay=80):
def check_container_health(self, service_name, max_retries=3, delay=80):
"""
Check if a container is running, accepting healthy, unhealthy, and starting states
Total wait time will be 4 minutes (3 retries * 80 seconds)
Uses fuzzy matching to find containers since Docker Compose naming varies by version:
- Docker Compose v1: project_service_1 (underscores)
- Docker Compose v2: project-service-1 (hyphens)
"""
try:
# Format container name to match Docker's naming convention
formatted_name = f"{self.data['ServiceName']}-{container_name}-1"
logging.writeToFile(f'Checking container health for: {formatted_name}')
logging.writeToFile(f'Checking container health for service: {service_name}')
for attempt in range(max_retries):
client = docker.from_env()
container = client.containers.get(formatted_name)
# Find container by searching all containers for a name containing the service name
# This handles both v1 (underscores) and v2 (hyphens) naming conventions
all_containers = client.containers.list(all=True)
container = None
# Normalize service name for matching (handle both - and _)
service_pattern = service_name.lower().replace(' ', '').replace('-', '').replace('_', '')
for c in all_containers:
container_pattern = c.name.lower().replace('-', '').replace('_', '')
if service_pattern in container_pattern:
container = c
logging.writeToFile(f'Found matching container: {c.name} for service: {service_name}')
break
if container is None:
logging.writeToFile(f'No container found matching service: {service_name}, attempt {attempt + 1}/{max_retries}')
time.sleep(delay)
continue
if container.status == 'running':
health = container.attrs.get('State', {}).get('Health', {}).get('Status')
# Accept healthy, unhealthy, and starting states as long as container is running
if health in ['healthy', 'unhealthy', 'starting'] or health is None:
logging.writeToFile(f'Container {formatted_name} is running with status: {health}')
logging.writeToFile(f'Container {container.name} is running with health status: {health}')
return True
else:
health_logs = container.attrs.get('State', {}).get('Health', {}).get('Log', [])
if health_logs:
last_log = health_logs[-1]
logging.writeToFile(f'Container health check failed: {last_log.get("Output", "")}')
logging.writeToFile(f'Container {formatted_name} status: {container.status}, health: {health}, attempt {attempt + 1}/{max_retries}')
logging.writeToFile(f'Container {container.name} status: {container.status}, health: {health}, attempt {attempt + 1}/{max_retries}')
time.sleep(delay)
return False
except docker.errors.NotFound:
logging.writeToFile(f'Container {formatted_name} not found')
return False
except Exception as e:
logging.writeToFile(f'Error checking container health: {str(e)}')
return False
@ -1067,12 +1086,39 @@ services:
logging.writeToFile(f"Cleanup failed: {str(e)}")
return False
def find_container_by_service(self, service_name):
"""
Find a container by service name using fuzzy matching.
Returns the container object or None if not found.
"""
try:
client = docker.from_env()
all_containers = client.containers.list(all=True)
# Normalize service name for matching
service_pattern = service_name.lower().replace(' ', '').replace('-', '').replace('_', '')
for c in all_containers:
container_pattern = c.name.lower().replace('-', '').replace('_', '')
if service_pattern in container_pattern:
return c
return None
except Exception as e:
logging.writeToFile(f'Error finding container: {str(e)}')
return None
def monitor_deployment(self):
try:
# Format container names
n8n_container_name = f"{self.data['ServiceName']}-{self.data['ServiceName']}-1"
db_container_name = f"{self.data['ServiceName']}-{self.data['ServiceName']}-db-1"
# Find containers dynamically using fuzzy matching
n8n_container = self.find_container_by_service(self.data['ServiceName'])
db_container = self.find_container_by_service(f"{self.data['ServiceName']}-db")
if not n8n_container or not db_container:
raise DockerDeploymentError("Could not find n8n or database containers")
n8n_container_name = n8n_container.name
db_container_name = db_container.name
logging.writeToFile(f'Monitoring containers: {n8n_container_name} and {db_container_name}')
# Check container health
@ -1080,7 +1126,7 @@ services:
result, status = ProcessUtilities.outputExecutioner(command, None, None, None, 1)
# Only raise error if container is exited
if "exited" in status:
if "exited" in status.lower():
# Get container logs
command = f"docker logs {n8n_container_name}"
result, logs = ProcessUtilities.outputExecutioner(command, None, None, None, 1)
@ -1095,19 +1141,16 @@ services:
# Check if database container is ready
command = f"docker exec {db_container_name} pg_isready -U postgres"
result, output = ProcessUtilities.outputExecutioner(command, None, None, None, 1)
if "accepting connections" in output:
db_ready = True
break
# Check container status
command = f"docker inspect --format='{{{{.State.Status}}}}' {db_container_name}"
result, db_status = ProcessUtilities.outputExecutioner(command, None, None, None, 1)
# Only raise error if database container is in a failed state
if db_status == 'exited':
raise DockerDeploymentError(f"Database container is in {db_status} state")
# Refresh container status
db_container = self.find_container_by_service(f"{self.data['ServiceName']}-db")
if db_container and db_container.status == 'exited':
raise DockerDeploymentError(f"Database container exited")
retry_count += 1
time.sleep(2)
logging.writeToFile(f'Waiting for database to be ready, attempt {retry_count}/{max_retries}')
@ -1116,13 +1159,11 @@ services:
raise DockerDeploymentError("Database failed to become ready within timeout period")
# Check n8n container status
command = f"docker inspect --format='{{{{.State.Status}}}}' {n8n_container_name}"
result, n8n_status = ProcessUtilities.outputExecutioner(command, None, None, None, 1)
# Only raise error if n8n container is in a failed state
if n8n_status == 'exited':
raise DockerDeploymentError(f"n8n container is in {n8n_status} state")
n8n_container = self.find_container_by_service(self.data['ServiceName'])
if n8n_container and n8n_container.status == 'exited':
raise DockerDeploymentError(f"n8n container exited")
n8n_status = n8n_container.status if n8n_container else 'unknown'
logging.writeToFile(f'Deployment monitoring completed successfully. n8n status: {n8n_status}, database ready: {db_ready}')
return True
@ -1370,7 +1411,7 @@ services:
'DB_POSTGRESDB_PASSWORD': self.data['MySQLPassword'],
'N8N_HOST': '0.0.0.0',
'N8N_PORT': '5678',
'NODE_ENV': 'production',
'NODE_ENV': 'development', # Required for OpenLiteSpeed compatibility - OLS cannot override browser Origin headers which n8n v1.87.0+ validates in production mode
'N8N_EDITOR_BASE_URL': f"https://{self.data['finalURL']}",
'WEBHOOK_URL': f"https://{self.data['finalURL']}",
'WEBHOOK_TUNNEL_URL': f"https://{self.data['finalURL']}",

View File

@ -1,5 +1,6 @@
from plogical import CyberCPLogFileWriter as logging
import os
import re
import requests
import json
import time
@ -9,6 +10,7 @@ import shlex
from multiprocessing import Process
from plogical.backupSchedule import backupSchedule
from shutil import rmtree
from plogical.acl import ACLManager
class remoteBackup:
@ -216,16 +218,42 @@ class remoteBackup:
@staticmethod
def sendBackup(completedPathToSend, IPAddress, folderNumber,writeToFile):
def sendBackup(completedPathToSend, IPAddress, folderNumber, writeToFile):
try:
## complete path is a path to the file need to send
command = 'sudo rsync -avz -e "ssh -i /root/.ssh/cyberpanel -o StrictHostKeyChecking=no" ' + completedPathToSend + ' root@' + IPAddress + ':/home/backup/transfer-'+folderNumber
# SECURITY: Validate IPAddress to prevent command injection
if ACLManager.commandInjectionCheck(IPAddress) == 1:
logging.CyberCPLogFileWriter.writeToFile("Invalid IP address - command injection attempt detected [sendBackup]")
return
# SECURITY: Validate IPAddress format (IPv4 or hostname)
ip_pattern = r'^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$'
if not re.match(ip_pattern, IPAddress):
logging.CyberCPLogFileWriter.writeToFile("Invalid IP address format [sendBackup]")
return
# SECURITY: Validate folderNumber is alphanumeric
if ACLManager.commandInjectionCheck(str(folderNumber)) == 1:
logging.CyberCPLogFileWriter.writeToFile("Invalid folder number - command injection attempt detected [sendBackup]")
return
if not re.match(r'^[a-zA-Z0-9_-]+$', str(folderNumber)):
logging.CyberCPLogFileWriter.writeToFile("Invalid folder number format [sendBackup]")
return
# SECURITY: Validate completedPathToSend - must be under /home/backup
if '..' in completedPathToSend or not completedPathToSend.startswith('/home/backup/'):
logging.CyberCPLogFileWriter.writeToFile("Invalid backup path - path traversal attempt detected [sendBackup]")
return
# SECURITY: Use shlex.quote for all user-controllable parameters
command = 'sudo rsync -avz -e "ssh -i /root/.ssh/cyberpanel -o StrictHostKeyChecking=no" ' + shlex.quote(completedPathToSend) + ' root@' + shlex.quote(IPAddress) + ':/home/backup/transfer-' + shlex.quote(str(folderNumber))
subprocess.call(shlex.split(command), stdout=writeToFile)
os.remove(completedPathToSend)
except BaseException as msg:
logging.CyberCPLogFileWriter.writeToFile(str(msg) + " [startBackup]")
logging.CyberCPLogFileWriter.writeToFile(str(msg) + " [sendBackup]")
@staticmethod
def backupProcess(ipAddress, dir, backupLogPath,folderNumber, accountsToTransfer):

View File

@ -2258,6 +2258,58 @@ CREATE TABLE `websiteFunctions_backupsv2` (`id` integer AUTO_INCREMENT NOT NULL
except:
pass
# Email Filtering Tables - Catch-All, Plus-Addressing, Pattern Forwarding
query = """CREATE TABLE IF NOT EXISTS `e_catchall` (
`domain_id` varchar(50) NOT NULL,
`destination` varchar(255) NOT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`domain_id`),
CONSTRAINT `fk_catchall_domain` FOREIGN KEY (`domain_id`) REFERENCES `e_domains` (`domain`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
try:
cursor.execute(query)
except:
pass
query = """CREATE TABLE IF NOT EXISTS `e_server_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`plus_addressing_enabled` tinyint(1) NOT NULL DEFAULT 0,
`plus_addressing_delimiter` varchar(1) NOT NULL DEFAULT '+',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
try:
cursor.execute(query)
except:
pass
query = """CREATE TABLE IF NOT EXISTS `e_plus_override` (
`domain_id` varchar(50) NOT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`domain_id`),
CONSTRAINT `fk_plus_override_domain` FOREIGN KEY (`domain_id`) REFERENCES `e_domains` (`domain`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
try:
cursor.execute(query)
except:
pass
query = """CREATE TABLE IF NOT EXISTS `e_pattern_forwarding` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` varchar(50) NOT NULL,
`pattern` varchar(255) NOT NULL,
`destination` varchar(255) NOT NULL,
`pattern_type` varchar(20) NOT NULL DEFAULT 'wildcard',
`priority` int(11) NOT NULL DEFAULT 100,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `fk_pattern_domain` (`domain_id`),
CONSTRAINT `fk_pattern_domain` FOREIGN KEY (`domain_id`) REFERENCES `e_domains` (`domain`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
try:
cursor.execute(query)
except:
pass
try:
connection.close()
except: