mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-06-27 11:18:35 +02:00
webmail: v2.5.5-dev UI and backend improvements
- Resizable folder sidebar with persisted width; nested folder tree with expand/collapse - Message search: scope all folders or single folder; listMessages honors UID filter - Drag-and-drop messages onto folders to move (multi-select supported) - SnappyMail import paths, folder settings store, wm DB migration and SQL install - IMAP quoted mailbox, IPv4 SMTP relay, compose recipient handling - Modal new/delete folder flows; dash-free UI copy; folder pills in search results
This commit is contained in:
105
webmail/migrations/0001_initial.py
Normal file
105
webmail/migrations/0001_initial.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# Generated by Django 4.2.14 on 2026-03-25 21:36
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Contact',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('owner_email', models.CharField(db_index=True, max_length=200)),
|
||||
('display_name', models.CharField(blank=True, default='', max_length=200)),
|
||||
('email_address', models.CharField(max_length=200)),
|
||||
('phone', models.CharField(blank=True, default='', max_length=50)),
|
||||
('organization', models.CharField(blank=True, default='', max_length=200)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('is_auto_collected', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_contacts',
|
||||
'unique_together': {('owner_email', 'email_address')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContactGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('owner_email', models.CharField(db_index=True, max_length=200)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_contact_groups',
|
||||
'unique_together': {('owner_email', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SieveRule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email_account', models.CharField(db_index=True, max_length=200)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('priority', models.IntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('condition_field', models.CharField(choices=[('from', 'From'), ('to', 'To'), ('subject', 'Subject'), ('size', 'Size')], max_length=50)),
|
||||
('condition_type', models.CharField(choices=[('contains', 'Contains'), ('is', 'Is'), ('matches', 'Matches'), ('greater_than', 'Greater than')], max_length=50)),
|
||||
('condition_value', models.CharField(max_length=500)),
|
||||
('action_type', models.CharField(choices=[('move', 'Move to folder'), ('forward', 'Forward to'), ('discard', 'Discard'), ('flag', 'Flag')], max_length=50)),
|
||||
('action_value', models.CharField(blank=True, default='', max_length=500)),
|
||||
('sieve_script', models.TextField(blank=True, default='')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_sieve_rules',
|
||||
'ordering': ['priority'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebmailSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('session_key', models.CharField(max_length=64, unique=True)),
|
||||
('email_account', models.CharField(max_length=200)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_active', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_sessions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebmailSettings',
|
||||
fields=[
|
||||
('email_account', models.CharField(max_length=200, primary_key=True, serialize=False)),
|
||||
('display_name', models.CharField(blank=True, default='', max_length=200)),
|
||||
('signature_html', models.TextField(blank=True, default='')),
|
||||
('messages_per_page', models.IntegerField(default=25)),
|
||||
('default_reply_behavior', models.CharField(choices=[('reply', 'Reply'), ('reply_all', 'Reply All')], default='reply', max_length=20)),
|
||||
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('auto', 'Auto')], default='auto', max_length=20)),
|
||||
('auto_collect_contacts', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContactGroupMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='webmail.contact')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='webmail.contactgroup')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'wm_contact_group_members',
|
||||
'unique_together': {('contact', 'group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
15
webmail/services/imap_defaults.py
Normal file
15
webmail/services/imap_defaults.py
Normal file
@@ -0,0 +1,15 @@
|
||||
class IMAPDefaults:
|
||||
"""
|
||||
Minimal defaults shared with the folder settings store.
|
||||
|
||||
Keep this separate from IMAPClient to avoid importing imaplib during settings reads.
|
||||
"""
|
||||
|
||||
SPECIAL_FOLDERS = {
|
||||
'sent': 'INBOX.Sent',
|
||||
'drafts': 'INBOX.Drafts',
|
||||
'trash': 'INBOX.Deleted Items',
|
||||
'junk': 'INBOX.Junk E-mail',
|
||||
'archive': 'INBOX.Archive',
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
186
webmail/services/snappymail_contacts_importer.py
Normal file
186
webmail/services/snappymail_contacts_importer.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import MySQLdb
|
||||
|
||||
|
||||
class SnappymailContactsImporter:
|
||||
"""
|
||||
Import contacts from SnappyMail (RainLoop) DB into CyberPanel Webmail.
|
||||
|
||||
SnappyMail stores addressbook data in RainLoop tables:
|
||||
- rainloop_users (rl_email -> id_user)
|
||||
- rainloop_ab_contacts
|
||||
- rainloop_ab_properties (prop_type == 30 => EMAIL, see RainLoop PropertyType::EMAIl)
|
||||
|
||||
This importer returns plain contact records; CyberPanel persists them into wm_contacts.
|
||||
"""
|
||||
|
||||
# SnappyMail may use either install tree; contacts [pdo_*] is identical in practice.
|
||||
CANDIDATE_APP_INI = (
|
||||
'/usr/local/lscp/cyberpanel/snappymail/data/_data_/_default_/configs/application.ini',
|
||||
'/usr/local/lscp/cyberpanel/rainloop/data/_data_/_default_/configs/application.ini',
|
||||
)
|
||||
|
||||
SNAPPYMAIL_APP_INI = CANDIDATE_APP_INI[0]
|
||||
|
||||
# RainLoop PropertyType::EMAIl === 30
|
||||
RAINLOOP_PROP_EMAIL = 30
|
||||
|
||||
def __init__(self, app_ini_path: str = None):
|
||||
self.app_ini_path = app_ini_path or self._first_existing_ini()
|
||||
|
||||
@classmethod
|
||||
def _first_existing_ini(cls) -> str:
|
||||
for p in cls.CANDIDATE_APP_INI:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return cls.SNAPPYMAIL_APP_INI
|
||||
|
||||
@staticmethod
|
||||
def _strip_quotes(value: str) -> str:
|
||||
value = value.strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
||||
return value[1:-1].strip()
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _parse_pdo_dsn(pdo_dsn: str) -> Dict[str, str]:
|
||||
# Example:
|
||||
# host=127.0.0.1;port=3306;dbname=snappymail
|
||||
parts = {}
|
||||
for chunk in pdo_dsn.split(';'):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if '=' not in chunk:
|
||||
continue
|
||||
k, v = chunk.split('=', 1)
|
||||
parts[k.strip()] = v.strip()
|
||||
return parts
|
||||
|
||||
def _load_snappymail_db_config(self) -> Tuple[str, int, str, str, str]:
|
||||
if not os.path.isfile(self.app_ini_path):
|
||||
raise FileNotFoundError('SnappyMail config not found: %s' % self.app_ini_path)
|
||||
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(self.app_ini_path)
|
||||
|
||||
if not parser.has_section('contacts'):
|
||||
raise ValueError('SnappyMail config missing [contacts] section.')
|
||||
|
||||
pdo_dsn = self._strip_quotes(parser.get('contacts', 'pdo_dsn', fallback=''))
|
||||
user = self._strip_quotes(parser.get('contacts', 'pdo_user', fallback=''))
|
||||
password = self._strip_quotes(parser.get('contacts', 'pdo_password', fallback=''))
|
||||
|
||||
if not pdo_dsn or not user:
|
||||
raise ValueError('SnappyMail contacts config incomplete (missing DSN/user).')
|
||||
|
||||
dsn_parts = self._parse_pdo_dsn(pdo_dsn)
|
||||
host = dsn_parts.get('host', '127.0.0.1')
|
||||
port_raw = dsn_parts.get('port', '3306')
|
||||
dbname = dsn_parts.get('dbname', '')
|
||||
|
||||
if not dbname:
|
||||
raise ValueError('SnappyMail contacts config missing dbname in DSN.')
|
||||
|
||||
try:
|
||||
port = int(port_raw)
|
||||
except Exception:
|
||||
port = 3306
|
||||
|
||||
return host, port, dbname, user, password
|
||||
|
||||
def import_contacts(self, owner_email: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Fetch contacts for a given owner email from RainLoop DB.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{'email_address': 'a@b.c', 'display_name': 'Name'},
|
||||
...
|
||||
]
|
||||
"""
|
||||
owner_email = (owner_email or '').strip()
|
||||
if not owner_email:
|
||||
return []
|
||||
|
||||
host, port, dbname, user, password = self._load_snappymail_db_config()
|
||||
|
||||
conn = None
|
||||
try:
|
||||
# autocommit=True so we never hold locks
|
||||
conn = MySQLdb.connect(host=host, port=port, user=user, passwd=password, db=dbname, charset='utf8mb4')
|
||||
try:
|
||||
conn.autocommit(True)
|
||||
except Exception:
|
||||
pass
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Find RainLoop id_user for this email
|
||||
cursor.execute(
|
||||
'SELECT id_user FROM rainloop_users WHERE LOWER(rl_email) = LOWER(%s) LIMIT 1',
|
||||
(owner_email,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return []
|
||||
id_user = int(row[0])
|
||||
|
||||
# Fetch emails + display values
|
||||
# Note: one contact can have multiple email entries. We create one webmail contact per email.
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT
|
||||
c.id_contact,
|
||||
c.display,
|
||||
p.prop_value AS email_address
|
||||
FROM rainloop_ab_contacts AS c
|
||||
JOIN rainloop_ab_properties AS p
|
||||
ON p.id_contact = c.id_contact
|
||||
AND p.id_user = c.id_user
|
||||
AND p.prop_type = %s
|
||||
WHERE c.id_user = %s
|
||||
AND c.deleted = 0
|
||||
AND p.prop_value IS NOT NULL
|
||||
AND TRIM(p.prop_value) <> ''
|
||||
ORDER BY c.display ASC
|
||||
''',
|
||||
(self.RAINLOOP_PROP_EMAIL, id_user)
|
||||
)
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
while True:
|
||||
r = cursor.fetchone()
|
||||
if not r:
|
||||
break
|
||||
display = r[1] or ''
|
||||
email_address = (r[2] or '').strip()
|
||||
if not email_address:
|
||||
continue
|
||||
key = email_address.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
if display:
|
||||
display_name = display
|
||||
else:
|
||||
display_name = email_address.split('@')[0] if '@' in email_address else email_address
|
||||
|
||||
results.append({
|
||||
'email_address': email_address,
|
||||
'display_name': display_name,
|
||||
})
|
||||
|
||||
return results
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
47
webmail/services/snappymail_rules_importer.py
Normal file
47
webmail/services/snappymail_rules_importer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .sieve_client import SieveClient
|
||||
|
||||
|
||||
class SnappymailRulesImporter:
|
||||
"""
|
||||
Import RainLoop/SnappyMail sieve filters from ManageSieve into CyberPanel Webmail.
|
||||
|
||||
SnappyMail/RainLoop uses ManageSieve scripts; the filter storage is typically:
|
||||
- rainloop.user (see RainLoop Providers\\Filters\\SieveStorage.php)
|
||||
|
||||
We import as a RAW script so rules keep working even if parsing differs from CyberPanel's
|
||||
own structured rule format.
|
||||
"""
|
||||
|
||||
RAINLOOP_DEFAULT_SCRIPT = 'rainloop.user'
|
||||
|
||||
def __init__(self, siege_script_name: str = None):
|
||||
self.script_name = siege_script_name or self.RAINLOOP_DEFAULT_SCRIPT
|
||||
|
||||
def fetch_raw_script(self, sieve: SieveClient) -> Tuple[str, str]:
|
||||
"""
|
||||
Returns:
|
||||
(script_name_used, raw_script_body)
|
||||
"""
|
||||
# Try the standard RainLoop script name first.
|
||||
scripts = sieve.list_scripts() or []
|
||||
script_names = [name for (name, _) in scripts]
|
||||
|
||||
chosen = self.script_name if self.script_name in script_names else None
|
||||
|
||||
if not chosen:
|
||||
# Fallback: choose first active script if available.
|
||||
for (name, is_active) in scripts:
|
||||
if is_active:
|
||||
chosen = name
|
||||
break
|
||||
if not chosen and script_names:
|
||||
chosen = script_names[0]
|
||||
|
||||
if not chosen:
|
||||
return '', ''
|
||||
|
||||
raw = sieve.get_script(chosen) or ''
|
||||
return chosen, raw
|
||||
|
||||
192
webmail/services/webmail_folder_settings_store.py
Normal file
192
webmail/services/webmail_folder_settings_store.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .imap_defaults import IMAPDefaults
|
||||
|
||||
|
||||
class WebmailFolderSettingsStore:
|
||||
"""
|
||||
File-based storage for folder mappings and ordering.
|
||||
|
||||
This avoids DB migrations for a fast server-side feature rollout.
|
||||
Data is stored per-email account inside /etc/cyberpanel/.
|
||||
"""
|
||||
|
||||
STORE_DIR = '/etc/cyberpanel'
|
||||
STORE_PATH = '/etc/cyberpanel/webmail_folder_settings.json'
|
||||
|
||||
DEFAULT_SPECIAL_DISPLAY_MODE = 'top'
|
||||
|
||||
def __init__(self, store_path: str = None):
|
||||
self.store_path = store_path or self.STORE_PATH
|
||||
|
||||
def _ensure_store_dir(self) -> None:
|
||||
try:
|
||||
os.makedirs(self.STORE_DIR, mode=0o700, exist_ok=True)
|
||||
except Exception:
|
||||
# If we can't create the dir, we'll fail on write with a clear error later.
|
||||
pass
|
||||
|
||||
def _defaults(self) -> Dict[str, Any]:
|
||||
# Semantic keys used by the UI.
|
||||
junk = IMAPDefaults.SPECIAL_FOLDERS.get('junk', 'INBOX.Junk E-mail')
|
||||
trash = IMAPDefaults.SPECIAL_FOLDERS.get('trash', 'INBOX.Deleted Items')
|
||||
drafts = IMAPDefaults.SPECIAL_FOLDERS.get('drafts', 'INBOX.Drafts')
|
||||
|
||||
return {
|
||||
'specialDisplayMode': self.DEFAULT_SPECIAL_DISPLAY_MODE, # 'top' or 'interleaved'
|
||||
'folderMappings': {
|
||||
'inbox': 'INBOX',
|
||||
'spam': junk, # semantic alias for junk folder
|
||||
'deleted_items': trash,
|
||||
'junk_e_mail': junk,
|
||||
'drafts': drafts,
|
||||
'trash': trash,
|
||||
},
|
||||
# Order of all folders when specialDisplayMode='interleaved'.
|
||||
# When 'top', this order is still kept as: special group + other group.
|
||||
'folderOrder': [],
|
||||
# Semantic group order for the "top" special section.
|
||||
'specialOrder': ['inbox', 'spam', 'deleted_items', 'junk_e_mail', 'drafts', 'trash'],
|
||||
'enableDragDrop': True,
|
||||
}
|
||||
|
||||
def _read_all(self) -> Dict[str, Any]:
|
||||
self._ensure_store_dir()
|
||||
if not os.path.isfile(self.store_path):
|
||||
return {}
|
||||
|
||||
with open(self.store_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
raw = f.read()
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write_all(self, all_data: Dict[str, Any]) -> None:
|
||||
self._ensure_store_dir()
|
||||
tmp_path = self.store_path + '.tmp'
|
||||
payload = json.dumps(all_data, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
f.write(payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restrict permissions; file can contain folder names but no secrets.
|
||||
try:
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os.rename(tmp_path, self.store_path)
|
||||
|
||||
def get_for_account(self, email_account: str) -> Dict[str, Any]:
|
||||
email_account = (email_account or '').strip()
|
||||
if not email_account:
|
||||
return self._defaults()
|
||||
|
||||
all_data = self._read_all()
|
||||
accounts = all_data.get('accounts', {})
|
||||
if not isinstance(accounts, dict):
|
||||
accounts = {}
|
||||
|
||||
if email_account in accounts and isinstance(accounts[email_account], dict):
|
||||
defaults = self._defaults()
|
||||
merged = defaults
|
||||
|
||||
account_cfg = accounts[email_account]
|
||||
if not isinstance(account_cfg, dict):
|
||||
account_cfg = {}
|
||||
|
||||
# Merge top-level keys (shallow).
|
||||
merged.update(account_cfg)
|
||||
|
||||
# Merge folderMappings with defaults (ensures required semantic keys exist).
|
||||
if not isinstance(account_cfg.get('folderMappings'), dict):
|
||||
merged['folderMappings'] = defaults['folderMappings']
|
||||
else:
|
||||
merged['folderMappings'] = defaults['folderMappings'].copy()
|
||||
merged['folderMappings'].update(account_cfg['folderMappings'])
|
||||
|
||||
if not isinstance(merged.get('folderOrder'), list):
|
||||
merged['folderOrder'] = []
|
||||
if not isinstance(merged.get('specialOrder'), list):
|
||||
merged['specialOrder'] = self._defaults()['specialOrder']
|
||||
edd = merged.get('enableDragDrop')
|
||||
if isinstance(edd, str):
|
||||
merged['enableDragDrop'] = edd.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
elif edd is None:
|
||||
merged['enableDragDrop'] = self._defaults()['enableDragDrop']
|
||||
else:
|
||||
merged['enableDragDrop'] = bool(edd)
|
||||
return merged
|
||||
|
||||
# Initialize for this account in-memory (write happens on save).
|
||||
return self._defaults()
|
||||
|
||||
def save_for_account(self, email_account: str, data: Dict[str, Any]) -> None:
|
||||
email_account = (email_account or '').strip()
|
||||
if not email_account:
|
||||
raise ValueError('email_account is required')
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('folder settings must be an object')
|
||||
|
||||
all_data = self._read_all()
|
||||
if not isinstance(all_data, dict):
|
||||
all_data = {}
|
||||
|
||||
if 'accounts' not in all_data or not isinstance(all_data['accounts'], dict):
|
||||
all_data['accounts'] = {}
|
||||
|
||||
current = self._defaults()
|
||||
|
||||
# Merge but only keep recognized top-level keys.
|
||||
merged = current
|
||||
for key in ['specialDisplayMode', 'folderMappings', 'folderOrder', 'specialOrder', 'enableDragDrop']:
|
||||
if key in data:
|
||||
merged[key] = data[key]
|
||||
|
||||
# Normalize shapes
|
||||
if not isinstance(merged.get('folderMappings'), dict):
|
||||
merged['folderMappings'] = current['folderMappings']
|
||||
if not isinstance(merged.get('folderOrder'), list):
|
||||
merged['folderOrder'] = []
|
||||
if not isinstance(merged.get('specialOrder'), list):
|
||||
merged['specialOrder'] = current['specialOrder']
|
||||
edd = merged.get('enableDragDrop')
|
||||
if isinstance(edd, bool):
|
||||
merged['enableDragDrop'] = edd
|
||||
elif isinstance(edd, str):
|
||||
merged['enableDragDrop'] = edd.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
elif edd is None:
|
||||
merged['enableDragDrop'] = current['enableDragDrop']
|
||||
else:
|
||||
merged['enableDragDrop'] = bool(edd)
|
||||
if merged.get('specialDisplayMode') not in ['top', 'interleaved']:
|
||||
merged['specialDisplayMode'] = self.DEFAULT_SPECIAL_DISPLAY_MODE
|
||||
|
||||
all_data['accounts'][email_account] = merged
|
||||
self._write_all(all_data)
|
||||
|
||||
78
webmail/sql/install_wm_tables.sql
Normal file
78
webmail/sql/install_wm_tables.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- CyberPanel Webmail: create wm_* tables when Django migration graph cannot run
|
||||
-- (e.g. dockerManager depends on loginSystem migrations that do not exist).
|
||||
-- Safe to run multiple times (CREATE TABLE IF NOT EXISTS).
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_contacts` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`owner_email` varchar(200) NOT NULL,
|
||||
`display_name` varchar(200) NOT NULL DEFAULT '',
|
||||
`email_address` varchar(200) NOT NULL,
|
||||
`phone` varchar(50) NOT NULL DEFAULT '',
|
||||
`organization` varchar(200) NOT NULL DEFAULT '',
|
||||
`notes` longtext NOT NULL,
|
||||
`is_auto_collected` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `wm_contacts_owner_email_unique` (`owner_email`,`email_address`),
|
||||
KEY `wm_contacts_owner_email_idx` (`owner_email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_contact_groups` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`owner_email` varchar(200) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `wm_contact_groups_owner_email_name_unique` (`owner_email`,`name`),
|
||||
KEY `wm_contact_groups_owner_email_idx` (`owner_email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_contact_group_members` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`contact_id` bigint(20) NOT NULL,
|
||||
`group_id` bigint(20) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `wm_cgm_contact_group_unique` (`contact_id`,`group_id`),
|
||||
KEY `wm_cgm_contact_fk` (`contact_id`),
|
||||
KEY `wm_cgm_group_fk` (`group_id`),
|
||||
CONSTRAINT `wm_cgm_contact_fk` FOREIGN KEY (`contact_id`) REFERENCES `wm_contacts` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `wm_cgm_group_fk` FOREIGN KEY (`group_id`) REFERENCES `wm_contact_groups` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_sessions` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`session_key` varchar(64) NOT NULL,
|
||||
`email_account` varchar(200) NOT NULL,
|
||||
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`last_active` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `wm_sessions_session_key_unique` (`session_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_settings` (
|
||||
`email_account` varchar(200) NOT NULL,
|
||||
`display_name` varchar(200) NOT NULL DEFAULT '',
|
||||
`signature_html` longtext NOT NULL,
|
||||
`messages_per_page` int(11) NOT NULL DEFAULT 25,
|
||||
`default_reply_behavior` varchar(20) NOT NULL DEFAULT 'reply',
|
||||
`theme_preference` varchar(20) NOT NULL DEFAULT 'auto',
|
||||
`auto_collect_contacts` tinyint(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`email_account`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wm_sieve_rules` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`email_account` varchar(200) NOT NULL,
|
||||
`name` varchar(200) NOT NULL,
|
||||
`priority` int(11) NOT NULL DEFAULT 0,
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`condition_field` varchar(50) NOT NULL,
|
||||
`condition_type` varchar(50) NOT NULL,
|
||||
`condition_value` varchar(500) NOT NULL,
|
||||
`action_type` varchar(50) NOT NULL,
|
||||
`action_value` varchar(500) NOT NULL DEFAULT '',
|
||||
`sieve_script` longtext NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `wm_sieve_rules_email_account_idx` (`email_account`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -51,16 +51,47 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
/* Sidebar (width overridden via ng-style when sidebarResizeEnabled) */
|
||||
.wm-sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wm-sidebar-resizer {
|
||||
width: 6px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border-color);
|
||||
align-self: stretch;
|
||||
margin: 0 -3px 0 -3px;
|
||||
padding: 0;
|
||||
z-index: 5;
|
||||
touch-action: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.wm-sidebar-resizer:hover,
|
||||
.wm-sidebar-resizer:focus {
|
||||
background: rgba(108, 92, 231, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body.wm-resizing-sidebar {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
body.wm-resizing-sidebar * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
.wm-compose-btn {
|
||||
@@ -91,12 +122,40 @@
|
||||
.wm-folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wm-folder-chevron {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wm-folder-chevron:hover {
|
||||
color: var(--accent-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wm-folder-chevron-spacer {
|
||||
width: 22px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wm-folder-item:hover {
|
||||
@@ -110,6 +169,18 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wm-folder-item.dragging {
|
||||
opacity: 0.6;
|
||||
background: rgba(108,92,231,0.08);
|
||||
color: var(--text-primary);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.wm-folder-item.drag-over {
|
||||
outline: 2px dashed var(--accent-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.wm-folder-item i {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
@@ -134,6 +205,29 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wm-folder-delete-btn {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.wm-folder-item:hover .wm-folder-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wm-folder-delete-btn:hover {
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
}
|
||||
|
||||
.wm-sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
@@ -174,6 +268,96 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button.wm-nav-link.wm-nav-btn {
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button.wm-nav-link.wm-nav-btn:hover,
|
||||
button.wm-nav-link.wm-nav-btn:focus {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* New-folder modal */
|
||||
.wm-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 1040;
|
||||
}
|
||||
|
||||
.wm-modal {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1050;
|
||||
min-width: 320px;
|
||||
max-width: 96vw;
|
||||
padding: 20px 22px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.wm-modal h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wm-modal-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.wm-modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm-modal-emphasis {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wm-modal-warning {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.wm-modal-confirm-delete .btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wm-modal-confirm-delete .btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wm-field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Message List Column */
|
||||
.wm-message-list {
|
||||
width: 380px;
|
||||
@@ -189,6 +373,21 @@
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm-search-scope {
|
||||
flex: 0 0 auto;
|
||||
min-width: 7.5rem;
|
||||
max-width: 10.5rem;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wm-search-input {
|
||||
@@ -294,12 +493,21 @@
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
gap: 8px;
|
||||
transition: background 0.1s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wm-msg-row:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.wm-msg-row .wm-checkbox-label,
|
||||
.wm-msg-row .wm-star-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wm-msg-row:hover {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
@@ -349,6 +557,22 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wm-msg-folder-pill {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
max-width: 7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
background: rgba(108, 92, 231, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.wm-msg-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
@@ -830,6 +1054,9 @@
|
||||
.wm-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.wm-sidebar-resizer {
|
||||
display: none !important;
|
||||
}
|
||||
.wm-sidebar {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,18 +40,32 @@
|
||||
<div class="wm-layout">
|
||||
|
||||
<!-- Column 1: Sidebar -->
|
||||
<div class="wm-sidebar">
|
||||
<div class="wm-sidebar" ng-style="sidebarResizeEnabled ? {width: sidebarWidthPx + 'px', minWidth: sidebarWidthPx + 'px', flexShrink: 0} : {}">
|
||||
<button class="btn btn-primary wm-compose-btn" ng-click="composeNew()">
|
||||
<i class="fa fa-pen-to-square"></i> {% trans "Compose" %}
|
||||
</button>
|
||||
|
||||
<div class="wm-folder-list">
|
||||
<div class="wm-folder-item" ng-repeat="folder in folders"
|
||||
ng-class="{'active': currentFolder === folder.name}"
|
||||
ng-click="selectFolder(folder.name)">
|
||||
<i class="fa" ng-class="getFolderIcon(folder)"></i>
|
||||
<span class="wm-folder-name">{$ folder.display_name || folder.name $}</span>
|
||||
<span class="wm-badge" ng-if="folder.unread_count > 0">{$ folder.unread_count $}</span>
|
||||
<div class="wm-folder-item" ng-repeat="row in displayFolderRows"
|
||||
ng-style="{'padding-left': (8 + row.depth * 14) + 'px'}"
|
||||
ng-class="{'active': currentFolder === row.folder.name, 'dragging': draggingFolder === row.folder.name, 'drag-over': dragOverFolder === row.folder.name}"
|
||||
ng-click="selectFolder(row.folder.name)"
|
||||
wm-folder-dnd="row.folder.name">
|
||||
<button type="button" class="wm-folder-chevron" ng-if="row.hasChildren"
|
||||
title="{% trans 'Expand or collapse subfolders' %}"
|
||||
ng-attr-aria-expanded="{$ isFolderRowExpanded(row.folder.name) ? 'true' : 'false' $}"
|
||||
ng-click="toggleFolderExpand(row.folder.name, $event)">
|
||||
<i class="fa" ng-class="isFolderRowExpanded(row.folder.name) ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
|
||||
</button>
|
||||
<span class="wm-folder-chevron-spacer" ng-if="!row.hasChildren" aria-hidden="true"></span>
|
||||
<i class="fa" ng-class="getFolderIcon(row.folder)"></i>
|
||||
<span class="wm-folder-name" title="{$ row.folder.display_name || row.folder.name $}">{$ getFolderRowLabel(row.folder, row.depth) $}</span>
|
||||
<span class="wm-badge" ng-if="row.folder.unread_count > 0">{$ row.folder.unread_count $}</span>
|
||||
<button type="button" class="wm-folder-delete-btn" ng-if="canDeleteFolder(row.folder)"
|
||||
title="{% trans 'Delete folder' %}"
|
||||
ng-click="$event.preventDefault(); $event.stopPropagation(); openDeleteFolderConfirm(row.folder)">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,9 +85,48 @@
|
||||
|
||||
<div class="wm-sidebar-divider"></div>
|
||||
<div class="wm-sidebar-nav">
|
||||
<a ng-click="createFolder()" class="wm-nav-link">
|
||||
<button type="button" class="wm-nav-link wm-nav-btn" ng-click="openNewFolderDialog()">
|
||||
<i class="fa fa-folder-plus"></i> {% trans "New Folder" %}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-sidebar-resizer"
|
||||
wm-sidebar-resizer-touch
|
||||
ng-show="sidebarResizeEnabled"
|
||||
title="{% trans 'Drag to resize folder panel' %}"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="{% trans 'Resize folder panel' %}"
|
||||
ng-mousedown="startSidebarResize($event)"></div>
|
||||
|
||||
<!-- New folder (no browser prompt; works when dialogs are blocked) -->
|
||||
<div class="wm-modal-backdrop" ng-if="showNewFolderDialog" ng-click="cancelNewFolderDialog()"></div>
|
||||
<div class="wm-modal" ng-if="showNewFolderDialog" role="dialog" aria-labelledby="wm-new-folder-title">
|
||||
<h4 id="wm-new-folder-title">{% trans "New folder" %}</h4>
|
||||
<p class="wm-modal-hint">{% trans "Enter a short name only. The server will create it under your account mailbox (INBOX plus dot and this name)." %}</p>
|
||||
<input type="text" id="wm-new-folder-input" class="form-control" ng-model="newFolderNameInput"
|
||||
placeholder="{% trans 'e.g. Projects or Work/Clients' %}"
|
||||
ng-keydown="$event.keyCode === 13 && submitNewFolderDialog(); $event.keyCode === 27 && cancelNewFolderDialog()">
|
||||
<div class="wm-modal-actions">
|
||||
<button type="button" class="btn btn-primary" ng-click="submitNewFolderDialog()">{% trans "Create" %}</button>
|
||||
<button type="button" class="btn btn-default" ng-click="cancelNewFolderDialog()">{% trans "Cancel" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete folder confirmation -->
|
||||
<div class="wm-modal-backdrop" ng-if="showDeleteFolderDialog" ng-click="cancelDeleteFolderDialog()"></div>
|
||||
<div class="wm-modal wm-modal-confirm-delete" ng-if="showDeleteFolderDialog" role="dialog" aria-labelledby="wm-delete-folder-title"
|
||||
ng-click="$event.stopPropagation()">
|
||||
<h4 id="wm-delete-folder-title">{% trans "Delete this folder?" %}</h4>
|
||||
<p class="wm-modal-hint">
|
||||
{% trans "You are about to permanently delete:" %}<br>
|
||||
<strong class="wm-modal-emphasis">{$ (folderPendingDelete.display_name || folderPendingDelete.name) $}</strong>
|
||||
</p>
|
||||
<p class="wm-modal-hint wm-modal-warning">{% trans "All messages in this folder may be removed. This cannot be undone." %}</p>
|
||||
<div class="wm-modal-actions">
|
||||
<button type="button" class="btn btn-danger" ng-click="confirmDeleteFolder()">{% trans "Delete folder" %}</button>
|
||||
<button type="button" class="btn btn-default" ng-click="cancelDeleteFolderDialog()">{% trans "Cancel" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,9 +134,15 @@
|
||||
<div class="wm-message-list" ng-show="viewMode === 'list' || viewMode === 'read'">
|
||||
<!-- Search Bar -->
|
||||
<div class="wm-search-bar">
|
||||
<select ng-model="messageSearchScope" class="wm-search-scope form-control"
|
||||
aria-label="{% trans 'Search scope' %}"
|
||||
title="{% trans 'Search all folders or pick one folder' %}">
|
||||
<option value="__all__">{% trans "All folders" %}</option>
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
<input type="text" ng-model="searchQuery" placeholder="{% trans 'Search messages...' %}"
|
||||
ng-keyup="$event.keyCode === 13 && searchMessages()" class="wm-search-input">
|
||||
<button class="wm-search-btn" ng-click="searchMessages()">
|
||||
<button type="button" class="wm-search-btn" ng-click="searchMessages()">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -123,6 +182,7 @@
|
||||
<!-- Message Rows -->
|
||||
<div class="wm-messages">
|
||||
<div class="wm-msg-row" ng-repeat="msg in messages"
|
||||
wm-message-drag
|
||||
ng-class="{'unread': !msg.is_read, 'flagged': msg.is_flagged, 'selected': msg.selected}"
|
||||
ng-click="openMessage(msg)">
|
||||
<label class="wm-checkbox-label" ng-click="$event.stopPropagation()">
|
||||
@@ -132,7 +192,11 @@
|
||||
<i class="fa" ng-class="msg.is_flagged ? 'fa-star wm-starred' : 'fa-star wm-unstarred'"></i>
|
||||
</button>
|
||||
<div class="wm-msg-from">{$ msg.from | limitTo:30 $}</div>
|
||||
<div class="wm-msg-subject">{$ msg.subject | limitTo:60 $}</div>
|
||||
<div class="wm-msg-subject">
|
||||
<span class="wm-msg-folder-pill" ng-if="messageListSearchActive && messageSearchScope === '__all__' && msg.folder"
|
||||
title="{$ msg.folder $}">{$ getFolderDisplayName(msg.folder) $}</span>
|
||||
{$ msg.subject | limitTo:60 $}
|
||||
</div>
|
||||
<div class="wm-msg-date">{$ msg.date | wmDate $}</div>
|
||||
</div>
|
||||
<div class="wm-empty" ng-if="messages.length === 0 && !loading">
|
||||
@@ -202,7 +266,10 @@
|
||||
<div class="wm-field">
|
||||
<label>{% trans "To" %}</label>
|
||||
<input type="text" ng-model="compose.to" class="form-control"
|
||||
wm-autocomplete required>
|
||||
wm-autocomplete
|
||||
placeholder="{% trans 'name@example.com (required. Use a full email address)' %}"
|
||||
autocomplete="off">
|
||||
<div class="wm-field-hint">{% trans "Separate multiple addresses with commas. Words like “test” are not valid email addresses." %}</div>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Cc" %}</label>
|
||||
@@ -265,9 +332,14 @@
|
||||
<div ng-if="viewMode === 'contacts'" class="wm-contacts-view">
|
||||
<div class="wm-section-header">
|
||||
<h3>{% trans "Contacts" %}</h3>
|
||||
<button class="btn btn-sm btn-primary" ng-click="newContact()">
|
||||
<i class="fa fa-plus"></i> {% trans "Add" %}
|
||||
</button>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<button class="btn btn-sm btn-primary" ng-click="newContact()">
|
||||
<i class="fa fa-plus"></i> {% trans "Add" %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-default" ng-click="importContactsFromSnappymail()">
|
||||
<i class="fa fa-download"></i> {% trans "Import from SnappyMail" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wm-contacts-search">
|
||||
<input type="text" ng-model="contactSearch" placeholder="{% trans 'Search contacts...' %}"
|
||||
@@ -327,21 +399,31 @@
|
||||
<div ng-if="viewMode === 'rules'" class="wm-rules-view">
|
||||
<div class="wm-section-header">
|
||||
<h3>{% trans "Mail Filter Rules" %}</h3>
|
||||
<button class="btn btn-sm btn-primary" ng-click="newRule()">
|
||||
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
|
||||
</button>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<button class="btn btn-sm btn-primary" ng-click="newRule()">
|
||||
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-default" ng-click="importRulesFromSnappymail()">
|
||||
<i class="fa fa-download"></i> {% trans "Import from SnappyMail" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wm-rule-list">
|
||||
<div class="wm-rule-item" ng-repeat="rule in sieveRules">
|
||||
<div class="wm-rule-info">
|
||||
<strong>{$ rule.name $}</strong>
|
||||
<span class="wm-rule-desc">
|
||||
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
|
||||
→ {$ rule.action_type $} {$ rule.action_value $}
|
||||
<span ng-if="rule.is_raw">
|
||||
{% trans "Imported RAW sieve rules (read-only)" %}
|
||||
</span>
|
||||
<span ng-if="!rule.is_raw">
|
||||
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
|
||||
→ {$ rule.action_type $} {$ rule.action_value $}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wm-rule-actions">
|
||||
<button class="wm-action-btn" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
|
||||
<button class="wm-action-btn" ng-if="!rule.is_raw" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
|
||||
<button class="wm-action-btn" ng-click="removeRule(rule)"><i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,6 +530,74 @@
|
||||
{% trans "Auto-collect contacts from sent messages" %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Special folders display" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.specialDisplayMode" class="form-control">
|
||||
<option value="top">{% trans "At top" %}</option>
|
||||
<option value="interleaved">{% trans "Interleaved" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label class="wm-checkbox-label" style="display:flex; align-items:center; gap:10px;">
|
||||
<input type="checkbox" ng-model="wmSettings.folderSettings.enableDragDrop">
|
||||
{% trans "Enable drag/drop reorder" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Inbox" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.inbox" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Spam" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.spam" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Deleted Items" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.deleted_items" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Junk E-mail" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.junk_e_mail" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-field-row">
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Drafts" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.drafts" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wm-field">
|
||||
<label>{% trans "Trash" %}</label>
|
||||
<select ng-model="wmSettings.folderSettings.folderMappings.trash" class="form-control">
|
||||
<option ng-repeat="f in folders" value="{$ f.name $}">{$ f.display_name || f.name $}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin:12px 0 2px;">
|
||||
<div style="font-size:12px; color:var(--text-secondary);">
|
||||
{% trans "Drag folders in the sidebar to reorder (when enabled). Folder order is saved automatically; use Save Settings for display name, signature, and folder role mappings." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wm-form-actions">
|
||||
<button class="btn btn-primary" ng-click="saveSettings()">
|
||||
<i class="fa fa-floppy-disk"></i> {% trans "Save Settings" %}
|
||||
@@ -466,6 +616,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'webmail/webmail.js' %}?v=6"></script>
|
||||
<script src="{% static 'webmail/webmail.js' %}?v=16"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user