diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index 5c32520af..8908a0259 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -1573,6 +1573,21 @@ Email Forwarding {% endif %} + {% if admin or emailForwarding %} + + Catch-All Email + + {% endif %} + {% if admin or emailForwarding %} + + Pattern Forwarding + + {% endif %} + {% if admin %} + + Plus-Addressing + + {% endif %} {% if admin or changeEmailPassword %} Change Password diff --git a/mailServer/mailserverManager.py b/mailServer/mailserverManager.py index f65f2c452..ab8831506 100644 --- a/mailServer/mailserverManager.py +++ b/mailServer/mailserverManager.py @@ -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') diff --git a/mailServer/migrations/0001_email_filtering_features.py b/mailServer/migrations/0001_email_filtering_features.py new file mode 100644 index 000000000..c8b0784ef --- /dev/null +++ b/mailServer/migrations/0001_email_filtering_features.py @@ -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'], + }, + ), + ] diff --git a/mailServer/models.py b/mailServer/models.py index 96fa544da..3f98b4676 100644 --- a/mailServer/models.py +++ b/mailServer/models.py @@ -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'] \ No newline at end of file diff --git a/mailServer/static/mailServer/mailServer.js b/mailServer/static/mailServer/mailServer.js index cc9b2b939..1a30126c6 100644 --- a/mailServer/static/mailServer/mailServer.js +++ b/mailServer/static/mailServer/mailServer.js @@ -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; + }); + }; + +}); diff --git a/mailServer/templates/mailServer/catchAllEmail.html b/mailServer/templates/mailServer/catchAllEmail.html new file mode 100644 index 000000000..8d22b0aaf --- /dev/null +++ b/mailServer/templates/mailServer/catchAllEmail.html @@ -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 %} + + + +
+ + +
+
+

+ + {% trans "Catch-All Configuration" %} + +

+
+
+ {% if not status %} + + {% else %} +
+
+
+
+
+ + +
+
+
+
+ +
+

{% trans "Configure Catch-All" %}

+ +
+

{% trans "Current Configuration" %}

+
+ {% trans "Status" %} + + {$ currentEnabled ? 'Enabled' : 'Disabled' $} + +
+
+ {% trans "Destination" %} + {$ currentDestination $} +
+
+ +
+
+
+ + + {% trans "All unmatched emails will be forwarded to this address" %} +
+
+
+
+ +
+ +
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + {$ errorMessage $} +
+ +
+ + {$ successMessage $} +
+ +
+ + {% trans "Could not connect to server. Please refresh this page." %} +
+
+
+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/mailServer/templates/mailServer/patternForwarding.html b/mailServer/templates/mailServer/patternForwarding.html new file mode 100644 index 000000000..cdc5ebd4f --- /dev/null +++ b/mailServer/templates/mailServer/patternForwarding.html @@ -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 %} + + + +
+ + +
+
+

+ + {% trans "Pattern Forwarding Rules" %} + +

+
+
+ {% if not status %} +
+ +

{% trans "Postfix is disabled" %}

+

{% trans "You need to enable Postfix to configure pattern forwarding" %}

+ + + {% trans "Enable Postfix Now" %} + +
+ {% else %} +
+
+
+
+
+ + +
+
+
+
+ +
+

{% trans "Create New Rule" %}

+ +
+

{% trans "Wildcard Pattern Examples" %}

+
    +
  • user_* - {% trans "Matches user_anything (e.g., user_sales, user_123)" %}
  • +
  • support-? - {% trans "Matches support- followed by any single character" %}
  • +
  • team* - {% trans "Matches anything starting with team" %}
  • +
+
+ +
+

{% trans "Regex Pattern (Advanced)" %}

+
    +
  • user_[0-9]+ - {% trans "Matches user_ followed by digits" %}
  • +
  • support-(sales|billing) - {% trans "Matches support-sales or support-billing" %}
  • +
  • {% trans "Note: Pattern is matched against the local part only (before @)" %}
  • +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
{% trans "Priority" %}{% trans "Type" %}{% trans "Pattern" %}{% trans "Destination" %}{% trans "Actions" %}
+ + + {$ rule.pattern_type $} + + @{$ selectedDomain $} + +
+ +
+ + {% trans "No pattern forwarding rules configured for this domain yet." %} +
+
+ + +
+
+ + {$ errorMessage $} +
+ +
+ + {$ successMessage $} +
+ +
+ + {% trans "Could not connect to server. Please refresh this page." %} +
+
+
+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/mailServer/templates/mailServer/plusAddressingSettings.html b/mailServer/templates/mailServer/plusAddressingSettings.html new file mode 100644 index 000000000..d15f6a3b1 --- /dev/null +++ b/mailServer/templates/mailServer/plusAddressingSettings.html @@ -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 %} + + + +
+ + + {% if not status %} +
+
+
+ +

{% trans "Postfix is disabled" %}

+

{% trans "You need to enable Postfix to configure plus-addressing" %}

+ + + {% trans "Enable Postfix Now" %} + +
+
+
+ {% else %} + +
+
+

+ + {% trans "Global Settings" %} + +

+
+
+
+

{% trans "What is Plus-Addressing?" %}

+

{% 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." %}

+
+ +
+
+
+ +
+ + + {$ globalEnabled ? 'Enabled' : 'Disabled' $} + +
+
+
+
+
+ + +
+
+
+ + + + +
+
+ + {$ globalErrorMessage $} +
+
+ + {$ globalSuccessMessage $} +
+
+
+
+ + +
+
+

+ + {% trans "Per-Domain Settings" %} +

+
+
+
+ + {% trans "Per-domain settings allow you to track which domains should use plus-addressing. Note: Actual filtering is server-wide in Postfix." %} +
+ +
+
+
+ + +
+
+
+
+ +
+ +
+
+
+
+ + + + +
+
+ + {$ domainErrorMessage $} +
+
+ + {$ domainSuccessMessage $} +
+
+
+
+ {% endif %} +
+ +{% endblock %} diff --git a/mailServer/urls.py b/mailServer/urls.py index 91cd9aeeb..6aa6becef 100644 --- a/mailServer/urls.py +++ b/mailServer/urls.py @@ -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'), ] diff --git a/mailServer/views.py b/mailServer/views.py index 62f6ca9b8..7d3a33dcf 100644 --- a/mailServer/views.py +++ b/mailServer/views.py @@ -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)