diff --git a/webmail/migrations/0001_initial.py b/webmail/migrations/0001_initial.py new file mode 100644 index 000000000..85a25650e --- /dev/null +++ b/webmail/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/webmail/services/email_composer.py b/webmail/services/email_composer.py index 41b4b49d5..58ce37f7c 100644 --- a/webmail/services/email_composer.py +++ b/webmail/services/email_composer.py @@ -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') diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py index ff81fe996..b52c07619 100644 --- a/webmail/services/imap_client.py +++ b/webmail/services/imap_client.py @@ -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' diff --git a/webmail/services/imap_defaults.py b/webmail/services/imap_defaults.py new file mode 100644 index 000000000..cd41e8460 --- /dev/null +++ b/webmail/services/imap_defaults.py @@ -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', + } + diff --git a/webmail/services/smtp_client.py b/webmail/services/smtp_client.py index 02673975d..2c36312da 100644 --- a/webmail/services/smtp_client.py +++ b/webmail/services/smtp_client.py @@ -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() diff --git a/webmail/services/snappymail_contacts_importer.py b/webmail/services/snappymail_contacts_importer.py new file mode 100644 index 000000000..493d805b0 --- /dev/null +++ b/webmail/services/snappymail_contacts_importer.py @@ -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 + diff --git a/webmail/services/snappymail_rules_importer.py b/webmail/services/snappymail_rules_importer.py new file mode 100644 index 000000000..c5db93c07 --- /dev/null +++ b/webmail/services/snappymail_rules_importer.py @@ -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 + diff --git a/webmail/services/webmail_folder_settings_store.py b/webmail/services/webmail_folder_settings_store.py new file mode 100644 index 000000000..986c66978 --- /dev/null +++ b/webmail/services/webmail_folder_settings_store.py @@ -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) + diff --git a/webmail/sql/install_wm_tables.sql b/webmail/sql/install_wm_tables.sql new file mode 100644 index 000000000..f7d54fc26 --- /dev/null +++ b/webmail/sql/install_wm_tables.sql @@ -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; diff --git a/webmail/static/webmail/webmail.css b/webmail/static/webmail/webmail.css index 35b13b24e..da7953a6d 100644 --- a/webmail/static/webmail/webmail.css +++ b/webmail/static/webmail/webmail.css @@ -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%; diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js index 40deb69df..30ee316ec 100644 --- a/webmail/static/webmail/webmail.js +++ b/webmail/static/webmail/webmail.js @@ -104,12 +104,256 @@ app.directive('wmAutocomplete', ['$http', function($http) { }; }]); -app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($scope, $http, $sce, $timeout) { +var WM_MIME_MAIL_ITEMS = 'application/x-cyberpanel-webmail-messages'; + +function _dataTransferOf(ev) { + if (!ev) return null; + return ev.dataTransfer || (ev.originalEvent && ev.originalEvent.dataTransfer) || null; +} + +function _dataTransferHasWebmailMessages(dt) { + if (!dt || !dt.types) return false; + var t = WM_MIME_MAIL_ITEMS; + if (typeof dt.types.includes === 'function') return dt.types.includes(t); + for (var i = 0; i < dt.types.length; i++) { + if (dt.types[i] === t) return true; + } + return false; +} + +/** + * Drag one or more messages (selected rows, or the row under the cursor) to move them. + */ +app.directive('wmMessageDrag', ['$timeout', function($timeout) { + return { + restrict: 'A', + link: function(scope, element) { + element.prop('draggable', true); + element.attr('title', element.attr('title') || ''); + + function safeDigest(fn) { + var phase = scope.$root.$$phase; + if (phase === '$apply' || phase === '$digest') { + $timeout(fn, 0); + } else { + scope.$apply(fn); + } + } + + element.on('dragstart', function(ev) { + var msg = scope.msg; + if (!msg || !scope.getDragMessageItemsForMove) return; + var items = scope.getDragMessageItemsForMove(msg); + if (!items.length) { + ev.preventDefault(); + return; + } + var dt = _dataTransferOf(ev); + if (dt) { + try { + dt.effectAllowed = 'move'; + dt.setData(WM_MIME_MAIL_ITEMS, JSON.stringify({ items: items })); + dt.setData('text/plain', 'webmail-messages'); + } catch (e) { /* ignore */ } + } + safeDigest(function() { + if (scope.onMailItemsDragStart) scope.onMailItemsDragStart(); + }); + }); + + element.on('dragend', function() { + safeDigest(function() { + if (scope.onMailItemsDragEnd) scope.onMailItemsDragEnd(); + }); + }); + + scope.$on('$destroy', function() { + element.off('dragstart dragend'); + }); + } + }; +}]); + +/** + * Native HTML5 drag/drop for folder rows with proper digest + dataTransfer (Safari/Firefox). + */ +app.directive('wmFolderDnd', ['$timeout', function($timeout) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + function folderName() { + try { + return scope.$eval(attrs.wmFolderDnd); + } catch (e) { + return null; + } + } + + function dragEnabled() { + var fs = scope.wmSettings && scope.wmSettings.folderSettings; + if (!fs || fs.enableDragDrop === undefined || fs.enableDragDrop === null) { + return true; + } + return !!fs.enableDragDrop; + } + + function safeDigest(fn) { + var phase = scope.$root.$$phase; + if (phase === '$apply' || phase === '$digest') { + $timeout(fn, 0); + } else { + scope.$apply(fn); + } + } + + function dataTransferOf(ev) { + return _dataTransferOf(ev); + } + + scope.$watch(function() { + return [folderName(), dragEnabled()]; + }, function() { + element.prop('draggable', dragEnabled() ? 'true' : 'false'); + }, true); + + element.on('dragstart', function(ev) { + if (!dragEnabled()) { + ev.preventDefault(); + return; + } + var name = folderName(); + if (!name) return; + var dt = dataTransferOf(ev); + if (dt) { + try { + dt.effectAllowed = 'move'; + dt.setData('text/plain', name); + } catch (e) {} + } + safeDigest(function() { + scope.onFolderDragStart(name); + }); + }); + + element.on('dragover', function(ev) { + var dt = dataTransferOf(ev); + if (_dataTransferHasWebmailMessages(dt)) { + ev.preventDefault(); + if (dt) { + try { + dt.dropEffect = 'move'; + } catch (e2) {} + } + var name = folderName(); + safeDigest(function() { + if (scope.onMessageDragOverFolder) scope.onMessageDragOverFolder(name); + }); + return; + } + if (!dragEnabled()) return; + ev.preventDefault(); + if (dt) { + try { + dt.dropEffect = 'move'; + } catch (e3) {} + } + var name2 = folderName(); + safeDigest(function() { + scope.onFolderDragOver(ev, name2); + }); + }); + + element.on('drop', function(ev) { + ev.preventDefault(); + var dt = dataTransferOf(ev); + var raw = ''; + if (dt) { + try { + raw = dt.getData(WM_MIME_MAIL_ITEMS) || ''; + } catch (e) { + raw = ''; + } + } + if (raw) { + var parsed = null; + try { + parsed = JSON.parse(raw); + } catch (e2) { + parsed = null; + } + if (parsed && parsed.items && parsed.items.length) { + var dropName = folderName(); + safeDigest(function() { + if (scope.onMessagesDropOnFolder) { + scope.onMessagesDropOnFolder(parsed.items, dropName); + } + }); + return; + } + } + if (!dragEnabled()) return; + var name3 = folderName(); + safeDigest(function() { + scope.onFolderDrop(ev, name3); + }); + }); + + element.on('dragend', function() { + safeDigest(function() { + scope.onFolderDragEnd(); + }); + }); + + scope.$on('$destroy', function() { + element.off('dragstart dragover drop dragend'); + }); + } + }; +}]); + +app.directive('wmSidebarResizerTouch', function() { + return { + restrict: 'A', + link: function(scope, element) { + element.on('touchstart', function(ev) { + try { + ev.preventDefault(); + } catch (e) { /* ignore */ } + scope.startSidebarResize(ev); + }); + scope.$on('$destroy', function() { + element.off('touchstart'); + }); + } + }; +}); + +app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', '$document', '$window', function($scope, $http, $sce, $timeout, $document, $window) { + + // System folders: must stay in sync with webmailManager.apiDeleteFolder protected set + var WM_SIDEBAR_WIDTH_KEY = 'wm_sidebar_width_px'; + var WM_SIDEBAR_MIN = 180; + var WM_SIDEBAR_MAX = 560; + var WM_SIDEBAR_DEFAULT = 220; + var sidebarResizeActive = false; + + var WM_FOLDER_PROTECTED = { + 'INBOX': true, + 'INBOX.Sent': true, 'INBOX.Drafts': true, 'INBOX.Deleted Items': true, + 'INBOX.Junk E-mail': true, 'INBOX.Archive': true, 'INBOX.spam': true, 'INBOX.Trash': true, + 'Sent': true, 'Drafts': true, 'Trash': true, 'Spam': true, 'Junk': true, 'Archive': true, + 'Deleted Items': true, 'Junk E-mail': true + }; // ── State ──────────────────────────────────────────────── $scope.currentEmail = ''; $scope.managedAccounts = []; $scope.folders = []; + $scope.displayFolders = []; + /** Nested sidebar rows: { folder, depth, hasChildren } */ + $scope.displayFolderRows = []; + /** folder name -> false when collapsed (expanded when unset/true) */ + $scope.folderExpanded = {}; $scope.currentFolder = 'INBOX'; $scope.messages = []; $scope.currentPage = 1; @@ -122,10 +366,76 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.loading = false; $scope.sending = false; $scope.searchQuery = ''; + /** '__all__' or concrete IMAP folder name */ + $scope.messageSearchScope = '__all__'; + $scope.messageListSearchActive = false; $scope.selectAll = false; + $scope.sidebarWidthPx = WM_SIDEBAR_DEFAULT; + $scope.sidebarResizeEnabled = true; + + function refreshSidebarResizeEnabled() { + try { + $scope.sidebarResizeEnabled = $window.matchMedia('(min-width: 769px)').matches; + } catch (e) { + $scope.sidebarResizeEnabled = true; + } + } + refreshSidebarResizeEnabled(); + + try { + var storedW = parseInt(localStorage.getItem(WM_SIDEBAR_WIDTH_KEY), 10); + if (!isNaN(storedW) && storedW >= WM_SIDEBAR_MIN && storedW <= WM_SIDEBAR_MAX) { + $scope.sidebarWidthPx = storedW; + } + } catch (e) { /* ignore */ } + + angular.element($window).on('resize', function() { + $scope.$applyAsync(refreshSidebarResizeEnabled); + }); + + $scope.startSidebarResize = function(event) { + if (!$scope.sidebarResizeEnabled) return; + if (sidebarResizeActive) return; + if (event.type === 'mousedown' && event.button !== 0) return; + event.preventDefault(); + sidebarResizeActive = true; + var startX = event.clientX != null ? event.clientX : (event.originalEvent && event.originalEvent.touches && event.originalEvent.touches[0] ? event.originalEvent.touches[0].clientX : (event.touches && event.touches[0] ? event.touches[0].clientX : 0)); + var startW = $scope.sidebarWidthPx; + + function clampW(n) { + return Math.max(WM_SIDEBAR_MIN, Math.min(WM_SIDEBAR_MAX, n)); + } + + function onMove(e) { + if (!$scope.sidebarResizeEnabled) return; + var oe = e.originalEvent || e; + var x = oe.clientX != null ? oe.clientX : (oe.touches && oe.touches[0] ? oe.touches[0].clientX : (e.clientX != null ? e.clientX : startX)); + var dx = x - startX; + $scope.sidebarWidthPx = clampW(Math.round(startW + dx)); + $scope.$digest(); + } + + function onUp() { + sidebarResizeActive = false; + $document.off('mousemove touchmove', onMove); + $document.off('mouseup touchend touchcancel', onUp); + try { + localStorage.setItem(WM_SIDEBAR_WIDTH_KEY, String($scope.sidebarWidthPx)); + } catch (err) { /* ignore */ } + angular.element($window.document.body).removeClass('wm-resizing-sidebar'); + } + + angular.element($window.document.body).addClass('wm-resizing-sidebar'); + $document.on('mousemove touchmove', onMove); + $document.on('mouseup touchend touchcancel', onUp); + }; $scope.showMoveDropdown = false; $scope.moveTarget = ''; $scope.showBcc = false; + $scope.showNewFolderDialog = false; + $scope.newFolderNameInput = ''; + $scope.showDeleteFolderDialog = false; + $scope.folderPendingDelete = null; // Compose $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; @@ -141,7 +451,26 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.editingRule = null; // Settings - $scope.wmSettings = {}; + $scope.wmSettings = { + folderSettings: { + specialDisplayMode: 'top', + folderMappings: { + inbox: 'INBOX', + spam: 'INBOX.Junk E-mail', + deleted_items: 'INBOX.Deleted Items', + junk_e_mail: 'INBOX.Junk E-mail', + drafts: 'INBOX.Drafts', + trash: 'INBOX.Deleted Items' + }, + folderOrder: [], + specialOrder: ['inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash'], + enableDragDrop: true + } + }; + $scope.draggingFolder = null; + $scope.dragOverFolder = null; + $scope.draggingMailItems = false; + $scope.folderLayoutDirty = false; // Draft auto-save var draftTimer = null; @@ -166,6 +495,28 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ new PNotify({title: type === 'error' ? 'Error' : 'Webmail', text: msg, type: type || 'success'}); } + function splitRecipients(s) { + if (!s || typeof s !== 'string') return []; + return s.split(/[,;]+/).map(function(t) { return (t || '').trim(); }).filter(Boolean); + } + + function isPlausibleEmail(addr) { + if (!addr || addr.indexOf('@') < 0) return false; + var p = addr.split('@'); + if (p.length !== 2 || !p[0] || !p[1] || p[1].indexOf('.') < 0) return false; + if (addr.length > 254) return false; + return true; + } + + function countValidRecipients(to, cc, bcc) { + var all = splitRecipients(to).concat(splitRecipients(cc || '')).concat(splitRecipients(bcc || '')); + var n = 0; + for (var i = 0; i < all.length; i++) { + if (isPlausibleEmail(all[i])) n++; + } + return n; + } + // ── Init ───────────────────────────────────────────────── $scope.init = function() { // Try SSO first @@ -173,8 +524,8 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ if (data.status === 1) { $scope.currentEmail = data.email; $scope.managedAccounts = data.accounts || []; - $scope.loadFolders(); $scope.loadSettings(); + $scope.loadFolders(); } else { notify(data.error_message || 'No email accounts found. Create an email account first or use the standalone login.', 'error'); } @@ -198,8 +549,8 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ apiCall('/webmail/api/switchAccount', {email: newEmail}, function(data) { if (data.status === 1) { - $scope.loadFolders(); $scope.loadSettings(); + $scope.loadFolders(); } else { notify(data.error_message || 'Failed to switch account', 'error'); console.error('switchAccount failed:', data); @@ -214,7 +565,8 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.loadFolders = function() { apiCall('/webmail/api/listFolders', {}, function(data) { if (data.status === 1) { - $scope.folders = data.folders; + $scope.folders = data.folders || []; + $scope.applyFolderLayout(); // Pick a sane default folder. // Some Dovecot setups may not expose a real "INBOX" mailbox (messages live under "INBOX.*"). // The UI previously hardcoded currentFolder='INBOX', which caused "No messages" even when mail exists. @@ -265,7 +617,35 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ return (best && best.name) ? best.name : (folders[0].name || 'INBOX'); }; - $scope.currentFolder = chooseDefaultFolder($scope.folders); + var mappings = (($scope.wmSettings || {}).folderSettings || {}).folderMappings || {}; + var mappedInbox = mappings.inbox || 'INBOX'; + var inboxFolder = null; + for (var i = 0; i < $scope.folders.length; i++) { + if ($scope.folders[i] && $scope.folders[i].name === mappedInbox) { + inboxFolder = $scope.folders[i]; + break; + } + } + if (inboxFolder && ((inboxFolder.unread_count || 0) > 0 || (inboxFolder.total_count || 0) > 0)) { + $scope.currentFolder = mappedInbox; + } else { + $scope.currentFolder = chooseDefaultFolder($scope.folders); + } + + // If folder ordering/mapping hides the selected folder from the UI list, + // ensure we still display something sensible. + if ($scope.displayFolders && $scope.displayFolders.length > 0) { + var ok = false; + for (var df = 0; df < $scope.displayFolders.length; df++) { + if ($scope.displayFolders[df] && $scope.displayFolders[df].name === $scope.currentFolder) { + ok = true; + break; + } + } + if (!ok) { + $scope.currentFolder = $scope.displayFolders[0].name; + } + } $scope.currentPage = 1; $scope.loadMessages(); } else { @@ -274,16 +654,450 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ }); }; + // ── Folder Layout (mapping + ordering + drag/drop) ───────── + function _getFolderMappings() { + return (($scope.wmSettings || {}).folderSettings || {}).folderMappings || {}; + } + + function _getSpecialDisplayMode() { + var mode = (($scope.wmSettings || {}).folderSettings || {}).specialDisplayMode; + return (mode === 'interleaved') ? 'interleaved' : 'top'; + } + + function _getEnableDragDrop() { + var enabled = (($scope.wmSettings || {}).folderSettings || {}).enableDragDrop; + return enabled === undefined ? true : !!enabled; + } + + 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 keys; + } + + function _getFolderByName() { + var map = {}; + for (var i = 0; i < ($scope.folders || []).length; i++) { + var f = $scope.folders[i]; + if (f && f.name) map[f.name] = f; + } + return map; + } + + function _folderDelimiterFromList() { + var list = $scope.folders || []; + for (var i = 0; i < list.length; i++) { + var d = list[i] && list[i].delimiter; + if (d != null && d !== '') { + var s = String(d).replace(/^"|"$/g, ''); + return s || '.'; + } + } + return '.'; + } + + /** Parent for nested display: INBOX.X is root; INBOX.X.Y parents to INBOX.X if it exists. */ + function _getEffectiveFolderParent(name, folderByName) { + if (!name || name === 'INBOX') return null; + var sep = _folderDelimiterFromList(); + var parts = name.split(sep); + if (parts.length <= 2) return null; + var parentPath = parts.slice(0, -1).join(sep); + while (parentPath && parentPath !== 'INBOX' && !folderByName[parentPath]) { + var pp = parentPath.split(sep); + if (pp.length <= 2) { + parentPath = null; + break; + } + parentPath = pp.slice(0, -1).join(sep); + } + if (!parentPath || parentPath === 'INBOX' || !folderByName[parentPath]) return null; + return parentPath; + } + + function _buildDisplayFolderRows(orderedFolders, folderByName) { + var names = orderedFolders.map(function(f) { return f.name; }); + var parentOf = {}; + var childMap = {}; + for (var i = 0; i < names.length; i++) { + childMap[names[i]] = []; + } + for (var j = 0; j < names.length; j++) { + var nm = names[j]; + var p = _getEffectiveFolderParent(nm, folderByName); + parentOf[nm] = p; + if (p && childMap[p] !== undefined) { + childMap[p].push(nm); + } + } + var indexByName = {}; + for (var ix = 0; ix < names.length; ix++) { + indexByName[names[ix]] = ix; + } + var key; + for (key in childMap) { + if (Object.prototype.hasOwnProperty.call(childMap, key)) { + childMap[key].sort(function(a, b) { + var ia = indexByName[a] !== undefined ? indexByName[a] : 999999; + var ib = indexByName[b] !== undefined ? indexByName[b] : 999999; + return ia - ib; + }); + } + } + var rows = []; + function pushVisible(fname, depth) { + var f = folderByName[fname]; + if (!f) return; + var kids = childMap[fname] || []; + var hasKids = kids.length > 0; + rows.push({ folder: f, depth: depth, hasChildren: hasKids }); + var expanded = $scope.folderExpanded[fname] !== false; + if (hasKids && expanded) { + for (var c = 0; c < kids.length; c++) { + pushVisible(kids[c], depth + 1); + } + } + } + for (var r = 0; r < names.length; r++) { + if (parentOf[names[r]] == null) { + pushVisible(names[r], 0); + } + } + return rows; + } + + function _normalizeFolderOrder(folderByName) { + var baseNames = Object.keys(folderByName); + var baseOrder = ($scope.folders || []).map(function(f) { return f.name; }); + // Normalize order to contain only known folders and keep backend order as a fallback. + var stored = (((($scope.wmSettings || {}).folderSettings || {}).folderOrder) || []).slice(); + var existing = {}; + for (var i = 0; i < baseOrder.length; i++) existing[baseOrder[i]] = true; + + var result = []; + var seen = {}; + for (var j = 0; j < stored.length; j++) { + var n = stored[j]; + if (existing[n] && !seen[n]) { + seen[n] = true; + result.push(n); + } + } + // Append any missing folders in backend order. + for (var k = 0; k < baseOrder.length; k++) { + var bn = baseOrder[k]; + if (existing[bn] && !seen[bn]) { + seen[bn] = true; + result.push(bn); + } + } + return result; + } + + function _getSpecialFolderNames(folderByName, normalizedOrder) { + var mappings = _getFolderMappings(); + var specialKeys = _getSpecialOrderKeys(); + var specialNamesInKeyOrder = []; + var seen = {}; + 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 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; + } + + $scope.ensureFolderAncestorsExpanded = function(folderName) { + if (!folderName) return; + var folderByName = _getFolderByName(); + var p = _getEffectiveFolderParent(folderName, folderByName); + while (p) { + $scope.folderExpanded[p] = true; + p = _getEffectiveFolderParent(p, folderByName); + } + }; + + $scope.toggleFolderExpand = function(folderName, evt) { + if (evt) { + evt.preventDefault(); + evt.stopPropagation(); + } + if (!folderName) return; + if ($scope.folderExpanded[folderName] === false) { + delete $scope.folderExpanded[folderName]; + } else { + $scope.folderExpanded[folderName] = false; + } + var folderByName = _getFolderByName(); + $scope.displayFolderRows = _buildDisplayFolderRows($scope.displayFolders, folderByName); + }; + + $scope.isFolderRowExpanded = function(folderName) { + return $scope.folderExpanded[folderName] !== false; + }; + + $scope.getFolderRowLabel = function(folder, depth) { + if (!folder) return ''; + var dn = folder.display_name || folder.name || ''; + if (depth <= 0) return dn; + var sep = _folderDelimiterFromList(); + var idx = dn.lastIndexOf(sep); + if (idx >= 0) return dn.slice(idx + sep.length); + return dn; + }; + + $scope.applyFolderLayout = function() { + if (!$scope.folders || $scope.folders.length === 0) { + $scope.displayFolders = []; + $scope.displayFolderRows = []; + return; + } + + var folderByName = _getFolderByName(); + var normalizedOrder = _normalizeFolderOrder(folderByName); + var mode = _getSpecialDisplayMode(); + + var specialNames = _getSpecialFolderNames(folderByName, normalizedOrder); + var specialSet = {}; + for (var i = 0; i < specialNames.length; i++) specialSet[specialNames[i]] = true; + + var displayNames = []; + if (mode === 'top') { + // Special section at top, others follow in normalized order. + displayNames = specialNames.slice(); + for (var j = 0; j < normalizedOrder.length; j++) { + var n = normalizedOrder[j]; + if (!specialSet[n]) displayNames.push(n); + } + } else { + // Fully interleaved order. + displayNames = normalizedOrder.slice(); + } + + $scope.displayFolders = displayNames.map(function(n) { return folderByName[n]; }).filter(function(x) { return !!x; }); + + // Ensure currentFolder is valid. + var found = false; + for (var k = 0; k < displayNames.length; k++) { + if (displayNames[k] === $scope.currentFolder) { + found = true; + break; + } + } + if (!found) { + var mappings = _getFolderMappings(); + var mappedInbox = mappings.inbox || 'INBOX'; + if (folderByName[mappedInbox]) { + $scope.currentFolder = mappedInbox; + } else if (displayNames.length > 0) { + $scope.currentFolder = displayNames[0]; + } + } + $scope.ensureFolderAncestorsExpanded($scope.currentFolder); + $scope.displayFolderRows = _buildDisplayFolderRows($scope.displayFolders, folderByName); + }; + + // React to settings changes without requiring a full reload. + $scope.$watch('wmSettings.folderSettings.folderMappings', function() { + if ($scope.folders && $scope.folders.length > 0) { + $scope.applyFolderLayout(); + var mappings = _getFolderMappings(); + if (mappings && mappings.inbox && ($scope.folders || []).some(function(f) { return f && f.name === mappings.inbox; })) { + $scope.currentFolder = mappings.inbox; + if ($scope.viewMode === 'list' || $scope.viewMode === 'read') { + $scope.loadMessages(); + } + } + } + }, true); + $scope.$watch('wmSettings.folderSettings.specialDisplayMode', function() { + if ($scope.folders && $scope.folders.length > 0) { + $scope.applyFolderLayout(); + } + }); + + function _updateFolderOrderAfterDrag(draggedName, targetName) { + if (!draggedName || !targetName || draggedName === targetName) return; + var folderByName = _getFolderByName(); + if (!folderByName[draggedName] || !folderByName[targetName]) return; + + var normalizedOrder = _normalizeFolderOrder(folderByName); + var mode = _getSpecialDisplayMode(); + + // Determine special membership. + var specialNames = _getSpecialFolderNames(folderByName, normalizedOrder); + var specialSet = {}; + for (var i = 0; i < specialNames.length; i++) specialSet[specialNames[i]] = true; + + var newOrder = []; + if (mode === 'interleaved') { + newOrder = normalizedOrder.slice(); + var fromIdx = newOrder.indexOf(draggedName); + var toIdx = newOrder.indexOf(targetName); + if (fromIdx < 0 || toIdx < 0) return; + newOrder.splice(fromIdx, 1); + // If removing dragged element shifts indices, recompute target index. + toIdx = newOrder.indexOf(targetName); + newOrder.splice(toIdx, 0, draggedName); + } else { + // top mode: reorder within the same group (special vs other) + var draggedIsSpecial = !!specialSet[draggedName]; + var targetIsSpecial = !!specialSet[targetName]; + if (draggedIsSpecial !== targetIsSpecial) return; + + var specialOrdered = []; + var otherOrdered = []; + for (var j = 0; j < normalizedOrder.length; j++) { + var n = normalizedOrder[j]; + if (specialSet[n]) specialOrdered.push(n); + else otherOrdered.push(n); + } + + if (draggedIsSpecial) { + var group = specialOrdered.slice(); + var fromIdx2 = group.indexOf(draggedName); + var toIdx2 = group.indexOf(targetName); + if (fromIdx2 < 0 || toIdx2 < 0) return; + group.splice(fromIdx2, 1); + toIdx2 = group.indexOf(targetName); + group.splice(toIdx2, 0, draggedName); + newOrder = group.concat(otherOrdered); + } else { + var group2 = otherOrdered.slice(); + var fromIdx3 = group2.indexOf(draggedName); + var toIdx3 = group2.indexOf(targetName); + if (fromIdx3 < 0 || toIdx3 < 0) return; + group2.splice(fromIdx3, 1); + toIdx3 = group2.indexOf(targetName); + group2.splice(toIdx3, 0, draggedName); + newOrder = specialOrdered.concat(group2); + } + } + + if (!$scope.wmSettings.folderSettings) $scope.wmSettings.folderSettings = {}; + $scope.wmSettings.folderSettings.folderOrder = newOrder; + $scope.folderLayoutDirty = true; + $scope.applyFolderLayout(); + } + + /** Persist folder layout only (silent on success). */ + $scope.persistFolderLayoutSettings = function(silent) { + if (!$scope.wmSettings || !$scope.wmSettings.folderSettings) return; + apiCall('/webmail/api/saveSettings', { + folderSettings: angular.copy($scope.wmSettings.folderSettings) + }, function(data) { + if (data.status === 1) { + $scope.folderLayoutDirty = false; + if (!silent) { + notify('Folder layout saved.'); + } + } else if (!silent) { + notify(data.error_message || 'Could not save folder layout.', 'error'); + } + }, function() { + if (!silent) { + notify('Could not save folder layout.', 'error'); + } + }); + }; + + $scope.onFolderDragStart = function(folderName) { + if (!_getEnableDragDrop()) return; + $scope.draggingFolder = folderName; + $scope.dragOverFolder = null; + }; + + $scope.onFolderDragOver = function(evt, targetFolderName) { + if (!_getEnableDragDrop()) return; + evt.preventDefault(); + if (!$scope.draggingFolder || $scope.draggingFolder === targetFolderName) { + $scope.dragOverFolder = null; + return; + } + + // In top mode, only allow drops within the same group. + var mode = _getSpecialDisplayMode(); + if (mode === 'top') { + var folderByName = _getFolderByName(); + var normalizedOrder = _normalizeFolderOrder(folderByName); + var specialNames = _getSpecialFolderNames(folderByName, normalizedOrder); + var specialSet = {}; + for (var i = 0; i < specialNames.length; i++) specialSet[specialNames[i]] = true; + + var draggedIsSpecial = !!specialSet[$scope.draggingFolder]; + var targetIsSpecial = !!specialSet[targetFolderName]; + if (draggedIsSpecial !== targetIsSpecial) { + $scope.dragOverFolder = null; + return; + } + } + $scope.dragOverFolder = targetFolderName; + }; + + $scope.onFolderDrop = function(evt, targetFolderName) { + if (!_getEnableDragDrop()) return; + evt.preventDefault(); + if (!$scope.draggingFolder) return; + _updateFolderOrderAfterDrag($scope.draggingFolder, targetFolderName); + $scope.persistFolderLayoutSettings(true); + $scope.draggingFolder = null; + $scope.dragOverFolder = null; + }; + + $scope.onFolderDragEnd = function() { + $scope.draggingFolder = null; + $scope.dragOverFolder = null; + }; + $scope.selectFolder = function(name) { + $scope.ensureFolderAncestorsExpanded(name); $scope.currentFolder = name; $scope.currentPage = 1; $scope.openMsg = null; $scope.viewMode = 'list'; $scope.searchQuery = ''; + $scope.messageListSearchActive = false; + var folderByName = _getFolderByName(); + $scope.displayFolderRows = _buildDisplayFolderRows($scope.displayFolders, folderByName); $scope.loadMessages(); }; + $scope.getFolderDisplayName = function(folderName) { + if (!folderName) return ''; + 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 folderName; + }; + $scope.getFolderIcon = function(folder) { + // Prefer semantic mapping selected in Settings. + 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.drafts && name === mappings.drafts) return 'fa-file'; + if ((mappings.trash && name === mappings.trash) || (mappings.deleted_items && name === mappings.deleted_items)) return 'fa-trash'; + // Use folder_type from backend if available (mapped from Dovecot folder names) var ftype = folder.folder_type || ''; if (ftype === 'inbox') return 'fa-inbox'; @@ -303,25 +1117,89 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ return 'fa-folder'; }; - $scope.createFolder = function() { - var name = prompt('Folder name:'); - if (!name) return; - // Dovecot namespace: prefix with INBOX. and use . as separator + $scope.canDeleteFolder = function(folder) { + if (!folder || !folder.name) return false; + return !WM_FOLDER_PROTECTED[folder.name]; + }; + + $scope.openDeleteFolderConfirm = function(folder) { + if (!$scope.canDeleteFolder(folder)) return; + $scope.folderPendingDelete = folder; + $scope.showDeleteFolderDialog = true; + }; + + $scope.cancelDeleteFolderDialog = function() { + $scope.showDeleteFolderDialog = false; + $scope.folderPendingDelete = null; + }; + + $scope.confirmDeleteFolder = function() { + var folder = $scope.folderPendingDelete; + if (!folder || !$scope.canDeleteFolder(folder)) { + $scope.cancelDeleteFolderDialog(); + return; + } + apiCall('/webmail/api/deleteFolder', {name: folder.name}, function(data) { + if (data.status === 1) { + $scope.cancelDeleteFolderDialog(); + if ($scope.currentFolder === folder.name) { + $scope.currentFolder = 'INBOX'; + $scope.viewMode = 'list'; + } + $scope.loadFolders(); + notify('Folder deleted.'); + } else { + notify(data.error_message || 'Failed to delete folder.', 'error'); + } + }, function(err) { + notify('Failed to delete folder.', 'error'); + console.error('deleteFolder:', err); + }); + }; + + $scope.openNewFolderDialog = function() { + $scope.newFolderNameInput = ''; + $scope.showNewFolderDialog = true; + $timeout(function() { + var el = document.getElementById('wm-new-folder-input'); + if (el) { + el.focus(); + } + }, 150); + }; + + $scope.cancelNewFolderDialog = function() { + $scope.showNewFolderDialog = false; + $scope.newFolderNameInput = ''; + }; + + $scope.submitNewFolderDialog = function() { + var name = ($scope.newFolderNameInput || '').trim(); + if (!name) { + notify('Type a folder name in the box, then click Create.', 'error'); + return; + } if (name.indexOf('INBOX.') !== 0) { name = 'INBOX.' + name; } apiCall('/webmail/api/createFolder', {name: name}, function(data) { if (data.status === 1) { + $scope.showNewFolderDialog = false; + $scope.newFolderNameInput = ''; $scope.loadFolders(); notify('Folder created.'); } else { - notify(data.error_message, 'error'); + notify(data.error_message || 'Failed to create folder.', 'error'); } + }, function(err) { + notify('Failed to create folder.', 'error'); + console.error('createFolder:', err); }); }; - // ── Messages ───────────────────────────────────────────── + // --- Messages --- $scope.loadMessages = function() { + $scope.messageListSearchActive = false; $scope.loading = true; apiCall('/webmail/api/listMessages', { folder: $scope.currentFolder, @@ -357,34 +1235,34 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ }; $scope.searchMessages = function() { - if (!$scope.searchQuery) { + var q = ($scope.searchQuery || '').trim(); + if (!q) { + $scope.messageListSearchActive = false; $scope.loadMessages(); return; } + var scopeParam = $scope.messageSearchScope === '__all__' ? 'all' : 'folder'; + var folderParam = $scope.messageSearchScope === '__all__' + ? $scope.currentFolder + : $scope.messageSearchScope; $scope.loading = true; apiCall('/webmail/api/searchMessages', { - folder: $scope.currentFolder, - query: $scope.searchQuery + query: q, + scope: scopeParam, + folder: folderParam }, function(data) { $scope.loading = false; - if (data.status === 1 && data.uids && data.uids.length > 0) { - // Fetch the found messages by their UIDs - apiCall('/webmail/api/listMessages', { - folder: $scope.currentFolder, - page: 1, - perPage: data.uids.length, - uids: data.uids - }, function(msgData) { - if (msgData.status === 1) { - $scope.messages = msgData.messages; - $scope.totalMessages = msgData.total; - $scope.totalPages = msgData.pages; - } - }); - } else if (data.status === 1) { - $scope.messages = []; - $scope.totalMessages = 0; - $scope.totalPages = 1; + if (data.status !== 1) { + notify(data.error_message || 'Search failed.', 'error'); + return; + } + $scope.messageListSearchActive = true; + $scope.messages = data.messages || []; + $scope.totalMessages = $scope.messages.length; + $scope.totalPages = 1; + $scope.currentPage = 1; + $scope.selectAll = false; + if ($scope.messages.length === 0) { notify('No messages found.', 'info'); } }, function() { @@ -394,12 +1272,14 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ // ── Open/Read Message ──────────────────────────────────── $scope.openMessage = function(msg) { + var folder = (msg && msg.folder) ? msg.folder : $scope.currentFolder; apiCall('/webmail/api/getMessage', { - folder: $scope.currentFolder, + folder: folder, uid: msg.uid }, function(data) { if (data.status === 1) { $scope.openMsg = data.message; + $scope.openMsg.folder = folder; var html = data.message.body_html || ''; var text = data.message.body_text || ''; // Use sanitized HTML from backend, or escape plain text @@ -415,7 +1295,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ if (!msg.is_read) { msg.is_read = true; $scope.folders.forEach(function(f) { - if (f.name === $scope.currentFolder && f.unread_count > 0) { + if (f.name === folder && f.unread_count > 0) { f.unread_count--; } }); @@ -546,6 +1426,11 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.sendMessage = function() { $scope.updateComposeBody(); + var rcpt = countValidRecipients($scope.compose.to, $scope.compose.cc, $scope.compose.bcc); + if (rcpt < 1) { + notify('Enter at least one full email address (e.g. user@hotmail.com). "Test" or a name alone is not a valid address. Use the full address in To, Cc, or Bcc.', 'error'); + return; + } $scope.sending = true; stopDraftAutoSave(); @@ -628,69 +1513,194 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.messages.forEach(function(m) { m.selected = $scope.selectAll; }); }; - function getSelectedUids() { - return $scope.messages.filter(function(m) { return m.selected; }).map(function(m) { return m.uid; }); + function selectedUidsByFolder() { + var map = {}; + $scope.messages.forEach(function(m) { + if (!m.selected) return; + var f = m.folder || $scope.currentFolder; + if (!map[f]) map[f] = []; + map[f].push(m.uid); + }); + return map; + } + + function refreshListAfterBulk() { + if ($scope.messageListSearchActive && ($scope.searchQuery || '').trim()) { + $scope.searchMessages(); + } else { + $scope.loadMessages(); + } + $scope.loadFolders(); + } + + $scope.getDragMessageItemsForMove = function(primaryMsg) { + var selected = ($scope.messages || []).filter(function(m) { return m.selected; }); + var list = selected.length ? selected : (primaryMsg ? [primaryMsg] : []); + var out = []; + for (var i = 0; i < list.length; i++) { + var m = list[i]; + if (!m || m.uid == null || m.uid === '') continue; + out.push({ folder: m.folder || $scope.currentFolder, uid: String(m.uid) }); + } + return out; + }; + + $scope.onMailItemsDragStart = function() { + $scope.draggingMailItems = true; + }; + + $scope.onMailItemsDragEnd = function() { + $scope.draggingMailItems = false; + $scope.dragOverFolder = null; + }; + + $scope.onMessageDragOverFolder = function(name) { + $scope.dragOverFolder = name; + }; + + $scope.onMessagesDropOnFolder = function(items, targetFolder) { + $scope.dragOverFolder = null; + $scope.draggingMailItems = false; + if (!targetFolder || !items || !items.length) return; + var map = {}; + for (var j = 0; j < items.length; j++) { + var it = items[j]; + if (!it || it.uid == null || it.uid === '' || !it.folder) continue; + if (it.folder === targetFolder) continue; + if (!map[it.folder]) map[it.folder] = []; + map[it.folder].push(String(it.uid)); + } + var keys = Object.keys(map); + if (keys.length === 0) { + notify('Messages are already in that folder.', 'info'); + return; + } + var state = { pending: keys.length, err: false }; + function finishMoves() { + state.pending--; + if (state.pending > 0) return; + if (!state.err) { + notify('Message(s) moved.', 'success'); + } else { + notify('Some messages could not be moved.', 'error'); + } + refreshListAfterBulk(); + } + keys.forEach(function(fld) { + apiCall('/webmail/api/moveMessages', { + folder: fld, + uids: map[fld], + targetFolder: targetFolder + }, function(data) { + if (!data || data.status !== 1) { + state.err = true; + } + finishMoves(); + }, function() { + state.err = true; + finishMoves(); + }); + }); + }; + + function bulkApiPerFolder(path, extra, done) { + var map = selectedUidsByFolder(); + var keys = Object.keys(map); + if (keys.length === 0) { + if (done) done(); + return; + } + var pending = keys.length; + keys.forEach(function(fld) { + var payload = angular.extend({folder: fld, uids: map[fld]}, extra || {}); + apiCall('/webmail/api/' + path, payload, function(data) { + if (!data || data.status !== 1) { + notify(data && data.error_message ? data.error_message : 'Action failed.', 'error'); + } + pending--; + if (pending <= 0 && done) done(); + }, function() { + pending--; + if (pending <= 0 && done) done(); + }); + }); } $scope.bulkDelete = function() { - var uids = getSelectedUids(); - if (uids.length === 0) return; - apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: uids}, function(data) { - if (data.status === 1) { - $scope.loadMessages(); - $scope.loadFolders(); - } + var map = selectedUidsByFolder(); + var n = Object.keys(map).reduce(function(acc, k) { return acc + map[k].length; }, 0); + if (n === 0) return; + bulkApiPerFolder('deleteMessages', {}, function() { + refreshListAfterBulk(); }); }; $scope.bulkMarkRead = function() { - var uids = getSelectedUids(); - if (uids.length === 0) return; - apiCall('/webmail/api/markRead', {folder: $scope.currentFolder, uids: uids}, function() { - $scope.loadMessages(); - $scope.loadFolders(); + var map = selectedUidsByFolder(); + var n = Object.keys(map).reduce(function(acc, k) { return acc + map[k].length; }, 0); + if (n === 0) return; + bulkApiPerFolder('markRead', {}, function() { + refreshListAfterBulk(); }); }; $scope.bulkMarkUnread = function() { - var uids = getSelectedUids(); - if (uids.length === 0) return; - apiCall('/webmail/api/markUnread', {folder: $scope.currentFolder, uids: uids}, function() { - $scope.loadMessages(); - $scope.loadFolders(); + var map = selectedUidsByFolder(); + var n = Object.keys(map).reduce(function(acc, k) { return acc + map[k].length; }, 0); + if (n === 0) return; + bulkApiPerFolder('markUnread', {}, function() { + refreshListAfterBulk(); }); }; $scope.bulkMove = function() { - var uids = getSelectedUids(); - if (uids.length === 0 || !$scope.moveTarget) return; - apiCall('/webmail/api/moveMessages', { - folder: $scope.currentFolder, - uids: uids, - targetFolder: $scope.moveTarget.name || $scope.moveTarget - }, function(data) { - if (data.status === 1) { - $scope.showMoveDropdown = false; - $scope.moveTarget = ''; - $scope.loadMessages(); - $scope.loadFolders(); - } + if (!$scope.moveTarget) return; + var map = selectedUidsByFolder(); + var n = Object.keys(map).reduce(function(acc, k) { return acc + map[k].length; }, 0); + if (n === 0) return; + var targetFolder = $scope.moveTarget.name || $scope.moveTarget; + var keys = Object.keys(map); + var pending = keys.length; + keys.forEach(function(fld) { + apiCall('/webmail/api/moveMessages', { + folder: fld, + uids: map[fld], + targetFolder: targetFolder + }, function(data) { + if (!data || data.status !== 1) { + notify(data && data.error_message ? data.error_message : 'Move failed.', 'error'); + } + pending--; + if (pending <= 0) { + $scope.showMoveDropdown = false; + $scope.moveTarget = ''; + refreshListAfterBulk(); + } + }, function() { + pending--; + if (pending <= 0) { + $scope.showMoveDropdown = false; + $scope.moveTarget = ''; + refreshListAfterBulk(); + } + }); }); }; $scope.toggleFlag = function(msg) { - apiCall('/webmail/api/markFlagged', {folder: $scope.currentFolder, uids: [msg.uid]}, function() { + var fld = msg.folder || $scope.currentFolder; + apiCall('/webmail/api/markFlagged', {folder: fld, uids: [msg.uid]}, function() { msg.is_flagged = !msg.is_flagged; }); }; $scope.deleteMsg = function(msg) { - apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: [msg.uid]}, function(data) { + var fld = (msg && msg.folder) ? msg.folder : $scope.currentFolder; + apiCall('/webmail/api/deleteMessages', {folder: fld, uids: [msg.uid]}, function(data) { if (data.status === 1) { $scope.openMsg = null; $scope.viewMode = 'list'; - $scope.loadMessages(); - $scope.loadFolders(); + refreshListAfterBulk(); } }); }; @@ -701,7 +1711,11 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ form.method = 'POST'; form.action = '/webmail/api/getAttachment'; form.target = '_blank'; - var fields = {folder: $scope.currentFolder, uid: $scope.openMsg.uid, partId: att.part_id}; + var fields = { + folder: $scope.openMsg.folder || $scope.currentFolder, + uid: $scope.openMsg.uid, + partId: att.part_id + }; fields['csrfmiddlewaretoken'] = getCookie('csrftoken'); for (var key in fields) { var input = document.createElement('input'); @@ -729,9 +1743,33 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.loadContacts = function() { apiCall('/webmail/api/listContacts', {}, function(data) { if (data.status === 1) { - $scope.contacts = data.contacts; - $scope.filteredContacts = data.contacts; + $scope.contacts = data.contacts || []; + $scope.filteredContacts = data.contacts || []; + $scope.filterContacts(); + } else { + $scope.contacts = []; + $scope.filteredContacts = []; + notify(data.error_message || 'Could not load contacts.', 'error'); } + }, function(err) { + $scope.contacts = []; + $scope.filteredContacts = []; + notify('Could not load contacts.', 'error'); + console.error('listContacts error:', err); + }); + }; + + $scope.importContactsFromSnappymail = function() { + apiCall('/webmail/api/importContactsFromSnappymail', {}, function(data) { + if (data.status === 1) { + notify('Contacts imported from SnappyMail (found: ' + (data.total_found || 0) + ').'); + $scope.loadContacts(); + } else { + notify(data.error_message || 'Failed to import contacts.', 'error'); + } + }, function(err) { + notify('Failed to import contacts.', 'error'); + console.error('importContactsFromSnappymail error:', err); }); }; @@ -801,8 +1839,29 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ $scope.loadRules = function() { apiCall('/webmail/api/listRules', {}, function(data) { if (data.status === 1) { - $scope.sieveRules = data.rules; + $scope.sieveRules = data.rules || []; + } else { + $scope.sieveRules = []; + notify(data.error_message || 'Could not load mail rules.', 'error'); } + }, function(err) { + $scope.sieveRules = []; + notify('Could not load mail rules.', 'error'); + console.error('listRules error:', err); + }); + }; + + $scope.importRulesFromSnappymail = function() { + apiCall('/webmail/api/importRulesFromSnappymail', {}, function(data) { + if (data.status === 1) { + notify('SnappyMail rules imported.'); + $scope.loadRules(); + } else { + notify(data.error_message || 'Failed to import rules.', 'error'); + } + }, function(err) { + notify('Failed to import rules.', 'error'); + console.error('importRulesFromSnappymail error:', err); }); }; @@ -855,9 +1914,15 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($ 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}; + } if ($scope.wmSettings.messagesPerPage) { $scope.perPage = parseInt($scope.wmSettings.messagesPerPage); } + if ($scope.folders && $scope.folders.length > 0 && typeof $scope.applyFolderLayout === 'function') { + $scope.applyFolderLayout(); + } } }); }; diff --git a/webmail/templates/webmail/index.html b/webmail/templates/webmail/index.html index fc93ca2b2..1f943abd9 100644 --- a/webmail/templates/webmail/index.html +++ b/webmail/templates/webmail/index.html @@ -40,18 +40,32 @@
-
+
-
- - {$ folder.display_name || folder.name $} - {$ folder.unread_count $} +
+ + + + {$ getFolderRowLabel(row.folder, row.depth) $} + {$ row.folder.unread_count $} +
@@ -71,9 +85,48 @@
+
+ + + + +
+ + + +
+ @@ -81,9 +134,15 @@
@@ -123,6 +182,7 @@
@@ -202,7 +266,10 @@
+ wm-autocomplete + placeholder="{% trans 'name@example.com (required. Use a full email address)' %}" + autocomplete="off"> +
{% trans "Separate multiple addresses with commas. Words like “test” are not valid email addresses." %}
@@ -265,9 +332,14 @@

{% trans "Contacts" %}

- +
+ + +
- + {% endblock %} diff --git a/webmail/urls.py b/webmail/urls.py index 78ffa2b1c..ee24cca6e 100644 --- a/webmail/urls.py +++ b/webmail/urls.py @@ -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'), diff --git a/webmail/views.py b/webmail/views.py index 94d333dc0..c93522ae7 100644 --- a/webmail/views.py +++ b/webmail/views.py @@ -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') diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py index 029f071c3..c79e2ecc6 100644 --- a/webmail/webmailManager.py +++ b/webmail/webmailManager.py @@ -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))