From f6fc11e69703f2c095133189d734cfac4f7724b6 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 00:09:38 +0100 Subject: [PATCH] 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 --- webmail/services/imap_client.py | 33 ++- .../services/webmail_folder_settings_store.py | 222 ++++++++++++---- webmail/static/webmail/webmail.js | 251 +++++++++++++++--- webmail/templates/webmail/index.html | 61 +++-- webmail/views.py | 6 +- webmail/webmailManager.py | 12 + 6 files changed, 459 insertions(+), 126 deletions(-) diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py index b52c07619..5bd36e043 100644 --- a/webmail/services/imap_client.py +++ b/webmail/services/imap_client.py @@ -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): diff --git a/webmail/services/webmail_folder_settings_store.py b/webmail/services/webmail_folder_settings_store.py index 986c66978..29ee3577e 100644 --- a/webmail/services/webmail_folder_settings_store.py +++ b/webmail/services/webmail_folder_settings_store.py @@ -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) diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js index 30ee316ec..37417e009 100644 --- a/webmail/static/webmail/webmail.js +++ b/webmail/static/webmail/webmail.js @@ -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'); } diff --git a/webmail/templates/webmail/index.html b/webmail/templates/webmail/index.html index 1f943abd9..513ae8db5 100644 --- a/webmail/templates/webmail/index.html +++ b/webmail/templates/webmail/index.html @@ -2,6 +2,11 @@ {% load i18n %} {% load static %} {% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %} +{% block header_scripts %} +{{ block.super }} + + +{% endblock %} {% block content %} @@ -497,9 +502,10 @@
-

{% trans "Settings" %}

+

{% trans "Settings" %} (UI build 2026-03-26 · Spam-only map · Sent row)

-
+
+
@@ -555,23 +561,8 @@
- - -
-
- -
-
- - -
-
- -
@@ -584,6 +575,24 @@
+
+ + +
+ {% trans "Maps your spam/junk mailbox to Spam in the sidebar. Folder names in the list come from the mail server." %} +
+
+
+ +
+
+ + +
+ + +
+
+ {% trans "There is only one junk/spam mapping above (Spam). Trash/Deleted Items can differ between clients." %} +
+
+
{% 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 @@
- + {% endblock %} diff --git a/webmail/views.py b/webmail/views.py index c93522ae7..4629fee1d 100644 --- a/webmail/views.py +++ b/webmail/views.py @@ -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) diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py index c79e2ecc6..309c77586 100644 --- a/webmail/webmailManager.py +++ b/webmail/webmailManager.py @@ -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):