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
This commit is contained in:
usmannasir
2026-03-05 03:08:07 +05:00
parent 72f33d3bcd
commit 6085364c98
6 changed files with 151 additions and 48 deletions

View File

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

View File

@@ -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: <authz_id>\x00<authn_id>\x00<password>
# 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()

View File

@@ -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

View File

@@ -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();

View File

@@ -34,8 +34,8 @@
<div class="wm-folder-item" ng-repeat="folder in folders"
ng-class="{'active': currentFolder === folder.name}"
ng-click="selectFolder(folder.name)">
<i class="fa" ng-class="getFolderIcon(folder.name)"></i>
<span class="wm-folder-name">{$ folder.name $}</span>
<i class="fa" ng-class="getFolderIcon(folder)"></i>
<span class="wm-folder-name">{$ folder.display_name || folder.name $}</span>
<span class="wm-badge" ng-if="folder.unread_count > 0">{$ folder.unread_count $}</span>
</div>
</div>

View File

@@ -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)))