Merge branch 'v2.4.4' of github.com:usmannasir/cyberpanel into v2.4.4
This commit is contained in:
commit
db35838f72
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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"> </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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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']}",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue