Add integrated webmail client with SSO, contacts, and Sieve rules

Replace SnappyMail link with a custom Django webmail app that provides:
- Full IMAP/SMTP integration (Dovecot + Postfix) with master user SSO
- 3-column responsive UI matching CyberPanel design system
- Compose with rich text editor, attachments, reply/forward
- Contact management with auto-collect from sent messages
- Sieve mail filter rules with ManageSieve protocol support
- Standalone login page for direct webmail access
- Account switcher for admins managing multiple email accounts
- HTML email sanitization (whitelist-based, external image proxy)
- Draft auto-save and per-user settings
This commit is contained in:
usmannasir
2026-03-05 02:49:00 +05:00
parent 39baa9b05e
commit 72f33d3bcd
20 changed files with 4561 additions and 1 deletions

View File

@@ -75,6 +75,7 @@ INSTALLED_APPS = [
'CLManager',
'IncBackups',
'aiScanner',
'webmail',
# 'WebTerminal'
]

View File

@@ -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')),
]

View File

@@ -1599,7 +1599,7 @@
</a>
{% endif %}
{% if admin or createEmail %}
<a href="/snappymail/index.php" class="menu-item" target="_blank" rel="noopener">
<a href="/webmail/" class="menu-item">
<span>Access Webmail</span>
</a>
{% endif %}

0
webmail/__init__.py Normal file
View File

5
webmail/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class WebmailConfig(AppConfig):
name = 'webmail'

View File

106
webmail/models.py Normal file
View File

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

View File

View File

@@ -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 = '<br><br><div class="wm-quoted">On %s, %s wrote:<br><blockquote>%s</blockquote></div>' % (
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 = (
'<br><br><div class="wm-forwarded">'
'---------- Forwarded message ----------<br>'
'From: %s<br>'
'Date: %s<br>'
'Subject: %s<br>'
'To: %s<br><br>'
'%s</div>'
) % (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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || ('<pre>' + (data.message.body_text || '') + '</pre>'));
$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 = '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>';
}
}
}, 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 ? '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>' : '';
editor.innerHTML = '<br>' + sig + '<br><div class="wm-quoted">On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
}
}, 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 = '<br><br><div class="wm-quoted">On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
}
}, 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 = '<br><br><div class="wm-forwarded">---------- Forwarded message ----------<br>From: ' + $scope.openMsg.from + '<br>Date: ' + $scope.openMsg.date + '<br>Subject: ' + $scope.openMsg.subject + '<br>To: ' + $scope.openMsg.to + '<br><br>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</div>';
}
}, 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');
}
});
};
}]);

View File

@@ -0,0 +1,440 @@
{% extends "baseTemplate/index.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static 'webmail/webmail.css' %}">
<div class="webmail-container" ng-controller="webmailCtrl" ng-init="init()">
<!-- Account Switcher Bar -->
<div class="wm-account-bar" ng-if="managedAccounts.length > 1">
<div class="wm-account-current">
<i class="fa fa-envelope"></i>
<span>{$ currentEmail $}</span>
</div>
<div class="wm-account-switch">
<select ng-model="switchEmail" ng-change="switchAccount()"
ng-options="a for a in managedAccounts" class="wm-account-select">
</select>
</div>
</div>
<!-- Main Layout -->
<div class="wm-layout">
<!-- Column 1: Sidebar -->
<div class="wm-sidebar">
<button class="btn btn-primary wm-compose-btn" ng-click="composeNew()">
<i class="fa fa-pen-to-square"></i> {% trans "Compose" %}
</button>
<div class="wm-folder-list">
<div class="wm-folder-item" ng-repeat="folder in folders"
ng-class="{'active': currentFolder === folder.name}"
ng-click="selectFolder(folder.name)">
<i class="fa" ng-class="getFolderIcon(folder.name)"></i>
<span class="wm-folder-name">{$ folder.name $}</span>
<span class="wm-badge" ng-if="folder.unread_count > 0">{$ folder.unread_count $}</span>
</div>
</div>
<div class="wm-sidebar-divider"></div>
<div class="wm-sidebar-nav">
<a ng-click="setView('contacts')" class="wm-nav-link" ng-class="{'active': viewMode === 'contacts'}">
<i class="fa fa-address-book"></i> {% trans "Contacts" %}
</a>
<a ng-click="setView('rules')" class="wm-nav-link" ng-class="{'active': viewMode === 'rules'}">
<i class="fa fa-filter"></i> {% trans "Rules" %}
</a>
<a ng-click="setView('settings')" class="wm-nav-link" ng-class="{'active': viewMode === 'settings'}">
<i class="fa fa-gear"></i> {% trans "Settings" %}
</a>
</div>
<div class="wm-sidebar-divider"></div>
<div class="wm-sidebar-nav">
<a ng-click="createFolder()" class="wm-nav-link">
<i class="fa fa-folder-plus"></i> {% trans "New Folder" %}
</a>
</div>
</div>
<!-- Column 2: Message List -->
<div class="wm-message-list" ng-show="viewMode === 'list' || viewMode === 'read'">
<!-- Search Bar -->
<div class="wm-search-bar">
<input type="text" ng-model="searchQuery" placeholder="{% trans 'Search messages...' %}"
ng-keyup="$event.keyCode === 13 && searchMessages()" class="wm-search-input">
<button class="wm-search-btn" ng-click="searchMessages()">
<i class="fa fa-search"></i>
</button>
</div>
<!-- Bulk Actions -->
<div class="wm-bulk-actions">
<label class="wm-checkbox-label">
<input type="checkbox" ng-model="selectAll" ng-change="toggleSelectAll()">
</label>
<button class="wm-action-btn" ng-click="bulkDelete()" title="{% trans 'Delete' %}">
<i class="fa fa-trash"></i>
</button>
<button class="wm-action-btn" ng-click="bulkMarkRead()" title="{% trans 'Mark read' %}">
<i class="fa fa-envelope-open"></i>
</button>
<button class="wm-action-btn" ng-click="bulkMarkUnread()" title="{% trans 'Mark unread' %}">
<i class="fa fa-envelope"></i>
</button>
<div class="wm-move-dropdown" ng-if="showMoveDropdown">
<select ng-model="moveTarget" ng-change="bulkMove()"
ng-options="f.name for f in folders">
<option value="">{% trans "Move to..." %}</option>
</select>
</div>
<button class="wm-action-btn" ng-click="showMoveDropdown = !showMoveDropdown" title="{% trans 'Move' %}">
<i class="fa fa-folder-open"></i>
</button>
<span class="wm-page-info">{$ currentPage $} / {$ totalPages $}</span>
<button class="wm-action-btn" ng-click="prevPage()" ng-disabled="currentPage <= 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="wm-action-btn" ng-click="nextPage()" ng-disabled="currentPage >= totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
<!-- Message Rows -->
<div class="wm-messages">
<div class="wm-msg-row" ng-repeat="msg in messages"
ng-class="{'unread': !msg.is_read, 'flagged': msg.is_flagged, 'selected': msg.selected}"
ng-click="openMessage(msg)">
<label class="wm-checkbox-label" ng-click="$event.stopPropagation()">
<input type="checkbox" ng-model="msg.selected">
</label>
<button class="wm-star-btn" ng-click="toggleFlag(msg); $event.stopPropagation()">
<i class="fa" ng-class="msg.is_flagged ? 'fa-star wm-starred' : 'fa-star wm-unstarred'"></i>
</button>
<div class="wm-msg-from">{$ msg.from | limitTo:30 $}</div>
<div class="wm-msg-subject">{$ msg.subject | limitTo:60 $}</div>
<div class="wm-msg-date">{$ msg.date | wmDate $}</div>
</div>
<div class="wm-empty" ng-if="messages.length === 0 && !loading">
<i class="fa fa-inbox"></i>
<p>{% trans "No messages" %}</p>
</div>
<div class="wm-loading" ng-if="loading">
<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}
</div>
</div>
</div>
<!-- Column 3: Detail Pane -->
<div class="wm-detail-pane">
<!-- Message Read View -->
<div ng-if="viewMode === 'read' && openMsg" class="wm-read-view">
<div class="wm-read-toolbar">
<button class="btn btn-sm btn-default" ng-click="replyTo()">
<i class="fa fa-reply"></i> {% trans "Reply" %}
</button>
<button class="btn btn-sm btn-default" ng-click="replyAll()">
<i class="fa fa-reply-all"></i> {% trans "Reply All" %}
</button>
<button class="btn btn-sm btn-default" ng-click="forwardMsg()">
<i class="fa fa-share"></i> {% trans "Forward" %}
</button>
<button class="btn btn-sm btn-danger" ng-click="deleteMsg(openMsg)">
<i class="fa fa-trash"></i> {% trans "Delete" %}
</button>
</div>
<div class="wm-read-header">
<h3 class="wm-read-subject">{$ openMsg.subject $}</h3>
<div class="wm-read-meta">
<div><strong>{% trans "From" %}:</strong> {$ openMsg.from $}</div>
<div><strong>{% trans "To" %}:</strong> {$ openMsg.to $}</div>
<div ng-if="openMsg.cc"><strong>{% trans "Cc" %}:</strong> {$ openMsg.cc $}</div>
<div><strong>{% trans "Date" %}:</strong> {$ openMsg.date $}</div>
</div>
</div>
<div class="wm-attachments" ng-if="openMsg.attachments.length > 0">
<div class="wm-attachment" ng-repeat="att in openMsg.attachments">
<a ng-click="downloadAttachment(att)">
<i class="fa fa-paperclip"></i> {$ att.filename $}
<span class="wm-att-size">({$ att.size | fileSize $})</span>
</a>
</div>
</div>
<div class="wm-read-body" ng-bind-html="trustedBody"></div>
</div>
<!-- Compose View -->
<div ng-if="viewMode === 'compose'" class="wm-compose-view">
<div class="wm-compose-header">
<h3>{% trans "Compose" %}</h3>
</div>
<form name="composeForm" ng-submit="sendMessage()" class="wm-compose-form">
<div class="wm-field">
<label>{% trans "To" %}</label>
<input type="text" ng-model="compose.to" class="form-control"
wm-autocomplete required>
</div>
<div class="wm-field">
<label>{% trans "Cc" %}</label>
<input type="text" ng-model="compose.cc" class="form-control"
wm-autocomplete>
</div>
<div class="wm-field" ng-show="showBcc">
<label>{% trans "Bcc" %}</label>
<input type="text" ng-model="compose.bcc" class="form-control"
wm-autocomplete>
</div>
<div class="wm-field">
<label>{% trans "Subject" %}</label>
<input type="text" ng-model="compose.subject" class="form-control">
</div>
<div class="wm-field">
<a ng-click="showBcc = !showBcc" class="wm-toggle-link">
{$ showBcc ? '{% trans "Hide Bcc" %}' : '{% trans "Show Bcc" %}' $}
</a>
</div>
<!-- Rich Text Toolbar -->
<div class="wm-editor-toolbar">
<button type="button" ng-click="execCmd('bold')" title="Bold"><i class="fa fa-bold"></i></button>
<button type="button" ng-click="execCmd('italic')" title="Italic"><i class="fa fa-italic"></i></button>
<button type="button" ng-click="execCmd('underline')" title="Underline"><i class="fa fa-underline"></i></button>
<button type="button" ng-click="execCmd('insertUnorderedList')" title="List"><i class="fa fa-list-ul"></i></button>
<button type="button" ng-click="execCmd('insertOrderedList')" title="Numbered List"><i class="fa fa-list-ol"></i></button>
<button type="button" ng-click="insertLink()" title="Link"><i class="fa fa-link"></i></button>
</div>
<div class="wm-editor" contenteditable="true" id="wm-compose-body"
ng-blur="updateComposeBody()"></div>
<div class="wm-compose-attachments">
<input type="file" id="wm-file-input" multiple
onchange="angular.element(this).scope().addFiles(this.files)">
<label for="wm-file-input" class="wm-attach-btn">
<i class="fa fa-paperclip"></i> {% trans "Attach files" %}
</label>
<div class="wm-file-list">
<span ng-repeat="f in compose.files" class="wm-file-tag">
{$ f.name $} ({$ f.size | fileSize $})
<i class="fa fa-times" ng-click="removeFile($index)"></i>
</span>
</div>
</div>
<div class="wm-compose-actions">
<button type="submit" class="btn btn-primary" ng-disabled="sending">
<i class="fa fa-paper-plane"></i> {% trans "Send" %}
</button>
<button type="button" class="btn btn-default" ng-click="saveDraft()">
<i class="fa fa-floppy-disk"></i> {% trans "Save Draft" %}
</button>
<button type="button" class="btn btn-default" ng-click="discardDraft()">
<i class="fa fa-xmark"></i> {% trans "Discard" %}
</button>
</div>
</form>
</div>
<!-- Contacts View -->
<div ng-if="viewMode === 'contacts'" class="wm-contacts-view">
<div class="wm-section-header">
<h3>{% trans "Contacts" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newContact()">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
</div>
<div class="wm-contacts-search">
<input type="text" ng-model="contactSearch" placeholder="{% trans 'Search contacts...' %}"
class="form-control" ng-change="filterContacts()">
</div>
<div class="wm-contact-list">
<div class="wm-contact-item" ng-repeat="c in filteredContacts">
<div class="wm-contact-info">
<div class="wm-contact-name">{$ c.display_name || c.email_address $}</div>
<div class="wm-contact-email">{$ c.email_address $}</div>
</div>
<div class="wm-contact-actions">
<button class="wm-action-btn" ng-click="composeToContact(c)" title="{% trans 'Email' %}">
<i class="fa fa-envelope"></i>
</button>
<button class="wm-action-btn" ng-click="editContact(c)" title="{% trans 'Edit' %}">
<i class="fa fa-pencil"></i>
</button>
<button class="wm-action-btn" ng-click="removeContact(c)" title="{% trans 'Delete' %}">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- Edit/New Contact Form -->
<div ng-if="editingContact" class="wm-contact-form">
<h4>{$ editingContact.id ? '{% trans "Edit Contact" %}' : '{% trans "New Contact" %}' $}</h4>
<div class="wm-field">
<label>{% trans "Name" %}</label>
<input type="text" ng-model="editingContact.display_name" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Email" %}</label>
<input type="email" ng-model="editingContact.email_address" class="form-control" required>
</div>
<div class="wm-field">
<label>{% trans "Phone" %}</label>
<input type="text" ng-model="editingContact.phone" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Organization" %}</label>
<input type="text" ng-model="editingContact.organization" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Notes" %}</label>
<textarea ng-model="editingContact.notes" class="form-control" rows="3"></textarea>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveContact()">{% trans "Save" %}</button>
<button class="btn btn-default" ng-click="editingContact = null">{% trans "Cancel" %}</button>
</div>
</div>
</div>
<!-- Rules View -->
<div ng-if="viewMode === 'rules'" class="wm-rules-view">
<div class="wm-section-header">
<h3>{% trans "Mail Filter Rules" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newRule()">
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
</button>
</div>
<div class="wm-rule-list">
<div class="wm-rule-item" ng-repeat="rule in sieveRules">
<div class="wm-rule-info">
<strong>{$ rule.name $}</strong>
<span class="wm-rule-desc">
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
&rarr; {$ rule.action_type $} {$ rule.action_value $}
</span>
</div>
<div class="wm-rule-actions">
<button class="wm-action-btn" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
<button class="wm-action-btn" ng-click="removeRule(rule)"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<!-- Edit/New Rule Form -->
<div ng-if="editingRule" class="wm-rule-form">
<h4>{$ editingRule.id ? '{% trans "Edit Rule" %}' : '{% trans "New Rule" %}' $}</h4>
<div class="wm-field">
<label>{% trans "Name" %}</label>
<input type="text" ng-model="editingRule.name" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Priority" %}</label>
<input type="number" ng-model="editingRule.priority" class="form-control">
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "If" %}</label>
<select ng-model="editingRule.conditionField" class="form-control">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
<option value="size">Size</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Condition" %}</label>
<select ng-model="editingRule.conditionType" class="form-control">
<option value="contains">contains</option>
<option value="is">is</option>
<option value="matches">matches</option>
<option value="greater_than">greater than</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Value" %}</label>
<input type="text" ng-model="editingRule.conditionValue" class="form-control">
</div>
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "Action" %}</label>
<select ng-model="editingRule.actionType" class="form-control">
<option value="move">Move to folder</option>
<option value="forward">Forward to</option>
<option value="discard">Discard</option>
<option value="flag">Flag</option>
</select>
</div>
<div class="wm-field" ng-if="editingRule.actionType !== 'discard' && editingRule.actionType !== 'flag'">
<label>{% trans "Target" %}</label>
<input type="text" ng-model="editingRule.actionValue" class="form-control"
placeholder="{$ editingRule.actionType === 'move' ? 'Folder name' : 'Email address' $}">
</div>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveRule()">{% trans "Save" %}</button>
<button class="btn btn-default" ng-click="editingRule = null">{% trans "Cancel" %}</button>
</div>
</div>
</div>
<!-- Settings View -->
<div ng-if="viewMode === 'settings'" class="wm-settings-view">
<div class="wm-section-header">
<h3>{% trans "Settings" %}</h3>
</div>
<div class="wm-settings-form">
<div class="wm-field">
<label>{% trans "Display Name" %}</label>
<input type="text" ng-model="wmSettings.displayName" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Signature" %}</label>
<textarea ng-model="wmSettings.signatureHtml" class="form-control" rows="4"></textarea>
</div>
<div class="wm-field">
<label>{% trans "Messages per page" %}</label>
<select ng-model="wmSettings.messagesPerPage" class="form-control">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Default reply behavior" %}</label>
<select ng-model="wmSettings.defaultReplyBehavior" class="form-control">
<option value="reply">Reply</option>
<option value="reply_all">Reply All</option>
</select>
</div>
<div class="wm-field">
<label class="wm-checkbox-label">
<input type="checkbox" ng-model="wmSettings.autoCollectContacts">
{% trans "Auto-collect contacts from sent messages" %}
</label>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveSettings()">
<i class="fa fa-floppy-disk"></i> {% trans "Save Settings" %}
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div ng-if="viewMode === 'list' && !openMsg" class="wm-empty-detail">
<i class="fa fa-envelope-open fa-3x"></i>
<p>{% trans "Select a message to read" %}</p>
</div>
</div>
</div>
</div>
<script src="{% static 'webmail/webmail.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,181 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" ng-app="CyberCP">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Webmail Login - CyberPanel" %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'baseTemplate/assets/bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="{% static 'baseTemplate/angularjs.1.6.5.js' %}"></script>
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
<style>
:root {
--bg-primary: #f0f0ff;
--bg-secondary: white;
--accent-color: #6C5CE7;
--accent-hover: #5A4BD1;
--text-primary: #2D3436;
--text-secondary: #636E72;
--border-color: #DFE6E9;
}
body {
background: var(--bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.wm-login-card {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
padding: 48px 40px;
width: 100%;
max-width: 420px;
}
.wm-login-logo {
text-align: center;
margin-bottom: 32px;
}
.wm-login-logo i {
font-size: 48px;
color: var(--accent-color);
}
.wm-login-logo h2 {
margin-top: 12px;
color: var(--text-primary);
font-weight: 600;
}
.wm-login-logo p {
color: var(--text-secondary);
font-size: 14px;
}
.wm-login-field {
margin-bottom: 20px;
}
.wm-login-field label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.wm-login-field input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 15px;
transition: border-color 0.2s;
box-sizing: border-box;
}
.wm-login-field input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
}
.wm-login-btn {
width: 100%;
padding: 12px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.wm-login-btn:hover {
background: var(--accent-hover);
}
.wm-login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wm-login-error {
background: #FFE0E0;
color: #D63031;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="wm-login-card" ng-controller="webmailLoginCtrl">
<div class="wm-login-logo">
<i class="fa fa-envelope"></i>
<h2>{% trans "CyberPanel Webmail" %}</h2>
<p>{% trans "Sign in to access your email" %}</p>
</div>
<div class="wm-login-error" ng-if="errorMsg">
{$ errorMsg $}
</div>
<form ng-submit="login()">
<div class="wm-login-field">
<label>{% trans "Email Address" %}</label>
<input type="email" ng-model="email" placeholder="user@example.com" required autofocus>
</div>
<div class="wm-login-field">
<label>{% trans "Password" %}</label>
<input type="password" ng-model="password" placeholder="{% trans 'Your email password' %}" required>
</div>
<button type="submit" class="wm-login-btn" ng-disabled="loggingIn">
<span ng-if="!loggingIn">{% trans "Sign In" %}</span>
<span ng-if="loggingIn"><i class="fa fa-spinner fa-spin"></i> {% trans "Signing in..." %}</span>
</button>
</form>
</div>
<script>
var app = angular.module('CyberCP', []);
app.config(['$interpolateProvider', function($ip) {
$ip.startSymbol('{$');
$ip.endSymbol('$}');
}]);
function getCookie(name) {
var value = '; ' + document.cookie;
var parts = value.split('; ' + name + '=');
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
app.controller('webmailLoginCtrl', function($scope, $http) {
$scope.email = '';
$scope.password = '';
$scope.errorMsg = '';
$scope.loggingIn = false;
$scope.login = function() {
$scope.loggingIn = true;
$scope.errorMsg = '';
$http.post('/webmail/api/login', {
email: $scope.email,
password: $scope.password
}, {
headers: { 'X-CSRFToken': getCookie('csrftoken') }
}).then(function(resp) {
if (resp.data.status === 1) {
window.location.href = '/webmail/';
} else {
$scope.errorMsg = resp.data.error_message || 'Login failed.';
}
$scope.loggingIn = false;
}, function(err) {
$scope.errorMsg = 'Connection error. Please try again.';
$scope.loggingIn = false;
});
};
});
</script>
</body>
</html>

60
webmail/urls.py Normal file
View File

@@ -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'),
]

397
webmail/views.py Normal file
View File

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

755
webmail/webmailManager.py Normal file
View File

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