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:
master3395
2026-03-26 00:09:38 +01:00
parent 3e8750ab58
commit f6fc11e697
6 changed files with 459 additions and 126 deletions

View File

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

View File

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

View File

@@ -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 nonCyberPanel 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');
}

View File

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

View File

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

View File

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