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 = '

On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
'; + editor.innerHTML = '

On ' + ($scope.openMsg.date || '') + ', ' + ($scope.openMsg.from || '') + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
'; } }, 100); startDraftAutoSave(); @@ -392,11 +431,12 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.forwardMsg = function() { if (!$scope.openMsg) return; + var fsubj = $scope.openMsg.subject || ''; $scope.compose = { to: '', cc: '', bcc: '', - subject: ($scope.openMsg.subject.match(/^Fwd:/i) ? '' : 'Fwd: ') + $scope.openMsg.subject, + subject: (fsubj.match(/^Fwd:/i) ? '' : 'Fwd: ') + fsubj, body: '', files: [], inReplyTo: '', @@ -614,6 +654,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ // ── View Mode ──────────────────────────────────────────── $scope.setView = function(mode) { + stopDraftAutoSave(); $scope.viewMode = mode; $scope.openMsg = null; if (mode === 'contacts') $scope.loadContacts(); @@ -680,6 +721,17 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.composeToContact = function(c) { $scope.compose = {to: c.email_address, cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; $scope.viewMode = 'compose'; + $scope.showBcc = false; + $timeout(function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + editor.innerHTML = ''; + if ($scope.wmSettings.signatureHtml) { + editor.innerHTML = '

--
' + $scope.wmSettings.signatureHtml + '
'; + } + } + }, 100); + startDraftAutoSave(); }; // ── Sieve Rules ────────────────────────────────────────── diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py index 61a6cd81d..3895c519d 100644 --- a/webmail/webmailManager.py +++ b/webmail/webmailManager.py @@ -313,7 +313,9 @@ class WebmailManager: return self._error('Attachment not found.') filename, content_type, payload = result response = HttpResponse(payload, content_type=content_type) - response['Content-Disposition'] = 'attachment; filename="%s"' % filename + # Sanitize filename to prevent header injection + safe_filename = filename.replace('"', '_').replace('\r', '').replace('\n', '') + response['Content-Disposition'] = 'attachment; filename="%s"' % safe_filename return response except Exception as e: return self._error(str(e)) @@ -409,6 +411,8 @@ class WebmailManager: def apiSaveDraft(self): try: email_addr = self._get_email() + if not email_addr: + return self._error('Not logged in.') data = self._get_post_data() to = data.get('to', '') subject = data.get('subject', '') @@ -756,6 +760,9 @@ class WebmailManager: def apiProxyImage(self): """Proxy external images to prevent tracking and mixed content.""" + if not self._get_email(): + return self._error('Not logged in.') + url_b64 = self.request.GET.get('url', '') or self.request.POST.get('url', '') try: url = base64.urlsafe_b64decode(url_b64).decode('utf-8') @@ -765,6 +772,16 @@ class WebmailManager: if not url.startswith(('http://', 'https://')): return self._error('Invalid URL scheme.') + # Block internal/private IPs to prevent SSRF + import urllib.parse + hostname = urllib.parse.urlparse(url).hostname or '' + if hostname in ('localhost', '127.0.0.1', '::1', '0.0.0.0') or \ + hostname.startswith(('10.', '192.168.', '172.16.', '172.17.', '172.18.', + '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', + '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', + '172.29.', '172.30.', '172.31.', '169.254.')): + return self._error('Invalid URL.') + try: import urllib.request req = urllib.request.Request(url, headers={ @@ -776,5 +793,5 @@ class WebmailManager: return self._error('Not an image.') data = resp.read(5 * 1024 * 1024) # 5MB max return HttpResponse(data, content_type=content_type) - except Exception as e: - return self._error(str(e)) + except Exception: + return self._error('Failed to fetch image.')