Add advanced email filtering features: catch-all, plus-addressing, and pattern forwarding

Features:
- Catch-All Email: Forward unmatched emails for a domain to a single address
- Plus-Addressing: Enable user+tag@domain.com delivery with configurable delimiter
- Pattern Forwarding: Wildcard and regex-based email forwarding rules

Implementation:
- New database models: CatchAllEmail, EmailServerSettings, PlusAddressingOverride, PatternForwarding
- New UI pages with AngularJS controllers
- Backend methods in mailserverManager.py with ACL permission checks
- Auto-generates /etc/postfix/virtual_regexp for pattern rules
- Menu items added under Email section
This commit is contained in:
usmannasir
2025-11-28 14:22:34 +05:00
parent d3621923e5
commit 082c63bfa9
10 changed files with 2509 additions and 1 deletions

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
# Generated migration for email filtering features
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='CatchAllEmail',
fields=[
('domain', models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to='mailServer.Domains'
)),
('destination', models.CharField(max_length=255)),
('enabled', models.BooleanField(default=True)),
],
options={
'db_table': 'e_catchall',
},
),
migrations.CreateModel(
name='EmailServerSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plus_addressing_enabled', models.BooleanField(default=False)),
('plus_addressing_delimiter', models.CharField(default='+', max_length=1)),
],
options={
'db_table': 'e_server_settings',
},
),
migrations.CreateModel(
name='PlusAddressingOverride',
fields=[
('domain', models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to='mailServer.Domains'
)),
('enabled', models.BooleanField(default=True)),
],
options={
'db_table': 'e_plus_override',
},
),
migrations.CreateModel(
name='PatternForwarding',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('pattern', models.CharField(max_length=255)),
('destination', models.CharField(max_length=255)),
('pattern_type', models.CharField(
choices=[('wildcard', 'Wildcard'), ('regex', 'Regular Expression')],
default='wildcard',
max_length=20
)),
('priority', models.IntegerField(default=100)),
('enabled', models.BooleanField(default=True)),
('domain', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='mailServer.Domains'
)),
],
options={
'db_table': 'e_pattern_forwarding',
'ordering': ['priority'],
},
),
]

View File

@@ -49,3 +49,58 @@ 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)
destination = models.CharField(max_length=255)
enabled = models.BooleanField(default=True)
class Meta:
db_table = 'e_catchall'
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'
@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)
enabled = models.BooleanField(default=True)
class Meta:
db_table = 'e_plus_override'
class PatternForwarding(models.Model):
"""Stores wildcard/regex forwarding rules"""
PATTERN_TYPES = [
('wildcard', 'Wildcard'),
('regex', 'Regular Expression'),
]
domain = models.ForeignKey(Domains, on_delete=models.CASCADE)
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'
ordering = ['priority']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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