mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-06-26 16:50:36 +02:00
webmail: folder settings store, spam/junk layout, cache busting
- Persist folder mappings outside /etc/cyberpanel (writable fallback under CyberCP) - IMAP client + manager: settings API, safer folder delete rules - Frontend: case-insensitive roles, layout race fix, single Spam mapping row - Views: no-store cache headers; template v5 UI marker and webmail.js v26
This commit is contained in:
@@ -96,12 +96,39 @@ class IMAPClient:
|
||||
return folder_name
|
||||
|
||||
def _folder_type(self, folder_name):
|
||||
"""Identify special folder type for UI icon mapping."""
|
||||
"""Identify special folder type for UI (icons, sidebar grouping).
|
||||
|
||||
CyberPanel/Dovecot uses INBOX.* names; accounts may also use Spam, Trash, etc.
|
||||
"""
|
||||
fn = (folder_name or '').strip()
|
||||
if not fn:
|
||||
return 'folder'
|
||||
for ftype, fname in self.SPECIAL_FOLDERS.items():
|
||||
if folder_name == fname:
|
||||
if fn == fname:
|
||||
return ftype
|
||||
if folder_name == 'INBOX':
|
||||
if fn == 'INBOX':
|
||||
return 'inbox'
|
||||
|
||||
tail = fn[len(self.NS_PREFIX):] if fn.startswith(self.NS_PREFIX) else fn
|
||||
tl = tail.strip().lower()
|
||||
|
||||
if tl in ('sent',):
|
||||
return 'sent'
|
||||
if tl in ('drafts',):
|
||||
return 'drafts'
|
||||
if tl in ('archive',):
|
||||
return 'archive'
|
||||
junk_tails = (
|
||||
'junk e-mail', 'junk email', 'junk-mail', 'junk mail', 'junk',
|
||||
'spam', 'bulk mail', 'bulk',
|
||||
)
|
||||
if tl in junk_tails or tl.endswith('.spam'):
|
||||
return 'junk'
|
||||
trash_tails = (
|
||||
'deleted items', 'trash', 'bin', 'deleted messages',
|
||||
)
|
||||
if tl in trash_tails:
|
||||
return 'trash'
|
||||
return 'folder'
|
||||
|
||||
def list_folders(self):
|
||||
|
||||
@@ -11,74 +11,76 @@ class WebmailFolderSettingsStore:
|
||||
File-based storage for folder mappings and ordering.
|
||||
|
||||
This avoids DB migrations for a fast server-side feature rollout.
|
||||
Data is stored per-email account inside /etc/cyberpanel/.
|
||||
|
||||
Default path is under /usr/local/CyberCP/ (root:cyberpanel, 775) so the
|
||||
lswsgi/cyberpanel user can write. Older installs used /etc/cyberpanel/
|
||||
(root:root) which causes Permission denied for the app user; we migrate
|
||||
reads from that legacy path when the new file does not exist yet.
|
||||
|
||||
Override with env CYBERPANEL_WEBMAIL_FOLDER_SETTINGS (absolute file path).
|
||||
"""
|
||||
|
||||
STORE_DIR = '/etc/cyberpanel'
|
||||
STORE_PATH = '/etc/cyberpanel/webmail_folder_settings.json'
|
||||
# Legacy location (often not writable by cyberpanel user)
|
||||
LEGACY_STORE_PATH = '/etc/cyberpanel/webmail_folder_settings.json'
|
||||
# Writable alongside CyberPanel code for typical CyberPanel deployments
|
||||
STORE_PATH = '/usr/local/CyberCP/webmail_folder_settings.json'
|
||||
|
||||
DEFAULT_SPECIAL_DISPLAY_MODE = 'top'
|
||||
|
||||
# If anything resolves here, force writes to this path (panel user can write CyberCP dir).
|
||||
_WRITABLE_FALLBACK = '/usr/local/CyberCP/webmail_folder_settings.json'
|
||||
|
||||
@staticmethod
|
||||
def _is_under_etc_cyberpanel(path: str) -> bool:
|
||||
try:
|
||||
ap = os.path.realpath(os.path.abspath(path))
|
||||
except Exception:
|
||||
ap = os.path.abspath(path or '')
|
||||
pref = '/etc/cyberpanel' + os.sep
|
||||
return ap == '/etc/cyberpanel' or ap.startswith(pref)
|
||||
|
||||
def __init__(self, store_path: str = None):
|
||||
self.store_path = store_path or self.STORE_PATH
|
||||
|
||||
def _ensure_store_dir(self) -> None:
|
||||
try:
|
||||
os.makedirs(self.STORE_DIR, mode=0o700, exist_ok=True)
|
||||
except Exception:
|
||||
# If we can't create the dir, we'll fail on write with a clear error later.
|
||||
pass
|
||||
|
||||
def _defaults(self) -> Dict[str, Any]:
|
||||
# Semantic keys used by the UI.
|
||||
junk = IMAPDefaults.SPECIAL_FOLDERS.get('junk', 'INBOX.Junk E-mail')
|
||||
trash = IMAPDefaults.SPECIAL_FOLDERS.get('trash', 'INBOX.Deleted Items')
|
||||
drafts = IMAPDefaults.SPECIAL_FOLDERS.get('drafts', 'INBOX.Drafts')
|
||||
|
||||
return {
|
||||
'specialDisplayMode': self.DEFAULT_SPECIAL_DISPLAY_MODE, # 'top' or 'interleaved'
|
||||
'folderMappings': {
|
||||
'inbox': 'INBOX',
|
||||
'spam': junk, # semantic alias for junk folder
|
||||
'deleted_items': trash,
|
||||
'junk_e_mail': junk,
|
||||
'drafts': drafts,
|
||||
'trash': trash,
|
||||
},
|
||||
# Order of all folders when specialDisplayMode='interleaved'.
|
||||
# When 'top', this order is still kept as: special group + other group.
|
||||
'folderOrder': [],
|
||||
# Semantic group order for the "top" special section.
|
||||
'specialOrder': ['inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash'],
|
||||
'enableDragDrop': True,
|
||||
}
|
||||
|
||||
def _read_all(self) -> Dict[str, Any]:
|
||||
self._ensure_store_dir()
|
||||
if not os.path.isfile(self.store_path):
|
||||
return {}
|
||||
|
||||
with open(self.store_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
"""Primary file used for *read* first lookup. Never an unwritable /etc path."""
|
||||
env = (os.environ.get('CYBERPANEL_WEBMAIL_FOLDER_SETTINGS') or '').strip()
|
||||
explicit = (store_path or env or '').strip()
|
||||
self.store_path = self.STORE_PATH
|
||||
if explicit:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
raw = f.read()
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return {}
|
||||
ap = os.path.realpath(os.path.abspath(explicit))
|
||||
except Exception:
|
||||
ap = os.path.abspath(explicit)
|
||||
try:
|
||||
leg = os.path.realpath(os.path.abspath(self.LEGACY_STORE_PATH))
|
||||
except Exception:
|
||||
leg = os.path.abspath(self.LEGACY_STORE_PATH)
|
||||
etc_bad = self._is_under_etc_cyberpanel(ap) or ap == leg
|
||||
if not etc_bad:
|
||||
self.store_path = explicit
|
||||
|
||||
def _write_store_path(self) -> str:
|
||||
"""Persist only to a writable path (never /etc/cyberpanel)."""
|
||||
if self._is_under_etc_cyberpanel(self.STORE_PATH):
|
||||
return self._WRITABLE_FALLBACK
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
sp = os.path.realpath(os.path.abspath((self.store_path or '').strip() or self.STORE_PATH))
|
||||
except Exception:
|
||||
return {}
|
||||
sp = os.path.abspath(self.STORE_PATH)
|
||||
if self._is_under_etc_cyberpanel(sp):
|
||||
return self._WRITABLE_FALLBACK
|
||||
try:
|
||||
leg = os.path.realpath(os.path.abspath(self.LEGACY_STORE_PATH))
|
||||
if sp == leg:
|
||||
return self._WRITABLE_FALLBACK
|
||||
except Exception:
|
||||
pass
|
||||
return self.store_path
|
||||
|
||||
def _write_all(self, all_data: Dict[str, Any]) -> None:
|
||||
self._ensure_store_dir()
|
||||
tmp_path = self.store_path + '.tmp'
|
||||
out_path = self._write_store_path()
|
||||
if self._is_under_etc_cyberpanel(out_path):
|
||||
out_path = self._WRITABLE_FALLBACK
|
||||
self._ensure_store_dir(out_path)
|
||||
tmp_path = out_path + '.tmp'
|
||||
payload = json.dumps(all_data, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
@@ -93,13 +95,99 @@ class WebmailFolderSettingsStore:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restrict permissions; file can contain folder names but no secrets.
|
||||
try:
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os.rename(tmp_path, self.store_path)
|
||||
os.rename(tmp_path, out_path)
|
||||
|
||||
def _ensure_store_dir(self, target_file: str = None) -> None:
|
||||
t = target_file or self.store_path
|
||||
d = os.path.dirname(os.path.abspath(t))
|
||||
if not d:
|
||||
return
|
||||
try:
|
||||
os.makedirs(d, mode=0o750, exist_ok=True)
|
||||
except Exception:
|
||||
# If we can't create the dir, we'll fail on write with a clear error later.
|
||||
pass
|
||||
|
||||
def _active_read_path(self) -> str:
|
||||
"""Prefer new store path; fall back to legacy file for one-time migration reads."""
|
||||
if os.path.isfile(self.store_path):
|
||||
return self.store_path
|
||||
if os.path.isfile(self.LEGACY_STORE_PATH):
|
||||
return self.LEGACY_STORE_PATH
|
||||
return self.store_path
|
||||
|
||||
# Older webmail default; migrate to SnappyMail-style order (Sent + Archive at top block).
|
||||
_LEGACY_SPECIAL_ORDER = (
|
||||
'inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash')
|
||||
|
||||
# Previous default (8 keys); migrate to compact top bar: Inbox..Spam..Trash..Archive.
|
||||
_INTERMEDIATE_SPECIAL_ORDER = (
|
||||
'inbox', 'sent', 'drafts', 'spam', 'deleted_items', 'archive',
|
||||
'junk_e_mail', 'trash',
|
||||
)
|
||||
|
||||
def _defaults(self) -> Dict[str, Any]:
|
||||
# Semantic keys used by the UI.
|
||||
junk = IMAPDefaults.SPECIAL_FOLDERS.get('junk', 'INBOX.Junk E-mail')
|
||||
trash = IMAPDefaults.SPECIAL_FOLDERS.get('trash', 'INBOX.Deleted Items')
|
||||
drafts = IMAPDefaults.SPECIAL_FOLDERS.get('drafts', 'INBOX.Drafts')
|
||||
sent = IMAPDefaults.SPECIAL_FOLDERS.get('sent', 'INBOX.Sent')
|
||||
archive = IMAPDefaults.SPECIAL_FOLDERS.get('archive', 'INBOX.Archive')
|
||||
|
||||
return {
|
||||
'specialDisplayMode': self.DEFAULT_SPECIAL_DISPLAY_MODE, # 'top' or 'interleaved'
|
||||
'folderMappings': {
|
||||
'inbox': 'INBOX',
|
||||
'sent': sent,
|
||||
'spam': junk, # semantic alias for junk folder
|
||||
'deleted_items': trash,
|
||||
'junk_e_mail': junk,
|
||||
'drafts': drafts,
|
||||
'trash': trash,
|
||||
'archive': archive,
|
||||
},
|
||||
# Order of all folders when specialDisplayMode='interleaved'.
|
||||
# When 'top', this order is still kept as: special group + other group.
|
||||
'folderOrder': [],
|
||||
# Special bar (top mode): Inbox, Sent, Drafts, Spam, Trash, Archive.
|
||||
# Semantic keys deleted_items / junk_e_mail stay in folderMappings only.
|
||||
'specialOrder': [
|
||||
'inbox', 'sent', 'drafts', 'spam', 'trash', 'archive',
|
||||
],
|
||||
'enableDragDrop': True,
|
||||
}
|
||||
|
||||
def _read_all(self) -> Dict[str, Any]:
|
||||
self._ensure_store_dir()
|
||||
read_path = self._active_read_path()
|
||||
if not os.path.isfile(read_path):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(read_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
raw = f.read()
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, PermissionError, IOError):
|
||||
return {}
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_for_account(self, email_account: str) -> Dict[str, Any]:
|
||||
email_account = (email_account or '').strip()
|
||||
@@ -129,10 +217,21 @@ class WebmailFolderSettingsStore:
|
||||
merged['folderMappings'] = defaults['folderMappings'].copy()
|
||||
merged['folderMappings'].update(account_cfg['folderMappings'])
|
||||
|
||||
# Single junk mailbox: Spam mapping drives junk_e_mail (no duplicate role).
|
||||
_sp = (merged['folderMappings'].get('spam') or '').strip()
|
||||
if _sp:
|
||||
merged['folderMappings']['junk_e_mail'] = _sp
|
||||
|
||||
if not isinstance(merged.get('folderOrder'), list):
|
||||
merged['folderOrder'] = []
|
||||
if not isinstance(merged.get('specialOrder'), list):
|
||||
merged['specialOrder'] = self._defaults()['specialOrder']
|
||||
else:
|
||||
so = tuple(merged.get('specialOrder') or [])
|
||||
if so == self._LEGACY_SPECIAL_ORDER:
|
||||
merged['specialOrder'] = list(self._defaults()['specialOrder'])
|
||||
elif so == self._INTERMEDIATE_SPECIAL_ORDER:
|
||||
merged['specialOrder'] = list(self._defaults()['specialOrder'])
|
||||
edd = merged.get('enableDragDrop')
|
||||
if isinstance(edd, str):
|
||||
merged['enableDragDrop'] = edd.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
@@ -187,6 +286,11 @@ class WebmailFolderSettingsStore:
|
||||
if merged.get('specialDisplayMode') not in ['top', 'interleaved']:
|
||||
merged['specialDisplayMode'] = self.DEFAULT_SPECIAL_DISPLAY_MODE
|
||||
|
||||
_sp = (merged.get('folderMappings') or {}).get('spam') or ''
|
||||
if isinstance(_sp, str) and _sp.strip():
|
||||
merged.setdefault('folderMappings', {})
|
||||
merged['folderMappings']['junk_e_mail'] = _sp.strip()
|
||||
|
||||
all_data['accounts'][email_account] = merged
|
||||
self._write_all(all_data)
|
||||
|
||||
|
||||
@@ -456,14 +456,16 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
specialDisplayMode: 'top',
|
||||
folderMappings: {
|
||||
inbox: 'INBOX',
|
||||
sent: 'INBOX.Sent',
|
||||
spam: 'INBOX.Junk E-mail',
|
||||
deleted_items: 'INBOX.Deleted Items',
|
||||
junk_e_mail: 'INBOX.Junk E-mail',
|
||||
drafts: 'INBOX.Drafts',
|
||||
trash: 'INBOX.Deleted Items'
|
||||
trash: 'INBOX.Deleted Items',
|
||||
archive: 'INBOX.Archive'
|
||||
},
|
||||
folderOrder: [],
|
||||
specialOrder: ['inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash'],
|
||||
specialOrder: ['inbox', 'sent', 'drafts', 'spam', 'trash', 'archive'],
|
||||
enableDragDrop: true
|
||||
}
|
||||
};
|
||||
@@ -524,8 +526,10 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
if (data.status === 1) {
|
||||
$scope.currentEmail = data.email;
|
||||
$scope.managedAccounts = data.accounts || [];
|
||||
$scope.loadSettings();
|
||||
$scope.loadFolders();
|
||||
// Load folder role mappings before listing folders so "At top" uses saved specialOrder.
|
||||
$scope.loadSettings(function() {
|
||||
$scope.loadFolders();
|
||||
});
|
||||
} else {
|
||||
notify(data.error_message || 'No email accounts found. Create an email account first or use the standalone login.', 'error');
|
||||
}
|
||||
@@ -549,8 +553,9 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
|
||||
apiCall('/webmail/api/switchAccount', {email: newEmail}, function(data) {
|
||||
if (data.status === 1) {
|
||||
$scope.loadSettings();
|
||||
$scope.loadFolders();
|
||||
$scope.loadSettings(function() {
|
||||
$scope.loadFolders();
|
||||
});
|
||||
} else {
|
||||
notify(data.error_message || 'Failed to switch account', 'error');
|
||||
console.error('switchAccount failed:', data);
|
||||
@@ -669,10 +674,99 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
return enabled === undefined ? true : !!enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default IMAP names when a semantic key has no mapping (SnappyMail-style order).
|
||||
* Order follows typical clients: Inbox, Sent, Drafts, Junk/Spam, Trash, Archive.
|
||||
*/
|
||||
var WM_SPECIAL_KEY_DEFAULT_FOLDERS = {
|
||||
inbox: 'INBOX',
|
||||
sent: 'INBOX.Sent',
|
||||
drafts: 'INBOX.Drafts',
|
||||
spam: 'INBOX.Junk E-mail',
|
||||
deleted_items: 'INBOX.Deleted Items',
|
||||
junk_e_mail: 'INBOX.Junk E-mail',
|
||||
trash: 'INBOX.Deleted Items',
|
||||
archive: 'INBOX.Archive'
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-settings-key candidate IMAP names (first existing wins). Handles Spam vs Junk E-mail,
|
||||
* Trash vs Deleted Items, and non–CyberPanel defaults.
|
||||
*/
|
||||
var WM_SPECIAL_ROLE_CANDIDATES = {
|
||||
inbox: ['INBOX'],
|
||||
sent: ['INBOX.Sent', 'Sent', 'INBOX.Sent Items', 'Sent Items'],
|
||||
drafts: ['INBOX.Drafts', 'Drafts'],
|
||||
spam: [
|
||||
'INBOX.Junk E-mail', 'Junk E-mail', 'INBOX.junk', 'INBOX.spam', 'INBOX.Spam',
|
||||
'Spam', 'Junk', 'junk', 'INBOX.Junk', 'Junk Mail'
|
||||
],
|
||||
deleted_items: ['INBOX.Deleted Items', 'Deleted Items', 'INBOX.Trash', 'Trash', 'Bin', 'Deleted'],
|
||||
junk_e_mail: [
|
||||
'INBOX.Junk E-mail', 'Junk E-mail', 'INBOX.junk', 'INBOX.spam', 'INBOX.Spam',
|
||||
'Spam', 'Junk', 'junk', 'INBOX.Junk', 'Junk Mail'
|
||||
],
|
||||
trash: ['INBOX.Deleted Items', 'Deleted Items', 'INBOX.Trash', 'Trash', 'Bin', 'Deleted'],
|
||||
archive: ['INBOX.Archive', 'Archive', 'Archive-Mail']
|
||||
};
|
||||
|
||||
/** Resolve IMAP folder key regardless of INBOX.Spam vs INBOX.spam vs stored mapping case. */
|
||||
function _folderNameMatch(folderByName, want) {
|
||||
if (!want || !folderByName) return null;
|
||||
if (folderByName[want]) return want;
|
||||
var wl = String(want).toLowerCase();
|
||||
for (var k in folderByName) {
|
||||
if (!Object.prototype.hasOwnProperty.call(folderByName, k)) continue;
|
||||
if (String(k).toLowerCase() === wl) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _pickFolderForRole(key, mappings, folderByName) {
|
||||
var mapped = mappings[key];
|
||||
if (mapped) {
|
||||
var mhit = _folderNameMatch(folderByName, mapped);
|
||||
if (mhit) return mhit;
|
||||
}
|
||||
var cands = WM_SPECIAL_ROLE_CANDIDATES[key];
|
||||
if (cands) {
|
||||
for (var ci = 0; ci < cands.length; ci++) {
|
||||
var chit = _folderNameMatch(folderByName, cands[ci]);
|
||||
if (chit) return chit;
|
||||
}
|
||||
}
|
||||
var fb = WM_SPECIAL_KEY_DEFAULT_FOLDERS[key];
|
||||
if (fb) {
|
||||
var fhit = _folderNameMatch(folderByName, fb);
|
||||
if (fhit) return fhit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _folderTypeOf(name, folderByName) {
|
||||
var f = folderByName[name];
|
||||
return (f && f.folder_type) ? f.folder_type : 'folder';
|
||||
}
|
||||
|
||||
function _specialCoversType(specialSet, folderByName, ftype) {
|
||||
if (!ftype || ftype === 'folder') {
|
||||
return false;
|
||||
}
|
||||
for (var sn in specialSet) {
|
||||
if (!Object.prototype.hasOwnProperty.call(specialSet, sn)) {
|
||||
continue;
|
||||
}
|
||||
if (_folderTypeOf(sn, folderByName) === ftype) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _getSpecialOrderKeys() {
|
||||
var keys = (($scope.wmSettings || {}).folderSettings || {}).specialOrder;
|
||||
if (!keys || !Array.isArray(keys) || keys.length === 0) {
|
||||
return ['inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash'];
|
||||
return ['inbox', 'sent', 'drafts', 'spam', 'trash', 'archive'];
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -796,30 +890,39 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "special" sidebar block in strict specialOrder key order.
|
||||
* Do not re-sort by IMAP listing order (that pushed Trash/Junk out of the SnappyMail-style top block).
|
||||
*/
|
||||
function _getSpecialFolderNames(folderByName, normalizedOrder) {
|
||||
var mappings = _getFolderMappings();
|
||||
var specialKeys = _getSpecialOrderKeys();
|
||||
var specialNamesInKeyOrder = [];
|
||||
var seen = {};
|
||||
var junkSlotFilled = false;
|
||||
var trashSlotFilled = false;
|
||||
for (var i = 0; i < specialKeys.length; i++) {
|
||||
var key = specialKeys[i];
|
||||
var mapped = mappings[key];
|
||||
if (mapped && folderByName[mapped] && !seen[mapped]) {
|
||||
seen[mapped] = true;
|
||||
specialNamesInKeyOrder.push(mapped);
|
||||
if ((key === 'junk_e_mail' || key === 'spam') && junkSlotFilled) {
|
||||
continue;
|
||||
}
|
||||
if ((key === 'trash' || key === 'deleted_items') && trashSlotFilled) {
|
||||
continue;
|
||||
}
|
||||
var picked = _pickFolderForRole(key, mappings, folderByName);
|
||||
if (!picked || seen[picked]) {
|
||||
continue;
|
||||
}
|
||||
seen[picked] = true;
|
||||
specialNamesInKeyOrder.push(picked);
|
||||
var ftPick = _folderTypeOf(picked, folderByName);
|
||||
if (key === 'junk_e_mail' || key === 'spam' || ftPick === 'junk') {
|
||||
junkSlotFilled = true;
|
||||
}
|
||||
if (key === 'trash' || key === 'deleted_items' || ftPick === 'trash') {
|
||||
trashSlotFilled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a stored order, keep special ordering consistent with it.
|
||||
var indexMap = {};
|
||||
for (var j = 0; j < normalizedOrder.length; j++) {
|
||||
indexMap[normalizedOrder[j]] = j;
|
||||
}
|
||||
specialNamesInKeyOrder.sort(function(a, b) {
|
||||
var ia = (indexMap[a] !== undefined) ? indexMap[a] : 999999;
|
||||
var ib = (indexMap[b] !== undefined) ? indexMap[b] : 999999;
|
||||
return ia - ib;
|
||||
});
|
||||
return specialNamesInKeyOrder;
|
||||
}
|
||||
|
||||
@@ -855,7 +958,17 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
$scope.getFolderRowLabel = function(folder, depth) {
|
||||
if (!folder) return '';
|
||||
var dn = folder.display_name || folder.name || '';
|
||||
if (depth <= 0) return dn;
|
||||
if (depth <= 0) {
|
||||
var maps = _getFolderMappings();
|
||||
var nm = folder.name || '';
|
||||
if (maps.inbox && nm === maps.inbox) return 'Inbox';
|
||||
if (maps.sent && nm === maps.sent) return 'Sent';
|
||||
if (maps.drafts && nm === maps.drafts) return 'Drafts';
|
||||
if ((maps.spam && nm === maps.spam) || (maps.junk_e_mail && nm === maps.junk_e_mail)) return 'Spam';
|
||||
if ((maps.trash && nm === maps.trash) || (maps.deleted_items && nm === maps.deleted_items)) return 'Trash';
|
||||
if (maps.archive && nm === maps.archive) return 'Archive';
|
||||
return dn;
|
||||
}
|
||||
var sep = _folderDelimiterFromList();
|
||||
var idx = dn.lastIndexOf(sep);
|
||||
if (idx >= 0) return dn.slice(idx + sep.length);
|
||||
@@ -883,7 +996,14 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
displayNames = specialNames.slice();
|
||||
for (var j = 0; j < normalizedOrder.length; j++) {
|
||||
var n = normalizedOrder[j];
|
||||
if (!specialSet[n]) displayNames.push(n);
|
||||
if (specialSet[n]) {
|
||||
continue;
|
||||
}
|
||||
var ft = _folderTypeOf(n, folderByName);
|
||||
if (_specialCoversType(specialSet, folderByName, ft)) {
|
||||
continue;
|
||||
}
|
||||
displayNames.push(n);
|
||||
}
|
||||
} else {
|
||||
// Fully interleaved order.
|
||||
@@ -933,9 +1053,9 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
});
|
||||
|
||||
function _updateFolderOrderAfterDrag(draggedName, targetName) {
|
||||
if (!draggedName || !targetName || draggedName === targetName) return;
|
||||
if (!draggedName || !targetName || draggedName === targetName) return false;
|
||||
var folderByName = _getFolderByName();
|
||||
if (!folderByName[draggedName] || !folderByName[targetName]) return;
|
||||
if (!folderByName[draggedName] || !folderByName[targetName]) return false;
|
||||
|
||||
var normalizedOrder = _normalizeFolderOrder(folderByName);
|
||||
var mode = _getSpecialDisplayMode();
|
||||
@@ -950,7 +1070,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
newOrder = normalizedOrder.slice();
|
||||
var fromIdx = newOrder.indexOf(draggedName);
|
||||
var toIdx = newOrder.indexOf(targetName);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
if (fromIdx < 0 || toIdx < 0) return false;
|
||||
newOrder.splice(fromIdx, 1);
|
||||
// If removing dragged element shifts indices, recompute target index.
|
||||
toIdx = newOrder.indexOf(targetName);
|
||||
@@ -959,7 +1079,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
// top mode: reorder within the same group (special vs other)
|
||||
var draggedIsSpecial = !!specialSet[draggedName];
|
||||
var targetIsSpecial = !!specialSet[targetName];
|
||||
if (draggedIsSpecial !== targetIsSpecial) return;
|
||||
if (draggedIsSpecial !== targetIsSpecial) return false;
|
||||
|
||||
var specialOrdered = [];
|
||||
var otherOrdered = [];
|
||||
@@ -973,7 +1093,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
var group = specialOrdered.slice();
|
||||
var fromIdx2 = group.indexOf(draggedName);
|
||||
var toIdx2 = group.indexOf(targetName);
|
||||
if (fromIdx2 < 0 || toIdx2 < 0) return;
|
||||
if (fromIdx2 < 0 || toIdx2 < 0) return false;
|
||||
group.splice(fromIdx2, 1);
|
||||
toIdx2 = group.indexOf(targetName);
|
||||
group.splice(toIdx2, 0, draggedName);
|
||||
@@ -982,7 +1102,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
var group2 = otherOrdered.slice();
|
||||
var fromIdx3 = group2.indexOf(draggedName);
|
||||
var toIdx3 = group2.indexOf(targetName);
|
||||
if (fromIdx3 < 0 || toIdx3 < 0) return;
|
||||
if (fromIdx3 < 0 || toIdx3 < 0) return false;
|
||||
group2.splice(fromIdx3, 1);
|
||||
toIdx3 = group2.indexOf(targetName);
|
||||
group2.splice(toIdx3, 0, draggedName);
|
||||
@@ -990,13 +1110,27 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
}
|
||||
}
|
||||
|
||||
if (!$scope.wmSettings.folderSettings) $scope.wmSettings.folderSettings = {};
|
||||
if (!$scope.wmSettings) {
|
||||
$scope.wmSettings = {};
|
||||
}
|
||||
if (!$scope.wmSettings.folderSettings) {
|
||||
$scope.wmSettings.folderSettings = {
|
||||
folderMappings: {},
|
||||
folderOrder: [],
|
||||
specialDisplayMode: 'top',
|
||||
enableDragDrop: true
|
||||
};
|
||||
}
|
||||
$scope.wmSettings.folderSettings.folderOrder = newOrder;
|
||||
$scope.folderLayoutDirty = true;
|
||||
$scope.applyFolderLayout();
|
||||
$timeout(function() {
|
||||
$scope.persistFolderLayoutSettings(true);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Persist folder layout only (silent on success). */
|
||||
/** Persist folder layout only (silent on success unless silent is false). Errors are always reported. */
|
||||
$scope.persistFolderLayoutSettings = function(silent) {
|
||||
if (!$scope.wmSettings || !$scope.wmSettings.folderSettings) return;
|
||||
apiCall('/webmail/api/saveSettings', {
|
||||
@@ -1007,13 +1141,11 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
if (!silent) {
|
||||
notify('Folder layout saved.');
|
||||
}
|
||||
} else if (!silent) {
|
||||
} else {
|
||||
notify(data.error_message || 'Could not save folder layout.', 'error');
|
||||
}
|
||||
}, function() {
|
||||
if (!silent) {
|
||||
notify('Could not save folder layout.', 'error');
|
||||
}
|
||||
notify('Could not save folder layout.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1055,7 +1187,6 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
evt.preventDefault();
|
||||
if (!$scope.draggingFolder) return;
|
||||
_updateFolderOrderAfterDrag($scope.draggingFolder, targetFolderName);
|
||||
$scope.persistFolderLayoutSettings(true);
|
||||
$scope.draggingFolder = null;
|
||||
$scope.dragOverFolder = null;
|
||||
};
|
||||
@@ -1083,7 +1214,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
var list = $scope.folders || [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].name === folderName) {
|
||||
return list[i].display_name || list[i].name || folderName;
|
||||
return $scope.getFolderRowLabel(list[i], 0);
|
||||
}
|
||||
}
|
||||
return folderName;
|
||||
@@ -1094,7 +1225,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
var mappings = (($scope.wmSettings || {}).folderSettings || {}).folderMappings || {};
|
||||
var name = folder.name || '';
|
||||
if (mappings.inbox && name === mappings.inbox) return 'fa-inbox';
|
||||
if ((mappings.spam && name === mappings.spam) || (mappings.junk_e_mail && name === mappings.junk_e_mail)) return 'fa-ban';
|
||||
if ((mappings.spam && name === mappings.spam) || (mappings.junk_e_mail && name === mappings.junk_e_mail)) return 'fa-exclamation-triangle';
|
||||
if (mappings.drafts && name === mappings.drafts) return 'fa-file';
|
||||
if ((mappings.trash && name === mappings.trash) || (mappings.deleted_items && name === mappings.deleted_items)) return 'fa-trash';
|
||||
|
||||
@@ -1104,7 +1235,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
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 === 'junk') return 'fa-exclamation-triangle';
|
||||
if (ftype === 'archive') return 'fa-box-archive';
|
||||
// Fallback to name-based detection
|
||||
var n = (folder.display_name || folder.name || '').toLowerCase();
|
||||
@@ -1112,14 +1243,33 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
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('junk') >= 0 || n.indexOf('spam') >= 0) return 'fa-exclamation-triangle';
|
||||
if (n.indexOf('archive') >= 0) return 'fa-box-archive';
|
||||
return 'fa-folder';
|
||||
};
|
||||
|
||||
$scope.canDeleteFolder = function(folder) {
|
||||
if (!folder || !folder.name) return false;
|
||||
return !WM_FOLDER_PROTECTED[folder.name];
|
||||
var ftype = folder.folder_type || '';
|
||||
if (['inbox', 'sent', 'drafts', 'trash', 'junk', 'archive'].indexOf(ftype) >= 0) {
|
||||
return false;
|
||||
}
|
||||
var maps = _getFolderMappings();
|
||||
var n = folder.name;
|
||||
var nl = n.toLowerCase();
|
||||
var roleKeys = ['inbox', 'sent', 'drafts', 'spam', 'junk_e_mail', 'trash', 'deleted_items', 'archive'];
|
||||
for (var ri = 0; ri < roleKeys.length; ri++) {
|
||||
var mv = maps[roleKeys[ri]];
|
||||
if (mv && String(mv).toLowerCase() === nl) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (var pk in WM_FOLDER_PROTECTED) {
|
||||
if (WM_FOLDER_PROTECTED[pk] && String(pk).toLowerCase() === nl) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$scope.openDeleteFolderConfirm = function(folder) {
|
||||
@@ -1910,13 +2060,17 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
};
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────
|
||||
$scope.loadSettings = function() {
|
||||
$scope.loadSettings = function(done) {
|
||||
apiCall('/webmail/api/getSettings', {}, function(data) {
|
||||
if (data.status === 1) {
|
||||
$scope.wmSettings = data.settings;
|
||||
if (!$scope.wmSettings.folderSettings) {
|
||||
$scope.wmSettings.folderSettings = {folderMappings: {}, folderOrder: [], specialDisplayMode: 'top', enableDragDrop: true};
|
||||
}
|
||||
var fm = $scope.wmSettings.folderSettings.folderMappings;
|
||||
if (fm && fm.spam) {
|
||||
fm.junk_e_mail = fm.spam;
|
||||
}
|
||||
if ($scope.wmSettings.messagesPerPage) {
|
||||
$scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
|
||||
}
|
||||
@@ -1924,16 +2078,27 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document
|
||||
$scope.applyFolderLayout();
|
||||
}
|
||||
}
|
||||
if (typeof done === 'function') {
|
||||
$timeout(function() { done(); }, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveSettings = function() {
|
||||
var fm = ($scope.wmSettings || {}).folderSettings;
|
||||
fm = fm && fm.folderMappings;
|
||||
if (fm && fm.spam) {
|
||||
fm.junk_e_mail = fm.spam;
|
||||
}
|
||||
apiCall('/webmail/api/saveSettings', $scope.wmSettings, function(data) {
|
||||
if (data.status === 1) {
|
||||
notify('Settings saved.');
|
||||
if ($scope.wmSettings.messagesPerPage) {
|
||||
$scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
|
||||
}
|
||||
if ($scope.folders && $scope.folders.length > 0 && typeof $scope.applyFolderLayout === 'function') {
|
||||
$scope.applyFolderLayout();
|
||||
}
|
||||
} else {
|
||||
notify(data.error_message, 'error');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %}
|
||||
{% block header_scripts %}
|
||||
{{ block.super }}
|
||||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'webmail/webmail.css' %}">
|
||||
@@ -497,9 +502,10 @@
|
||||
<!-- Settings View -->
|
||||
<div ng-if="viewMode === 'settings'" class="wm-settings-view">
|
||||
<div class="wm-section-header">
|
||||
<h3>{% trans "Settings" %}</h3>
|
||||
<h3>{% trans "Settings" %} <span style="font-size:11px;font-weight:600;color:var(--text-secondary);">(UI build 2026-03-26 · Spam-only map · Sent row)</span></h3>
|
||||
</div>
|
||||
<div class="wm-settings-form">
|
||||
<div class="wm-settings-form" data-webmail-folder-mapping-ui="v5-spam-only">
|
||||
<!-- webmail-settings-ui:v5 no Junk-E-mail mapping row; only Spam maps junk/spam -->
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Display Name" %}</label>
|
||||
<input type="text" ng-model="wmSettings.displayName" class="form-control">
|
||||
@@ -555,23 +561,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Spam" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.spam" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Deleted Items" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.deleted_items" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Junk E-mail" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.junk_e_mail" class="form-control">
|
||||
<label>{% trans "Sent" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.sent" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -584,6 +575,24 @@
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Spam" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.spam" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
<div style="font-size:11px; color:var(--text-secondary); margin-top:6px; line-height:1.35;">
|
||||
{% trans "Maps your spam/junk mailbox to Spam in the sidebar. Folder names in the list come from the mail server." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Deleted Items" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.deleted_items" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Trash" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.trash" class="form-control">
|
||||
@@ -592,6 +601,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Archive" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.archive" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field" style="font-size:12px; color:var(--text-secondary); padding-top:28px; line-height:1.35;">
|
||||
{% trans "There is only one junk/spam mapping above (Spam). Trash/Deleted Items can differ between clients." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin:12px 0 2px;">
|
||||
<div style="font-size:12px; color:var(--text-secondary);">
|
||||
{% trans "Drag folders in the sidebar to reorder (when enabled). Folder order is saved automatically; use Save Settings for display name, signature, and folder role mappings." %}
|
||||
@@ -616,6 +637,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'webmail/webmail.js' %}?v=16"></script>
|
||||
<script src="{% static 'webmail/webmail.js' %}?v=26"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,11 @@ from .webmailManager import WebmailManager
|
||||
def loadWebmail(request):
|
||||
try:
|
||||
wm = WebmailManager(request)
|
||||
return wm.loadWebmail()
|
||||
response = wm.loadWebmail()
|
||||
# Always fetch fresh HTML (avoid stale shell w/ old folder-mapping UI).
|
||||
response['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response['Pragma'] = 'no-cache'
|
||||
return response
|
||||
except KeyError:
|
||||
return redirect(loadLoginPage)
|
||||
|
||||
|
||||
@@ -291,6 +291,18 @@ class WebmailManager:
|
||||
protected.update(set(IMAPClient.SPECIAL_FOLDERS.values()))
|
||||
if name in protected:
|
||||
return self._error('Cannot delete system folder.')
|
||||
nl = (name or '').lower()
|
||||
if any((p or '').lower() == nl for p in protected):
|
||||
return self._error('Cannot delete system folder.')
|
||||
# Match UI/backend folder_type (Spam, Trash, Junk E-mail variants, etc.)
|
||||
try:
|
||||
_imap_ty = IMAPClient.__new__(IMAPClient)
|
||||
if _imap_ty._folder_type(name) in (
|
||||
'inbox', 'sent', 'drafts', 'trash', 'junk', 'archive',
|
||||
):
|
||||
return self._error('Cannot delete system folder.')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with self._get_imap() as imap:
|
||||
if imap.delete_folder(name):
|
||||
|
||||
Reference in New Issue
Block a user