webmail: v2.5.5-dev UI and backend improvements

- Resizable folder sidebar with persisted width; nested folder tree with expand/collapse
- Message search: scope all folders or single folder; listMessages honors UID filter
- Drag-and-drop messages onto folders to move (multi-select supported)
- SnappyMail import paths, folder settings store, wm DB migration and SQL install
- IMAP quoted mailbox, IPv4 SMTP relay, compose recipient handling
- Modal new/delete folder flows; dash-free UI copy; folder pills in search results
This commit is contained in:
master3395
2026-03-25 23:18:50 +01:00
parent 17c66c8485
commit 856606d6a3
15 changed files with 2694 additions and 209 deletions

View File

@@ -0,0 +1,105 @@
# Generated by Django 4.2.14 on 2026-03-25 21:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Contact',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('owner_email', models.CharField(db_index=True, max_length=200)),
('display_name', models.CharField(blank=True, default='', max_length=200)),
('email_address', models.CharField(max_length=200)),
('phone', models.CharField(blank=True, default='', max_length=50)),
('organization', models.CharField(blank=True, default='', max_length=200)),
('notes', models.TextField(blank=True, default='')),
('is_auto_collected', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'wm_contacts',
'unique_together': {('owner_email', 'email_address')},
},
),
migrations.CreateModel(
name='ContactGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('owner_email', models.CharField(db_index=True, max_length=200)),
('name', models.CharField(max_length=100)),
],
options={
'db_table': 'wm_contact_groups',
'unique_together': {('owner_email', 'name')},
},
),
migrations.CreateModel(
name='SieveRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email_account', models.CharField(db_index=True, max_length=200)),
('name', models.CharField(max_length=200)),
('priority', models.IntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('condition_field', models.CharField(choices=[('from', 'From'), ('to', 'To'), ('subject', 'Subject'), ('size', 'Size')], max_length=50)),
('condition_type', models.CharField(choices=[('contains', 'Contains'), ('is', 'Is'), ('matches', 'Matches'), ('greater_than', 'Greater than')], max_length=50)),
('condition_value', models.CharField(max_length=500)),
('action_type', models.CharField(choices=[('move', 'Move to folder'), ('forward', 'Forward to'), ('discard', 'Discard'), ('flag', 'Flag')], max_length=50)),
('action_value', models.CharField(blank=True, default='', max_length=500)),
('sieve_script', models.TextField(blank=True, default='')),
],
options={
'db_table': 'wm_sieve_rules',
'ordering': ['priority'],
},
),
migrations.CreateModel(
name='WebmailSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(max_length=64, unique=True)),
('email_account', models.CharField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_active', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'wm_sessions',
},
),
migrations.CreateModel(
name='WebmailSettings',
fields=[
('email_account', models.CharField(max_length=200, primary_key=True, serialize=False)),
('display_name', models.CharField(blank=True, default='', max_length=200)),
('signature_html', models.TextField(blank=True, default='')),
('messages_per_page', models.IntegerField(default=25)),
('default_reply_behavior', models.CharField(choices=[('reply', 'Reply'), ('reply_all', 'Reply All')], default='reply', max_length=20)),
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('auto', 'Auto')], default='auto', max_length=20)),
('auto_collect_contacts', models.BooleanField(default=True)),
],
options={
'db_table': 'wm_settings',
},
),
migrations.CreateModel(
name='ContactGroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='webmail.contact')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='webmail.contactgroup')),
],
options={
'db_table': 'wm_contact_group_members',
'unique_together': {('contact', 'group')},
},
),
]

View File

@@ -1,17 +1,56 @@
import email
import re
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, formataddr
from email.utils import formatdate, make_msgid, formataddr, parseaddr
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import mimetypes
import re
class EmailComposer:
"""Construct MIME messages for sending."""
# Light validation after parseaddr (avoid empty / garbage tokens from trailing commas).
@staticmethod
def _valid_email(addr):
if not addr or '@' not in addr:
return False
local, _, domain = addr.partition('@')
if not local or not domain or '.' not in domain:
return False
if len(addr) > 254:
return False
return True
@classmethod
def normalize_address_list(cls, raw):
"""Split comma/semicolon-separated addresses; drop empties and obviously invalid tokens."""
if not raw:
return []
if isinstance(raw, (list, tuple)):
chunks = raw
else:
txt = str(raw).replace(';', ',')
chunks = txt.split(',')
out = []
seen = set()
for chunk in chunks:
part = (chunk or '').strip()
if not part:
continue
_name, addr = parseaddr(part)
addr = (addr or '').strip()
if not addr:
continue
key = addr.lower()
if key in seen:
continue
if not cls._valid_email(addr):
continue
seen.add(key)
out.append(addr)
return out
@staticmethod
def compose(from_addr, to_addrs, subject, body_html='', body_text='',
cc_addrs='', bcc_addrs='', attachments=None,
@@ -62,10 +101,23 @@ class EmailComposer:
elif not body_text:
msg.attach(MIMEText('', 'plain', 'utf-8'))
to_list = EmailComposer.normalize_address_list(to_addrs)
cc_list = EmailComposer.normalize_address_list(cc_addrs)
bcc_list = EmailComposer.normalize_address_list(bcc_addrs)
if not to_list and not cc_list and not bcc_list:
raise ValueError('No valid recipients after parsing To/Cc/Bcc.')
msg['From'] = from_addr
msg['To'] = to_addrs
if cc_addrs:
msg['Cc'] = cc_addrs
if to_list:
msg['To'] = ', '.join(to_list)
elif cc_list:
msg['To'] = ', '.join(cc_list)
else:
msg['To'] = 'undisclosed-recipients:;'
if cc_list and to_list:
msg['Cc'] = ', '.join(cc_list)
if bcc_list:
msg['Bcc'] = ', '.join(bcc_list)
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain=from_addr.split('@')[-1] if '@' in from_addr else 'localhost')

View File

@@ -27,7 +27,7 @@ class IMAPClient:
'archive': 'INBOX.Archive',
}
def __init__(self, email_address, password, host='localhost', port=993,
def __init__(self, email_address, password, host='127.0.0.1', port=993,
master_user=None, master_password=None):
self.email_address = email_address
self.host = host
@@ -143,33 +143,53 @@ class IMAPClient:
})
return folders
def _mbox_quoted(self, folder):
"""Quote IMAP mailbox name (spaces and special characters)."""
if folder is None:
return '""'
name = str(folder).strip()
return '"' + name.replace('\\', '\\\\').replace('"', '\\"') + '"'
def _select(self, folder):
"""Select a folder, quoting names with spaces."""
return self.conn.select('"%s"' % folder)
return self.conn.select(self._mbox_quoted(folder))
def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc'):
def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc', uids_filter=None):
self._select(folder)
# Try IMAP SORT for proper date ordering (Dovecot supports this)
uids = []
try:
if sort == 'date_desc':
status, data = self.conn.uid('sort', '(REVERSE DATE)', 'UTF-8', 'ALL')
else:
status, data = self.conn.uid('sort', '(DATE)', 'UTF-8', 'ALL')
if status == 'OK' and data[0]:
uids = data[0].split()
except Exception:
pass
uids = None
if uids_filter is not None:
uids = []
for u in uids_filter:
if u is None:
continue
s = u.decode('utf-8', errors='replace') if isinstance(u, bytes) else str(u).strip()
if s.isdigit():
uids.append(s.encode('ascii'))
if not uids:
return {'messages': [], 'total': 0, 'page': 1, 'pages': 0}
# Fallback to search + reverse UIDs if SORT not supported
if not uids:
status, data = self.conn.uid('search', None, 'ALL')
if status != 'OK':
return {'messages': [], 'total': 0, 'page': page, 'pages': 0}
uids = data[0].split() if data[0] else []
if sort == 'date_desc':
uids = list(reversed(uids))
if uids is None:
# Try IMAP SORT for proper date ordering (Dovecot supports this)
uids = []
try:
if sort == 'date_desc':
status, data = self.conn.uid('sort', '(REVERSE DATE)', 'UTF-8', 'ALL')
else:
status, data = self.conn.uid('sort', '(DATE)', 'UTF-8', 'ALL')
if status == 'OK' and data[0]:
uids = data[0].split()
except Exception:
pass
# Fallback to search + reverse UIDs if SORT not supported
if not uids:
status, data = self.conn.uid('search', None, 'ALL')
if status != 'OK':
return {'messages': [], 'total': 0, 'page': page, 'pages': 0}
uids = data[0].split() if data[0] else []
if sort == 'date_desc':
uids = list(reversed(uids))
total = len(uids)
pages = max(1, (total + per_page - 1) // per_page)
@@ -354,20 +374,21 @@ class IMAPClient:
return self.set_flags(folder, uids, ['\\Flagged'], 'add')
def create_folder(self, name):
status, _ = self.conn.create(name)
status, _ = self.conn.create(self._mbox_quoted(name))
return status == 'OK'
def rename_folder(self, old_name, new_name):
status, _ = self.conn.rename(old_name, new_name)
status, _ = self.conn.rename(
self._mbox_quoted(old_name), self._mbox_quoted(new_name))
return status == 'OK'
def delete_folder(self, name):
status, _ = self.conn.delete(name)
status, _ = self.conn.delete(self._mbox_quoted(name))
return status == 'OK'
def append_message(self, folder, raw_message, flags=''):
if isinstance(raw_message, str):
raw_message = raw_message.encode('utf-8')
flag_str = '(%s)' % flags if flags else None
status, _ = self.conn.append('"%s"' % folder, flag_str, None, raw_message)
status, _ = self.conn.append(self._mbox_quoted(folder), flag_str, None, raw_message)
return status == 'OK'

View File

@@ -0,0 +1,15 @@
class IMAPDefaults:
"""
Minimal defaults shared with the folder settings store.
Keep this separate from IMAPClient to avoid importing imaplib during settings reads.
"""
SPECIAL_FOLDERS = {
'sent': 'INBOX.Sent',
'drafts': 'INBOX.Drafts',
'trash': 'INBOX.Deleted Items',
'junk': 'INBOX.Junk E-mail',
'archive': 'INBOX.Archive',
}

View File

@@ -6,8 +6,8 @@ class SMTPClient:
"""Wrapper around smtplib.SMTP for sending mail via Postfix.
Supports two modes:
1. Authenticated (port 587 + STARTTLS) for standalone login sessions
2. Local relay (port 25, no auth) for SSO sessions using master user
1. Authenticated (port 587 + STARTTLS) for standalone login sessions.
2. Local relay (port 25, no auth) for SSO sessions using master user.
Postfix accepts relay from localhost (permit_mynetworks in main.cf)
"""
@@ -15,10 +15,22 @@ class SMTPClient:
use_local_relay=False):
self.email_address = email_address
self.password = password
self.host = host
# Postfix on AlmaLinux/CyberPanel often sets mynetworks=127.0.0.0/8 only.
# Python resolves "localhost" to ::1 first → SMTP is not treated as mynetworks
# → 554 Relay access denied. Force IPv4 loopback for predictable relay.
self.host = self._smtp_host_ipv4_loopback(host)
self.port = port
self.use_local_relay = use_local_relay
@staticmethod
def _smtp_host_ipv4_loopback(host):
if not host:
return '127.0.0.1'
h = str(host).strip().lower()
if h in ('localhost', '::1', '[::1]'):
return '127.0.0.1'
return host
def send_message(self, mime_message):
"""Send a composed email via SMTP.
@@ -26,10 +38,12 @@ class SMTPClient:
dict: {success: bool, message_id: str or None, error: str or None}
"""
try:
# Bind outbound socket to IPv4 so Postfix sees 127.0.0.1 (mynetworks), not ::1.
src = ('127.0.0.1', 0)
if self.use_local_relay:
# SSO mode: send via port 25 without auth
# Postfix permits relay from localhost (permit_mynetworks)
smtp = smtplib.SMTP(self.host, 25)
smtp = smtplib.SMTP(self.host, 25, source_address=src)
smtp.ehlo()
smtp.send_message(mime_message)
smtp.quit()
@@ -39,7 +53,7 @@ class SMTPClient:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
smtp = smtplib.SMTP(self.host, self.port)
smtp = smtplib.SMTP(self.host, self.port, source_address=src)
smtp.ehlo()
smtp.starttls(context=ctx)
smtp.ehlo()

View File

@@ -0,0 +1,186 @@
import configparser
import os
import re
from typing import Dict, List, Tuple
import MySQLdb
class SnappymailContactsImporter:
"""
Import contacts from SnappyMail (RainLoop) DB into CyberPanel Webmail.
SnappyMail stores addressbook data in RainLoop tables:
- rainloop_users (rl_email -> id_user)
- rainloop_ab_contacts
- rainloop_ab_properties (prop_type == 30 => EMAIL, see RainLoop PropertyType::EMAIl)
This importer returns plain contact records; CyberPanel persists them into wm_contacts.
"""
# SnappyMail may use either install tree; contacts [pdo_*] is identical in practice.
CANDIDATE_APP_INI = (
'/usr/local/lscp/cyberpanel/snappymail/data/_data_/_default_/configs/application.ini',
'/usr/local/lscp/cyberpanel/rainloop/data/_data_/_default_/configs/application.ini',
)
SNAPPYMAIL_APP_INI = CANDIDATE_APP_INI[0]
# RainLoop PropertyType::EMAIl === 30
RAINLOOP_PROP_EMAIL = 30
def __init__(self, app_ini_path: str = None):
self.app_ini_path = app_ini_path or self._first_existing_ini()
@classmethod
def _first_existing_ini(cls) -> str:
for p in cls.CANDIDATE_APP_INI:
if os.path.isfile(p):
return p
return cls.SNAPPYMAIL_APP_INI
@staticmethod
def _strip_quotes(value: str) -> str:
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
return value[1:-1].strip()
return value
@staticmethod
def _parse_pdo_dsn(pdo_dsn: str) -> Dict[str, str]:
# Example:
# host=127.0.0.1;port=3306;dbname=snappymail
parts = {}
for chunk in pdo_dsn.split(';'):
chunk = chunk.strip()
if not chunk:
continue
if '=' not in chunk:
continue
k, v = chunk.split('=', 1)
parts[k.strip()] = v.strip()
return parts
def _load_snappymail_db_config(self) -> Tuple[str, int, str, str, str]:
if not os.path.isfile(self.app_ini_path):
raise FileNotFoundError('SnappyMail config not found: %s' % self.app_ini_path)
parser = configparser.ConfigParser()
parser.read(self.app_ini_path)
if not parser.has_section('contacts'):
raise ValueError('SnappyMail config missing [contacts] section.')
pdo_dsn = self._strip_quotes(parser.get('contacts', 'pdo_dsn', fallback=''))
user = self._strip_quotes(parser.get('contacts', 'pdo_user', fallback=''))
password = self._strip_quotes(parser.get('contacts', 'pdo_password', fallback=''))
if not pdo_dsn or not user:
raise ValueError('SnappyMail contacts config incomplete (missing DSN/user).')
dsn_parts = self._parse_pdo_dsn(pdo_dsn)
host = dsn_parts.get('host', '127.0.0.1')
port_raw = dsn_parts.get('port', '3306')
dbname = dsn_parts.get('dbname', '')
if not dbname:
raise ValueError('SnappyMail contacts config missing dbname in DSN.')
try:
port = int(port_raw)
except Exception:
port = 3306
return host, port, dbname, user, password
def import_contacts(self, owner_email: str) -> List[Dict[str, str]]:
"""
Fetch contacts for a given owner email from RainLoop DB.
Returns:
[
{'email_address': 'a@b.c', 'display_name': 'Name'},
...
]
"""
owner_email = (owner_email or '').strip()
if not owner_email:
return []
host, port, dbname, user, password = self._load_snappymail_db_config()
conn = None
try:
# autocommit=True so we never hold locks
conn = MySQLdb.connect(host=host, port=port, user=user, passwd=password, db=dbname, charset='utf8mb4')
try:
conn.autocommit(True)
except Exception:
pass
cursor = conn.cursor()
# Find RainLoop id_user for this email
cursor.execute(
'SELECT id_user FROM rainloop_users WHERE LOWER(rl_email) = LOWER(%s) LIMIT 1',
(owner_email,)
)
row = cursor.fetchone()
if not row:
return []
id_user = int(row[0])
# Fetch emails + display values
# Note: one contact can have multiple email entries. We create one webmail contact per email.
cursor.execute(
'''
SELECT
c.id_contact,
c.display,
p.prop_value AS email_address
FROM rainloop_ab_contacts AS c
JOIN rainloop_ab_properties AS p
ON p.id_contact = c.id_contact
AND p.id_user = c.id_user
AND p.prop_type = %s
WHERE c.id_user = %s
AND c.deleted = 0
AND p.prop_value IS NOT NULL
AND TRIM(p.prop_value) <> ''
ORDER BY c.display ASC
''',
(self.RAINLOOP_PROP_EMAIL, id_user)
)
results = []
seen = set()
while True:
r = cursor.fetchone()
if not r:
break
display = r[1] or ''
email_address = (r[2] or '').strip()
if not email_address:
continue
key = email_address.lower()
if key in seen:
continue
seen.add(key)
if display:
display_name = display
else:
display_name = email_address.split('@')[0] if '@' in email_address else email_address
results.append({
'email_address': email_address,
'display_name': display_name,
})
return results
finally:
try:
if conn:
conn.close()
except Exception:
pass

View File

@@ -0,0 +1,47 @@
from typing import Optional, Tuple
from .sieve_client import SieveClient
class SnappymailRulesImporter:
"""
Import RainLoop/SnappyMail sieve filters from ManageSieve into CyberPanel Webmail.
SnappyMail/RainLoop uses ManageSieve scripts; the filter storage is typically:
- rainloop.user (see RainLoop Providers\\Filters\\SieveStorage.php)
We import as a RAW script so rules keep working even if parsing differs from CyberPanel's
own structured rule format.
"""
RAINLOOP_DEFAULT_SCRIPT = 'rainloop.user'
def __init__(self, siege_script_name: str = None):
self.script_name = siege_script_name or self.RAINLOOP_DEFAULT_SCRIPT
def fetch_raw_script(self, sieve: SieveClient) -> Tuple[str, str]:
"""
Returns:
(script_name_used, raw_script_body)
"""
# Try the standard RainLoop script name first.
scripts = sieve.list_scripts() or []
script_names = [name for (name, _) in scripts]
chosen = self.script_name if self.script_name in script_names else None
if not chosen:
# Fallback: choose first active script if available.
for (name, is_active) in scripts:
if is_active:
chosen = name
break
if not chosen and script_names:
chosen = script_names[0]
if not chosen:
return '', ''
raw = sieve.get_script(chosen) or ''
return chosen, raw

View File

@@ -0,0 +1,192 @@
import fcntl
import json
import os
from typing import Any, Dict, List
from .imap_defaults import IMAPDefaults
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/.
"""
STORE_DIR = '/etc/cyberpanel'
STORE_PATH = '/etc/cyberpanel/webmail_folder_settings.json'
DEFAULT_SPECIAL_DISPLAY_MODE = 'top'
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:
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 {}
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def _write_all(self, all_data: Dict[str, Any]) -> None:
self._ensure_store_dir()
tmp_path = self.store_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:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
f.write(payload)
f.flush()
os.fsync(f.fileno())
finally:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
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)
def get_for_account(self, email_account: str) -> Dict[str, Any]:
email_account = (email_account or '').strip()
if not email_account:
return self._defaults()
all_data = self._read_all()
accounts = all_data.get('accounts', {})
if not isinstance(accounts, dict):
accounts = {}
if email_account in accounts and isinstance(accounts[email_account], dict):
defaults = self._defaults()
merged = defaults
account_cfg = accounts[email_account]
if not isinstance(account_cfg, dict):
account_cfg = {}
# Merge top-level keys (shallow).
merged.update(account_cfg)
# Merge folderMappings with defaults (ensures required semantic keys exist).
if not isinstance(account_cfg.get('folderMappings'), dict):
merged['folderMappings'] = defaults['folderMappings']
else:
merged['folderMappings'] = defaults['folderMappings'].copy()
merged['folderMappings'].update(account_cfg['folderMappings'])
if not isinstance(merged.get('folderOrder'), list):
merged['folderOrder'] = []
if not isinstance(merged.get('specialOrder'), list):
merged['specialOrder'] = self._defaults()['specialOrder']
edd = merged.get('enableDragDrop')
if isinstance(edd, str):
merged['enableDragDrop'] = edd.strip().lower() in ('1', 'true', 'yes', 'on')
elif edd is None:
merged['enableDragDrop'] = self._defaults()['enableDragDrop']
else:
merged['enableDragDrop'] = bool(edd)
return merged
# Initialize for this account in-memory (write happens on save).
return self._defaults()
def save_for_account(self, email_account: str, data: Dict[str, Any]) -> None:
email_account = (email_account or '').strip()
if not email_account:
raise ValueError('email_account is required')
if not isinstance(data, dict):
raise ValueError('folder settings must be an object')
all_data = self._read_all()
if not isinstance(all_data, dict):
all_data = {}
if 'accounts' not in all_data or not isinstance(all_data['accounts'], dict):
all_data['accounts'] = {}
current = self._defaults()
# Merge but only keep recognized top-level keys.
merged = current
for key in ['specialDisplayMode', 'folderMappings', 'folderOrder', 'specialOrder', 'enableDragDrop']:
if key in data:
merged[key] = data[key]
# Normalize shapes
if not isinstance(merged.get('folderMappings'), dict):
merged['folderMappings'] = current['folderMappings']
if not isinstance(merged.get('folderOrder'), list):
merged['folderOrder'] = []
if not isinstance(merged.get('specialOrder'), list):
merged['specialOrder'] = current['specialOrder']
edd = merged.get('enableDragDrop')
if isinstance(edd, bool):
merged['enableDragDrop'] = edd
elif isinstance(edd, str):
merged['enableDragDrop'] = edd.strip().lower() in ('1', 'true', 'yes', 'on')
elif edd is None:
merged['enableDragDrop'] = current['enableDragDrop']
else:
merged['enableDragDrop'] = bool(edd)
if merged.get('specialDisplayMode') not in ['top', 'interleaved']:
merged['specialDisplayMode'] = self.DEFAULT_SPECIAL_DISPLAY_MODE
all_data['accounts'][email_account] = merged
self._write_all(all_data)

View File

@@ -0,0 +1,78 @@
-- CyberPanel Webmail: create wm_* tables when Django migration graph cannot run
-- (e.g. dockerManager depends on loginSystem migrations that do not exist).
-- Safe to run multiple times (CREATE TABLE IF NOT EXISTS).
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `wm_contacts` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`owner_email` varchar(200) NOT NULL,
`display_name` varchar(200) NOT NULL DEFAULT '',
`email_address` varchar(200) NOT NULL,
`phone` varchar(50) NOT NULL DEFAULT '',
`organization` varchar(200) NOT NULL DEFAULT '',
`notes` longtext NOT NULL,
`is_auto_collected` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `wm_contacts_owner_email_unique` (`owner_email`,`email_address`),
KEY `wm_contacts_owner_email_idx` (`owner_email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `wm_contact_groups` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`owner_email` varchar(200) NOT NULL,
`name` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `wm_contact_groups_owner_email_name_unique` (`owner_email`,`name`),
KEY `wm_contact_groups_owner_email_idx` (`owner_email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `wm_contact_group_members` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`contact_id` bigint(20) NOT NULL,
`group_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `wm_cgm_contact_group_unique` (`contact_id`,`group_id`),
KEY `wm_cgm_contact_fk` (`contact_id`),
KEY `wm_cgm_group_fk` (`group_id`),
CONSTRAINT `wm_cgm_contact_fk` FOREIGN KEY (`contact_id`) REFERENCES `wm_contacts` (`id`) ON DELETE CASCADE,
CONSTRAINT `wm_cgm_group_fk` FOREIGN KEY (`group_id`) REFERENCES `wm_contact_groups` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `wm_sessions` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`session_key` varchar(64) NOT NULL,
`email_account` varchar(200) NOT NULL,
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`last_active` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `wm_sessions_session_key_unique` (`session_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `wm_settings` (
`email_account` varchar(200) NOT NULL,
`display_name` varchar(200) NOT NULL DEFAULT '',
`signature_html` longtext NOT NULL,
`messages_per_page` int(11) NOT NULL DEFAULT 25,
`default_reply_behavior` varchar(20) NOT NULL DEFAULT 'reply',
`theme_preference` varchar(20) NOT NULL DEFAULT 'auto',
`auto_collect_contacts` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`email_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `wm_sieve_rules` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`email_account` varchar(200) NOT NULL,
`name` varchar(200) NOT NULL,
`priority` int(11) NOT NULL DEFAULT 0,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`condition_field` varchar(50) NOT NULL,
`condition_type` varchar(50) NOT NULL,
`condition_value` varchar(500) NOT NULL,
`action_type` varchar(50) NOT NULL,
`action_value` varchar(500) NOT NULL DEFAULT '',
`sieve_script` longtext NOT NULL,
PRIMARY KEY (`id`),
KEY `wm_sieve_rules_email_account_idx` (`email_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -51,16 +51,47 @@
overflow: hidden;
}
/* Sidebar */
/* Sidebar (width overridden via ng-style when sidebarResizeEnabled) */
.wm-sidebar {
width: 220px;
min-width: 220px;
max-width: 100%;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
border-right: none;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 0;
flex-shrink: 0;
}
.wm-sidebar-resizer {
width: 6px;
flex-shrink: 0;
cursor: col-resize;
background: transparent;
border-right: 1px solid var(--border-color);
align-self: stretch;
margin: 0 -3px 0 -3px;
padding: 0;
z-index: 5;
touch-action: none;
transition: background 0.15s ease;
}
.wm-sidebar-resizer:hover,
.wm-sidebar-resizer:focus {
background: rgba(108, 92, 231, 0.18);
outline: none;
}
body.wm-resizing-sidebar {
cursor: col-resize !important;
user-select: none !important;
}
body.wm-resizing-sidebar * {
cursor: col-resize !important;
}
.wm-compose-btn {
@@ -91,12 +122,40 @@
.wm-folder-item {
display: flex;
align-items: center;
padding: 8px 16px;
padding: 8px 12px 8px 8px;
cursor: pointer;
color: var(--text-secondary);
font-size: 14px;
transition: all 0.15s;
gap: 10px;
gap: 6px;
}
.wm-folder-chevron {
flex-shrink: 0;
width: 22px;
height: 28px;
border: none;
background: transparent;
padding: 0;
margin: 0;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
.wm-folder-chevron:hover {
color: var(--accent-color);
background: var(--bg-primary);
}
.wm-folder-chevron-spacer {
width: 22px;
flex-shrink: 0;
display: inline-block;
}
.wm-folder-item:hover {
@@ -110,6 +169,18 @@
font-weight: 600;
}
.wm-folder-item.dragging {
opacity: 0.6;
background: rgba(108,92,231,0.08);
color: var(--text-primary);
cursor: move;
}
.wm-folder-item.drag-over {
outline: 2px dashed var(--accent-color);
outline-offset: -2px;
}
.wm-folder-item i {
width: 18px;
text-align: center;
@@ -134,6 +205,29 @@
text-align: center;
}
.wm-folder-delete-btn {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0.85;
z-index: 2;
}
.wm-folder-item:hover .wm-folder-delete-btn {
opacity: 1;
}
.wm-folder-delete-btn:hover {
color: #dc2626;
background: rgba(220, 38, 38, 0.12);
}
.wm-sidebar-divider {
height: 1px;
background: var(--border-color);
@@ -174,6 +268,96 @@
text-align: center;
}
button.wm-nav-link.wm-nav-btn {
font-family: inherit;
width: 100%;
border: none;
background: transparent;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
margin: 0;
}
button.wm-nav-link.wm-nav-btn:hover,
button.wm-nav-link.wm-nav-btn:focus {
background: var(--bg-primary);
color: var(--text-primary);
}
/* New-folder modal */
.wm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 1040;
}
.wm-modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1050;
min-width: 320px;
max-width: 96vw;
padding: 20px 22px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.wm-modal h4 {
margin: 0 0 10px;
font-size: 17px;
color: var(--text-primary);
}
.wm-modal-hint {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 12px;
line-height: 1.45;
}
.wm-modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
flex-wrap: wrap;
}
.wm-modal-emphasis {
color: var(--text-primary);
font-size: 15px;
word-break: break-word;
}
.wm-modal-warning {
color: #b45309;
}
.wm-modal-confirm-delete .btn-danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.wm-modal-confirm-delete .btn-danger:hover {
background: #b91c1c;
border-color: #b91c1c;
color: #fff;
}
.wm-field-hint {
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
line-height: 1.35;
}
/* Message List Column */
.wm-message-list {
width: 380px;
@@ -189,6 +373,21 @@
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
gap: 8px;
align-items: stretch;
flex-wrap: wrap;
}
.wm-search-scope {
flex: 0 0 auto;
min-width: 7.5rem;
max-width: 10.5rem;
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
}
.wm-search-input {
@@ -294,12 +493,21 @@
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
cursor: grab;
gap: 8px;
transition: background 0.1s;
font-size: 13px;
}
.wm-msg-row:active {
cursor: grabbing;
}
.wm-msg-row .wm-checkbox-label,
.wm-msg-row .wm-star-btn {
cursor: pointer;
}
.wm-msg-row:hover {
background: var(--bg-primary);
}
@@ -349,6 +557,22 @@
color: var(--text-secondary);
}
.wm-msg-folder-pill {
display: inline-block;
vertical-align: middle;
max-width: 7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
font-weight: 600;
color: var(--accent-color);
background: rgba(108, 92, 231, 0.12);
padding: 1px 6px;
border-radius: 4px;
margin-right: 6px;
}
.wm-msg-date {
font-size: 11px;
color: var(--text-secondary);
@@ -830,6 +1054,9 @@
.wm-layout {
flex-direction: column;
}
.wm-sidebar-resizer {
display: none !important;
}
.wm-sidebar {
width: 100%;
min-width: 100%;

File diff suppressed because it is too large Load Diff

View File

@@ -40,18 +40,32 @@
<div class="wm-layout">
<!-- Column 1: Sidebar -->
<div class="wm-sidebar">
<div class="wm-sidebar" ng-style="sidebarResizeEnabled ? {width: sidebarWidthPx + 'px', minWidth: sidebarWidthPx + 'px', flexShrink: 0} : {}">
<button class="btn btn-primary wm-compose-btn" ng-click="composeNew()">
<i class="fa fa-pen-to-square"></i> {% trans "Compose" %}
</button>
<div class="wm-folder-list">
<div class="wm-folder-item" ng-repeat="folder in folders"
ng-class="{'active': currentFolder === folder.name}"
ng-click="selectFolder(folder.name)">
<i class="fa" ng-class="getFolderIcon(folder)"></i>
<span class="wm-folder-name">{$ folder.display_name || folder.name $}</span>
<span class="wm-badge" ng-if="folder.unread_count > 0">{$ folder.unread_count $}</span>
<div class="wm-folder-item" ng-repeat="row in displayFolderRows"
ng-style="{'padding-left': (8 + row.depth * 14) + 'px'}"
ng-class="{'active': currentFolder === row.folder.name, 'dragging': draggingFolder === row.folder.name, 'drag-over': dragOverFolder === row.folder.name}"
ng-click="selectFolder(row.folder.name)"
wm-folder-dnd="row.folder.name">
<button type="button" class="wm-folder-chevron" ng-if="row.hasChildren"
title="{% trans 'Expand or collapse subfolders' %}"
ng-attr-aria-expanded="{$ isFolderRowExpanded(row.folder.name) ? 'true' : 'false' $}"
ng-click="toggleFolderExpand(row.folder.name, $event)">
<i class="fa" ng-class="isFolderRowExpanded(row.folder.name) ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</button>
<span class="wm-folder-chevron-spacer" ng-if="!row.hasChildren" aria-hidden="true"></span>
<i class="fa" ng-class="getFolderIcon(row.folder)"></i>
<span class="wm-folder-name" title="{$ row.folder.display_name || row.folder.name $}">{$ getFolderRowLabel(row.folder, row.depth) $}</span>
<span class="wm-badge" ng-if="row.folder.unread_count > 0">{$ row.folder.unread_count $}</span>
<button type="button" class="wm-folder-delete-btn" ng-if="canDeleteFolder(row.folder)"
title="{% trans 'Delete folder' %}"
ng-click="$event.preventDefault(); $event.stopPropagation(); openDeleteFolderConfirm(row.folder)">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
@@ -71,9 +85,48 @@
<div class="wm-sidebar-divider"></div>
<div class="wm-sidebar-nav">
<a ng-click="createFolder()" class="wm-nav-link">
<button type="button" class="wm-nav-link wm-nav-btn" ng-click="openNewFolderDialog()">
<i class="fa fa-folder-plus"></i> {% trans "New Folder" %}
</a>
</button>
</div>
</div>
<div class="wm-sidebar-resizer"
wm-sidebar-resizer-touch
ng-show="sidebarResizeEnabled"
title="{% trans 'Drag to resize folder panel' %}"
role="separator"
aria-orientation="vertical"
aria-label="{% trans 'Resize folder panel' %}"
ng-mousedown="startSidebarResize($event)"></div>
<!-- New folder (no browser prompt; works when dialogs are blocked) -->
<div class="wm-modal-backdrop" ng-if="showNewFolderDialog" ng-click="cancelNewFolderDialog()"></div>
<div class="wm-modal" ng-if="showNewFolderDialog" role="dialog" aria-labelledby="wm-new-folder-title">
<h4 id="wm-new-folder-title">{% trans "New folder" %}</h4>
<p class="wm-modal-hint">{% trans "Enter a short name only. The server will create it under your account mailbox (INBOX plus dot and this name)." %}</p>
<input type="text" id="wm-new-folder-input" class="form-control" ng-model="newFolderNameInput"
placeholder="{% trans 'e.g. Projects or Work/Clients' %}"
ng-keydown="$event.keyCode === 13 && submitNewFolderDialog(); $event.keyCode === 27 && cancelNewFolderDialog()">
<div class="wm-modal-actions">
<button type="button" class="btn btn-primary" ng-click="submitNewFolderDialog()">{% trans "Create" %}</button>
<button type="button" class="btn btn-default" ng-click="cancelNewFolderDialog()">{% trans "Cancel" %}</button>
</div>
</div>
<!-- Delete folder confirmation -->
<div class="wm-modal-backdrop" ng-if="showDeleteFolderDialog" ng-click="cancelDeleteFolderDialog()"></div>
<div class="wm-modal wm-modal-confirm-delete" ng-if="showDeleteFolderDialog" role="dialog" aria-labelledby="wm-delete-folder-title"
ng-click="$event.stopPropagation()">
<h4 id="wm-delete-folder-title">{% trans "Delete this folder?" %}</h4>
<p class="wm-modal-hint">
{% trans "You are about to permanently delete:" %}<br>
<strong class="wm-modal-emphasis">{$ (folderPendingDelete.display_name || folderPendingDelete.name) $}</strong>
</p>
<p class="wm-modal-hint wm-modal-warning">{% trans "All messages in this folder may be removed. This cannot be undone." %}</p>
<div class="wm-modal-actions">
<button type="button" class="btn btn-danger" ng-click="confirmDeleteFolder()">{% trans "Delete folder" %}</button>
<button type="button" class="btn btn-default" ng-click="cancelDeleteFolderDialog()">{% trans "Cancel" %}</button>
</div>
</div>
@@ -81,9 +134,15 @@
<div class="wm-message-list" ng-show="viewMode === 'list' || viewMode === 'read'">
<!-- Search Bar -->
<div class="wm-search-bar">
<select ng-model="messageSearchScope" class="wm-search-scope form-control"
aria-label="{% trans 'Search scope' %}"
title="{% trans 'Search all folders or pick one folder' %}">
<option value="__all__">{% trans "All folders" %}</option>
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
</select>
<input type="text" ng-model="searchQuery" placeholder="{% trans 'Search messages...' %}"
ng-keyup="$event.keyCode === 13 && searchMessages()" class="wm-search-input">
<button class="wm-search-btn" ng-click="searchMessages()">
<button type="button" class="wm-search-btn" ng-click="searchMessages()">
<i class="fa fa-search"></i>
</button>
</div>
@@ -123,6 +182,7 @@
<!-- Message Rows -->
<div class="wm-messages">
<div class="wm-msg-row" ng-repeat="msg in messages"
wm-message-drag
ng-class="{'unread': !msg.is_read, 'flagged': msg.is_flagged, 'selected': msg.selected}"
ng-click="openMessage(msg)">
<label class="wm-checkbox-label" ng-click="$event.stopPropagation()">
@@ -132,7 +192,11 @@
<i class="fa" ng-class="msg.is_flagged ? 'fa-star wm-starred' : 'fa-star wm-unstarred'"></i>
</button>
<div class="wm-msg-from">{$ msg.from | limitTo:30 $}</div>
<div class="wm-msg-subject">{$ msg.subject | limitTo:60 $}</div>
<div class="wm-msg-subject">
<span class="wm-msg-folder-pill" ng-if="messageListSearchActive && messageSearchScope === '__all__' && msg.folder"
title="{$ msg.folder $}">{$ getFolderDisplayName(msg.folder) $}</span>
{$ msg.subject | limitTo:60 $}
</div>
<div class="wm-msg-date">{$ msg.date | wmDate $}</div>
</div>
<div class="wm-empty" ng-if="messages.length === 0 && !loading">
@@ -202,7 +266,10 @@
<div class="wm-field">
<label>{% trans "To" %}</label>
<input type="text" ng-model="compose.to" class="form-control"
wm-autocomplete required>
wm-autocomplete
placeholder="{% trans 'name@example.com (required. Use a full email address)' %}"
autocomplete="off">
<div class="wm-field-hint">{% trans "Separate multiple addresses with commas. Words like “test” are not valid email addresses." %}</div>
</div>
<div class="wm-field">
<label>{% trans "Cc" %}</label>
@@ -265,9 +332,14 @@
<div ng-if="viewMode === 'contacts'" class="wm-contacts-view">
<div class="wm-section-header">
<h3>{% trans "Contacts" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newContact()">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn btn-sm btn-primary" ng-click="newContact()">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
<button class="btn btn-sm btn-default" ng-click="importContactsFromSnappymail()">
<i class="fa fa-download"></i> {% trans "Import from SnappyMail" %}
</button>
</div>
</div>
<div class="wm-contacts-search">
<input type="text" ng-model="contactSearch" placeholder="{% trans 'Search contacts...' %}"
@@ -327,21 +399,31 @@
<div ng-if="viewMode === 'rules'" class="wm-rules-view">
<div class="wm-section-header">
<h3>{% trans "Mail Filter Rules" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newRule()">
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
</button>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn btn-sm btn-primary" ng-click="newRule()">
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
</button>
<button class="btn btn-sm btn-default" ng-click="importRulesFromSnappymail()">
<i class="fa fa-download"></i> {% trans "Import from SnappyMail" %}
</button>
</div>
</div>
<div class="wm-rule-list">
<div class="wm-rule-item" ng-repeat="rule in sieveRules">
<div class="wm-rule-info">
<strong>{$ rule.name $}</strong>
<span class="wm-rule-desc">
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
&rarr; {$ rule.action_type $} {$ rule.action_value $}
<span ng-if="rule.is_raw">
{% trans "Imported RAW sieve rules (read-only)" %}
</span>
<span ng-if="!rule.is_raw">
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
&rarr; {$ rule.action_type $} {$ rule.action_value $}
</span>
</span>
</div>
<div class="wm-rule-actions">
<button class="wm-action-btn" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
<button class="wm-action-btn" ng-if="!rule.is_raw" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
<button class="wm-action-btn" ng-click="removeRule(rule)"><i class="fa fa-trash"></i></button>
</div>
</div>
@@ -448,6 +530,74 @@
{% trans "Auto-collect contacts from sent messages" %}
</label>
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "Special folders display" %}</label>
<select ng-model="wmSettings.folderSettings.specialDisplayMode" class="form-control">
<option value="top">{% trans "At top" %}</option>
<option value="interleaved">{% trans "Interleaved" %}</option>
</select>
</div>
<div class="wm-field">
<label class="wm-checkbox-label" style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" ng-model="wmSettings.folderSettings.enableDragDrop">
{% trans "Enable drag/drop reorder" %}
</label>
</div>
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "Inbox" %}</label>
<select ng-model="wmSettings.folderSettings.folderMappings.inbox" 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 "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">
<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 "Drafts" %}</label>
<select ng-model="wmSettings.folderSettings.folderMappings.drafts" 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">
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
</select>
</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." %}
</div>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveSettings()">
<i class="fa fa-floppy-disk"></i> {% trans "Save Settings" %}
@@ -466,6 +616,6 @@
</div>
</div>
<script src="{% static 'webmail/webmail.js' %}?v=6"></script>
<script src="{% static 'webmail/webmail.js' %}?v=16"></script>
{% endblock %}

View File

@@ -44,6 +44,10 @@ urlpatterns = [
re_path(r'^api/createContactGroup$', views.apiCreateContactGroup, name='wmApiCreateContactGroup'),
re_path(r'^api/deleteContactGroup$', views.apiDeleteContactGroup, name='wmApiDeleteContactGroup'),
# SnappyMail Imports
re_path(r'^api/importContactsFromSnappymail$', views.apiImportContactsFromSnappymail, name='wmApiImportContactsFromSnappymail'),
re_path(r'^api/importRulesFromSnappymail$', views.apiImportRulesFromSnappymail, name='wmApiImportRulesFromSnappymail'),
# Sieve Rules
re_path(r'^api/listRules$', views.apiListRules, name='wmApiListRules'),
re_path(r'^api/createRule$', views.apiCreateRule, name='wmApiCreateRule'),

View File

@@ -5,8 +5,7 @@ from loginSystem.views import loadLoginPage
from .webmailManager import WebmailManager
# ── Page Views ────────────────────────────────────────────────
# --- Page Views ---
def loadWebmail(request):
try:
wm = WebmailManager(request)
@@ -20,8 +19,7 @@ def loadLogin(request):
return wm.loadLogin()
# ── Auth APIs ─────────────────────────────────────────────────
# --- Auth APIs ---
def apiLogin(request):
try:
wm = WebmailManager(request)
@@ -68,8 +66,7 @@ def apiSwitchAccount(request):
return _error_response(e)
# ── Folder APIs ───────────────────────────────────────────────
# --- Folder APIs ---
def apiListFolders(request):
try:
wm = WebmailManager(request)
@@ -110,8 +107,7 @@ def apiDeleteFolder(request):
return _error_response(e)
# ── Message APIs ──────────────────────────────────────────────
# --- Message APIs ---
def apiListMessages(request):
try:
wm = WebmailManager(request)
@@ -152,8 +148,7 @@ def apiGetAttachment(request):
return _error_response(e)
# ── Action APIs ───────────────────────────────────────────────
# --- Action APIs ---
def apiSendMessage(request):
try:
wm = WebmailManager(request)
@@ -224,8 +219,7 @@ def apiMarkFlagged(request):
return _error_response(e)
# ── Contact APIs ──────────────────────────────────────────────
# --- Contact APIs ---
def apiListContacts(request):
try:
wm = WebmailManager(request)
@@ -305,9 +299,27 @@ def apiDeleteContactGroup(request):
except Exception as e:
return _error_response(e)
def apiImportContactsFromSnappymail(request):
try:
wm = WebmailManager(request)
return wm.apiImportContactsFromSnappymail()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Sieve Rule APIs ──────────────────────────────────────────
def apiImportRulesFromSnappymail(request):
try:
wm = WebmailManager(request)
return wm.apiImportRulesFromSnappymail()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# --- Sieve Rule APIs ---
def apiListRules(request):
try:
wm = WebmailManager(request)
@@ -358,8 +370,7 @@ def apiActivateRules(request):
return _error_response(e)
# ── Settings APIs ─────────────────────────────────────────────
# --- Settings APIs ---
def apiGetSettings(request):
try:
wm = WebmailManager(request)
@@ -380,8 +391,7 @@ def apiSaveSettings(request):
return _error_response(e)
# ── Image Proxy ───────────────────────────────────────────────
# --- Image Proxy ---
def apiProxyImage(request):
try:
wm = WebmailManager(request)
@@ -390,8 +400,7 @@ def apiProxyImage(request):
return _error_response(e)
# ── Helpers ───────────────────────────────────────────────────
# --- Helpers ---
def _error_response(e):
data = {'status': 0, 'error_message': str(e)}
return HttpResponse(json.dumps(data), content_type='application/json')

View File

@@ -4,17 +4,40 @@ import base64
from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.db import transaction
from .models import Contact, ContactGroup, ContactGroupMembership, WebmailSettings, SieveRule
from .services.imap_client import IMAPClient
from .services.smtp_client import SMTPClient
from .services.email_composer import EmailComposer
from .services.sieve_client import SieveClient
from .services.snappymail_contacts_importer import SnappymailContactsImporter
from .services.snappymail_rules_importer import SnappymailRulesImporter
from .services.webmail_folder_settings_store import WebmailFolderSettingsStore
import plogical.CyberCPLogFileWriter as logging
WEBMAIL_CONF = '/etc/cyberpanel/webmail.conf'
WEBMAIL_SEARCH_MAX_RESULTS = 500
def _webmail_message_sort_ts(msg):
"""Parse message date header for sorting (best-effort)."""
if not msg or not isinstance(msg, dict):
return 0.0
raw = msg.get('date') or ''
if not raw:
return 0.0
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(raw)
if dt is not None:
return dt.timestamp()
except Exception:
pass
return 0.0
class WebmailManager:
@@ -259,8 +282,13 @@ class WebmailManager:
if not name:
return self._error('Folder name is required.')
# CyberPanel/Dovecot folder names (INBOX. prefix, separator '.')
protected = ['INBOX', 'INBOX.Sent', 'INBOX.Drafts', 'INBOX.Deleted Items',
'INBOX.Junk E-mail', 'INBOX.Archive']
protected = {
'INBOX', 'INBOX.Sent', 'INBOX.Drafts', 'INBOX.Deleted Items',
'INBOX.Junk E-mail', 'INBOX.Archive', 'INBOX.spam', 'INBOX.Trash',
'Sent', 'Drafts', 'Trash', 'Spam', 'Junk', 'Archive',
'Deleted Items', 'Junk E-mail',
}
protected.update(set(IMAPClient.SPECIAL_FOLDERS.values()))
if name in protected:
return self._error('Cannot delete system folder.')
try:
@@ -278,22 +306,87 @@ class WebmailManager:
folder = data.get('folder', 'INBOX')
page = int(data.get('page', 1))
per_page = int(data.get('perPage', 25))
uids_filter = data.get('uids')
if uids_filter is not None and not isinstance(uids_filter, list):
uids_filter = None
try:
with self._get_imap() as imap:
result = imap.list_messages(folder, page, per_page)
result = imap.list_messages(
folder, page, per_page, uids_filter=uids_filter)
return self._success(result)
except Exception as e:
return self._error(str(e))
def apiSearchMessages(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
query = data.get('query', '')
folder = (data.get('folder') or 'INBOX').strip()
query = (data.get('query') or '').strip()
scope = (data.get('scope') or 'all').strip().lower()
if not query:
return self._error('Search text is required.')
if scope not in ('all', 'folder'):
scope = 'all'
try:
with self._get_imap() as imap:
uids = imap.search_messages(folder, query)
uids = [u.decode() if isinstance(u, bytes) else str(u) for u in uids]
return self._success({'uids': uids})
if scope == 'folder':
uids = imap.search_messages(folder, query)
uids = [
u.decode() if isinstance(u, bytes) else str(u)
for u in uids if u]
if not uids:
return self._success({
'messages': [],
'scope': 'folder',
'folder': folder,
})
per = max(len(uids), 1)
result = imap.list_messages(
folder, 1, per, uids_filter=uids)
for m in result.get('messages') or []:
m['folder'] = folder
return self._success({
'messages': result.get('messages') or [],
'scope': 'folder',
'folder': folder,
})
# Search all selectable folders
folders = imap.list_folders()
all_messages = []
for finfo in folders:
if len(all_messages) >= WEBMAIL_SEARCH_MAX_RESULTS:
break
fname = finfo.get('name')
if not fname:
continue
try:
uids = imap.search_messages(fname, query)
if not uids:
continue
uids = [
u.decode() if isinstance(u, bytes) else str(u)
for u in uids if u]
remaining = WEBMAIL_SEARCH_MAX_RESULTS - len(all_messages)
if remaining <= 0:
break
if len(uids) > remaining:
uids = uids[:remaining]
result = imap.list_messages(
fname, 1, len(uids), uids_filter=uids)
for m in result.get('messages') or []:
m['folder'] = fname
all_messages.append(m)
except Exception:
continue
try:
all_messages.sort(
key=_webmail_message_sort_ts, reverse=True)
except Exception:
pass
return self._success({
'messages': all_messages,
'scope': 'all',
})
except Exception as e:
return self._error(str(e))
@@ -377,20 +470,20 @@ class WebmailManager:
references = data.get('references', '')
attachments = None
if not to:
return self._error('At least one recipient is required.')
mime_msg = EmailComposer.compose(
from_addr=email_addr,
to_addrs=to,
subject=subject,
body_html=body_html,
cc_addrs=cc,
bcc_addrs=bcc,
attachments=attachments,
in_reply_to=in_reply_to,
references=references,
)
try:
mime_msg = EmailComposer.compose(
from_addr=email_addr,
to_addrs=to,
subject=subject,
body_html=body_html,
cc_addrs=cc,
bcc_addrs=bcc,
attachments=attachments,
in_reply_to=in_reply_to,
references=references,
)
except ValueError as ve:
return self._error(str(ve))
smtp = self._get_smtp()
result = smtp.send_message(mime_msg)
@@ -533,9 +626,40 @@ class WebmailManager:
def apiListContacts(self):
email = self._get_email()
try:
contacts = list(Contact.objects.filter(owner_email=email).values(
contacts_qs = Contact.objects.filter(owner_email=email)
contacts = list(contacts_qs.values(
'id', 'display_name', 'email_address', 'phone', 'organization', 'notes', 'is_auto_collected'
))
if not contacts and not contacts_qs.exists():
# Best-effort: auto-import contacts from SnappyMail if our DB is empty.
try:
importer = SnappymailContactsImporter()
sn_contacts = importer.import_contacts(email)
if sn_contacts:
with transaction.atomic():
for c in sn_contacts:
c_email = (c.get('email_address') or '').strip()
if not c_email:
continue
display = (c.get('display_name') or '').strip()
Contact.objects.get_or_create(
owner_email=email,
email_address=c_email,
defaults={
'display_name': display,
'is_auto_collected': False,
}
)
contacts = list(Contact.objects.filter(owner_email=email).values(
'id', 'display_name', 'email_address', 'phone', 'organization', 'notes', 'is_auto_collected'
))
except Exception as e:
# Don't break UI if auto-import fails.
try:
logging.CyberCPLogFileWriter.writeToFile(
'SnappyMail auto-import contacts failed for %s: %s' % (email, str(e)))
except Exception:
pass
return self._success({'contacts': contacts})
except Exception as e:
return self._error(str(e))
@@ -629,16 +753,181 @@ class WebmailManager:
except Exception as e:
return self._error(str(e))
# ── SnappyMail Imports ─────────────────────────────────────
def apiImportContactsFromSnappymail(self):
"""
Import contacts from SnappyMail/RainLoop into wm_contacts.
Optional POST body:
- clearExisting (bool): delete existing contacts for this email before importing.
"""
email = self._get_email()
data = self._get_post_data()
clear_existing = bool(data.get('clearExisting', False))
try:
if clear_existing:
Contact.objects.filter(owner_email=email).delete()
importer = SnappymailContactsImporter()
contacts = importer.import_contacts(email)
imported_new = 0
updated_existing = 0
with transaction.atomic():
for c in contacts:
c_email = (c.get('email_address') or '').strip()
if not c_email:
continue
display = (c.get('display_name') or '').strip()
obj, created = Contact.objects.get_or_create(
owner_email=email,
email_address=c_email,
defaults={
'display_name': display,
'is_auto_collected': False,
}
)
if created:
imported_new += 1
else:
# Only overwrite if current display name is empty.
if display and not (obj.display_name or '').strip():
obj.display_name = display
obj.is_auto_collected = False
obj.save(update_fields=['display_name', 'is_auto_collected'])
updated_existing += 1
return self._success({
'imported_new': imported_new,
'updated_existing': updated_existing,
'total_found': len(contacts),
})
except Exception as e:
# Do not leak DB credentials in error output.
try:
logging.CyberCPLogFileWriter.writeToFile(
'SnappyMail contacts import failed for %s: %s' % (email, str(e)))
except Exception:
pass
return self._error('SnappyMail contacts import failed.')
def apiImportRulesFromSnappymail(self):
"""
Import RainLoop/SnappyMail sieve filters into CyberPanel webmail.
Approach:
- Fetch RainLoop ManageSieve script body (typically `rainloop.user`)
- Store it as RAW in wm_sieve_rules.sieve_script
- Upload it to ManageSieve as CyberPanel's `cyberpanel` active script
"""
email = self._get_email()
data = self._get_post_data()
clear_existing = bool(data.get('clearExisting', True))
try:
importer = SnappymailRulesImporter()
script_name = ''
raw_script = ''
with self._get_sieve(email) as sieve:
script_name, raw_script = importer.fetch_raw_script(sieve)
raw_script = (raw_script or '').strip()
if not raw_script:
return self._error('No RainLoop sieve script content found to import.')
if clear_existing:
SieveRule.objects.filter(email_account=email, sieve_script__gt='').delete()
priority = -1000
rule_name = 'SnappyMail import: %s' % (script_name or 'rainloop.user')
SieveRule.objects.create(
email_account=email,
name=rule_name,
priority=priority,
is_active=True,
condition_field='from',
condition_type='contains',
condition_value='',
action_type='move',
action_value='',
sieve_script=raw_script,
)
self._sync_sieve_rules(email)
return self._success({'imported_rule': rule_name})
except ConnectionRefusedError:
return self._error('ManageSieve connection refused. Is dovecot-sieve running on port 4190?')
except Exception as e:
try:
logging.CyberCPLogFileWriter.writeToFile(
'SnappyMail rules import failed for %s: %s' % (email, str(e)))
except Exception:
pass
return self._error('SnappyMail rules import failed.')
# ── Sieve Rule APIs ───────────────────────────────────────
def apiListRules(self):
email = self._get_email()
try:
rules = list(SieveRule.objects.filter(email_account=email).values(
'id', 'name', 'priority', 'is_active',
'condition_field', 'condition_type', 'condition_value',
'action_type', 'action_value',
))
rules_qs = SieveRule.objects.filter(email_account=email)
# Best-effort auto-import when our DB doesn't have rules yet.
if not rules_qs.exists():
try:
importer = SnappymailRulesImporter()
script_name = ''
raw_script = ''
with self._get_sieve(email) as sieve:
script_name, raw_script = importer.fetch_raw_script(sieve)
raw_script = (raw_script or '').strip()
if raw_script:
# Create a single RAW rule. This makes the imported script active.
SieveRule.objects.create(
email_account=email,
name='SnappyMail import: %s' % (script_name or 'rainloop.user'),
priority=-1000,
is_active=True,
condition_field='from',
condition_type='contains',
condition_value='',
action_type='move',
action_value='',
sieve_script=raw_script,
)
self._sync_sieve_rules(email)
rules_qs = SieveRule.objects.filter(email_account=email)
except Exception as e:
try:
logging.CyberCPLogFileWriter.writeToFile(
'SnappyMail auto-import rules failed for %s: %s' % (email, str(e)))
except Exception:
pass
rules_qs = rules_qs.order_by('priority')
rules = []
for r in rules_qs:
sieve_script = getattr(r, 'sieve_script', '') or ''
rules.append({
'id': r.id,
'name': r.name,
'priority': r.priority,
'is_active': bool(r.is_active),
'condition_field': r.condition_field,
'condition_type': r.condition_type,
'condition_value': r.condition_value,
'action_type': r.action_type,
'action_value': r.action_value,
'is_raw': bool(sieve_script.strip()),
})
return self._success({'rules': rules})
except Exception as e:
return self._error(str(e))
@@ -715,18 +1004,28 @@ class WebmailManager:
Rules are always saved to the database; Sieve sync is best-effort.
"""
rules = SieveRule.objects.filter(email_account=email, is_active=True).order_by('priority')
rule_dicts = []
raw_script = ''
for r in rules:
rule_dicts.append({
'name': r.name,
'condition_field': r.condition_field,
'condition_type': r.condition_type,
'condition_value': r.condition_value,
'action_type': r.action_type,
'action_value': r.action_value,
})
sieve_script = getattr(r, 'sieve_script', '') or ''
if sieve_script.strip():
raw_script = sieve_script
break
script = SieveClient.rules_to_sieve(rule_dicts)
if raw_script.strip():
# If we imported raw ManageSieve rules, override CyberPanel-generated rules.
script = raw_script
else:
rule_dicts = []
for r in rules:
rule_dicts.append({
'name': r.name,
'condition_field': r.condition_field,
'condition_type': r.condition_type,
'condition_value': r.condition_value,
'action_type': r.action_type,
'action_value': r.action_value,
})
script = SieveClient.rules_to_sieve(rule_dicts)
try:
with self._get_sieve(email) as sieve:
@@ -745,6 +1044,8 @@ class WebmailManager:
email = self._get_email()
try:
settings, created = WebmailSettings.objects.get_or_create(email_account=email)
folder_store = WebmailFolderSettingsStore()
folder_settings = folder_store.get_for_account(email)
return self._success({
'settings': {
'displayName': settings.display_name,
@@ -753,6 +1054,7 @@ class WebmailManager:
'defaultReplyBehavior': settings.default_reply_behavior,
'themePreference': settings.theme_preference,
'autoCollectContacts': settings.auto_collect_contacts,
'folderSettings': folder_settings,
}
})
except Exception as e:
@@ -763,19 +1065,37 @@ class WebmailManager:
data = self._get_post_data()
try:
settings, created = WebmailSettings.objects.get_or_create(email_account=email)
if 'displayName' in data:
settings.display_name = data['displayName']
if 'signatureHtml' in data:
settings.signature_html = data['signatureHtml']
if 'messagesPerPage' in data:
settings.messages_per_page = int(data['messagesPerPage'])
if 'defaultReplyBehavior' in data:
settings.default_reply_behavior = data['defaultReplyBehavior']
if 'themePreference' in data:
settings.theme_preference = data['themePreference']
if 'autoCollectContacts' in data:
settings.auto_collect_contacts = bool(data['autoCollectContacts'])
if isinstance(data, dict):
if 'displayName' in data:
settings.display_name = (data.get('displayName') or '')[:200]
if 'signatureHtml' in data:
settings.signature_html = data.get('signatureHtml') or ''
if 'messagesPerPage' in data:
try:
mp = int(data.get('messagesPerPage'))
if mp < 1:
mp = 25
settings.messages_per_page = mp
except Exception:
pass
if 'defaultReplyBehavior' in data:
drb = data.get('defaultReplyBehavior') or 'reply'
if drb in ['reply', 'reply_all']:
settings.default_reply_behavior = drb
if 'themePreference' in data:
tp = data.get('themePreference') or 'auto'
if tp in ['light', 'dark', 'auto']:
settings.theme_preference = tp
if 'autoCollectContacts' in data:
settings.auto_collect_contacts = bool(data.get('autoCollectContacts'))
settings.save()
# Folder settings are stored outside the DB (file-based) to avoid migrations.
folder_settings = data.get('folderSettings') if isinstance(data, dict) else None
if isinstance(folder_settings, dict):
folder_store = WebmailFolderSettingsStore()
folder_store.save_for_account(email, folder_settings)
return self._success()
except Exception as e:
return self._error(str(e))