diff --git a/CyberCP/settings.py b/CyberCP/settings.py
index 4587e58eb..b0026fdeb 100644
--- a/CyberCP/settings.py
+++ b/CyberCP/settings.py
@@ -75,6 +75,7 @@ INSTALLED_APPS = [
'CLManager',
'IncBackups',
'aiScanner',
+ 'webmail',
# 'WebTerminal'
]
diff --git a/CyberCP/urls.py b/CyberCP/urls.py
index da7ab903a..6cbfbe68c 100644
--- a/CyberCP/urls.py
+++ b/CyberCP/urls.py
@@ -45,5 +45,6 @@ urlpatterns = [
path('CloudLinux/', include('CLManager.urls')),
path('IncrementalBackups/', include('IncBackups.urls')),
path('aiscanner/', include('aiScanner.urls')),
+ path('webmail/', include('webmail.urls')),
# path('Terminal/', include('WebTerminal.urls')),
]
diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html
index 8908a0259..8e2053473 100644
--- a/baseTemplate/templates/baseTemplate/index.html
+++ b/baseTemplate/templates/baseTemplate/index.html
@@ -1599,7 +1599,7 @@
{% endif %}
{% if admin or createEmail %}
-
{% endif %}
diff --git a/webmail/__init__.py b/webmail/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/webmail/apps.py b/webmail/apps.py
new file mode 100644
index 000000000..ca6d5e750
--- /dev/null
+++ b/webmail/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class WebmailConfig(AppConfig):
+ name = 'webmail'
diff --git a/webmail/migrations/__init__.py b/webmail/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/webmail/models.py b/webmail/models.py
new file mode 100644
index 000000000..6a74d74c6
--- /dev/null
+++ b/webmail/models.py
@@ -0,0 +1,106 @@
+from django.db import models
+
+
+class WebmailSession(models.Model):
+ 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)
+
+ class Meta:
+ db_table = 'wm_sessions'
+
+ def __str__(self):
+ return '%s (%s)' % (self.email_account, self.session_key[:8])
+
+
+class Contact(models.Model):
+ owner_email = models.CharField(max_length=200, db_index=True)
+ display_name = models.CharField(max_length=200, blank=True, default='')
+ email_address = models.CharField(max_length=200)
+ phone = models.CharField(max_length=50, blank=True, default='')
+ organization = models.CharField(max_length=200, blank=True, default='')
+ notes = models.TextField(blank=True, default='')
+ is_auto_collected = models.BooleanField(default=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ db_table = 'wm_contacts'
+ unique_together = ('owner_email', 'email_address')
+
+ def __str__(self):
+ return '%s <%s>' % (self.display_name, self.email_address)
+
+
+class ContactGroup(models.Model):
+ owner_email = models.CharField(max_length=200, db_index=True)
+ name = models.CharField(max_length=100)
+
+ class Meta:
+ db_table = 'wm_contact_groups'
+ unique_together = ('owner_email', 'name')
+
+ def __str__(self):
+ return self.name
+
+
+class ContactGroupMembership(models.Model):
+ contact = models.ForeignKey(Contact, on_delete=models.CASCADE)
+ group = models.ForeignKey(ContactGroup, on_delete=models.CASCADE)
+
+ class Meta:
+ db_table = 'wm_contact_group_members'
+ unique_together = ('contact', 'group')
+
+
+class WebmailSettings(models.Model):
+ email_account = models.CharField(max_length=200, primary_key=True)
+ display_name = models.CharField(max_length=200, blank=True, default='')
+ signature_html = models.TextField(blank=True, default='')
+ messages_per_page = models.IntegerField(default=25)
+ default_reply_behavior = models.CharField(max_length=20, default='reply',
+ choices=[('reply', 'Reply'),
+ ('reply_all', 'Reply All')])
+ theme_preference = models.CharField(max_length=20, default='auto',
+ choices=[('light', 'Light'),
+ ('dark', 'Dark'),
+ ('auto', 'Auto')])
+ auto_collect_contacts = models.BooleanField(default=True)
+
+ class Meta:
+ db_table = 'wm_settings'
+
+ def __str__(self):
+ return self.email_account
+
+
+class SieveRule(models.Model):
+ email_account = models.CharField(max_length=200, db_index=True)
+ name = models.CharField(max_length=200)
+ priority = models.IntegerField(default=0)
+ is_active = models.BooleanField(default=True)
+ condition_field = models.CharField(max_length=50,
+ choices=[('from', 'From'),
+ ('to', 'To'),
+ ('subject', 'Subject'),
+ ('size', 'Size')])
+ condition_type = models.CharField(max_length=50,
+ choices=[('contains', 'Contains'),
+ ('is', 'Is'),
+ ('matches', 'Matches'),
+ ('greater_than', 'Greater than')])
+ condition_value = models.CharField(max_length=500)
+ action_type = models.CharField(max_length=50,
+ choices=[('move', 'Move to folder'),
+ ('forward', 'Forward to'),
+ ('discard', 'Discard'),
+ ('flag', 'Flag')])
+ action_value = models.CharField(max_length=500, blank=True, default='')
+ sieve_script = models.TextField(blank=True, default='')
+
+ class Meta:
+ db_table = 'wm_sieve_rules'
+ ordering = ['priority']
+
+ def __str__(self):
+ return '%s: %s' % (self.email_account, self.name)
diff --git a/webmail/services/__init__.py b/webmail/services/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/webmail/services/email_composer.py b/webmail/services/email_composer.py
new file mode 100644
index 000000000..0652fdb30
--- /dev/null
+++ b/webmail/services/email_composer.py
@@ -0,0 +1,178 @@
+import email
+from email.message import EmailMessage
+from email.utils import formatdate, make_msgid, formataddr
+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."""
+
+ @staticmethod
+ def compose(from_addr, to_addrs, subject, body_html='', body_text='',
+ cc_addrs='', bcc_addrs='', attachments=None,
+ in_reply_to='', references=''):
+ """Build a MIME message.
+
+ Args:
+ from_addr: sender email
+ to_addrs: comma-separated recipients
+ subject: email subject
+ body_html: HTML body content
+ body_text: plain text body content
+ cc_addrs: comma-separated CC recipients
+ bcc_addrs: comma-separated BCC recipients
+ attachments: list of (filename, content_type, bytes) tuples
+ in_reply_to: Message-ID being replied to
+ references: space-separated Message-IDs
+
+ Returns:
+ MIMEMultipart message ready for sending
+ """
+ if attachments:
+ msg = MIMEMultipart('mixed')
+ body_part = MIMEMultipart('alternative')
+ if body_text:
+ body_part.attach(MIMEText(body_text, 'plain', 'utf-8'))
+ if body_html:
+ body_part.attach(MIMEText(body_html, 'html', 'utf-8'))
+ elif not body_text:
+ body_part.attach(MIMEText('', 'plain', 'utf-8'))
+ msg.attach(body_part)
+
+ for filename, content_type, data in attachments:
+ if not content_type:
+ content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
+ maintype, subtype = content_type.split('/', 1)
+ attachment = MIMEBase(maintype, subtype)
+ attachment.set_payload(data)
+ encoders.encode_base64(attachment)
+ attachment.add_header('Content-Disposition', 'attachment', filename=filename)
+ msg.attach(attachment)
+ else:
+ msg = MIMEMultipart('alternative')
+ if body_text:
+ msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
+ if body_html:
+ msg.attach(MIMEText(body_html, 'html', 'utf-8'))
+ elif not body_text:
+ msg.attach(MIMEText('', 'plain', 'utf-8'))
+
+ msg['From'] = from_addr
+ msg['To'] = to_addrs
+ if cc_addrs:
+ msg['Cc'] = cc_addrs
+ msg['Subject'] = subject
+ msg['Date'] = formatdate(localtime=True)
+ msg['Message-ID'] = make_msgid(domain=from_addr.split('@')[-1] if '@' in from_addr else 'localhost')
+
+ if in_reply_to:
+ msg['In-Reply-To'] = in_reply_to
+ if references:
+ msg['References'] = references
+
+ msg['MIME-Version'] = '1.0'
+ msg['X-Mailer'] = 'CyberPanel Webmail'
+
+ return msg
+
+ @classmethod
+ def compose_reply(cls, original, body_html, from_addr, reply_all=False):
+ """Build a reply message from the original parsed message.
+
+ Args:
+ original: parsed message dict from EmailParser
+ body_html: reply HTML body
+ from_addr: sender email
+ reply_all: whether to reply all
+
+ Returns:
+ MIMEMultipart message
+ """
+ to = original.get('from', '')
+ cc = ''
+ if reply_all:
+ orig_to = original.get('to', '')
+ orig_cc = original.get('cc', '')
+ all_addrs = []
+ if orig_to:
+ all_addrs.append(orig_to)
+ if orig_cc:
+ all_addrs.append(orig_cc)
+ cc = ', '.join(all_addrs)
+ # Remove self from CC
+ cc_parts = [a.strip() for a in cc.split(',') if from_addr not in a]
+ cc = ', '.join(cc_parts)
+
+ subject = original.get('subject', '')
+ if not subject.lower().startswith('re:'):
+ subject = 'Re: %s' % subject
+
+ in_reply_to = original.get('message_id', '')
+ references = original.get('references', '')
+ if in_reply_to:
+ references = ('%s %s' % (references, in_reply_to)).strip()
+
+ # Quote original
+ orig_date = original.get('date', '')
+ orig_from = original.get('from', '')
+ quoted = '
' % (
+ orig_date, orig_from, original.get('body_html', '') or original.get('body_text', '')
+ )
+ full_html = body_html + quoted
+
+ return cls.compose(
+ from_addr=from_addr,
+ to_addrs=to,
+ subject=subject,
+ body_html=full_html,
+ cc_addrs=cc,
+ in_reply_to=in_reply_to,
+ references=references,
+ )
+
+ @classmethod
+ def compose_forward(cls, original, body_html, from_addr, to_addrs):
+ """Build a forward message including original attachments.
+
+ Args:
+ original: parsed message dict
+ body_html: forward body HTML
+ from_addr: sender email
+ to_addrs: comma-separated recipients
+
+ Returns:
+ MIMEMultipart message (without attachments - caller must add them)
+ """
+ subject = original.get('subject', '')
+ if not subject.lower().startswith('fwd:'):
+ subject = 'Fwd: %s' % subject
+
+ orig_from = original.get('from', '')
+ orig_to = original.get('to', '')
+ orig_date = original.get('date', '')
+ orig_subject = original.get('subject', '')
+
+ forwarded = (
+ '
'
+ '---------- Forwarded message ----------
'
+ 'From: %s
'
+ 'Date: %s
'
+ 'Subject: %s
'
+ 'To: %s
'
+ '%s
'
+ ) % (orig_from, orig_date, orig_subject, orig_to,
+ original.get('body_html', '') or original.get('body_text', ''))
+
+ full_html = body_html + forwarded
+
+ return cls.compose(
+ from_addr=from_addr,
+ to_addrs=to_addrs,
+ subject=subject,
+ body_html=full_html,
+ )
diff --git a/webmail/services/email_parser.py b/webmail/services/email_parser.py
new file mode 100644
index 000000000..4182961c0
--- /dev/null
+++ b/webmail/services/email_parser.py
@@ -0,0 +1,194 @@
+import email
+import re
+from email.header import decode_header
+from email.utils import parsedate_to_datetime
+
+
+class EmailParser:
+ """Parse MIME messages and sanitize HTML content."""
+
+ SAFE_TAGS = {
+ 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'cite', 'code',
+ 'col', 'colgroup', 'dd', 'del', 'details', 'div', 'dl', 'dt', 'em',
+ 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
+ 'i', 'img', 'ins', 'li', 'mark', 'ol', 'p', 'pre', 'q', 's',
+ 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table',
+ 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', 'wbr',
+ 'font', 'center', 'big',
+ }
+
+ SAFE_ATTRS = {
+ 'href', 'src', 'alt', 'title', 'width', 'height', 'style',
+ 'class', 'id', 'colspan', 'rowspan', 'cellpadding', 'cellspacing',
+ 'border', 'align', 'valign', 'bgcolor', 'color', 'size', 'face',
+ 'dir', 'lang', 'start', 'type', 'target', 'rel',
+ }
+
+ DANGEROUS_CSS_PATTERNS = [
+ re.compile(r'expression\s*\(', re.IGNORECASE),
+ re.compile(r'javascript\s*:', re.IGNORECASE),
+ re.compile(r'vbscript\s*:', re.IGNORECASE),
+ re.compile(r'url\s*\(\s*["\']?\s*javascript:', re.IGNORECASE),
+ re.compile(r'-moz-binding', re.IGNORECASE),
+ re.compile(r'behavior\s*:', re.IGNORECASE),
+ ]
+
+ @staticmethod
+ def _decode_header_value(value):
+ if value is None:
+ return ''
+ decoded_parts = decode_header(value)
+ result = []
+ for part, charset in decoded_parts:
+ if isinstance(part, bytes):
+ result.append(part.decode(charset or 'utf-8', errors='replace'))
+ else:
+ result.append(part)
+ return ''.join(result)
+
+ @classmethod
+ def parse_message(cls, raw_bytes):
+ """Parse raw email bytes into a structured dict."""
+ if isinstance(raw_bytes, str):
+ raw_bytes = raw_bytes.encode('utf-8')
+ msg = email.message_from_bytes(raw_bytes)
+
+ subject = cls._decode_header_value(msg.get('Subject', ''))
+ from_addr = cls._decode_header_value(msg.get('From', ''))
+ to_addr = cls._decode_header_value(msg.get('To', ''))
+ cc_addr = cls._decode_header_value(msg.get('Cc', ''))
+ date_str = msg.get('Date', '')
+ message_id = msg.get('Message-ID', '')
+ in_reply_to = msg.get('In-Reply-To', '')
+ references = msg.get('References', '')
+
+ date_iso = ''
+ try:
+ dt = parsedate_to_datetime(date_str)
+ date_iso = dt.isoformat()
+ except Exception:
+ date_iso = date_str
+
+ body_html = ''
+ body_text = ''
+ attachments = []
+ part_idx = 0
+
+ if msg.is_multipart():
+ for part in msg.walk():
+ content_type = part.get_content_type()
+ disposition = str(part.get('Content-Disposition', ''))
+
+ if content_type == 'multipart':
+ continue
+
+ if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition):
+ filename = part.get_filename()
+ if filename:
+ filename = cls._decode_header_value(filename)
+ else:
+ filename = 'attachment_%d' % part_idx
+ attachments.append({
+ 'part_id': part_idx,
+ 'filename': filename,
+ 'content_type': content_type,
+ 'size': len(part.get_payload(decode=True) or b''),
+ })
+ part_idx += 1
+ elif content_type == 'text/html':
+ payload = part.get_payload(decode=True)
+ charset = part.get_content_charset() or 'utf-8'
+ body_html = payload.decode(charset, errors='replace') if payload else ''
+ elif content_type == 'text/plain':
+ payload = part.get_payload(decode=True)
+ charset = part.get_content_charset() or 'utf-8'
+ body_text = payload.decode(charset, errors='replace') if payload else ''
+ else:
+ content_type = msg.get_content_type()
+ payload = msg.get_payload(decode=True)
+ charset = msg.get_content_charset() or 'utf-8'
+ if payload:
+ decoded = payload.decode(charset, errors='replace')
+ if content_type == 'text/html':
+ body_html = decoded
+ else:
+ body_text = decoded
+
+ if body_html:
+ body_html = cls.sanitize_html(body_html)
+
+ preview = cls.extract_preview(body_text or body_html, 200)
+
+ return {
+ 'subject': subject,
+ 'from': from_addr,
+ 'to': to_addr,
+ 'cc': cc_addr,
+ 'date': date_str,
+ 'date_iso': date_iso,
+ 'message_id': message_id,
+ 'in_reply_to': in_reply_to,
+ 'references': references,
+ 'body_html': body_html,
+ 'body_text': body_text,
+ 'attachments': attachments,
+ 'preview': preview,
+ 'has_attachments': len(attachments) > 0,
+ }
+
+ @classmethod
+ def sanitize_html(cls, html):
+ """Whitelist-based HTML sanitization. Strips dangerous content."""
+ if not html:
+ return ''
+
+ # Remove script, style, iframe, object, embed, form tags and their content
+ for tag in ['script', 'style', 'iframe', 'object', 'embed', 'form', 'applet', 'base', 'link', 'meta']:
+ html = re.sub(r'<%s\b[^>]*>.*?%s>' % (tag, tag), '', html, flags=re.IGNORECASE | re.DOTALL)
+ html = re.sub(r'<%s\b[^>]*/?\s*>' % tag, '', html, flags=re.IGNORECASE)
+
+ # Remove event handler attributes (on*)
+ html = re.sub(r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', '', html, flags=re.IGNORECASE)
+
+ # Remove javascript: and data: URIs in href/src
+ html = re.sub(r'(href|src)\s*=\s*["\']?\s*javascript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
+ html = re.sub(r'(href|src)\s*=\s*["\']?\s*data:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
+ html = re.sub(r'(href|src)\s*=\s*["\']?\s*vbscript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
+
+ # Sanitize style attributes - remove dangerous CSS
+ def clean_style(match):
+ style = match.group(1)
+ for pattern in cls.DANGEROUS_CSS_PATTERNS:
+ if pattern.search(style):
+ return 'style=""'
+ return match.group(0)
+
+ html = re.sub(r'style\s*=\s*"([^"]*)"', clean_style, html, flags=re.IGNORECASE)
+ html = re.sub(r"style\s*=\s*'([^']*)'", clean_style, html, flags=re.IGNORECASE)
+
+ # Rewrite external image src to proxy endpoint
+ def proxy_image(match):
+ src = match.group(1)
+ if src.startswith(('http://', 'https://')):
+ from django.utils.http import urlencode
+ import base64
+ encoded_url = base64.urlsafe_b64encode(src.encode()).decode()
+ return 'src="/webmail/api/proxyImage?url=%s"' % encoded_url
+ return match.group(0)
+
+ html = re.sub(r'src\s*=\s*"(https?://[^"]*)"', proxy_image, html, flags=re.IGNORECASE)
+
+ return html
+
+ @staticmethod
+ def extract_preview(text, max_length=200):
+ """Extract a short text preview from email body."""
+ if not text:
+ return ''
+ # Strip HTML tags if present
+ clean = re.sub(r'<[^>]+>', ' ', text)
+ # Collapse whitespace
+ clean = re.sub(r'\s+', ' ', clean).strip()
+ if len(clean) > max_length:
+ return clean[:max_length] + '...'
+ return clean
diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py
new file mode 100644
index 000000000..058e2eddf
--- /dev/null
+++ b/webmail/services/imap_client.py
@@ -0,0 +1,308 @@
+import imaplib
+import ssl
+import email
+import re
+from email.header import decode_header
+
+
+class IMAPClient:
+ """Wrapper around imaplib.IMAP4_SSL for Dovecot IMAP operations."""
+
+ def __init__(self, email_address, password, host='localhost', port=993,
+ master_user=None, master_password=None):
+ self.email_address = email_address
+ self.host = host
+ self.port = port
+ self.conn = None
+
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+
+ self.conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
+
+ if master_user and master_password:
+ login_user = '%s*%s' % (email_address, master_user)
+ self.conn.login(login_user, master_password)
+ else:
+ self.conn.login(email_address, password)
+
+ def close(self):
+ try:
+ self.conn.close()
+ except Exception:
+ pass
+ try:
+ self.conn.logout()
+ except Exception:
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ def _decode_header_value(self, value):
+ if value is None:
+ return ''
+ decoded_parts = decode_header(value)
+ result = []
+ for part, charset in decoded_parts:
+ if isinstance(part, bytes):
+ result.append(part.decode(charset or 'utf-8', errors='replace'))
+ else:
+ result.append(part)
+ return ''.join(result)
+
+ def _parse_folder_list(self, line):
+ if isinstance(line, bytes):
+ line = line.decode('utf-8', errors='replace')
+ match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+"?([^"]+)"?', line)
+ if not match:
+ match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+(.+)', line)
+ if match:
+ flags = match.group(1)
+ delimiter = match.group(2)
+ name = match.group(3).strip('"')
+ return {'name': name, 'delimiter': delimiter, 'flags': flags}
+ return None
+
+ def list_folders(self):
+ status, data = self.conn.list()
+ if status != 'OK':
+ return []
+ folders = []
+ for item in data:
+ if item is None:
+ continue
+ parsed = self._parse_folder_list(item)
+ if parsed is None:
+ continue
+ folder_name = parsed['name']
+ unread = 0
+ total = 0
+ try:
+ st, counts = self.conn.status(
+ '"%s"' % folder_name if ' ' in folder_name else folder_name,
+ '(MESSAGES UNSEEN)'
+ )
+ if st == 'OK' and counts[0]:
+ count_str = counts[0].decode('utf-8', errors='replace') if isinstance(counts[0], bytes) else counts[0]
+ m = re.search(r'MESSAGES\s+(\d+)', count_str)
+ u = re.search(r'UNSEEN\s+(\d+)', count_str)
+ if m:
+ total = int(m.group(1))
+ if u:
+ unread = int(u.group(1))
+ except Exception:
+ pass
+ folders.append({
+ 'name': folder_name,
+ 'delimiter': parsed['delimiter'],
+ 'flags': parsed['flags'],
+ 'unread_count': unread,
+ 'total_count': total,
+ })
+ return folders
+
+ def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc'):
+ self.conn.select(folder)
+ 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)
+ page = max(1, min(page, pages))
+
+ start = (page - 1) * per_page
+ end = start + per_page
+ page_uids = uids[start:end]
+
+ if not page_uids:
+ return {'messages': [], 'total': total, 'page': page, 'pages': pages}
+
+ uid_str = b','.join(page_uids)
+ status, msg_data = self.conn.uid('fetch', uid_str,
+ '(UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])')
+ if status != 'OK':
+ return {'messages': [], 'total': total, 'page': page, 'pages': pages}
+
+ messages = []
+ i = 0
+ while i < len(msg_data):
+ item = msg_data[i]
+ if isinstance(item, tuple) and len(item) == 2:
+ meta_line = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0]
+ header_bytes = item[1]
+
+ uid_match = re.search(r'UID\s+(\d+)', meta_line)
+ flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta_line)
+ size_match = re.search(r'RFC822\.SIZE\s+(\d+)', meta_line)
+
+ uid = uid_match.group(1) if uid_match else '0'
+ flags = flags_match.group(1) if flags_match else ''
+ size = int(size_match.group(1)) if size_match else 0
+
+ msg = email.message_from_bytes(header_bytes) if isinstance(header_bytes, bytes) else email.message_from_string(header_bytes)
+ messages.append({
+ 'uid': uid,
+ 'from': self._decode_header_value(msg.get('From', '')),
+ 'to': self._decode_header_value(msg.get('To', '')),
+ 'subject': self._decode_header_value(msg.get('Subject', '(No Subject)')),
+ 'date': msg.get('Date', ''),
+ 'flags': flags,
+ 'is_read': '\\Seen' in flags,
+ 'is_flagged': '\\Flagged' in flags,
+ 'has_attachment': False,
+ 'size': size,
+ })
+ i += 1
+
+ return {'messages': messages, 'total': total, 'page': page, 'pages': pages}
+
+ def search_messages(self, folder='INBOX', query='', criteria='ALL'):
+ self.conn.select(folder)
+ if query:
+ search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (query, query, query)
+ else:
+ search_criteria = criteria
+ status, data = self.conn.uid('search', None, search_criteria)
+ if status != 'OK':
+ return []
+ return data[0].split() if data[0] else []
+
+ def get_message(self, folder, uid):
+ self.conn.select(folder)
+ status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822 FLAGS)')
+ if status != 'OK' or not data or not data[0]:
+ return None
+
+ raw = None
+ flags = ''
+ for item in data:
+ if isinstance(item, tuple) and len(item) == 2:
+ meta = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0]
+ raw = item[1]
+ flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta)
+ if flags_match:
+ flags = flags_match.group(1)
+ break
+
+ if raw is None:
+ return None
+
+ from .email_parser import EmailParser
+ parsed = EmailParser.parse_message(raw)
+ parsed['uid'] = str(uid)
+ parsed['flags'] = flags
+ parsed['is_read'] = '\\Seen' in flags
+ parsed['is_flagged'] = '\\Flagged' in flags
+ return parsed
+
+ def get_attachment(self, folder, uid, part_id):
+ self.conn.select(folder)
+ status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822)')
+ if status != 'OK' or not data or not data[0]:
+ return None
+
+ raw = None
+ for item in data:
+ if isinstance(item, tuple) and len(item) == 2:
+ raw = item[1]
+ break
+
+ if raw is None:
+ return None
+
+ msg = email.message_from_bytes(raw) if isinstance(raw, bytes) else email.message_from_string(raw)
+ part_idx = 0
+ for part in msg.walk():
+ if part.get_content_maintype() == 'multipart':
+ continue
+ if part.get('Content-Disposition') and 'attachment' in part.get('Content-Disposition', ''):
+ if str(part_idx) == str(part_id):
+ filename = part.get_filename() or 'attachment'
+ filename = self._decode_header_value(filename)
+ content_type = part.get_content_type()
+ payload = part.get_payload(decode=True)
+ return (filename, content_type, payload)
+ part_idx += 1
+
+ return None
+
+ def move_messages(self, folder, uids, target_folder):
+ self.conn.select(folder)
+ uid_str = ','.join(str(u) for u in uids)
+ try:
+ status, _ = self.conn.uid('move', uid_str, target_folder)
+ if status == 'OK':
+ return True
+ except Exception:
+ pass
+ status, _ = self.conn.uid('copy', uid_str, target_folder)
+ if status == 'OK':
+ self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
+ self.conn.expunge()
+ return True
+ return False
+
+ def delete_messages(self, folder, uids):
+ self.conn.select(folder)
+ uid_str = ','.join(str(u) for u in uids)
+ trash_folders = ['Trash', 'INBOX.Trash', '[Gmail]/Trash']
+ if folder not in trash_folders:
+ for trash in trash_folders:
+ try:
+ status, _ = self.conn.uid('copy', uid_str, trash)
+ if status == 'OK':
+ self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
+ self.conn.expunge()
+ return True
+ except Exception:
+ continue
+ self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
+ self.conn.expunge()
+ return True
+
+ def set_flags(self, folder, uids, flags, action='add'):
+ self.conn.select(folder)
+ uid_str = ','.join(str(u) for u in uids)
+ flag_str = '(%s)' % ' '.join(flags)
+ if action == 'add':
+ self.conn.uid('store', uid_str, '+FLAGS', flag_str)
+ elif action == 'remove':
+ self.conn.uid('store', uid_str, '-FLAGS', flag_str)
+ return True
+
+ def mark_read(self, folder, uids):
+ return self.set_flags(folder, uids, ['\\Seen'], 'add')
+
+ def mark_unread(self, folder, uids):
+ return self.set_flags(folder, uids, ['\\Seen'], 'remove')
+
+ def mark_flagged(self, folder, uids):
+ return self.set_flags(folder, uids, ['\\Flagged'], 'add')
+
+ def create_folder(self, name):
+ status, _ = self.conn.create(name)
+ return status == 'OK'
+
+ def rename_folder(self, old_name, new_name):
+ status, _ = self.conn.rename(old_name, new_name)
+ return status == 'OK'
+
+ def delete_folder(self, name):
+ status, _ = self.conn.delete(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(folder, flag_str, None, raw_message)
+ return status == 'OK'
diff --git a/webmail/services/sieve_client.py b/webmail/services/sieve_client.py
new file mode 100644
index 000000000..aef6907db
--- /dev/null
+++ b/webmail/services/sieve_client.py
@@ -0,0 +1,261 @@
+import socket
+import ssl
+import re
+import base64
+
+
+class SieveClient:
+ """ManageSieve protocol client (RFC 5804) for managing mail filter rules."""
+
+ def __init__(self, email_address, password, host='localhost', port=4190,
+ master_user=None, master_password=None):
+ self.email_address = email_address
+ self.host = host
+ self.port = port
+ self.sock = None
+ self.buf = b''
+
+ self.sock = socket.create_connection((host, port), timeout=30)
+ self._read_welcome()
+ self._starttls()
+
+ if master_user and master_password:
+ self._authenticate_master(email_address, master_user, master_password)
+ else:
+ self._authenticate(email_address, password)
+
+ def _read_line(self):
+ while b'\r\n' not in self.buf:
+ data = self.sock.recv(4096)
+ if not data:
+ break
+ self.buf += data
+ if b'\r\n' in self.buf:
+ line, self.buf = self.buf.split(b'\r\n', 1)
+ return line.decode('utf-8', errors='replace')
+ return ''
+
+ def _read_response(self):
+ lines = []
+ while True:
+ line = self._read_line()
+ if line.startswith('OK'):
+ return True, lines, line
+ elif line.startswith('NO'):
+ return False, lines, line
+ elif line.startswith('BYE'):
+ return False, lines, line
+ else:
+ lines.append(line)
+
+ def _read_welcome(self):
+ lines = []
+ while True:
+ line = self._read_line()
+ lines.append(line)
+ if line.startswith('OK'):
+ break
+ return lines
+
+ def _send(self, command):
+ self.sock.sendall(('%s\r\n' % command).encode('utf-8'))
+
+ def _starttls(self):
+ self._send('STARTTLS')
+ ok, _, _ = self._read_response()
+ if ok:
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ self.sock = ctx.wrap_socket(self.sock, server_hostname=self.host)
+ self.buf = b''
+ self._read_welcome()
+
+ def _authenticate(self, user, password):
+ auth_str = base64.b64encode(('\x00%s\x00%s' % (user, password)).encode('utf-8')).decode('ascii')
+ self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str)
+ ok, _, msg = self._read_response()
+ if not ok:
+ raise Exception('Sieve authentication failed: %s' % msg)
+
+ def _authenticate_master(self, user, master_user, master_password):
+ auth_str = base64.b64encode(
+ ('%s\x00%s*%s\x00%s' % (user, user, master_user, master_password)).encode('utf-8')
+ ).decode('ascii')
+ self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str)
+ ok, _, msg = self._read_response()
+ if not ok:
+ raise Exception('Sieve master authentication failed: %s' % msg)
+
+ def list_scripts(self):
+ """List all Sieve scripts. Returns [(name, is_active), ...]"""
+ self._send('LISTSCRIPTS')
+ ok, lines, _ = self._read_response()
+ if not ok:
+ return []
+ scripts = []
+ for line in lines:
+ match = re.match(r'"([^"]+)"(\s+ACTIVE)?', line)
+ if match:
+ scripts.append((match.group(1), bool(match.group(2))))
+ return scripts
+
+ def get_script(self, name):
+ """Get the content of a Sieve script."""
+ self._send('GETSCRIPT "%s"' % name)
+ ok, lines, _ = self._read_response()
+ if not ok:
+ return ''
+ return '\n'.join(lines)
+
+ def put_script(self, name, content):
+ """Upload a Sieve script."""
+ encoded = content.encode('utf-8')
+ self._send('PUTSCRIPT "%s" {%d+}' % (name, len(encoded)))
+ self.sock.sendall(encoded + b'\r\n')
+ ok, _, msg = self._read_response()
+ if not ok:
+ raise Exception('Failed to put script: %s' % msg)
+ return True
+
+ def activate_script(self, name):
+ """Set a script as the active script."""
+ self._send('SETACTIVE "%s"' % name)
+ ok, _, msg = self._read_response()
+ return ok
+
+ def deactivate_scripts(self):
+ """Deactivate all scripts."""
+ self._send('SETACTIVE ""')
+ ok, _, _ = self._read_response()
+ return ok
+
+ def delete_script(self, name):
+ """Delete a Sieve script."""
+ self._send('DELETESCRIPT "%s"' % name)
+ ok, _, _ = self._read_response()
+ return ok
+
+ def close(self):
+ try:
+ self._send('LOGOUT')
+ self._read_response()
+ except Exception:
+ pass
+ try:
+ self.sock.close()
+ except Exception:
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ @staticmethod
+ def rules_to_sieve(rules):
+ """Convert a list of rule dicts to a Sieve script.
+
+ Each rule: {condition_field, condition_type, condition_value, action_type, action_value, name}
+ """
+ requires = set()
+ rule_blocks = []
+
+ for rule in rules:
+ field = rule.get('condition_field', 'from')
+ cond_type = rule.get('condition_type', 'contains')
+ cond_value = rule.get('condition_value', '')
+ action_type = rule.get('action_type', 'move')
+ action_value = rule.get('action_value', '')
+
+ # Map field to Sieve header
+ if field == 'from':
+ header = 'From'
+ elif field == 'to':
+ header = 'To'
+ elif field == 'subject':
+ header = 'Subject'
+ else:
+ header = field
+
+ # Map condition type to Sieve test
+ if cond_type == 'contains':
+ test = 'header :contains "%s" "%s"' % (header, cond_value)
+ elif cond_type == 'is':
+ test = 'header :is "%s" "%s"' % (header, cond_value)
+ elif cond_type == 'matches':
+ test = 'header :matches "%s" "%s"' % (header, cond_value)
+ elif cond_type == 'greater_than' and field == 'size':
+ test = 'size :over %s' % cond_value
+ else:
+ test = 'header :contains "%s" "%s"' % (header, cond_value)
+
+ # Map action
+ if action_type == 'move':
+ requires.add('fileinto')
+ action = 'fileinto "%s";' % action_value
+ elif action_type == 'forward':
+ requires.add('redirect')
+ action = 'redirect "%s";' % action_value
+ elif action_type == 'discard':
+ action = 'discard;'
+ elif action_type == 'flag':
+ requires.add('imap4flags')
+ action = 'addflag "\\\\Flagged";'
+ else:
+ action = 'keep;'
+
+ name = rule.get('name', 'Rule')
+ rule_blocks.append('# %s\nif %s {\n %s\n}' % (name, test, action))
+
+ # Build full script
+ parts = []
+ if requires:
+ parts.append('require [%s];' % ', '.join('"%s"' % r for r in sorted(requires)))
+ parts.append('')
+ parts.extend(rule_blocks)
+
+ return '\n'.join(parts)
+
+ @staticmethod
+ def sieve_to_rules(script):
+ """Best-effort parse of a Sieve script into rule dicts."""
+ rules = []
+ # Match if-blocks with comments as names
+ pattern = re.compile(
+ r'#\s*(.+?)\n\s*if\s+header\s+:(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*\{([^}]+)\}',
+ re.DOTALL
+ )
+ for match in pattern.finditer(script):
+ name = match.group(1).strip()
+ cond_type = match.group(2)
+ field_name = match.group(3).lower()
+ cond_value = match.group(4)
+ action_block = match.group(5).strip()
+
+ action_type = 'keep'
+ action_value = ''
+ if 'fileinto' in action_block:
+ action_type = 'move'
+ av = re.search(r'fileinto\s+"([^"]+)"', action_block)
+ action_value = av.group(1) if av else ''
+ elif 'redirect' in action_block:
+ action_type = 'forward'
+ av = re.search(r'redirect\s+"([^"]+)"', action_block)
+ action_value = av.group(1) if av else ''
+ elif 'discard' in action_block:
+ action_type = 'discard'
+ elif 'addflag' in action_block:
+ action_type = 'flag'
+
+ rules.append({
+ 'name': name,
+ 'condition_field': field_name,
+ 'condition_type': cond_type,
+ 'condition_value': cond_value,
+ 'action_type': action_type,
+ 'action_value': action_value,
+ })
+
+ return rules
diff --git a/webmail/services/smtp_client.py b/webmail/services/smtp_client.py
new file mode 100644
index 000000000..51eb65f0e
--- /dev/null
+++ b/webmail/services/smtp_client.py
@@ -0,0 +1,55 @@
+import smtplib
+import ssl
+
+
+class SMTPClient:
+ """Wrapper around smtplib.SMTP for sending mail via Postfix."""
+
+ def __init__(self, email_address, password, host='localhost', port=587):
+ self.email_address = email_address
+ self.password = password
+ self.host = host
+ self.port = port
+
+ def send_message(self, mime_message):
+ """Send a composed email.message.EmailMessage via SMTP with STARTTLS.
+
+ Returns:
+ dict: {success: bool, message_id: str or None, error: str or None}
+ """
+ try:
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+
+ smtp = smtplib.SMTP(self.host, self.port)
+ smtp.ehlo()
+ smtp.starttls(context=ctx)
+ smtp.ehlo()
+ smtp.login(self.email_address, self.password)
+ smtp.send_message(mime_message)
+ smtp.quit()
+
+ message_id = mime_message.get('Message-ID', '')
+ return {'success': True, 'message_id': message_id}
+ except smtplib.SMTPAuthenticationError as e:
+ return {'success': False, 'message_id': None, 'error': 'Authentication failed: %s' % str(e)}
+ except smtplib.SMTPRecipientsRefused as e:
+ return {'success': False, 'message_id': None, 'error': 'Recipients refused: %s' % str(e)}
+ except Exception as e:
+ return {'success': False, 'message_id': None, 'error': str(e)}
+
+ def save_to_sent(self, imap_client, raw_message):
+ """Append sent message to the Sent folder via IMAP."""
+ sent_folders = ['Sent', 'INBOX.Sent', 'Sent Messages', 'Sent Items']
+ for folder in sent_folders:
+ try:
+ if imap_client.append_message(folder, raw_message, '\\Seen'):
+ return True
+ except Exception:
+ continue
+ try:
+ imap_client.create_folder('Sent')
+ return imap_client.append_message('Sent', raw_message, '\\Seen')
+ except Exception:
+ return False
diff --git a/webmail/static/webmail/webmail.css b/webmail/static/webmail/webmail.css
new file mode 100644
index 000000000..35b13b24e
--- /dev/null
+++ b/webmail/static/webmail/webmail.css
@@ -0,0 +1,872 @@
+/* CyberPanel Webmail Styles */
+
+.webmail-container {
+ height: calc(100vh - 80px);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-primary);
+ border-radius: 12px;
+ overflow: hidden;
+ margin: -20px -15px;
+}
+
+/* Account Switcher */
+.wm-account-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.wm-account-current {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-primary);
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.wm-account-current i {
+ color: var(--accent-color);
+}
+
+.wm-account-select {
+ padding: 4px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+/* Main Layout */
+.wm-layout {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Sidebar */
+.wm-sidebar {
+ width: 220px;
+ min-width: 220px;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 12px 0;
+}
+
+.wm-compose-btn {
+ margin: 0 12px 12px;
+ padding: 10px 16px;
+ border-radius: 10px;
+ font-weight: 600;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: center;
+ background: var(--accent-color) !important;
+ border-color: var(--accent-color) !important;
+ transition: all 0.2s;
+}
+
+.wm-compose-btn:hover {
+ background: var(--accent-hover, #5A4BD1) !important;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(108,92,231,0.3);
+}
+
+.wm-folder-list {
+ flex: 1;
+}
+
+.wm-folder-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ cursor: pointer;
+ color: var(--text-secondary);
+ font-size: 14px;
+ transition: all 0.15s;
+ gap: 10px;
+}
+
+.wm-folder-item:hover {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.wm-folder-item.active {
+ background: var(--bg-primary);
+ color: var(--accent-color);
+ font-weight: 600;
+}
+
+.wm-folder-item i {
+ width: 18px;
+ text-align: center;
+ font-size: 13px;
+}
+
+.wm-folder-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.wm-badge {
+ background: var(--accent-color);
+ color: white;
+ border-radius: 10px;
+ padding: 1px 7px;
+ font-size: 11px;
+ font-weight: 600;
+ min-width: 20px;
+ text-align: center;
+}
+
+.wm-sidebar-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 8px 16px;
+}
+
+.wm-sidebar-nav {
+ padding: 0 4px;
+}
+
+.wm-nav-link {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 13px;
+ border-radius: 8px;
+ cursor: pointer;
+ gap: 10px;
+ transition: all 0.15s;
+}
+
+.wm-nav-link:hover {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ text-decoration: none;
+}
+
+.wm-nav-link.active {
+ background: var(--bg-primary);
+ color: var(--accent-color);
+ font-weight: 600;
+}
+
+.wm-nav-link i {
+ width: 18px;
+ text-align: center;
+}
+
+/* Message List Column */
+.wm-message-list {
+ width: 380px;
+ min-width: 320px;
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+}
+
+.wm-search-bar {
+ display: flex;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border-color);
+ gap: 8px;
+}
+
+.wm-search-input {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ font-size: 13px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.wm-search-input:focus {
+ border-color: var(--accent-color);
+}
+
+.wm-search-btn {
+ padding: 8px 12px;
+ border: none;
+ background: var(--accent-color);
+ color: white;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.wm-search-btn:hover {
+ background: var(--accent-hover, #5A4BD1);
+}
+
+/* Bulk Actions */
+.wm-bulk-actions {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--border-color);
+ gap: 4px;
+ background: var(--bg-secondary);
+ flex-wrap: wrap;
+}
+
+.wm-action-btn {
+ padding: 4px 8px;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: 6px;
+ font-size: 13px;
+ transition: all 0.15s;
+}
+
+.wm-action-btn:hover {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.wm-action-btn:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.wm-page-info {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-left: auto;
+ padding: 0 4px;
+}
+
+.wm-checkbox-label {
+ display: inline-flex;
+ align-items: center;
+ margin: 0;
+ cursor: pointer;
+}
+
+.wm-checkbox-label input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ accent-color: var(--accent-color);
+}
+
+.wm-move-dropdown select {
+ padding: 4px 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ font-size: 12px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+/* Message Rows */
+.wm-messages {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.wm-msg-row {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ gap: 8px;
+ transition: background 0.1s;
+ font-size: 13px;
+}
+
+.wm-msg-row:hover {
+ background: var(--bg-primary);
+}
+
+.wm-msg-row.unread {
+ font-weight: 600;
+}
+
+.wm-msg-row.unread .wm-msg-subject {
+ color: var(--text-primary);
+}
+
+.wm-msg-row.selected {
+ background: rgba(108, 92, 231, 0.06);
+}
+
+.wm-star-btn {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ padding: 2px;
+ font-size: 13px;
+}
+
+.wm-starred {
+ color: #F39C12;
+}
+
+.wm-unstarred {
+ color: var(--border-color);
+}
+
+.wm-msg-from {
+ width: 120px;
+ min-width: 80px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-primary);
+}
+
+.wm-msg-subject {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-secondary);
+}
+
+.wm-msg-date {
+ font-size: 11px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ min-width: 50px;
+ text-align: right;
+}
+
+.wm-empty, .wm-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ gap: 12px;
+}
+
+.wm-empty i {
+ font-size: 48px;
+ opacity: 0.3;
+}
+
+/* Detail Pane */
+.wm-detail-pane {
+ flex: 1;
+ overflow-y: auto;
+ background: var(--bg-secondary);
+ padding: 0;
+}
+
+.wm-empty-detail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-secondary);
+ opacity: 0.4;
+ gap: 16px;
+}
+
+/* Read View */
+.wm-read-view {
+ padding: 0;
+}
+
+.wm-read-toolbar {
+ display: flex;
+ gap: 8px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.wm-read-toolbar .btn {
+ font-size: 13px;
+ padding: 6px 14px;
+ border-radius: 8px;
+}
+
+.wm-read-header {
+ padding: 20px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.wm-read-subject {
+ margin: 0 0 12px;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.wm-read-meta {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.8;
+}
+
+.wm-read-meta strong {
+ color: var(--text-primary);
+}
+
+.wm-attachments {
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.wm-attachment a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ font-size: 13px;
+ color: var(--accent-color);
+ cursor: pointer;
+ text-decoration: none;
+ transition: background 0.15s;
+}
+
+.wm-attachment a:hover {
+ background: var(--border-color);
+}
+
+.wm-att-size {
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.wm-read-body {
+ padding: 20px;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.wm-read-body img {
+ max-width: 100%;
+ height: auto;
+}
+
+.wm-read-body blockquote {
+ border-left: 3px solid var(--border-color);
+ margin: 8px 0;
+ padding: 4px 12px;
+ color: var(--text-secondary);
+}
+
+.wm-read-body pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ background: var(--bg-primary);
+ padding: 12px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+/* Compose View */
+.wm-compose-view {
+ padding: 20px;
+}
+
+.wm-compose-header h3 {
+ margin: 0 0 16px;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.wm-compose-form .wm-field {
+ margin-bottom: 12px;
+}
+
+.wm-compose-form label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.wm-compose-form .form-control {
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ padding: 8px 12px;
+ font-size: 14px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.wm-compose-form .form-control:focus {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
+}
+
+.wm-toggle-link {
+ font-size: 12px;
+ color: var(--accent-color);
+ cursor: pointer;
+}
+
+.wm-editor-toolbar {
+ display: flex;
+ gap: 2px;
+ padding: 6px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-bottom: none;
+ border-radius: 8px 8px 0 0;
+}
+
+.wm-editor-toolbar button {
+ padding: 6px 10px;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.wm-editor-toolbar button:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+.wm-editor {
+ min-height: 300px;
+ max-height: 500px;
+ overflow-y: auto;
+ padding: 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 0 0 8px 8px;
+ background: white;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ outline: none;
+}
+
+.wm-editor:focus {
+ border-color: var(--accent-color);
+}
+
+.wm-compose-attachments {
+ margin-top: 12px;
+}
+
+.wm-compose-attachments input[type="file"] {
+ display: none;
+}
+
+.wm-attach-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: var(--bg-primary);
+ border: 1px dashed var(--border-color);
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-secondary);
+ transition: all 0.15s;
+}
+
+.wm-attach-btn:hover {
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+}
+
+.wm-file-list {
+ margin-top: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.wm-file-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ background: var(--bg-primary);
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--text-primary);
+}
+
+.wm-file-tag i {
+ cursor: pointer;
+ color: var(--text-secondary);
+}
+
+.wm-file-tag i:hover {
+ color: #E74C3C;
+}
+
+.wm-compose-actions {
+ margin-top: 16px;
+ display: flex;
+ gap: 8px;
+}
+
+.wm-compose-actions .btn {
+ border-radius: 8px;
+ padding: 8px 20px;
+ font-size: 14px;
+}
+
+/* Contacts View */
+.wm-contacts-view, .wm-rules-view, .wm-settings-view {
+ padding: 20px;
+}
+
+.wm-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.wm-section-header h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.wm-contacts-search {
+ margin-bottom: 16px;
+}
+
+.wm-contacts-search .form-control {
+ border-radius: 8px;
+ background: var(--bg-primary);
+}
+
+.wm-contact-list, .wm-rule-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.wm-contact-item, .wm-rule-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ transition: background 0.15s;
+}
+
+.wm-contact-item:hover, .wm-rule-item:hover {
+ background: var(--border-color);
+}
+
+.wm-contact-info, .wm-rule-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.wm-contact-name {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.wm-contact-email {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.wm-contact-actions, .wm-rule-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.wm-rule-desc {
+ display: block;
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+/* Forms */
+.wm-contact-form, .wm-rule-form, .wm-settings-form {
+ margin-top: 20px;
+ padding: 20px;
+ background: var(--bg-primary);
+ border-radius: 12px;
+}
+
+.wm-contact-form h4, .wm-rule-form h4 {
+ margin: 0 0 16px;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.wm-field {
+ margin-bottom: 12px;
+}
+
+.wm-field label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.wm-field .form-control {
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ padding: 8px 12px;
+ font-size: 14px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+.wm-field .form-control:focus {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
+ outline: none;
+}
+
+.wm-field-row {
+ display: flex;
+ gap: 12px;
+}
+
+.wm-field-row .wm-field {
+ flex: 1;
+}
+
+.wm-form-actions {
+ margin-top: 16px;
+ display: flex;
+ gap: 8px;
+}
+
+.wm-form-actions .btn {
+ border-radius: 8px;
+ padding: 8px 20px;
+}
+
+/* Autocomplete Dropdown */
+.wm-autocomplete-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.1);
+ z-index: 100;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.wm-autocomplete-item {
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.wm-autocomplete-item:hover {
+ background: var(--bg-primary);
+}
+
+/* Responsive */
+@media (max-width: 1024px) {
+ .wm-sidebar {
+ width: 180px;
+ min-width: 180px;
+ }
+ .wm-message-list {
+ width: 300px;
+ min-width: 240px;
+ }
+}
+
+@media (max-width: 768px) {
+ .wm-layout {
+ flex-direction: column;
+ }
+ .wm-sidebar {
+ width: 100%;
+ min-width: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ padding: 8px;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ }
+ .wm-folder-list {
+ display: flex;
+ gap: 4px;
+ }
+ .wm-folder-item {
+ white-space: nowrap;
+ padding: 6px 12px;
+ border-radius: 8px;
+ }
+ .wm-sidebar-divider, .wm-sidebar-nav {
+ display: none;
+ }
+ .wm-compose-btn {
+ margin: 0 4px 0 0;
+ padding: 6px 14px;
+ white-space: nowrap;
+ }
+ .wm-message-list {
+ width: 100%;
+ min-width: 100%;
+ max-height: 40vh;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ }
+ .wm-detail-pane {
+ min-height: 50vh;
+ }
+ .wm-field-row {
+ flex-direction: column;
+ }
+}
diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js
new file mode 100644
index 000000000..5592ec501
--- /dev/null
+++ b/webmail/static/webmail/webmail.js
@@ -0,0 +1,746 @@
+/* CyberPanel Webmail - AngularJS Controller */
+
+app.filter('fileSize', function() {
+ return function(bytes) {
+ if (!bytes || bytes === 0) return '0 B';
+ var k = 1024;
+ var sizes = ['B', 'KB', 'MB', 'GB'];
+ var i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ };
+});
+
+app.filter('wmDate', function() {
+ return function(dateStr) {
+ if (!dateStr) return '';
+ try {
+ var d = new Date(dateStr);
+ var now = new Date();
+ if (d.toDateString() === now.toDateString()) {
+ return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
+ }
+ if (d.getFullYear() === now.getFullYear()) {
+ return d.toLocaleDateString([], {month: 'short', day: 'numeric'});
+ }
+ return d.toLocaleDateString([], {year: 'numeric', month: 'short', day: 'numeric'});
+ } catch(e) {
+ return dateStr;
+ }
+ };
+});
+
+app.filter('trustHtml', ['$sce', function($sce) {
+ return function(html) {
+ return $sce.trustAsHtml(html);
+ };
+}]);
+
+app.directive('wmAutocomplete', ['$http', function($http) {
+ return {
+ restrict: 'A',
+ link: function(scope, element, attrs) {
+ var dropdown = null;
+ var debounce = null;
+
+ element.on('input', function() {
+ var val = element.val();
+ var lastComma = val.lastIndexOf(',');
+ var query = lastComma >= 0 ? val.substring(lastComma + 1).trim() : val.trim();
+
+ if (query.length < 2) {
+ hideDropdown();
+ return;
+ }
+
+ clearTimeout(debounce);
+ debounce = setTimeout(function() {
+ $http.post('/webmail/api/searchContacts', {query: query}, {
+ headers: {'X-CSRFToken': getCookie('csrftoken')}
+ }).then(function(resp) {
+ if (resp.data.status === 1 && resp.data.contacts.length > 0) {
+ showDropdown(resp.data.contacts, val, lastComma);
+ } else {
+ hideDropdown();
+ }
+ });
+ }, 300);
+ });
+
+ function showDropdown(contacts, currentVal, lastComma) {
+ hideDropdown();
+ dropdown = document.createElement('div');
+ dropdown.className = 'wm-autocomplete-dropdown';
+ contacts.forEach(function(c) {
+ var item = document.createElement('div');
+ item.className = 'wm-autocomplete-item';
+ item.textContent = c.display_name ? c.display_name + ' <' + c.email_address + '>' : c.email_address;
+ item.addEventListener('click', function() {
+ var prefix = lastComma >= 0 ? currentVal.substring(0, lastComma + 1) + ' ' : '';
+ var newVal = prefix + c.email_address + ', ';
+ element.val(newVal);
+ element.triggerHandler('input');
+ scope.$apply(function() {
+ scope.$eval(attrs.ngModel + ' = "' + newVal.replace(/"/g, '\\"') + '"');
+ });
+ hideDropdown();
+ });
+ dropdown.appendChild(item);
+ });
+ element[0].parentNode.style.position = 'relative';
+ element[0].parentNode.appendChild(dropdown);
+ }
+
+ function hideDropdown() {
+ if (dropdown && dropdown.parentNode) {
+ dropdown.parentNode.removeChild(dropdown);
+ }
+ dropdown = null;
+ }
+
+ element.on('blur', function() {
+ setTimeout(hideDropdown, 200);
+ });
+ }
+ };
+}]);
+
+app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($scope, $http, $sce, $timeout) {
+
+ // ── State ────────────────────────────────────────────────
+ $scope.currentEmail = '';
+ $scope.managedAccounts = [];
+ $scope.switchEmail = '';
+ $scope.folders = [];
+ $scope.currentFolder = 'INBOX';
+ $scope.messages = [];
+ $scope.currentPage = 1;
+ $scope.totalPages = 1;
+ $scope.totalMessages = 0;
+ $scope.perPage = 25;
+ $scope.openMsg = null;
+ $scope.trustedBody = '';
+ $scope.viewMode = 'list'; // list, read, compose, contacts, rules, settings
+ $scope.loading = false;
+ $scope.sending = false;
+ $scope.searchQuery = '';
+ $scope.selectAll = false;
+ $scope.showMoveDropdown = false;
+ $scope.moveTarget = '';
+ $scope.showBcc = false;
+
+ // Compose
+ $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
+
+ // Contacts
+ $scope.contacts = [];
+ $scope.filteredContacts = [];
+ $scope.contactSearch = '';
+ $scope.editingContact = null;
+
+ // Rules
+ $scope.sieveRules = [];
+ $scope.editingRule = null;
+
+ // Settings
+ $scope.wmSettings = {};
+
+ // Draft auto-save
+ var draftTimer = null;
+
+ // ── Helper ───────────────────────────────────────────────
+ function apiCall(url, data, callback) {
+ var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}};
+ $http.post(url, data || {}, config).then(function(resp) {
+ if (callback) callback(resp.data);
+ }, function(err) {
+ console.error('API error:', url, err);
+ });
+ }
+
+ function notify(msg, type) {
+ new PNotify({title: type === 'error' ? 'Error' : 'Webmail', text: msg, type: type || 'success'});
+ }
+
+ // ── Init ─────────────────────────────────────────────────
+ $scope.init = function() {
+ // Try SSO first
+ apiCall('/webmail/api/sso', {}, function(data) {
+ if (data.status === 1) {
+ $scope.currentEmail = data.email;
+ $scope.managedAccounts = data.accounts || [];
+ $scope.switchEmail = data.email;
+ $scope.loadFolders();
+ $scope.loadSettings();
+ }
+ });
+ };
+
+ // ── Account Switching ────────────────────────────────────
+ $scope.switchAccount = function() {
+ if (!$scope.switchEmail || $scope.switchEmail === $scope.currentEmail) return;
+ apiCall('/webmail/api/switchAccount', {email: $scope.switchEmail}, function(data) {
+ if (data.status === 1) {
+ $scope.currentEmail = data.email;
+ $scope.currentFolder = 'INBOX';
+ $scope.currentPage = 1;
+ $scope.openMsg = null;
+ $scope.viewMode = 'list';
+ $scope.loadFolders();
+ $scope.loadSettings();
+ } else {
+ notify(data.error_message, 'error');
+ }
+ });
+ };
+
+ // ── Folders ──────────────────────────────────────────────
+ $scope.loadFolders = function() {
+ apiCall('/webmail/api/listFolders', {}, function(data) {
+ if (data.status === 1) {
+ $scope.folders = data.folders;
+ $scope.loadMessages();
+ }
+ });
+ };
+
+ $scope.selectFolder = function(name) {
+ $scope.currentFolder = name;
+ $scope.currentPage = 1;
+ $scope.openMsg = null;
+ $scope.viewMode = 'list';
+ $scope.searchQuery = '';
+ $scope.loadMessages();
+ };
+
+ $scope.getFolderIcon = function(name) {
+ var n = name.toLowerCase();
+ if (n === 'inbox') return 'fa-inbox';
+ if (n === 'sent' || n.indexOf('sent') >= 0) return 'fa-paper-plane';
+ if (n === 'drafts' || n.indexOf('draft') >= 0) return 'fa-file';
+ if (n === 'trash' || n.indexOf('trash') >= 0) return 'fa-trash';
+ if (n === 'junk' || n === 'spam' || n.indexOf('junk') >= 0) return 'fa-ban';
+ if (n.indexOf('archive') >= 0) return 'fa-box-archive';
+ return 'fa-folder';
+ };
+
+ $scope.createFolder = function() {
+ var name = prompt('Folder name:');
+ if (!name) return;
+ apiCall('/webmail/api/createFolder', {name: name}, function(data) {
+ if (data.status === 1) {
+ $scope.loadFolders();
+ notify('Folder created.');
+ } else {
+ notify(data.error_message, 'error');
+ }
+ });
+ };
+
+ // ── Messages ─────────────────────────────────────────────
+ $scope.loadMessages = function() {
+ $scope.loading = true;
+ apiCall('/webmail/api/listMessages', {
+ folder: $scope.currentFolder,
+ page: $scope.currentPage,
+ perPage: $scope.perPage
+ }, function(data) {
+ $scope.loading = false;
+ if (data.status === 1) {
+ $scope.messages = data.messages;
+ $scope.totalMessages = data.total;
+ $scope.totalPages = data.pages;
+ $scope.selectAll = false;
+ }
+ });
+ };
+
+ $scope.prevPage = function() {
+ if ($scope.currentPage > 1) {
+ $scope.currentPage--;
+ $scope.loadMessages();
+ }
+ };
+
+ $scope.nextPage = function() {
+ if ($scope.currentPage < $scope.totalPages) {
+ $scope.currentPage++;
+ $scope.loadMessages();
+ }
+ };
+
+ $scope.searchMessages = function() {
+ if (!$scope.searchQuery) {
+ $scope.loadMessages();
+ return;
+ }
+ $scope.loading = true;
+ apiCall('/webmail/api/searchMessages', {
+ folder: $scope.currentFolder,
+ query: $scope.searchQuery
+ }, function(data) {
+ $scope.loading = false;
+ if (data.status === 1) {
+ // Re-fetch with found UIDs (simplified: reload)
+ $scope.loadMessages();
+ }
+ });
+ };
+
+ // ── Open/Read Message ────────────────────────────────────
+ $scope.openMessage = function(msg) {
+ apiCall('/webmail/api/getMessage', {
+ folder: $scope.currentFolder,
+ uid: msg.uid
+ }, function(data) {
+ if (data.status === 1) {
+ $scope.openMsg = data.message;
+ $scope.trustedBody = $sce.trustAsHtml(data.message.body_html || ('' + (data.message.body_text || '') + '
'));
+ $scope.viewMode = 'read';
+ msg.is_read = true;
+ // Update folder unread count
+ $scope.folders.forEach(function(f) {
+ if (f.name === $scope.currentFolder && f.unread_count > 0) {
+ f.unread_count--;
+ }
+ });
+ }
+ });
+ };
+
+ // ── Compose ──────────────────────────────────────────────
+ $scope.composeNew = function() {
+ $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
+ $scope.viewMode = 'compose';
+ $scope.showBcc = false;
+ $timeout(function() {
+ var editor = document.getElementById('wm-compose-body');
+ if (editor) {
+ editor.innerHTML = '';
+ // Add signature if available
+ if ($scope.wmSettings.signatureHtml) {
+ editor.innerHTML = '
--
' + $scope.wmSettings.signatureHtml + '
';
+ }
+ }
+ }, 100);
+ startDraftAutoSave();
+ };
+
+ $scope.replyTo = function() {
+ if (!$scope.openMsg) return;
+ $scope.compose = {
+ to: $scope.openMsg.from,
+ cc: '',
+ bcc: '',
+ subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject,
+ body: '',
+ files: [],
+ inReplyTo: $scope.openMsg.message_id || '',
+ references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim()
+ };
+ $scope.viewMode = 'compose';
+ $timeout(function() {
+ var editor = document.getElementById('wm-compose-body');
+ if (editor) {
+ var sig = $scope.wmSettings.signatureHtml ? '
--
' + $scope.wmSettings.signatureHtml + '
' : '';
+ editor.innerHTML = '
' + sig + '
On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
';
+ }
+ }, 100);
+ startDraftAutoSave();
+ };
+
+ $scope.replyAll = function() {
+ if (!$scope.openMsg) return;
+ var cc = [];
+ if ($scope.openMsg.to) cc.push($scope.openMsg.to);
+ if ($scope.openMsg.cc) cc.push($scope.openMsg.cc);
+ $scope.compose = {
+ to: $scope.openMsg.from,
+ cc: cc.join(', '),
+ bcc: '',
+ subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject,
+ body: '',
+ files: [],
+ inReplyTo: $scope.openMsg.message_id || '',
+ references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim()
+ };
+ $scope.viewMode = 'compose';
+ $timeout(function() {
+ var editor = document.getElementById('wm-compose-body');
+ if (editor) {
+ editor.innerHTML = '
On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
';
+ }
+ }, 100);
+ startDraftAutoSave();
+ };
+
+ $scope.forwardMsg = function() {
+ if (!$scope.openMsg) return;
+ $scope.compose = {
+ to: '',
+ cc: '',
+ bcc: '',
+ subject: ($scope.openMsg.subject.match(/^Fwd:/i) ? '' : 'Fwd: ') + $scope.openMsg.subject,
+ body: '',
+ files: [],
+ inReplyTo: '',
+ references: ''
+ };
+ $scope.viewMode = 'compose';
+ $timeout(function() {
+ var editor = document.getElementById('wm-compose-body');
+ if (editor) {
+ editor.innerHTML = '
---------- Forwarded message ----------
From: ' + $scope.openMsg.from + '
Date: ' + $scope.openMsg.date + '
Subject: ' + $scope.openMsg.subject + '
To: ' + $scope.openMsg.to + '
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
';
+ }
+ }, 100);
+ startDraftAutoSave();
+ };
+
+ $scope.updateComposeBody = function() {
+ var editor = document.getElementById('wm-compose-body');
+ if (editor) {
+ $scope.compose.body = editor.innerHTML;
+ }
+ };
+
+ $scope.execCmd = function(cmd) {
+ document.execCommand(cmd, false, null);
+ };
+
+ $scope.insertLink = function() {
+ var url = prompt('Enter URL:');
+ if (url) {
+ document.execCommand('createLink', false, url);
+ }
+ };
+
+ $scope.addFiles = function(files) {
+ $scope.$apply(function() {
+ for (var i = 0; i < files.length; i++) {
+ $scope.compose.files.push(files[i]);
+ }
+ });
+ };
+
+ $scope.removeFile = function(index) {
+ $scope.compose.files.splice(index, 1);
+ };
+
+ $scope.sendMessage = function() {
+ $scope.updateComposeBody();
+ $scope.sending = true;
+ stopDraftAutoSave();
+
+ var fd = new FormData();
+ fd.append('to', $scope.compose.to);
+ fd.append('cc', $scope.compose.cc || '');
+ fd.append('bcc', $scope.compose.bcc || '');
+ fd.append('subject', $scope.compose.subject);
+ fd.append('body', $scope.compose.body);
+ fd.append('inReplyTo', $scope.compose.inReplyTo || '');
+ fd.append('references', $scope.compose.references || '');
+ for (var i = 0; i < $scope.compose.files.length; i++) {
+ fd.append('attachment_' + i, $scope.compose.files[i]);
+ }
+
+ $http.post('/webmail/api/sendMessage', fd, {
+ headers: {
+ 'X-CSRFToken': getCookie('csrftoken'),
+ 'Content-Type': undefined
+ },
+ transformRequest: angular.identity
+ }).then(function(resp) {
+ $scope.sending = false;
+ if (resp.data.status === 1) {
+ notify('Message sent.');
+ $scope.viewMode = 'list';
+ $scope.loadMessages();
+ } else {
+ notify(resp.data.error_message, 'error');
+ }
+ }, function() {
+ $scope.sending = false;
+ notify('Failed to send message.', 'error');
+ });
+ };
+
+ $scope.saveDraft = function() {
+ $scope.updateComposeBody();
+ apiCall('/webmail/api/saveDraft', {
+ to: $scope.compose.to,
+ subject: $scope.compose.subject,
+ body: $scope.compose.body
+ }, function(data) {
+ if (data.status === 1) {
+ notify('Draft saved.');
+ }
+ });
+ };
+
+ $scope.discardDraft = function() {
+ stopDraftAutoSave();
+ $scope.viewMode = 'list';
+ $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
+ };
+
+ function startDraftAutoSave() {
+ stopDraftAutoSave();
+ draftTimer = setInterval(function() {
+ $scope.updateComposeBody();
+ if ($scope.compose.subject || $scope.compose.body || $scope.compose.to) {
+ apiCall('/webmail/api/saveDraft', {
+ to: $scope.compose.to,
+ subject: $scope.compose.subject,
+ body: $scope.compose.body
+ });
+ }
+ }, 60000); // Auto-save every 60 seconds
+ }
+
+ function stopDraftAutoSave() {
+ if (draftTimer) {
+ clearInterval(draftTimer);
+ draftTimer = null;
+ }
+ }
+
+ // ── Bulk Actions ─────────────────────────────────────────
+ $scope.toggleSelectAll = 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; });
+ }
+
+ $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();
+ }
+ });
+ };
+
+ $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();
+ });
+ };
+
+ $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();
+ });
+ };
+
+ $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();
+ }
+ });
+ };
+
+ $scope.toggleFlag = function(msg) {
+ apiCall('/webmail/api/markFlagged', {folder: $scope.currentFolder, 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) {
+ if (data.status === 1) {
+ $scope.openMsg = null;
+ $scope.viewMode = 'list';
+ $scope.loadMessages();
+ $scope.loadFolders();
+ }
+ });
+ };
+
+ // ── Attachments ──────────────────────────────────────────
+ $scope.downloadAttachment = function(att) {
+ var form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/webmail/api/getAttachment';
+ form.target = '_blank';
+ var fields = {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');
+ input.type = 'hidden';
+ input.name = key;
+ input.value = fields[key];
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ document.body.removeChild(form);
+ };
+
+ // ── View Mode ────────────────────────────────────────────
+ $scope.setView = function(mode) {
+ $scope.viewMode = mode;
+ $scope.openMsg = null;
+ if (mode === 'contacts') $scope.loadContacts();
+ if (mode === 'rules') $scope.loadRules();
+ if (mode === 'settings') $scope.loadSettings();
+ };
+
+ // ── Contacts ─────────────────────────────────────────────
+ $scope.loadContacts = function() {
+ apiCall('/webmail/api/listContacts', {}, function(data) {
+ if (data.status === 1) {
+ $scope.contacts = data.contacts;
+ $scope.filteredContacts = data.contacts;
+ }
+ });
+ };
+
+ $scope.filterContacts = function() {
+ var q = ($scope.contactSearch || '').toLowerCase();
+ $scope.filteredContacts = $scope.contacts.filter(function(c) {
+ return (c.display_name || '').toLowerCase().indexOf(q) >= 0 ||
+ (c.email_address || '').toLowerCase().indexOf(q) >= 0;
+ });
+ };
+
+ $scope.newContact = function() {
+ $scope.editingContact = {display_name: '', email_address: '', phone: '', organization: '', notes: ''};
+ };
+
+ $scope.editContact = function(c) {
+ $scope.editingContact = angular.copy(c);
+ };
+
+ $scope.saveContact = function() {
+ var c = $scope.editingContact;
+ var url = c.id ? '/webmail/api/updateContact' : '/webmail/api/createContact';
+ apiCall(url, {
+ id: c.id,
+ displayName: c.display_name,
+ emailAddress: c.email_address,
+ phone: c.phone,
+ organization: c.organization,
+ notes: c.notes
+ }, function(data) {
+ if (data.status === 1) {
+ $scope.editingContact = null;
+ $scope.loadContacts();
+ notify('Contact saved.');
+ } else {
+ notify(data.error_message, 'error');
+ }
+ });
+ };
+
+ $scope.removeContact = function(c) {
+ if (!confirm('Delete contact ' + (c.display_name || c.email_address) + '?')) return;
+ apiCall('/webmail/api/deleteContact', {id: c.id}, function(data) {
+ if (data.status === 1) {
+ $scope.loadContacts();
+ }
+ });
+ };
+
+ $scope.composeToContact = function(c) {
+ $scope.compose = {to: c.email_address, cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
+ $scope.viewMode = 'compose';
+ };
+
+ // ── Sieve Rules ──────────────────────────────────────────
+ $scope.loadRules = function() {
+ apiCall('/webmail/api/listRules', {}, function(data) {
+ if (data.status === 1) {
+ $scope.sieveRules = data.rules;
+ }
+ });
+ };
+
+ $scope.newRule = function() {
+ $scope.editingRule = {
+ name: '', priority: 0, conditionField: 'from',
+ conditionType: 'contains', conditionValue: '',
+ actionType: 'move', actionValue: ''
+ };
+ };
+
+ $scope.editRule = function(rule) {
+ $scope.editingRule = {
+ id: rule.id,
+ name: rule.name,
+ priority: rule.priority,
+ conditionField: rule.condition_field,
+ conditionType: rule.condition_type,
+ conditionValue: rule.condition_value,
+ actionType: rule.action_type,
+ actionValue: rule.action_value
+ };
+ };
+
+ $scope.saveRule = function() {
+ var r = $scope.editingRule;
+ var url = r.id ? '/webmail/api/updateRule' : '/webmail/api/createRule';
+ apiCall(url, r, function(data) {
+ if (data.status === 1) {
+ $scope.editingRule = null;
+ $scope.loadRules();
+ notify('Rule saved.');
+ } else {
+ notify(data.error_message, 'error');
+ }
+ });
+ };
+
+ $scope.removeRule = function(rule) {
+ if (!confirm('Delete rule "' + rule.name + '"?')) return;
+ apiCall('/webmail/api/deleteRule', {id: rule.id}, function(data) {
+ if (data.status === 1) {
+ $scope.loadRules();
+ }
+ });
+ };
+
+ // ── Settings ─────────────────────────────────────────────
+ $scope.loadSettings = function() {
+ apiCall('/webmail/api/getSettings', {}, function(data) {
+ if (data.status === 1) {
+ $scope.wmSettings = data.settings;
+ if ($scope.wmSettings.messagesPerPage) {
+ $scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
+ }
+ }
+ });
+ };
+
+ $scope.saveSettings = function() {
+ apiCall('/webmail/api/saveSettings', $scope.wmSettings, function(data) {
+ if (data.status === 1) {
+ notify('Settings saved.');
+ if ($scope.wmSettings.messagesPerPage) {
+ $scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
+ }
+ } else {
+ notify(data.error_message, 'error');
+ }
+ });
+ };
+
+}]);
diff --git a/webmail/templates/webmail/index.html b/webmail/templates/webmail/index.html
new file mode 100644
index 000000000..28bee4abb
--- /dev/null
+++ b/webmail/templates/webmail/index.html
@@ -0,0 +1,440 @@
+{% extends "baseTemplate/index.html" %}
+{% load i18n %}
+{% load static %}
+{% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+ {$ currentEmail $}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{$ currentPage $} / {$ totalPages $}
+
+
+
+
+
+
+
+
+
+
{$ msg.from | limitTo:30 $}
+
{$ msg.subject | limitTo:60 $}
+
{$ msg.date | wmDate $}
+
+
+
+
{% trans "No messages" %}
+
+
+ {% trans "Loading..." %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {$ rule.name $}
+
+ If {$ rule.condition_field $} {$ rule.condition_type $} "{$ rule.condition_value $}"
+ → {$ rule.action_type $} {$ rule.action_value $}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Select a message to read" %}
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/webmail/templates/webmail/login.html b/webmail/templates/webmail/login.html
new file mode 100644
index 000000000..7eb4aca9b
--- /dev/null
+++ b/webmail/templates/webmail/login.html
@@ -0,0 +1,181 @@
+{% load i18n %}
+{% load static %}
+
+
+
+
+
+ {% trans "Webmail Login - CyberPanel" %}
+
+
+
+
+
+
+
+
+
+
+
{% trans "CyberPanel Webmail" %}
+
{% trans "Sign in to access your email" %}
+
+
+
+ {$ errorMsg $}
+
+
+
+
+
+
+
+
diff --git a/webmail/urls.py b/webmail/urls.py
new file mode 100644
index 000000000..78ffa2b1c
--- /dev/null
+++ b/webmail/urls.py
@@ -0,0 +1,60 @@
+from django.urls import re_path
+from . import views
+
+urlpatterns = [
+ # Pages
+ re_path(r'^$', views.loadWebmail, name='loadWebmail'),
+ re_path(r'^login$', views.loadLogin, name='loadWebmailLogin'),
+
+ # Auth
+ re_path(r'^api/login$', views.apiLogin, name='wmApiLogin'),
+ re_path(r'^api/logout$', views.apiLogout, name='wmApiLogout'),
+ re_path(r'^api/sso$', views.apiSSO, name='wmApiSSO'),
+ re_path(r'^api/listAccounts$', views.apiListAccounts, name='wmApiListAccounts'),
+ re_path(r'^api/switchAccount$', views.apiSwitchAccount, name='wmApiSwitchAccount'),
+
+ # Folders
+ re_path(r'^api/listFolders$', views.apiListFolders, name='wmApiListFolders'),
+ re_path(r'^api/createFolder$', views.apiCreateFolder, name='wmApiCreateFolder'),
+ re_path(r'^api/renameFolder$', views.apiRenameFolder, name='wmApiRenameFolder'),
+ re_path(r'^api/deleteFolder$', views.apiDeleteFolder, name='wmApiDeleteFolder'),
+
+ # Messages
+ re_path(r'^api/listMessages$', views.apiListMessages, name='wmApiListMessages'),
+ re_path(r'^api/searchMessages$', views.apiSearchMessages, name='wmApiSearchMessages'),
+ re_path(r'^api/getMessage$', views.apiGetMessage, name='wmApiGetMessage'),
+ re_path(r'^api/getAttachment$', views.apiGetAttachment, name='wmApiGetAttachment'),
+
+ # Actions
+ re_path(r'^api/sendMessage$', views.apiSendMessage, name='wmApiSendMessage'),
+ re_path(r'^api/saveDraft$', views.apiSaveDraft, name='wmApiSaveDraft'),
+ re_path(r'^api/deleteMessages$', views.apiDeleteMessages, name='wmApiDeleteMessages'),
+ re_path(r'^api/moveMessages$', views.apiMoveMessages, name='wmApiMoveMessages'),
+ re_path(r'^api/markRead$', views.apiMarkRead, name='wmApiMarkRead'),
+ re_path(r'^api/markUnread$', views.apiMarkUnread, name='wmApiMarkUnread'),
+ re_path(r'^api/markFlagged$', views.apiMarkFlagged, name='wmApiMarkFlagged'),
+
+ # Contacts
+ re_path(r'^api/listContacts$', views.apiListContacts, name='wmApiListContacts'),
+ re_path(r'^api/createContact$', views.apiCreateContact, name='wmApiCreateContact'),
+ re_path(r'^api/updateContact$', views.apiUpdateContact, name='wmApiUpdateContact'),
+ re_path(r'^api/deleteContact$', views.apiDeleteContact, name='wmApiDeleteContact'),
+ re_path(r'^api/searchContacts$', views.apiSearchContacts, name='wmApiSearchContacts'),
+ re_path(r'^api/listContactGroups$', views.apiListContactGroups, name='wmApiListContactGroups'),
+ re_path(r'^api/createContactGroup$', views.apiCreateContactGroup, name='wmApiCreateContactGroup'),
+ re_path(r'^api/deleteContactGroup$', views.apiDeleteContactGroup, name='wmApiDeleteContactGroup'),
+
+ # Sieve Rules
+ re_path(r'^api/listRules$', views.apiListRules, name='wmApiListRules'),
+ re_path(r'^api/createRule$', views.apiCreateRule, name='wmApiCreateRule'),
+ re_path(r'^api/updateRule$', views.apiUpdateRule, name='wmApiUpdateRule'),
+ re_path(r'^api/deleteRule$', views.apiDeleteRule, name='wmApiDeleteRule'),
+ re_path(r'^api/activateRules$', views.apiActivateRules, name='wmApiActivateRules'),
+
+ # Settings
+ re_path(r'^api/getSettings$', views.apiGetSettings, name='wmApiGetSettings'),
+ re_path(r'^api/saveSettings$', views.apiSaveSettings, name='wmApiSaveSettings'),
+
+ # Image Proxy
+ re_path(r'^api/proxyImage$', views.apiProxyImage, name='wmApiProxyImage'),
+]
diff --git a/webmail/views.py b/webmail/views.py
new file mode 100644
index 000000000..94d333dc0
--- /dev/null
+++ b/webmail/views.py
@@ -0,0 +1,397 @@
+import json
+from django.shortcuts import redirect
+from django.http import HttpResponse
+from loginSystem.views import loadLoginPage
+from .webmailManager import WebmailManager
+
+
+# ── Page Views ────────────────────────────────────────────────
+
+def loadWebmail(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.loadWebmail()
+ except KeyError:
+ return redirect(loadLoginPage)
+
+
+def loadLogin(request):
+ wm = WebmailManager(request)
+ return wm.loadLogin()
+
+
+# ── Auth APIs ─────────────────────────────────────────────────
+
+def apiLogin(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiLogin()
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiLogout(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiLogout()
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSSO(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSSO()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiListAccounts(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListAccounts()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSwitchAccount(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSwitchAccount()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Folder APIs ───────────────────────────────────────────────
+
+def apiListFolders(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListFolders()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiCreateFolder(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiCreateFolder()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiRenameFolder(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiRenameFolder()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiDeleteFolder(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiDeleteFolder()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Message APIs ──────────────────────────────────────────────
+
+def apiListMessages(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListMessages()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSearchMessages(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSearchMessages()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiGetMessage(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiGetMessage()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiGetAttachment(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiGetAttachment()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Action APIs ───────────────────────────────────────────────
+
+def apiSendMessage(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSendMessage()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSaveDraft(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSaveDraft()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiDeleteMessages(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiDeleteMessages()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiMoveMessages(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiMoveMessages()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiMarkRead(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiMarkRead()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiMarkUnread(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiMarkUnread()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiMarkFlagged(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiMarkFlagged()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Contact APIs ──────────────────────────────────────────────
+
+def apiListContacts(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListContacts()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiCreateContact(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiCreateContact()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiUpdateContact(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiUpdateContact()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiDeleteContact(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiDeleteContact()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSearchContacts(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSearchContacts()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiListContactGroups(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListContactGroups()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiCreateContactGroup(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiCreateContactGroup()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiDeleteContactGroup(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiDeleteContactGroup()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Sieve Rule APIs ──────────────────────────────────────────
+
+def apiListRules(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiListRules()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiCreateRule(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiCreateRule()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiUpdateRule(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiUpdateRule()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiDeleteRule(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiDeleteRule()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiActivateRules(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiActivateRules()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Settings APIs ─────────────────────────────────────────────
+
+def apiGetSettings(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiGetSettings()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+def apiSaveSettings(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiSaveSettings()
+ except KeyError:
+ return redirect(loadLoginPage)
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── Image Proxy ───────────────────────────────────────────────
+
+def apiProxyImage(request):
+ try:
+ wm = WebmailManager(request)
+ return wm.apiProxyImage()
+ except Exception as e:
+ return _error_response(e)
+
+
+# ── 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
new file mode 100644
index 000000000..9727f9957
--- /dev/null
+++ b/webmail/webmailManager.py
@@ -0,0 +1,755 @@
+import json
+import os
+import base64
+
+from django.http import HttpResponse
+from django.shortcuts import render, redirect
+
+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
+
+import plogical.CyberCPLogFileWriter as logging
+
+WEBMAIL_CONF = '/etc/cyberpanel/webmail.conf'
+
+
+class WebmailManager:
+
+ def __init__(self, request):
+ self.request = request
+
+ # ── Helpers ────────────────────────────────────────────────
+
+ @staticmethod
+ def _json_response(data):
+ return HttpResponse(json.dumps(data), content_type='application/json')
+
+ @staticmethod
+ def _error(msg):
+ return HttpResponse(json.dumps({'status': 0, 'error_message': str(msg)}),
+ content_type='application/json')
+
+ @staticmethod
+ def _success(extra=None):
+ data = {'status': 1}
+ if extra:
+ data.update(extra)
+ return HttpResponse(json.dumps(data), content_type='application/json')
+
+ def _get_post_data(self):
+ try:
+ return json.loads(self.request.body)
+ except Exception:
+ return self.request.POST.dict()
+
+ def _get_email(self):
+ return self.request.session.get('webmail_email')
+
+ def _get_master_config(self):
+ """Read master user config from /etc/cyberpanel/webmail.conf"""
+ try:
+ with open(WEBMAIL_CONF, 'r') as f:
+ config = json.load(f)
+ return config.get('master_user'), config.get('master_password')
+ except Exception:
+ return None, None
+
+ def _get_imap(self, email_addr=None):
+ """Create IMAP client, preferring master user auth for SSO sessions."""
+ addr = email_addr or self._get_email()
+ if not addr:
+ raise Exception('No email account selected')
+
+ master_user, master_pass = self._get_master_config()
+ if master_user and master_pass:
+ return IMAPClient(addr, '', master_user=master_user, master_password=master_pass)
+
+ # Fallback: standalone login with stored password
+ password = self.request.session.get('webmail_password', '')
+ return IMAPClient(addr, password)
+
+ def _get_smtp(self):
+ addr = self._get_email()
+ if not addr:
+ raise Exception('No email account selected')
+ password = self.request.session.get('webmail_password', '')
+ return SMTPClient(addr, password)
+
+ def _get_sieve(self, email_addr=None):
+ addr = email_addr or self._get_email()
+ if not addr:
+ raise Exception('No email account selected')
+
+ master_user, master_pass = self._get_master_config()
+ if master_user and master_pass:
+ return SieveClient(addr, '', master_user=master_user, master_password=master_pass)
+
+ password = self.request.session.get('webmail_password', '')
+ return SieveClient(addr, password)
+
+ def _get_managed_accounts(self):
+ """Get email accounts the current CyberPanel user can access."""
+ try:
+ from plogical.acl import ACLManager
+ from loginSystem.models import Administrator
+ from mailServer.models import Domains, EUsers
+
+ userID = self.request.session['userID']
+ currentACL = ACLManager.loadedACL(userID)
+
+ websites = ACLManager.findAllSites(currentACL, userID)
+ websites = websites + ACLManager.findChildDomains(websites)
+
+ accounts = []
+ for site in websites:
+ try:
+ domain = Domains.objects.get(domain=site)
+ for eu in EUsers.objects.filter(emailOwner=domain):
+ accounts.append(eu.email)
+ except Exception:
+ continue
+ return accounts
+ except Exception:
+ return []
+
+ # ── Page Renders ──────────────────────────────────────────
+
+ def loadWebmail(self):
+ from plogical.httpProc import httpProc
+ email = self._get_email()
+ accounts = self._get_managed_accounts()
+
+ if not email and accounts:
+ if len(accounts) == 1:
+ self.request.session['webmail_email'] = accounts[0]
+ email = accounts[0]
+ else:
+ # Multiple accounts - render picker
+ proc = httpProc(self.request, 'webmail/index.html',
+ {'accounts': json.dumps(accounts), 'show_picker': True},
+ 'listEmails')
+ return proc.render()
+
+ proc = httpProc(self.request, 'webmail/index.html',
+ {'email': email or '',
+ 'accounts': json.dumps(accounts),
+ 'show_picker': False},
+ 'listEmails')
+ return proc.render()
+
+ def loadLogin(self):
+ return render(self.request, 'webmail/login.html')
+
+ # ── Auth APIs ─────────────────────────────────────────────
+
+ def apiLogin(self):
+ data = self._get_post_data()
+ email_addr = data.get('email', '')
+ password = data.get('password', '')
+
+ if not email_addr or not password:
+ return self._error('Email and password are required.')
+
+ try:
+ client = IMAPClient(email_addr, password)
+ client.close()
+ except Exception as e:
+ return self._error('Login failed: %s' % str(e))
+
+ self.request.session['webmail_email'] = email_addr
+ self.request.session['webmail_password'] = password
+ self.request.session['webmail_standalone'] = True
+ return self._success()
+
+ def apiLogout(self):
+ for key in ['webmail_email', 'webmail_password', 'webmail_standalone']:
+ self.request.session.pop(key, None)
+ return self._success()
+
+ def apiSSO(self):
+ """Auto-login for CyberPanel users."""
+ accounts = self._get_managed_accounts()
+ if not accounts:
+ return self._error('No email accounts found for your user.')
+ email = accounts[0]
+ self.request.session['webmail_email'] = email
+ return self._success({'email': email, 'accounts': accounts})
+
+ def apiListAccounts(self):
+ accounts = self._get_managed_accounts()
+ return self._success({'accounts': accounts})
+
+ def apiSwitchAccount(self):
+ data = self._get_post_data()
+ email = data.get('email', '')
+ accounts = self._get_managed_accounts()
+ if email not in accounts:
+ return self._error('You do not have access to this account.')
+ self.request.session['webmail_email'] = email
+ return self._success({'email': email})
+
+ # ── Folder APIs ───────────────────────────────────────────
+
+ def apiListFolders(self):
+ try:
+ with self._get_imap() as imap:
+ folders = imap.list_folders()
+ return self._success({'folders': folders})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiCreateFolder(self):
+ data = self._get_post_data()
+ name = data.get('name', '')
+ if not name:
+ return self._error('Folder name is required.')
+ try:
+ with self._get_imap() as imap:
+ if imap.create_folder(name):
+ return self._success()
+ return self._error('Failed to create folder.')
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiRenameFolder(self):
+ data = self._get_post_data()
+ old_name = data.get('oldName', '')
+ new_name = data.get('newName', '')
+ if not old_name or not new_name:
+ return self._error('Both old and new folder names are required.')
+ try:
+ with self._get_imap() as imap:
+ if imap.rename_folder(old_name, new_name):
+ return self._success()
+ return self._error('Failed to rename folder.')
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiDeleteFolder(self):
+ data = self._get_post_data()
+ name = data.get('name', '')
+ if not name:
+ return self._error('Folder name is required.')
+ protected = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam']
+ if name in protected:
+ return self._error('Cannot delete system folder.')
+ try:
+ with self._get_imap() as imap:
+ if imap.delete_folder(name):
+ return self._success()
+ return self._error('Failed to delete folder.')
+ except Exception as e:
+ return self._error(str(e))
+
+ # ── Message APIs ──────────────────────────────────────────
+
+ def apiListMessages(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ page = int(data.get('page', 1))
+ per_page = int(data.get('perPage', 25))
+ try:
+ with self._get_imap() as imap:
+ result = imap.list_messages(folder, page, per_page)
+ 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', '')
+ 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})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiGetMessage(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uid = data.get('uid', '')
+ if not uid:
+ return self._error('Message UID is required.')
+ try:
+ with self._get_imap() as imap:
+ msg = imap.get_message(folder, uid)
+ if msg is None:
+ return self._error('Message not found.')
+ imap.mark_read(folder, [uid])
+ return self._success({'message': msg})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiGetAttachment(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uid = data.get('uid', '')
+ part_id = data.get('partId', '')
+ try:
+ with self._get_imap() as imap:
+ result = imap.get_attachment(folder, uid, part_id)
+ if result is None:
+ return self._error('Attachment not found.')
+ filename, content_type, payload = result
+ response = HttpResponse(payload, content_type=content_type)
+ response['Content-Disposition'] = 'attachment; filename="%s"' % filename
+ return response
+ except Exception as e:
+ return self._error(str(e))
+
+ # ── Action APIs ───────────────────────────────────────────
+
+ def apiSendMessage(self):
+ try:
+ email_addr = self._get_email()
+ if not email_addr:
+ return self._error('Not logged in.')
+
+ # Handle multipart form data for attachments
+ if self.request.content_type and 'multipart' in self.request.content_type:
+ to = self.request.POST.get('to', '')
+ cc = self.request.POST.get('cc', '')
+ bcc = self.request.POST.get('bcc', '')
+ subject = self.request.POST.get('subject', '')
+ body_html = self.request.POST.get('body', '')
+ in_reply_to = self.request.POST.get('inReplyTo', '')
+ references = self.request.POST.get('references', '')
+
+ attachments = []
+ for key in self.request.FILES:
+ f = self.request.FILES[key]
+ attachments.append((f.name, f.content_type, f.read()))
+ else:
+ data = self._get_post_data()
+ to = data.get('to', '')
+ cc = data.get('cc', '')
+ bcc = data.get('bcc', '')
+ subject = data.get('subject', '')
+ body_html = data.get('body', '')
+ in_reply_to = data.get('inReplyTo', '')
+ 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,
+ )
+
+ smtp = self._get_smtp()
+ result = smtp.send_message(mime_msg)
+
+ if not result['success']:
+ return self._error(result.get('error', 'Failed to send.'))
+
+ # Save to Sent folder
+ try:
+ with self._get_imap() as imap:
+ raw = mime_msg.as_bytes()
+ smtp.save_to_sent(imap, raw)
+ except Exception:
+ pass
+
+ # Auto-collect contacts
+ try:
+ settings = WebmailSettings.objects.filter(email_account=email_addr).first()
+ if settings is None or settings.auto_collect_contacts:
+ self._auto_collect(email_addr, to, cc)
+ except Exception:
+ pass
+
+ return self._success({'messageId': result['message_id']})
+ except Exception as e:
+ return self._error(str(e))
+
+ def _auto_collect(self, owner, to_addrs, cc_addrs=''):
+ """Auto-save recipients as contacts."""
+ import re
+ all_addrs = '%s,%s' % (to_addrs, cc_addrs) if cc_addrs else to_addrs
+ emails = re.findall(r'[\w.+-]+@[\w-]+\.[\w.-]+', all_addrs)
+ for addr in emails:
+ if addr == owner:
+ continue
+ Contact.objects.get_or_create(
+ owner_email=owner,
+ email_address=addr,
+ defaults={'is_auto_collected': True, 'display_name': addr.split('@')[0]},
+ )
+
+ def apiSaveDraft(self):
+ try:
+ email_addr = self._get_email()
+ data = self._get_post_data()
+ to = data.get('to', '')
+ subject = data.get('subject', '')
+ body_html = data.get('body', '')
+
+ mime_msg = EmailComposer.compose(
+ from_addr=email_addr,
+ to_addrs=to,
+ subject=subject,
+ body_html=body_html,
+ )
+
+ with self._get_imap() as imap:
+ draft_folders = ['Drafts', 'INBOX.Drafts', 'Draft']
+ saved = False
+ for folder in draft_folders:
+ try:
+ if imap.append_message(folder, mime_msg.as_bytes(), '\\Draft \\Seen'):
+ saved = True
+ break
+ except Exception:
+ continue
+ if not saved:
+ imap.create_folder('Drafts')
+ imap.append_message('Drafts', mime_msg.as_bytes(), '\\Draft \\Seen')
+
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiDeleteMessages(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uids = data.get('uids', [])
+ if not uids:
+ return self._error('No messages selected.')
+ try:
+ with self._get_imap() as imap:
+ imap.delete_messages(folder, uids)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiMoveMessages(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uids = data.get('uids', [])
+ target = data.get('targetFolder', '')
+ if not uids or not target:
+ return self._error('Messages and target folder are required.')
+ try:
+ with self._get_imap() as imap:
+ imap.move_messages(folder, uids, target)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiMarkRead(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uids = data.get('uids', [])
+ try:
+ with self._get_imap() as imap:
+ imap.mark_read(folder, uids)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiMarkUnread(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uids = data.get('uids', [])
+ try:
+ with self._get_imap() as imap:
+ imap.mark_unread(folder, uids)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiMarkFlagged(self):
+ data = self._get_post_data()
+ folder = data.get('folder', 'INBOX')
+ uids = data.get('uids', [])
+ try:
+ with self._get_imap() as imap:
+ imap.mark_flagged(folder, uids)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ # ── Contact APIs ──────────────────────────────────────────
+
+ def apiListContacts(self):
+ email = self._get_email()
+ try:
+ contacts = list(Contact.objects.filter(owner_email=email).values(
+ 'id', 'display_name', 'email_address', 'phone', 'organization', 'notes', 'is_auto_collected'
+ ))
+ return self._success({'contacts': contacts})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiCreateContact(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ try:
+ contact = Contact.objects.create(
+ owner_email=email,
+ display_name=data.get('displayName', ''),
+ email_address=data.get('emailAddress', ''),
+ phone=data.get('phone', ''),
+ organization=data.get('organization', ''),
+ notes=data.get('notes', ''),
+ )
+ return self._success({'id': contact.id})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiUpdateContact(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ contact_id = data.get('id')
+ try:
+ contact = Contact.objects.get(id=contact_id, owner_email=email)
+ for field in ['display_name', 'email_address', 'phone', 'organization', 'notes']:
+ camel = field.replace('_', ' ').title().replace(' ', '')
+ camel = camel[0].lower() + camel[1:]
+ if camel in data:
+ setattr(contact, field, data[camel])
+ contact.save()
+ return self._success()
+ except Contact.DoesNotExist:
+ return self._error('Contact not found.')
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiDeleteContact(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ contact_id = data.get('id')
+ try:
+ Contact.objects.filter(id=contact_id, owner_email=email).delete()
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiSearchContacts(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ query = data.get('query', '')
+ try:
+ from django.db.models import Q
+ contacts = Contact.objects.filter(
+ owner_email=email
+ ).filter(
+ Q(display_name__icontains=query) | Q(email_address__icontains=query)
+ ).values('id', 'display_name', 'email_address')[:20]
+ return self._success({'contacts': list(contacts)})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiListContactGroups(self):
+ email = self._get_email()
+ try:
+ groups = list(ContactGroup.objects.filter(owner_email=email).values('id', 'name'))
+ return self._success({'groups': groups})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiCreateContactGroup(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ name = data.get('name', '')
+ if not name:
+ return self._error('Group name is required.')
+ try:
+ group = ContactGroup.objects.create(owner_email=email, name=name)
+ return self._success({'id': group.id})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiDeleteContactGroup(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ group_id = data.get('id')
+ try:
+ ContactGroup.objects.filter(id=group_id, owner_email=email).delete()
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ # ── 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',
+ ))
+ return self._success({'rules': rules})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiCreateRule(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ try:
+ rule = SieveRule.objects.create(
+ email_account=email,
+ name=data.get('name', 'New Rule'),
+ priority=data.get('priority', 0),
+ is_active=data.get('isActive', True),
+ condition_field=data.get('conditionField', 'from'),
+ condition_type=data.get('conditionType', 'contains'),
+ condition_value=data.get('conditionValue', ''),
+ action_type=data.get('actionType', 'move'),
+ action_value=data.get('actionValue', ''),
+ )
+ self._sync_sieve_rules(email)
+ return self._success({'id': rule.id})
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiUpdateRule(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ rule_id = data.get('id')
+ try:
+ rule = SieveRule.objects.get(id=rule_id, email_account=email)
+ for field in ['name', 'priority', 'is_active', 'condition_field',
+ 'condition_type', 'condition_value', 'action_type', 'action_value']:
+ camel = field.replace('_', ' ').title().replace(' ', '')
+ camel = camel[0].lower() + camel[1:]
+ if camel in data:
+ val = data[camel]
+ if field == 'is_active':
+ val = bool(val)
+ elif field == 'priority':
+ val = int(val)
+ setattr(rule, field, val)
+ rule.save()
+ self._sync_sieve_rules(email)
+ return self._success()
+ except SieveRule.DoesNotExist:
+ return self._error('Rule not found.')
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiDeleteRule(self):
+ email = self._get_email()
+ data = self._get_post_data()
+ rule_id = data.get('id')
+ try:
+ SieveRule.objects.filter(id=rule_id, email_account=email).delete()
+ self._sync_sieve_rules(email)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiActivateRules(self):
+ email = self._get_email()
+ try:
+ self._sync_sieve_rules(email)
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ def _sync_sieve_rules(self, email):
+ """Generate sieve script from DB rules and upload to Dovecot."""
+ rules = SieveRule.objects.filter(email_account=email, is_active=True).order_by('priority')
+ 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:
+ sieve.put_script('cyberpanel', script)
+ sieve.activate_script('cyberpanel')
+ except Exception as e:
+ logging.CyberCPLogFileWriter.writeToFile('Sieve sync failed for %s: %s' % (email, str(e)))
+
+ # ── Settings APIs ─────────────────────────────────────────
+
+ def apiGetSettings(self):
+ email = self._get_email()
+ try:
+ settings, created = WebmailSettings.objects.get_or_create(email_account=email)
+ return self._success({
+ 'settings': {
+ 'displayName': settings.display_name,
+ 'signatureHtml': settings.signature_html,
+ 'messagesPerPage': settings.messages_per_page,
+ 'defaultReplyBehavior': settings.default_reply_behavior,
+ 'themePreference': settings.theme_preference,
+ 'autoCollectContacts': settings.auto_collect_contacts,
+ }
+ })
+ except Exception as e:
+ return self._error(str(e))
+
+ def apiSaveSettings(self):
+ email = self._get_email()
+ 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'])
+ settings.save()
+ return self._success()
+ except Exception as e:
+ return self._error(str(e))
+
+ # ── Image Proxy ───────────────────────────────────────────
+
+ def apiProxyImage(self):
+ """Proxy external images to prevent tracking and mixed content."""
+ url_b64 = self.request.GET.get('url', '') or self.request.POST.get('url', '')
+ try:
+ url = base64.urlsafe_b64decode(url_b64).decode('utf-8')
+ except Exception:
+ return self._error('Invalid URL.')
+
+ if not url.startswith(('http://', 'https://')):
+ return self._error('Invalid URL scheme.')
+
+ try:
+ import urllib.request
+ req = urllib.request.Request(url, headers={
+ 'User-Agent': 'CyberPanel-Webmail-Proxy/1.0',
+ })
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ content_type = resp.headers.get('Content-Type', 'image/png')
+ if not content_type.startswith('image/'):
+ return self._error('Not an image.')
+ data = resp.read(5 * 1024 * 1024) # 5MB max
+ return HttpResponse(data, content_type=content_type)
+ except Exception as e:
+ return self._error(str(e))