diff --git a/install/install.py b/install/install.py index c3ced5ecd..b447d5742 100644 --- a/install/install.py +++ b/install/install.py @@ -2880,11 +2880,13 @@ def main(): checks.install_postfix_dovecot() checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql) checks.setup_postfix_dovecot_config(mysql) + installCyberPanel.InstallCyberPanel.setupWebmail() else: if args.postfix == 'ON': checks.install_postfix_dovecot() checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql) checks.setup_postfix_dovecot_config(mysql) + installCyberPanel.InstallCyberPanel.setupWebmail() checks.install_unzip() checks.install_zip() diff --git a/install/installCyberPanel.py b/install/installCyberPanel.py index b4837b05c..aa3d1b0f6 100644 --- a/install/installCyberPanel.py +++ b/install/installCyberPanel.py @@ -705,7 +705,8 @@ module cyberpanel_ols { logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [installSieve]") return 0 - def setupWebmail(self): + @staticmethod + def setupWebmail(): """Set up Dovecot master user and webmail config for SSO""" try: InstallCyberPanel.stdOut("Setting up webmail master user for SSO...", 1) @@ -1364,8 +1365,7 @@ def Main(cwd, mysql, distro, ent, serial=None, port="8090", ftp=None, dns=None, logging.InstallLog.writeToFile('Installing Sieve for email filtering..,55') installer.installSieve() - logging.InstallLog.writeToFile('Setting up webmail master user..,57') - installer.setupWebmail() + ## setupWebmail is called later, after Dovecot is installed (see install.py) logging.InstallLog.writeToFile('Installing MySQL,60') installer.installMySQL(mysql) diff --git a/plogical/upgrade.py b/plogical/upgrade.py index 1fa7e3b2e..678a7e15a 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -2805,48 +2805,48 @@ CREATE TABLE `websiteFunctions_backupsv2` (`id` integer AUTO_INCREMENT NOT NULL def setupWebmail(): """Set up Dovecot master user and webmail config for SSO (idempotent)""" try: - # Skip if already configured - if os.path.exists('/etc/cyberpanel/webmail.conf'): - Upgrade.stdOut("Webmail master user already configured, skipping.", 0) - return - # Skip if no mail server installed if not os.path.exists('/etc/dovecot/dovecot.conf'): Upgrade.stdOut("Dovecot not installed, skipping webmail setup.", 0) return - Upgrade.stdOut("Setting up webmail master user for SSO...", 0) + # Always run migrations and dovecot.conf patching even if conf exists + already_configured = os.path.exists('/etc/cyberpanel/webmail.conf') and \ + os.path.exists('/etc/dovecot/master-users') - from plogical.randomPassword import generate_pass + if not already_configured: + Upgrade.stdOut("Setting up webmail master user for SSO...", 0) - master_password = generate_pass(32) + from plogical.randomPassword import generate_pass - # Hash the password using doveadm - result = subprocess.run( - ['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password], - capture_output=True, text=True - ) - if result.returncode != 0: - Upgrade.stdOut("doveadm pw failed: " + result.stderr, 0) - return + master_password = generate_pass(32) - password_hash = result.stdout.strip() + # Hash the password using doveadm + result = subprocess.run( + ['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password], + capture_output=True, text=True + ) + if result.returncode != 0: + Upgrade.stdOut("doveadm pw failed: " + result.stderr, 0) + return - # Write /etc/dovecot/master-users - with open('/etc/dovecot/master-users', 'w') as f: - f.write('cyberpanel_master:' + password_hash + '\n') - os.chmod('/etc/dovecot/master-users', 0o600) - subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users']) + password_hash = result.stdout.strip() - # Write /etc/cyberpanel/webmail.conf - webmail_conf = { - 'master_user': 'cyberpanel_master', - 'master_password': master_password - } - with open('/etc/cyberpanel/webmail.conf', 'w') as f: - json.dump(webmail_conf, f) - os.chmod('/etc/cyberpanel/webmail.conf', 0o600) - subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf']) + # Write /etc/dovecot/master-users + with open('/etc/dovecot/master-users', 'w') as f: + f.write('cyberpanel_master:' + password_hash + '\n') + os.chmod('/etc/dovecot/master-users', 0o600) + subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users']) + + # Write /etc/cyberpanel/webmail.conf + webmail_conf = { + 'master_user': 'cyberpanel_master', + 'master_password': master_password + } + with open('/etc/cyberpanel/webmail.conf', 'w') as f: + json.dump(webmail_conf, f) + os.chmod('/etc/cyberpanel/webmail.conf', 0o600) + subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf']) # Patch dovecot.conf if master user config not present dovecot_conf_path = '/etc/dovecot/dovecot.conf' diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py index 1fe378b70..57976909a 100644 --- a/webmail/services/imap_client.py +++ b/webmail/services/imap_client.py @@ -209,7 +209,9 @@ class IMAPClient: def search_messages(self, folder='INBOX', query='', criteria='ALL'): self._select(folder) if query: - search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (query, query, query) + # Escape quotes to prevent IMAP search injection + safe_query = query.replace('\\', '\\\\').replace('"', '\\"') + search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (safe_query, safe_query, safe_query) else: search_criteria = criteria status, data = self.conn.uid('search', None, search_criteria) @@ -263,13 +265,16 @@ class IMAPClient: msg = email.message_from_bytes(raw) if isinstance(raw, bytes) else email.message_from_string(raw) part_idx = 0 for part in msg.walk(): - if part.get_content_maintype() == 'multipart': + content_type = part.get_content_type() + if content_type.startswith('multipart/'): continue - if part.get('Content-Disposition') and 'attachment' in part.get('Content-Disposition', ''): + disposition = str(part.get('Content-Disposition', '')) + # Match the same indexing logic as email_parser.py: + # count parts that are attachments or non-text with disposition + if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition): if str(part_idx) == str(part_id): filename = part.get_filename() or 'attachment' filename = self._decode_header_value(filename) - content_type = part.get_content_type() payload = part.get_payload(decode=True) return (filename, content_type, payload) part_idx += 1 diff --git a/webmail/services/sieve_client.py b/webmail/services/sieve_client.py index 5d1c8d38a..20b71b6ac 100644 --- a/webmail/services/sieve_client.py +++ b/webmail/services/sieve_client.py @@ -39,6 +39,8 @@ class SieveClient: lines = [] while True: line = self._read_line() + if not line and not self.buf: + return False, lines, 'Connection closed' if line.startswith('OK'): return True, lines, line elif line.startswith('NO'): @@ -167,9 +169,9 @@ class SieveClient: for rule in rules: field = rule.get('condition_field', 'from') cond_type = rule.get('condition_type', 'contains') - cond_value = rule.get('condition_value', '') + cond_value = rule.get('condition_value', '').replace('\\', '\\\\').replace('"', '\\"') action_type = rule.get('action_type', 'move') - action_value = rule.get('action_value', '') + action_value = rule.get('action_value', '').replace('\\', '\\\\').replace('"', '\\"') # Map field to Sieve header if field == 'from': diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js index 4d46ab32d..64c154de6 100644 --- a/webmail/static/webmail/webmail.js +++ b/webmail/static/webmail/webmail.js @@ -148,12 +148,13 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ var draftTimer = null; // ── Helper ─────────────────────────────────────────────── - function apiCall(url, data, callback) { + function apiCall(url, data, callback, errback) { var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}}; $http.post(url, data || {}, config).then(function(resp) { if (callback) callback(resp.data); }, function(err) { console.error('API error:', url, err); + if (errback) errback(err); }); } @@ -187,6 +188,10 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.currentPage = 1; $scope.openMsg = null; $scope.viewMode = 'list'; + $scope.messages = []; + $scope.contacts = []; + $scope.filteredContacts = []; + $scope.sieveRules = []; $scope.loadFolders(); $scope.loadSettings(); } else { @@ -267,7 +272,11 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.totalMessages = data.total; $scope.totalPages = data.pages; $scope.selectAll = false; + } else { + notify(data.error_message || 'Failed to load messages.', 'error'); } + }, function() { + $scope.loading = false; }); }; @@ -296,10 +305,28 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ query: $scope.searchQuery }, function(data) { $scope.loading = false; - if (data.status === 1) { - // Re-fetch with found UIDs (simplified: reload) - $scope.loadMessages(); + if (data.status === 1 && data.uids && data.uids.length > 0) { + // Fetch the found messages by their UIDs + apiCall('/webmail/api/listMessages', { + folder: $scope.currentFolder, + page: 1, + perPage: data.uids.length, + uids: data.uids + }, function(msgData) { + if (msgData.status === 1) { + $scope.messages = msgData.messages; + $scope.totalMessages = msgData.total; + $scope.totalPages = msgData.pages; + } + }); + } else if (data.status === 1) { + $scope.messages = []; + $scope.totalMessages = 0; + $scope.totalPages = 1; + notify('No messages found.', 'info'); } + }, function() { + $scope.loading = false; }); }; @@ -311,15 +338,26 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ }, function(data) { if (data.status === 1) { $scope.openMsg = data.message; - $scope.trustedBody = $sce.trustAsHtml(data.message.body_html || ('
' + (data.message.body_text || '') + '')); + var html = data.message.body_html || ''; + var text = data.message.body_text || ''; + // Use sanitized HTML from backend, or escape plain text + if (html) { + $scope.trustedBody = $sce.trustAsHtml(html); + } else { + // Escape plain text to prevent XSS + var escaped = text.replace(/&/g,'&').replace(//g,'>'); + $scope.trustedBody = $sce.trustAsHtml('
' + escaped + ''); + } $scope.viewMode = 'read'; - msg.is_read = true; - // Update folder unread count - $scope.folders.forEach(function(f) { - if (f.name === $scope.currentFolder && f.unread_count > 0) { - f.unread_count--; - } - }); + // Only decrement unread count if message was actually unread + if (!msg.is_read) { + msg.is_read = true; + $scope.folders.forEach(function(f) { + if (f.name === $scope.currentFolder && f.unread_count > 0) { + f.unread_count--; + } + }); + } } }); }; @@ -344,11 +382,12 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.replyTo = function() { if (!$scope.openMsg) return; + var subj = $scope.openMsg.subject || ''; $scope.compose = { to: $scope.openMsg.from, cc: '', bcc: '', - subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject, + subject: (subj.match(/^Re:/i) ? '' : 'Re: ') + subj, body: '', files: [], inReplyTo: $scope.openMsg.message_id || '', @@ -374,7 +413,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ to: $scope.openMsg.from, cc: cc.join(', '), bcc: '', - subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject, + subject: (($scope.openMsg.subject || '').match(/^Re:/i) ? '' : 'Re: ') + ($scope.openMsg.subject || ''), body: '', files: [], inReplyTo: $scope.openMsg.message_id || '', @@ -384,7 +423,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $timeout(function() { var editor = document.getElementById('wm-compose-body'); if (editor) { - editor.innerHTML = '
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '