From 6085364c98a16560c847051a8064a06de10d9f90 Mon Sep 17 00:00:00 2001 From: usmannasir Date: Thu, 5 Mar 2026 03:08:07 +0500 Subject: [PATCH] Fix webmail to match CyberPanel Dovecot/Postfix configuration - Use correct Dovecot namespace (separator='.', prefix='INBOX.'): folders are INBOX.Sent, INBOX.Drafts, INBOX.Deleted Items, etc. - Quote IMAP folder names with spaces (e.g. "INBOX.Deleted Items") - Add display_name and folder_type to folder list API response - Fix SMTP for SSO: use local relay on port 25 (permit_mynetworks) since Dovecot has no auth_master_user_separator for port 587 - Fix Sieve SASL PLAIN auth to use clean RFC 4616 format - Handle ManageSieve unavailability gracefully with helpful logging - Update frontend to show clean folder names and correct icons - Auto-prefix new folder names with INBOX. namespace --- webmail/services/imap_client.py | 79 ++++++++++++++++++++++------ webmail/services/sieve_client.py | 4 +- webmail/services/smtp_client.py | 55 +++++++++++++------ webmail/static/webmail/webmail.js | 25 ++++++--- webmail/templates/webmail/index.html | 4 +- webmail/webmailManager.py | 32 +++++++++-- 6 files changed, 151 insertions(+), 48 deletions(-) diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py index 058e2eddf..1fe378b70 100644 --- a/webmail/services/imap_client.py +++ b/webmail/services/imap_client.py @@ -6,7 +6,26 @@ from email.header import decode_header class IMAPClient: - """Wrapper around imaplib.IMAP4_SSL for Dovecot IMAP operations.""" + """Wrapper around imaplib.IMAP4_SSL for Dovecot IMAP operations. + + CyberPanel's Dovecot uses namespace: separator='.', prefix='INBOX.' + So folders are: INBOX, INBOX.Sent, INBOX.Drafts, INBOX.Deleted Items, + INBOX.Junk E-mail, INBOX.Archive, etc. + """ + + # Dovecot namespace config: separator='.', prefix='INBOX.' + NS_PREFIX = 'INBOX.' + NS_SEP = '.' + + # Map of standard folder purposes to actual Dovecot folder names + # (CyberPanel creates these in mailUtilities.py) + SPECIAL_FOLDERS = { + 'sent': 'INBOX.Sent', + 'drafts': 'INBOX.Drafts', + 'trash': 'INBOX.Deleted Items', + 'junk': 'INBOX.Junk E-mail', + 'archive': 'INBOX.Archive', + } def __init__(self, email_address, password, host='localhost', port=993, master_user=None, master_password=None): @@ -68,6 +87,23 @@ class IMAPClient: return {'name': name, 'delimiter': delimiter, 'flags': flags} return None + def _display_name(self, folder_name): + """Strip INBOX. prefix for display, keep INBOX as-is.""" + if folder_name == 'INBOX': + return 'Inbox' + if folder_name.startswith(self.NS_PREFIX): + return folder_name[len(self.NS_PREFIX):] + return folder_name + + def _folder_type(self, folder_name): + """Identify special folder type for UI icon mapping.""" + for ftype, fname in self.SPECIAL_FOLDERS.items(): + if folder_name == fname: + return ftype + if folder_name == 'INBOX': + return 'inbox' + return 'folder' + def list_folders(self): status, data = self.conn.list() if status != 'OK': @@ -83,10 +119,9 @@ class IMAPClient: unread = 0 total = 0 try: - st, counts = self.conn.status( - '"%s"' % folder_name if ' ' in folder_name else folder_name, - '(MESSAGES UNSEEN)' - ) + # Quote folder names with spaces for STATUS command + quoted = '"%s"' % folder_name + st, counts = self.conn.status(quoted, '(MESSAGES UNSEEN)') if st == 'OK' and counts[0]: count_str = counts[0].decode('utf-8', errors='replace') if isinstance(counts[0], bytes) else counts[0] m = re.search(r'MESSAGES\s+(\d+)', count_str) @@ -99,6 +134,8 @@ class IMAPClient: pass folders.append({ 'name': folder_name, + 'display_name': self._display_name(folder_name), + 'folder_type': self._folder_type(folder_name), 'delimiter': parsed['delimiter'], 'flags': parsed['flags'], 'unread_count': unread, @@ -106,8 +143,12 @@ class IMAPClient: }) return folders + def _select(self, folder): + """Select a folder, quoting names with spaces.""" + return self.conn.select('"%s"' % folder) + def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc'): - self.conn.select(folder) + self._select(folder) status, data = self.conn.uid('search', None, 'ALL') if status != 'OK': return {'messages': [], 'total': 0, 'page': page, 'pages': 0} @@ -166,7 +207,7 @@ class IMAPClient: return {'messages': messages, 'total': total, 'page': page, 'pages': pages} def search_messages(self, folder='INBOX', query='', criteria='ALL'): - self.conn.select(folder) + self._select(folder) if query: search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (query, query, query) else: @@ -177,7 +218,7 @@ class IMAPClient: return data[0].split() if data[0] else [] def get_message(self, folder, uid): - self.conn.select(folder) + self._select(folder) status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822 FLAGS)') if status != 'OK' or not data or not data[0]: return None @@ -205,7 +246,7 @@ class IMAPClient: return parsed def get_attachment(self, folder, uid, part_id): - self.conn.select(folder) + self._select(folder) status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822)') if status != 'OK' or not data or not data[0]: return None @@ -236,15 +277,17 @@ class IMAPClient: return None def move_messages(self, folder, uids, target_folder): - self.conn.select(folder) + self._select(folder) uid_str = ','.join(str(u) for u in uids) + # Quote target folder name for folders with spaces (e.g. "INBOX.Deleted Items") + quoted_target = '"%s"' % target_folder try: - status, _ = self.conn.uid('move', uid_str, target_folder) + status, _ = self.conn.uid('move', uid_str, quoted_target) if status == 'OK': return True except Exception: pass - status, _ = self.conn.uid('copy', uid_str, target_folder) + status, _ = self.conn.uid('copy', uid_str, quoted_target) if status == 'OK': self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') self.conn.expunge() @@ -252,25 +295,27 @@ class IMAPClient: return False def delete_messages(self, folder, uids): - self.conn.select(folder) + self._select(folder) uid_str = ','.join(str(u) for u in uids) - trash_folders = ['Trash', 'INBOX.Trash', '[Gmail]/Trash'] + # CyberPanel/Dovecot uses "INBOX.Deleted Items" as trash + trash_folders = ['INBOX.Deleted Items', 'INBOX.Trash', 'Trash'] if folder not in trash_folders: for trash in trash_folders: try: - status, _ = self.conn.uid('copy', uid_str, trash) + status, _ = self.conn.uid('copy', uid_str, '"%s"' % trash) if status == 'OK': self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') self.conn.expunge() return True except Exception: continue + # Already in trash or no trash folder found - permanently delete self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') self.conn.expunge() return True def set_flags(self, folder, uids, flags, action='add'): - self.conn.select(folder) + self._select(folder) uid_str = ','.join(str(u) for u in uids) flag_str = '(%s)' % ' '.join(flags) if action == 'add': @@ -304,5 +349,5 @@ class IMAPClient: if isinstance(raw_message, str): raw_message = raw_message.encode('utf-8') flag_str = '(%s)' % flags if flags else None - status, _ = self.conn.append(folder, flag_str, None, raw_message) + status, _ = self.conn.append('"%s"' % folder, flag_str, None, raw_message) return status == 'OK' diff --git a/webmail/services/sieve_client.py b/webmail/services/sieve_client.py index aef6907db..5d1c8d38a 100644 --- a/webmail/services/sieve_client.py +++ b/webmail/services/sieve_client.py @@ -79,8 +79,10 @@ class SieveClient: raise Exception('Sieve authentication failed: %s' % msg) def _authenticate_master(self, user, master_user, master_password): + # SASL PLAIN format per RFC 4616: \x00\x00 + # authz_id = target user, authn_id = master user, password = master password auth_str = base64.b64encode( - ('%s\x00%s*%s\x00%s' % (user, user, master_user, master_password)).encode('utf-8') + ('%s\x00%s\x00%s' % (user, master_user, master_password)).encode('utf-8') ).decode('ascii') self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str) ok, _, msg = self._read_response() diff --git a/webmail/services/smtp_client.py b/webmail/services/smtp_client.py index 51eb65f0e..02673975d 100644 --- a/webmail/services/smtp_client.py +++ b/webmail/services/smtp_client.py @@ -3,32 +3,49 @@ import ssl class SMTPClient: - """Wrapper around smtplib.SMTP for sending mail via Postfix.""" + """Wrapper around smtplib.SMTP for sending mail via Postfix. - def __init__(self, email_address, password, host='localhost', port=587): + Supports two modes: + 1. Authenticated (port 587 + STARTTLS) — for standalone login sessions + 2. Local relay (port 25, no auth) — for SSO sessions using master user + Postfix accepts relay from localhost (permit_mynetworks in main.cf) + """ + + def __init__(self, email_address, password, host='localhost', port=587, + use_local_relay=False): self.email_address = email_address self.password = password self.host = host self.port = port + self.use_local_relay = use_local_relay def send_message(self, mime_message): - """Send a composed email.message.EmailMessage via SMTP with STARTTLS. + """Send a composed email via SMTP. Returns: dict: {success: bool, message_id: str or None, error: str or None} """ try: - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE + if self.use_local_relay: + # SSO mode: send via port 25 without auth + # Postfix permits relay from localhost (permit_mynetworks) + smtp = smtplib.SMTP(self.host, 25) + smtp.ehlo() + smtp.send_message(mime_message) + smtp.quit() + else: + # Standalone mode: authenticated via port 587 + STARTTLS + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE - smtp = smtplib.SMTP(self.host, self.port) - smtp.ehlo() - smtp.starttls(context=ctx) - smtp.ehlo() - smtp.login(self.email_address, self.password) - smtp.send_message(mime_message) - smtp.quit() + smtp = smtplib.SMTP(self.host, self.port) + smtp.ehlo() + smtp.starttls(context=ctx) + smtp.ehlo() + smtp.login(self.email_address, self.password) + smtp.send_message(mime_message) + smtp.quit() message_id = mime_message.get('Message-ID', '') return {'success': True, 'message_id': message_id} @@ -40,8 +57,12 @@ class SMTPClient: return {'success': False, 'message_id': None, 'error': str(e)} def save_to_sent(self, imap_client, raw_message): - """Append sent message to the Sent folder via IMAP.""" - sent_folders = ['Sent', 'INBOX.Sent', 'Sent Messages', 'Sent Items'] + """Append sent message to the Sent folder via IMAP. + + CyberPanel's Dovecot uses INBOX.Sent as the Sent folder. + """ + # Try CyberPanel's actual folder name first, then fallbacks + sent_folders = ['INBOX.Sent', 'Sent', 'Sent Messages', 'Sent Items'] for folder in sent_folders: try: if imap_client.append_message(folder, raw_message, '\\Seen'): @@ -49,7 +70,7 @@ class SMTPClient: except Exception: continue try: - imap_client.create_folder('Sent') - return imap_client.append_message('Sent', raw_message, '\\Seen') + imap_client.create_folder('INBOX.Sent') + return imap_client.append_message('INBOX.Sent', raw_message, '\\Seen') except Exception: return False diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js index 5592ec501..7a80f0d52 100644 --- a/webmail/static/webmail/webmail.js +++ b/webmail/static/webmail/webmail.js @@ -212,13 +212,22 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.loadMessages(); }; - $scope.getFolderIcon = function(name) { - var n = name.toLowerCase(); + $scope.getFolderIcon = function(folder) { + // Use folder_type from backend if available (mapped from Dovecot folder names) + var ftype = folder.folder_type || ''; + if (ftype === 'inbox') return 'fa-inbox'; + if (ftype === 'sent') return 'fa-paper-plane'; + if (ftype === 'drafts') return 'fa-file'; + if (ftype === 'trash') return 'fa-trash'; + if (ftype === 'junk') return 'fa-ban'; + if (ftype === 'archive') return 'fa-box-archive'; + // Fallback to name-based detection + var n = (folder.display_name || folder.name || '').toLowerCase(); if (n === 'inbox') return 'fa-inbox'; - if (n === 'sent' || n.indexOf('sent') >= 0) return 'fa-paper-plane'; - if (n === 'drafts' || n.indexOf('draft') >= 0) return 'fa-file'; - if (n === 'trash' || n.indexOf('trash') >= 0) return 'fa-trash'; - if (n === 'junk' || n === 'spam' || n.indexOf('junk') >= 0) return 'fa-ban'; + if (n.indexOf('sent') >= 0) return 'fa-paper-plane'; + if (n.indexOf('draft') >= 0) return 'fa-file'; + if (n.indexOf('deleted') >= 0 || n.indexOf('trash') >= 0) return 'fa-trash'; + if (n.indexOf('junk') >= 0 || n.indexOf('spam') >= 0) return 'fa-ban'; if (n.indexOf('archive') >= 0) return 'fa-box-archive'; return 'fa-folder'; }; @@ -226,6 +235,10 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.createFolder = function() { var name = prompt('Folder name:'); if (!name) return; + // Dovecot namespace: prefix with INBOX. and use . as separator + if (name.indexOf('INBOX.') !== 0) { + name = 'INBOX.' + name; + } apiCall('/webmail/api/createFolder', {name: name}, function(data) { if (data.status === 1) { $scope.loadFolders(); diff --git a/webmail/templates/webmail/index.html b/webmail/templates/webmail/index.html index 28bee4abb..64c9e1400 100644 --- a/webmail/templates/webmail/index.html +++ b/webmail/templates/webmail/index.html @@ -34,8 +34,8 @@
- - {$ folder.name $} + + {$ folder.display_name || folder.name $} {$ folder.unread_count $}
diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py index 9727f9957..ece2e07ff 100644 --- a/webmail/webmailManager.py +++ b/webmail/webmailManager.py @@ -75,6 +75,16 @@ class WebmailManager: addr = self._get_email() if not addr: raise Exception('No email account selected') + + # If using master user (SSO), we can't auth to SMTP since + # auth_master_user_separator is not set in Dovecot. + # Use local relay via port 25 instead (Postfix permits localhost). + master_user, master_pass = self._get_master_config() + is_standalone = self.request.session.get('webmail_standalone', False) + + if master_user and master_pass and not is_standalone: + return SMTPClient(addr, '', use_local_relay=True) + password = self.request.session.get('webmail_password', '') return SMTPClient(addr, password) @@ -233,7 +243,9 @@ class WebmailManager: name = data.get('name', '') if not name: return self._error('Folder name is required.') - protected = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam'] + # CyberPanel/Dovecot folder names (INBOX. prefix, separator '.') + protected = ['INBOX', 'INBOX.Sent', 'INBOX.Drafts', 'INBOX.Deleted Items', + 'INBOX.Junk E-mail', 'INBOX.Archive'] if name in protected: return self._error('Cannot delete system folder.') try: @@ -407,7 +419,8 @@ class WebmailManager: ) with self._get_imap() as imap: - draft_folders = ['Drafts', 'INBOX.Drafts', 'Draft'] + # CyberPanel's Dovecot uses INBOX.Drafts + draft_folders = ['INBOX.Drafts', 'Drafts', 'Draft'] saved = False for folder in draft_folders: try: @@ -417,8 +430,8 @@ class WebmailManager: except Exception: continue if not saved: - imap.create_folder('Drafts') - imap.append_message('Drafts', mime_msg.as_bytes(), '\\Draft \\Seen') + imap.create_folder('INBOX.Drafts') + imap.append_message('INBOX.Drafts', mime_msg.as_bytes(), '\\Draft \\Seen') return self._success() except Exception as e: @@ -664,7 +677,12 @@ class WebmailManager: return self._error(str(e)) def _sync_sieve_rules(self, email): - """Generate sieve script from DB rules and upload to Dovecot.""" + """Generate sieve script from DB rules and upload to Dovecot. + + ManageSieve may not be available if dovecot-sieve/pigeonhole is not + installed or if the ManageSieve service isn't running on port 4190. + Rules are always saved to the database; Sieve sync is best-effort. + """ rules = SieveRule.objects.filter(email_account=email, is_active=True).order_by('priority') rule_dicts = [] for r in rules: @@ -683,6 +701,10 @@ class WebmailManager: with self._get_sieve(email) as sieve: sieve.put_script('cyberpanel', script) sieve.activate_script('cyberpanel') + except ConnectionRefusedError: + logging.CyberCPLogFileWriter.writeToFile( + 'Sieve sync skipped for %s: ManageSieve not running on port 4190. ' + 'Install dovecot-sieve and enable ManageSieve.' % email) except Exception as e: logging.CyberCPLogFileWriter.writeToFile('Sieve sync failed for %s: %s' % (email, str(e)))