mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-05-06 20:26:13 +02:00
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:
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user