diff --git a/CyberCP/settings.py b/CyberCP/settings.py index 4587e58eb..b0026fdeb 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -75,6 +75,7 @@ INSTALLED_APPS = [ 'CLManager', 'IncBackups', 'aiScanner', + 'webmail', # 'WebTerminal' ] diff --git a/CyberCP/urls.py b/CyberCP/urls.py index da7ab903a..6cbfbe68c 100644 --- a/CyberCP/urls.py +++ b/CyberCP/urls.py @@ -45,5 +45,6 @@ urlpatterns = [ path('CloudLinux/', include('CLManager.urls')), path('IncrementalBackups/', include('IncBackups.urls')), path('aiscanner/', include('aiScanner.urls')), + path('webmail/', include('webmail.urls')), # path('Terminal/', include('WebTerminal.urls')), ] diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index 8908a0259..8e2053473 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -1599,7 +1599,7 @@ {% endif %} {% if admin or createEmail %} - + Access Webmail {% endif %} diff --git a/webmail/__init__.py b/webmail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webmail/apps.py b/webmail/apps.py new file mode 100644 index 000000000..ca6d5e750 --- /dev/null +++ b/webmail/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebmailConfig(AppConfig): + name = 'webmail' diff --git a/webmail/migrations/__init__.py b/webmail/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webmail/models.py b/webmail/models.py new file mode 100644 index 000000000..6a74d74c6 --- /dev/null +++ b/webmail/models.py @@ -0,0 +1,106 @@ +from django.db import models + + +class WebmailSession(models.Model): + session_key = models.CharField(max_length=64, unique=True) + email_account = models.CharField(max_length=200) + created_at = models.DateTimeField(auto_now_add=True) + last_active = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'wm_sessions' + + def __str__(self): + return '%s (%s)' % (self.email_account, self.session_key[:8]) + + +class Contact(models.Model): + owner_email = models.CharField(max_length=200, db_index=True) + display_name = models.CharField(max_length=200, blank=True, default='') + email_address = models.CharField(max_length=200) + phone = models.CharField(max_length=50, blank=True, default='') + organization = models.CharField(max_length=200, blank=True, default='') + notes = models.TextField(blank=True, default='') + is_auto_collected = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'wm_contacts' + unique_together = ('owner_email', 'email_address') + + def __str__(self): + return '%s <%s>' % (self.display_name, self.email_address) + + +class ContactGroup(models.Model): + owner_email = models.CharField(max_length=200, db_index=True) + name = models.CharField(max_length=100) + + class Meta: + db_table = 'wm_contact_groups' + unique_together = ('owner_email', 'name') + + def __str__(self): + return self.name + + +class ContactGroupMembership(models.Model): + contact = models.ForeignKey(Contact, on_delete=models.CASCADE) + group = models.ForeignKey(ContactGroup, on_delete=models.CASCADE) + + class Meta: + db_table = 'wm_contact_group_members' + unique_together = ('contact', 'group') + + +class WebmailSettings(models.Model): + email_account = models.CharField(max_length=200, primary_key=True) + display_name = models.CharField(max_length=200, blank=True, default='') + signature_html = models.TextField(blank=True, default='') + messages_per_page = models.IntegerField(default=25) + default_reply_behavior = models.CharField(max_length=20, default='reply', + choices=[('reply', 'Reply'), + ('reply_all', 'Reply All')]) + theme_preference = models.CharField(max_length=20, default='auto', + choices=[('light', 'Light'), + ('dark', 'Dark'), + ('auto', 'Auto')]) + auto_collect_contacts = models.BooleanField(default=True) + + class Meta: + db_table = 'wm_settings' + + def __str__(self): + return self.email_account + + +class SieveRule(models.Model): + email_account = models.CharField(max_length=200, db_index=True) + name = models.CharField(max_length=200) + priority = models.IntegerField(default=0) + is_active = models.BooleanField(default=True) + condition_field = models.CharField(max_length=50, + choices=[('from', 'From'), + ('to', 'To'), + ('subject', 'Subject'), + ('size', 'Size')]) + condition_type = models.CharField(max_length=50, + choices=[('contains', 'Contains'), + ('is', 'Is'), + ('matches', 'Matches'), + ('greater_than', 'Greater than')]) + condition_value = models.CharField(max_length=500) + action_type = models.CharField(max_length=50, + choices=[('move', 'Move to folder'), + ('forward', 'Forward to'), + ('discard', 'Discard'), + ('flag', 'Flag')]) + action_value = models.CharField(max_length=500, blank=True, default='') + sieve_script = models.TextField(blank=True, default='') + + class Meta: + db_table = 'wm_sieve_rules' + ordering = ['priority'] + + def __str__(self): + return '%s: %s' % (self.email_account, self.name) diff --git a/webmail/services/__init__.py b/webmail/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webmail/services/email_composer.py b/webmail/services/email_composer.py new file mode 100644 index 000000000..0652fdb30 --- /dev/null +++ b/webmail/services/email_composer.py @@ -0,0 +1,178 @@ +import email +from email.message import EmailMessage +from email.utils import formatdate, make_msgid, formataddr +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +import mimetypes +import re + + +class EmailComposer: + """Construct MIME messages for sending.""" + + @staticmethod + def compose(from_addr, to_addrs, subject, body_html='', body_text='', + cc_addrs='', bcc_addrs='', attachments=None, + in_reply_to='', references=''): + """Build a MIME message. + + Args: + from_addr: sender email + to_addrs: comma-separated recipients + subject: email subject + body_html: HTML body content + body_text: plain text body content + cc_addrs: comma-separated CC recipients + bcc_addrs: comma-separated BCC recipients + attachments: list of (filename, content_type, bytes) tuples + in_reply_to: Message-ID being replied to + references: space-separated Message-IDs + + Returns: + MIMEMultipart message ready for sending + """ + if attachments: + msg = MIMEMultipart('mixed') + body_part = MIMEMultipart('alternative') + if body_text: + body_part.attach(MIMEText(body_text, 'plain', 'utf-8')) + if body_html: + body_part.attach(MIMEText(body_html, 'html', 'utf-8')) + elif not body_text: + body_part.attach(MIMEText('', 'plain', 'utf-8')) + msg.attach(body_part) + + for filename, content_type, data in attachments: + if not content_type: + content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + maintype, subtype = content_type.split('/', 1) + attachment = MIMEBase(maintype, subtype) + attachment.set_payload(data) + encoders.encode_base64(attachment) + attachment.add_header('Content-Disposition', 'attachment', filename=filename) + msg.attach(attachment) + else: + msg = MIMEMultipart('alternative') + if body_text: + msg.attach(MIMEText(body_text, 'plain', 'utf-8')) + if body_html: + msg.attach(MIMEText(body_html, 'html', 'utf-8')) + elif not body_text: + msg.attach(MIMEText('', 'plain', 'utf-8')) + + msg['From'] = from_addr + msg['To'] = to_addrs + if cc_addrs: + msg['Cc'] = cc_addrs + msg['Subject'] = subject + msg['Date'] = formatdate(localtime=True) + msg['Message-ID'] = make_msgid(domain=from_addr.split('@')[-1] if '@' in from_addr else 'localhost') + + if in_reply_to: + msg['In-Reply-To'] = in_reply_to + if references: + msg['References'] = references + + msg['MIME-Version'] = '1.0' + msg['X-Mailer'] = 'CyberPanel Webmail' + + return msg + + @classmethod + def compose_reply(cls, original, body_html, from_addr, reply_all=False): + """Build a reply message from the original parsed message. + + Args: + original: parsed message dict from EmailParser + body_html: reply HTML body + from_addr: sender email + reply_all: whether to reply all + + Returns: + MIMEMultipart message + """ + to = original.get('from', '') + cc = '' + if reply_all: + orig_to = original.get('to', '') + orig_cc = original.get('cc', '') + all_addrs = [] + if orig_to: + all_addrs.append(orig_to) + if orig_cc: + all_addrs.append(orig_cc) + cc = ', '.join(all_addrs) + # Remove self from CC + cc_parts = [a.strip() for a in cc.split(',') if from_addr not in a] + cc = ', '.join(cc_parts) + + subject = original.get('subject', '') + if not subject.lower().startswith('re:'): + subject = 'Re: %s' % subject + + in_reply_to = original.get('message_id', '') + references = original.get('references', '') + if in_reply_to: + references = ('%s %s' % (references, in_reply_to)).strip() + + # Quote original + orig_date = original.get('date', '') + orig_from = original.get('from', '') + quoted = '

On %s, %s wrote:
%s
' % ( + orig_date, orig_from, original.get('body_html', '') or original.get('body_text', '') + ) + full_html = body_html + quoted + + return cls.compose( + from_addr=from_addr, + to_addrs=to, + subject=subject, + body_html=full_html, + cc_addrs=cc, + in_reply_to=in_reply_to, + references=references, + ) + + @classmethod + def compose_forward(cls, original, body_html, from_addr, to_addrs): + """Build a forward message including original attachments. + + Args: + original: parsed message dict + body_html: forward body HTML + from_addr: sender email + to_addrs: comma-separated recipients + + Returns: + MIMEMultipart message (without attachments - caller must add them) + """ + subject = original.get('subject', '') + if not subject.lower().startswith('fwd:'): + subject = 'Fwd: %s' % subject + + orig_from = original.get('from', '') + orig_to = original.get('to', '') + orig_date = original.get('date', '') + orig_subject = original.get('subject', '') + + forwarded = ( + '

' + '---------- Forwarded message ----------
' + 'From: %s
' + 'Date: %s
' + 'Subject: %s
' + 'To: %s

' + '%s
' + ) % (orig_from, orig_date, orig_subject, orig_to, + original.get('body_html', '') or original.get('body_text', '')) + + full_html = body_html + forwarded + + return cls.compose( + from_addr=from_addr, + to_addrs=to_addrs, + subject=subject, + body_html=full_html, + ) diff --git a/webmail/services/email_parser.py b/webmail/services/email_parser.py new file mode 100644 index 000000000..4182961c0 --- /dev/null +++ b/webmail/services/email_parser.py @@ -0,0 +1,194 @@ +import email +import re +from email.header import decode_header +from email.utils import parsedate_to_datetime + + +class EmailParser: + """Parse MIME messages and sanitize HTML content.""" + + SAFE_TAGS = { + 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'cite', 'code', + 'col', 'colgroup', 'dd', 'del', 'details', 'div', 'dl', 'dt', 'em', + 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', + 'i', 'img', 'ins', 'li', 'mark', 'ol', 'p', 'pre', 'q', 's', + 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table', + 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', 'wbr', + 'font', 'center', 'big', + } + + SAFE_ATTRS = { + 'href', 'src', 'alt', 'title', 'width', 'height', 'style', + 'class', 'id', 'colspan', 'rowspan', 'cellpadding', 'cellspacing', + 'border', 'align', 'valign', 'bgcolor', 'color', 'size', 'face', + 'dir', 'lang', 'start', 'type', 'target', 'rel', + } + + DANGEROUS_CSS_PATTERNS = [ + re.compile(r'expression\s*\(', re.IGNORECASE), + re.compile(r'javascript\s*:', re.IGNORECASE), + re.compile(r'vbscript\s*:', re.IGNORECASE), + re.compile(r'url\s*\(\s*["\']?\s*javascript:', re.IGNORECASE), + re.compile(r'-moz-binding', re.IGNORECASE), + re.compile(r'behavior\s*:', re.IGNORECASE), + ] + + @staticmethod + def _decode_header_value(value): + if value is None: + return '' + decoded_parts = decode_header(value) + result = [] + for part, charset in decoded_parts: + if isinstance(part, bytes): + result.append(part.decode(charset or 'utf-8', errors='replace')) + else: + result.append(part) + return ''.join(result) + + @classmethod + def parse_message(cls, raw_bytes): + """Parse raw email bytes into a structured dict.""" + if isinstance(raw_bytes, str): + raw_bytes = raw_bytes.encode('utf-8') + msg = email.message_from_bytes(raw_bytes) + + subject = cls._decode_header_value(msg.get('Subject', '')) + from_addr = cls._decode_header_value(msg.get('From', '')) + to_addr = cls._decode_header_value(msg.get('To', '')) + cc_addr = cls._decode_header_value(msg.get('Cc', '')) + date_str = msg.get('Date', '') + message_id = msg.get('Message-ID', '') + in_reply_to = msg.get('In-Reply-To', '') + references = msg.get('References', '') + + date_iso = '' + try: + dt = parsedate_to_datetime(date_str) + date_iso = dt.isoformat() + except Exception: + date_iso = date_str + + body_html = '' + body_text = '' + attachments = [] + part_idx = 0 + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get('Content-Disposition', '')) + + if content_type == 'multipart': + continue + + if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition): + filename = part.get_filename() + if filename: + filename = cls._decode_header_value(filename) + else: + filename = 'attachment_%d' % part_idx + attachments.append({ + 'part_id': part_idx, + 'filename': filename, + 'content_type': content_type, + 'size': len(part.get_payload(decode=True) or b''), + }) + part_idx += 1 + elif content_type == 'text/html': + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + body_html = payload.decode(charset, errors='replace') if payload else '' + elif content_type == 'text/plain': + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + body_text = payload.decode(charset, errors='replace') if payload else '' + else: + content_type = msg.get_content_type() + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or 'utf-8' + if payload: + decoded = payload.decode(charset, errors='replace') + if content_type == 'text/html': + body_html = decoded + else: + body_text = decoded + + if body_html: + body_html = cls.sanitize_html(body_html) + + preview = cls.extract_preview(body_text or body_html, 200) + + return { + 'subject': subject, + 'from': from_addr, + 'to': to_addr, + 'cc': cc_addr, + 'date': date_str, + 'date_iso': date_iso, + 'message_id': message_id, + 'in_reply_to': in_reply_to, + 'references': references, + 'body_html': body_html, + 'body_text': body_text, + 'attachments': attachments, + 'preview': preview, + 'has_attachments': len(attachments) > 0, + } + + @classmethod + def sanitize_html(cls, html): + """Whitelist-based HTML sanitization. Strips dangerous content.""" + if not html: + return '' + + # Remove script, style, iframe, object, embed, form tags and their content + for tag in ['script', 'style', 'iframe', 'object', 'embed', 'form', 'applet', 'base', 'link', 'meta']: + html = re.sub(r'<%s\b[^>]*>.*?' % (tag, tag), '', html, flags=re.IGNORECASE | re.DOTALL) + html = re.sub(r'<%s\b[^>]*/?\s*>' % tag, '', html, flags=re.IGNORECASE) + + # Remove event handler attributes (on*) + html = re.sub(r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', '', html, flags=re.IGNORECASE) + + # Remove javascript: and data: URIs in href/src + html = re.sub(r'(href|src)\s*=\s*["\']?\s*javascript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE) + html = re.sub(r'(href|src)\s*=\s*["\']?\s*data:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE) + html = re.sub(r'(href|src)\s*=\s*["\']?\s*vbscript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE) + + # Sanitize style attributes - remove dangerous CSS + def clean_style(match): + style = match.group(1) + for pattern in cls.DANGEROUS_CSS_PATTERNS: + if pattern.search(style): + return 'style=""' + return match.group(0) + + html = re.sub(r'style\s*=\s*"([^"]*)"', clean_style, html, flags=re.IGNORECASE) + html = re.sub(r"style\s*=\s*'([^']*)'", clean_style, html, flags=re.IGNORECASE) + + # Rewrite external image src to proxy endpoint + def proxy_image(match): + src = match.group(1) + if src.startswith(('http://', 'https://')): + from django.utils.http import urlencode + import base64 + encoded_url = base64.urlsafe_b64encode(src.encode()).decode() + return 'src="/webmail/api/proxyImage?url=%s"' % encoded_url + return match.group(0) + + html = re.sub(r'src\s*=\s*"(https?://[^"]*)"', proxy_image, html, flags=re.IGNORECASE) + + return html + + @staticmethod + def extract_preview(text, max_length=200): + """Extract a short text preview from email body.""" + if not text: + return '' + # Strip HTML tags if present + clean = re.sub(r'<[^>]+>', ' ', text) + # Collapse whitespace + clean = re.sub(r'\s+', ' ', clean).strip() + if len(clean) > max_length: + return clean[:max_length] + '...' + return clean diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py new file mode 100644 index 000000000..058e2eddf --- /dev/null +++ b/webmail/services/imap_client.py @@ -0,0 +1,308 @@ +import imaplib +import ssl +import email +import re +from email.header import decode_header + + +class IMAPClient: + """Wrapper around imaplib.IMAP4_SSL for Dovecot IMAP operations.""" + + def __init__(self, email_address, password, host='localhost', port=993, + master_user=None, master_password=None): + self.email_address = email_address + self.host = host + self.port = port + self.conn = None + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + self.conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) + + if master_user and master_password: + login_user = '%s*%s' % (email_address, master_user) + self.conn.login(login_user, master_password) + else: + self.conn.login(email_address, password) + + def close(self): + try: + self.conn.close() + except Exception: + pass + try: + self.conn.logout() + except Exception: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def _decode_header_value(self, value): + if value is None: + return '' + decoded_parts = decode_header(value) + result = [] + for part, charset in decoded_parts: + if isinstance(part, bytes): + result.append(part.decode(charset or 'utf-8', errors='replace')) + else: + result.append(part) + return ''.join(result) + + def _parse_folder_list(self, line): + if isinstance(line, bytes): + line = line.decode('utf-8', errors='replace') + match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+"?([^"]+)"?', line) + if not match: + match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+(.+)', line) + if match: + flags = match.group(1) + delimiter = match.group(2) + name = match.group(3).strip('"') + return {'name': name, 'delimiter': delimiter, 'flags': flags} + return None + + def list_folders(self): + status, data = self.conn.list() + if status != 'OK': + return [] + folders = [] + for item in data: + if item is None: + continue + parsed = self._parse_folder_list(item) + if parsed is None: + continue + folder_name = parsed['name'] + unread = 0 + total = 0 + try: + st, counts = self.conn.status( + '"%s"' % folder_name if ' ' in folder_name else folder_name, + '(MESSAGES UNSEEN)' + ) + if st == 'OK' and counts[0]: + count_str = counts[0].decode('utf-8', errors='replace') if isinstance(counts[0], bytes) else counts[0] + m = re.search(r'MESSAGES\s+(\d+)', count_str) + u = re.search(r'UNSEEN\s+(\d+)', count_str) + if m: + total = int(m.group(1)) + if u: + unread = int(u.group(1)) + except Exception: + pass + folders.append({ + 'name': folder_name, + 'delimiter': parsed['delimiter'], + 'flags': parsed['flags'], + 'unread_count': unread, + 'total_count': total, + }) + return folders + + def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc'): + self.conn.select(folder) + status, data = self.conn.uid('search', None, 'ALL') + if status != 'OK': + return {'messages': [], 'total': 0, 'page': page, 'pages': 0} + + uids = data[0].split() if data[0] else [] + if sort == 'date_desc': + uids = list(reversed(uids)) + total = len(uids) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(page, pages)) + + start = (page - 1) * per_page + end = start + per_page + page_uids = uids[start:end] + + if not page_uids: + return {'messages': [], 'total': total, 'page': page, 'pages': pages} + + uid_str = b','.join(page_uids) + status, msg_data = self.conn.uid('fetch', uid_str, + '(UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])') + if status != 'OK': + return {'messages': [], 'total': total, 'page': page, 'pages': pages} + + messages = [] + i = 0 + while i < len(msg_data): + item = msg_data[i] + if isinstance(item, tuple) and len(item) == 2: + meta_line = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0] + header_bytes = item[1] + + uid_match = re.search(r'UID\s+(\d+)', meta_line) + flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta_line) + size_match = re.search(r'RFC822\.SIZE\s+(\d+)', meta_line) + + uid = uid_match.group(1) if uid_match else '0' + flags = flags_match.group(1) if flags_match else '' + size = int(size_match.group(1)) if size_match else 0 + + msg = email.message_from_bytes(header_bytes) if isinstance(header_bytes, bytes) else email.message_from_string(header_bytes) + messages.append({ + 'uid': uid, + 'from': self._decode_header_value(msg.get('From', '')), + 'to': self._decode_header_value(msg.get('To', '')), + 'subject': self._decode_header_value(msg.get('Subject', '(No Subject)')), + 'date': msg.get('Date', ''), + 'flags': flags, + 'is_read': '\\Seen' in flags, + 'is_flagged': '\\Flagged' in flags, + 'has_attachment': False, + 'size': size, + }) + i += 1 + + return {'messages': messages, 'total': total, 'page': page, 'pages': pages} + + def search_messages(self, folder='INBOX', query='', criteria='ALL'): + self.conn.select(folder) + if query: + search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (query, query, query) + else: + search_criteria = criteria + status, data = self.conn.uid('search', None, search_criteria) + if status != 'OK': + return [] + return data[0].split() if data[0] else [] + + def get_message(self, folder, uid): + self.conn.select(folder) + status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822 FLAGS)') + if status != 'OK' or not data or not data[0]: + return None + + raw = None + flags = '' + for item in data: + if isinstance(item, tuple) and len(item) == 2: + meta = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0] + raw = item[1] + flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta) + if flags_match: + flags = flags_match.group(1) + break + + if raw is None: + return None + + from .email_parser import EmailParser + parsed = EmailParser.parse_message(raw) + parsed['uid'] = str(uid) + parsed['flags'] = flags + parsed['is_read'] = '\\Seen' in flags + parsed['is_flagged'] = '\\Flagged' in flags + return parsed + + def get_attachment(self, folder, uid, part_id): + self.conn.select(folder) + status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822)') + if status != 'OK' or not data or not data[0]: + return None + + raw = None + for item in data: + if isinstance(item, tuple) and len(item) == 2: + raw = item[1] + break + + if raw is None: + return None + + msg = email.message_from_bytes(raw) if isinstance(raw, bytes) else email.message_from_string(raw) + part_idx = 0 + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part.get('Content-Disposition') and 'attachment' in part.get('Content-Disposition', ''): + if str(part_idx) == str(part_id): + filename = part.get_filename() or 'attachment' + filename = self._decode_header_value(filename) + content_type = part.get_content_type() + payload = part.get_payload(decode=True) + return (filename, content_type, payload) + part_idx += 1 + + return None + + def move_messages(self, folder, uids, target_folder): + self.conn.select(folder) + uid_str = ','.join(str(u) for u in uids) + try: + status, _ = self.conn.uid('move', uid_str, target_folder) + if status == 'OK': + return True + except Exception: + pass + status, _ = self.conn.uid('copy', uid_str, target_folder) + if status == 'OK': + self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') + self.conn.expunge() + return True + return False + + def delete_messages(self, folder, uids): + self.conn.select(folder) + uid_str = ','.join(str(u) for u in uids) + trash_folders = ['Trash', 'INBOX.Trash', '[Gmail]/Trash'] + if folder not in trash_folders: + for trash in trash_folders: + try: + status, _ = self.conn.uid('copy', uid_str, trash) + if status == 'OK': + self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') + self.conn.expunge() + return True + except Exception: + continue + self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)') + self.conn.expunge() + return True + + def set_flags(self, folder, uids, flags, action='add'): + self.conn.select(folder) + uid_str = ','.join(str(u) for u in uids) + flag_str = '(%s)' % ' '.join(flags) + if action == 'add': + self.conn.uid('store', uid_str, '+FLAGS', flag_str) + elif action == 'remove': + self.conn.uid('store', uid_str, '-FLAGS', flag_str) + return True + + def mark_read(self, folder, uids): + return self.set_flags(folder, uids, ['\\Seen'], 'add') + + def mark_unread(self, folder, uids): + return self.set_flags(folder, uids, ['\\Seen'], 'remove') + + def mark_flagged(self, folder, uids): + return self.set_flags(folder, uids, ['\\Flagged'], 'add') + + def create_folder(self, name): + status, _ = self.conn.create(name) + return status == 'OK' + + def rename_folder(self, old_name, new_name): + status, _ = self.conn.rename(old_name, new_name) + return status == 'OK' + + def delete_folder(self, name): + status, _ = self.conn.delete(name) + return status == 'OK' + + def append_message(self, folder, raw_message, flags=''): + if isinstance(raw_message, str): + raw_message = raw_message.encode('utf-8') + flag_str = '(%s)' % flags if flags else None + status, _ = self.conn.append(folder, flag_str, None, raw_message) + return status == 'OK' diff --git a/webmail/services/sieve_client.py b/webmail/services/sieve_client.py new file mode 100644 index 000000000..aef6907db --- /dev/null +++ b/webmail/services/sieve_client.py @@ -0,0 +1,261 @@ +import socket +import ssl +import re +import base64 + + +class SieveClient: + """ManageSieve protocol client (RFC 5804) for managing mail filter rules.""" + + def __init__(self, email_address, password, host='localhost', port=4190, + master_user=None, master_password=None): + self.email_address = email_address + self.host = host + self.port = port + self.sock = None + self.buf = b'' + + self.sock = socket.create_connection((host, port), timeout=30) + self._read_welcome() + self._starttls() + + if master_user and master_password: + self._authenticate_master(email_address, master_user, master_password) + else: + self._authenticate(email_address, password) + + def _read_line(self): + while b'\r\n' not in self.buf: + data = self.sock.recv(4096) + if not data: + break + self.buf += data + if b'\r\n' in self.buf: + line, self.buf = self.buf.split(b'\r\n', 1) + return line.decode('utf-8', errors='replace') + return '' + + def _read_response(self): + lines = [] + while True: + line = self._read_line() + if line.startswith('OK'): + return True, lines, line + elif line.startswith('NO'): + return False, lines, line + elif line.startswith('BYE'): + return False, lines, line + else: + lines.append(line) + + def _read_welcome(self): + lines = [] + while True: + line = self._read_line() + lines.append(line) + if line.startswith('OK'): + break + return lines + + def _send(self, command): + self.sock.sendall(('%s\r\n' % command).encode('utf-8')) + + def _starttls(self): + self._send('STARTTLS') + ok, _, _ = self._read_response() + if ok: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + self.sock = ctx.wrap_socket(self.sock, server_hostname=self.host) + self.buf = b'' + self._read_welcome() + + def _authenticate(self, user, password): + auth_str = base64.b64encode(('\x00%s\x00%s' % (user, password)).encode('utf-8')).decode('ascii') + self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str) + ok, _, msg = self._read_response() + if not ok: + raise Exception('Sieve authentication failed: %s' % msg) + + def _authenticate_master(self, user, master_user, master_password): + auth_str = base64.b64encode( + ('%s\x00%s*%s\x00%s' % (user, user, master_user, master_password)).encode('utf-8') + ).decode('ascii') + self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str) + ok, _, msg = self._read_response() + if not ok: + raise Exception('Sieve master authentication failed: %s' % msg) + + def list_scripts(self): + """List all Sieve scripts. Returns [(name, is_active), ...]""" + self._send('LISTSCRIPTS') + ok, lines, _ = self._read_response() + if not ok: + return [] + scripts = [] + for line in lines: + match = re.match(r'"([^"]+)"(\s+ACTIVE)?', line) + if match: + scripts.append((match.group(1), bool(match.group(2)))) + return scripts + + def get_script(self, name): + """Get the content of a Sieve script.""" + self._send('GETSCRIPT "%s"' % name) + ok, lines, _ = self._read_response() + if not ok: + return '' + return '\n'.join(lines) + + def put_script(self, name, content): + """Upload a Sieve script.""" + encoded = content.encode('utf-8') + self._send('PUTSCRIPT "%s" {%d+}' % (name, len(encoded))) + self.sock.sendall(encoded + b'\r\n') + ok, _, msg = self._read_response() + if not ok: + raise Exception('Failed to put script: %s' % msg) + return True + + def activate_script(self, name): + """Set a script as the active script.""" + self._send('SETACTIVE "%s"' % name) + ok, _, msg = self._read_response() + return ok + + def deactivate_scripts(self): + """Deactivate all scripts.""" + self._send('SETACTIVE ""') + ok, _, _ = self._read_response() + return ok + + def delete_script(self, name): + """Delete a Sieve script.""" + self._send('DELETESCRIPT "%s"' % name) + ok, _, _ = self._read_response() + return ok + + def close(self): + try: + self._send('LOGOUT') + self._read_response() + except Exception: + pass + try: + self.sock.close() + except Exception: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + @staticmethod + def rules_to_sieve(rules): + """Convert a list of rule dicts to a Sieve script. + + Each rule: {condition_field, condition_type, condition_value, action_type, action_value, name} + """ + requires = set() + rule_blocks = [] + + for rule in rules: + field = rule.get('condition_field', 'from') + cond_type = rule.get('condition_type', 'contains') + cond_value = rule.get('condition_value', '') + action_type = rule.get('action_type', 'move') + action_value = rule.get('action_value', '') + + # Map field to Sieve header + if field == 'from': + header = 'From' + elif field == 'to': + header = 'To' + elif field == 'subject': + header = 'Subject' + else: + header = field + + # Map condition type to Sieve test + if cond_type == 'contains': + test = 'header :contains "%s" "%s"' % (header, cond_value) + elif cond_type == 'is': + test = 'header :is "%s" "%s"' % (header, cond_value) + elif cond_type == 'matches': + test = 'header :matches "%s" "%s"' % (header, cond_value) + elif cond_type == 'greater_than' and field == 'size': + test = 'size :over %s' % cond_value + else: + test = 'header :contains "%s" "%s"' % (header, cond_value) + + # Map action + if action_type == 'move': + requires.add('fileinto') + action = 'fileinto "%s";' % action_value + elif action_type == 'forward': + requires.add('redirect') + action = 'redirect "%s";' % action_value + elif action_type == 'discard': + action = 'discard;' + elif action_type == 'flag': + requires.add('imap4flags') + action = 'addflag "\\\\Flagged";' + else: + action = 'keep;' + + name = rule.get('name', 'Rule') + rule_blocks.append('# %s\nif %s {\n %s\n}' % (name, test, action)) + + # Build full script + parts = [] + if requires: + parts.append('require [%s];' % ', '.join('"%s"' % r for r in sorted(requires))) + parts.append('') + parts.extend(rule_blocks) + + return '\n'.join(parts) + + @staticmethod + def sieve_to_rules(script): + """Best-effort parse of a Sieve script into rule dicts.""" + rules = [] + # Match if-blocks with comments as names + pattern = re.compile( + r'#\s*(.+?)\n\s*if\s+header\s+:(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*\{([^}]+)\}', + re.DOTALL + ) + for match in pattern.finditer(script): + name = match.group(1).strip() + cond_type = match.group(2) + field_name = match.group(3).lower() + cond_value = match.group(4) + action_block = match.group(5).strip() + + action_type = 'keep' + action_value = '' + if 'fileinto' in action_block: + action_type = 'move' + av = re.search(r'fileinto\s+"([^"]+)"', action_block) + action_value = av.group(1) if av else '' + elif 'redirect' in action_block: + action_type = 'forward' + av = re.search(r'redirect\s+"([^"]+)"', action_block) + action_value = av.group(1) if av else '' + elif 'discard' in action_block: + action_type = 'discard' + elif 'addflag' in action_block: + action_type = 'flag' + + rules.append({ + 'name': name, + 'condition_field': field_name, + 'condition_type': cond_type, + 'condition_value': cond_value, + 'action_type': action_type, + 'action_value': action_value, + }) + + return rules diff --git a/webmail/services/smtp_client.py b/webmail/services/smtp_client.py new file mode 100644 index 000000000..51eb65f0e --- /dev/null +++ b/webmail/services/smtp_client.py @@ -0,0 +1,55 @@ +import smtplib +import ssl + + +class SMTPClient: + """Wrapper around smtplib.SMTP for sending mail via Postfix.""" + + def __init__(self, email_address, password, host='localhost', port=587): + self.email_address = email_address + self.password = password + self.host = host + self.port = port + + def send_message(self, mime_message): + """Send a composed email.message.EmailMessage via SMTP with STARTTLS. + + Returns: + dict: {success: bool, message_id: str or None, error: str or None} + """ + try: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + smtp = smtplib.SMTP(self.host, self.port) + smtp.ehlo() + smtp.starttls(context=ctx) + smtp.ehlo() + smtp.login(self.email_address, self.password) + smtp.send_message(mime_message) + smtp.quit() + + message_id = mime_message.get('Message-ID', '') + return {'success': True, 'message_id': message_id} + except smtplib.SMTPAuthenticationError as e: + return {'success': False, 'message_id': None, 'error': 'Authentication failed: %s' % str(e)} + except smtplib.SMTPRecipientsRefused as e: + return {'success': False, 'message_id': None, 'error': 'Recipients refused: %s' % str(e)} + except Exception as e: + return {'success': False, 'message_id': None, 'error': str(e)} + + def save_to_sent(self, imap_client, raw_message): + """Append sent message to the Sent folder via IMAP.""" + sent_folders = ['Sent', 'INBOX.Sent', 'Sent Messages', 'Sent Items'] + for folder in sent_folders: + try: + if imap_client.append_message(folder, raw_message, '\\Seen'): + return True + except Exception: + continue + try: + imap_client.create_folder('Sent') + return imap_client.append_message('Sent', raw_message, '\\Seen') + except Exception: + return False diff --git a/webmail/static/webmail/webmail.css b/webmail/static/webmail/webmail.css new file mode 100644 index 000000000..35b13b24e --- /dev/null +++ b/webmail/static/webmail/webmail.css @@ -0,0 +1,872 @@ +/* CyberPanel Webmail Styles */ + +.webmail-container { + height: calc(100vh - 80px); + display: flex; + flex-direction: column; + background: var(--bg-primary); + border-radius: 12px; + overflow: hidden; + margin: -20px -15px; +} + +/* Account Switcher */ +.wm-account-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.wm-account-current { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + font-weight: 500; + font-size: 14px; +} + +.wm-account-current i { + color: var(--accent-color); +} + +.wm-account-select { + padding: 4px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; +} + +/* Main Layout */ +.wm-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Sidebar */ +.wm-sidebar { + width: 220px; + min-width: 220px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 12px 0; +} + +.wm-compose-btn { + margin: 0 12px 12px; + padding: 10px 16px; + border-radius: 10px; + font-weight: 600; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + background: var(--accent-color) !important; + border-color: var(--accent-color) !important; + transition: all 0.2s; +} + +.wm-compose-btn:hover { + background: var(--accent-hover, #5A4BD1) !important; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(108,92,231,0.3); +} + +.wm-folder-list { + flex: 1; +} + +.wm-folder-item { + display: flex; + align-items: center; + padding: 8px 16px; + cursor: pointer; + color: var(--text-secondary); + font-size: 14px; + transition: all 0.15s; + gap: 10px; +} + +.wm-folder-item:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.wm-folder-item.active { + background: var(--bg-primary); + color: var(--accent-color); + font-weight: 600; +} + +.wm-folder-item i { + width: 18px; + text-align: center; + font-size: 13px; +} + +.wm-folder-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wm-badge { + background: var(--accent-color); + color: white; + border-radius: 10px; + padding: 1px 7px; + font-size: 11px; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +.wm-sidebar-divider { + height: 1px; + background: var(--border-color); + margin: 8px 16px; +} + +.wm-sidebar-nav { + padding: 0 4px; +} + +.wm-nav-link { + display: flex; + align-items: center; + padding: 8px 12px; + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + border-radius: 8px; + cursor: pointer; + gap: 10px; + transition: all 0.15s; +} + +.wm-nav-link:hover { + background: var(--bg-primary); + color: var(--text-primary); + text-decoration: none; +} + +.wm-nav-link.active { + background: var(--bg-primary); + color: var(--accent-color); + font-weight: 600; +} + +.wm-nav-link i { + width: 18px; + text-align: center; +} + +/* Message List Column */ +.wm-message-list { + width: 380px; + min-width: 320px; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + background: var(--bg-secondary); +} + +.wm-search-bar { + display: flex; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + gap: 8px; +} + +.wm-search-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + background: var(--bg-primary); + color: var(--text-primary); + outline: none; + transition: border-color 0.2s; +} + +.wm-search-input:focus { + border-color: var(--accent-color); +} + +.wm-search-btn { + padding: 8px 12px; + border: none; + background: var(--accent-color); + color: white; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.wm-search-btn:hover { + background: var(--accent-hover, #5A4BD1); +} + +/* Bulk Actions */ +.wm-bulk-actions { + display: flex; + align-items: center; + padding: 6px 12px; + border-bottom: 1px solid var(--border-color); + gap: 4px; + background: var(--bg-secondary); + flex-wrap: wrap; +} + +.wm-action-btn { + padding: 4px 8px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 6px; + font-size: 13px; + transition: all 0.15s; +} + +.wm-action-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.wm-action-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.wm-page-info { + font-size: 12px; + color: var(--text-secondary); + margin-left: auto; + padding: 0 4px; +} + +.wm-checkbox-label { + display: inline-flex; + align-items: center; + margin: 0; + cursor: pointer; +} + +.wm-checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent-color); +} + +.wm-move-dropdown select { + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 12px; + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* Message Rows */ +.wm-messages { + flex: 1; + overflow-y: auto; +} + +.wm-msg-row { + display: flex; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + gap: 8px; + transition: background 0.1s; + font-size: 13px; +} + +.wm-msg-row:hover { + background: var(--bg-primary); +} + +.wm-msg-row.unread { + font-weight: 600; +} + +.wm-msg-row.unread .wm-msg-subject { + color: var(--text-primary); +} + +.wm-msg-row.selected { + background: rgba(108, 92, 231, 0.06); +} + +.wm-star-btn { + border: none; + background: transparent; + cursor: pointer; + padding: 2px; + font-size: 13px; +} + +.wm-starred { + color: #F39C12; +} + +.wm-unstarred { + color: var(--border-color); +} + +.wm-msg-from { + width: 120px; + min-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} + +.wm-msg-subject { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} + +.wm-msg-date { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + min-width: 50px; + text-align: right; +} + +.wm-empty, .wm-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-secondary); + font-size: 14px; + gap: 12px; +} + +.wm-empty i { + font-size: 48px; + opacity: 0.3; +} + +/* Detail Pane */ +.wm-detail-pane { + flex: 1; + overflow-y: auto; + background: var(--bg-secondary); + padding: 0; +} + +.wm-empty-detail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + opacity: 0.4; + gap: 16px; +} + +/* Read View */ +.wm-read-view { + padding: 0; +} + +.wm-read-toolbar { + display: flex; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + position: sticky; + top: 0; + z-index: 10; +} + +.wm-read-toolbar .btn { + font-size: 13px; + padding: 6px 14px; + border-radius: 8px; +} + +.wm-read-header { + padding: 20px; + border-bottom: 1px solid var(--border-color); +} + +.wm-read-subject { + margin: 0 0 12px; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.wm-read-meta { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.8; +} + +.wm-read-meta strong { + color: var(--text-primary); +} + +.wm-attachments { + padding: 12px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.wm-attachment a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-primary); + border-radius: 8px; + font-size: 13px; + color: var(--accent-color); + cursor: pointer; + text-decoration: none; + transition: background 0.15s; +} + +.wm-attachment a:hover { + background: var(--border-color); +} + +.wm-att-size { + color: var(--text-secondary); + font-size: 11px; +} + +.wm-read-body { + padding: 20px; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.wm-read-body img { + max-width: 100%; + height: auto; +} + +.wm-read-body blockquote { + border-left: 3px solid var(--border-color); + margin: 8px 0; + padding: 4px 12px; + color: var(--text-secondary); +} + +.wm-read-body pre { + white-space: pre-wrap; + word-wrap: break-word; + background: var(--bg-primary); + padding: 12px; + border-radius: 8px; + font-size: 13px; +} + +/* Compose View */ +.wm-compose-view { + padding: 20px; +} + +.wm-compose-header h3 { + margin: 0 0 16px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.wm-compose-form .wm-field { + margin-bottom: 12px; +} + +.wm-compose-form label { + display: block; + margin-bottom: 4px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.wm-compose-form .form-control { + border-radius: 8px; + border: 1px solid var(--border-color); + padding: 8px 12px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); +} + +.wm-compose-form .form-control:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(108,92,231,0.1); +} + +.wm-toggle-link { + font-size: 12px; + color: var(--accent-color); + cursor: pointer; +} + +.wm-editor-toolbar { + display: flex; + gap: 2px; + padding: 6px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-bottom: none; + border-radius: 8px 8px 0 0; +} + +.wm-editor-toolbar button { + padding: 6px 10px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 4px; + font-size: 13px; +} + +.wm-editor-toolbar button:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.wm-editor { + min-height: 300px; + max-height: 500px; + overflow-y: auto; + padding: 16px; + border: 1px solid var(--border-color); + border-radius: 0 0 8px 8px; + background: white; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + outline: none; +} + +.wm-editor:focus { + border-color: var(--accent-color); +} + +.wm-compose-attachments { + margin-top: 12px; +} + +.wm-compose-attachments input[type="file"] { + display: none; +} + +.wm-attach-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: all 0.15s; +} + +.wm-attach-btn:hover { + border-color: var(--accent-color); + color: var(--accent-color); +} + +.wm-file-list { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.wm-file-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--bg-primary); + border-radius: 6px; + font-size: 12px; + color: var(--text-primary); +} + +.wm-file-tag i { + cursor: pointer; + color: var(--text-secondary); +} + +.wm-file-tag i:hover { + color: #E74C3C; +} + +.wm-compose-actions { + margin-top: 16px; + display: flex; + gap: 8px; +} + +.wm-compose-actions .btn { + border-radius: 8px; + padding: 8px 20px; + font-size: 14px; +} + +/* Contacts View */ +.wm-contacts-view, .wm-rules-view, .wm-settings-view { + padding: 20px; +} + +.wm-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.wm-section-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.wm-contacts-search { + margin-bottom: 16px; +} + +.wm-contacts-search .form-control { + border-radius: 8px; + background: var(--bg-primary); +} + +.wm-contact-list, .wm-rule-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.wm-contact-item, .wm-rule-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-primary); + border-radius: 8px; + transition: background 0.15s; +} + +.wm-contact-item:hover, .wm-rule-item:hover { + background: var(--border-color); +} + +.wm-contact-info, .wm-rule-info { + flex: 1; + min-width: 0; +} + +.wm-contact-name { + font-weight: 500; + color: var(--text-primary); + font-size: 14px; +} + +.wm-contact-email { + font-size: 12px; + color: var(--text-secondary); +} + +.wm-contact-actions, .wm-rule-actions { + display: flex; + gap: 4px; +} + +.wm-rule-desc { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* Forms */ +.wm-contact-form, .wm-rule-form, .wm-settings-form { + margin-top: 20px; + padding: 20px; + background: var(--bg-primary); + border-radius: 12px; +} + +.wm-contact-form h4, .wm-rule-form h4 { + margin: 0 0 16px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.wm-field { + margin-bottom: 12px; +} + +.wm-field label { + display: block; + margin-bottom: 4px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.wm-field .form-control { + border-radius: 8px; + border: 1px solid var(--border-color); + padding: 8px 12px; + font-size: 14px; + background: var(--bg-secondary); + color: var(--text-primary); +} + +.wm-field .form-control:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(108,92,231,0.1); + outline: none; +} + +.wm-field-row { + display: flex; + gap: 12px; +} + +.wm-field-row .wm-field { + flex: 1; +} + +.wm-form-actions { + margin-top: 16px; + display: flex; + gap: 8px; +} + +.wm-form-actions .btn { + border-radius: 8px; + padding: 8px 20px; +} + +/* Autocomplete Dropdown */ +.wm-autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.wm-autocomplete-item { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); +} + +.wm-autocomplete-item:hover { + background: var(--bg-primary); +} + +/* Responsive */ +@media (max-width: 1024px) { + .wm-sidebar { + width: 180px; + min-width: 180px; + } + .wm-message-list { + width: 300px; + min-width: 240px; + } +} + +@media (max-width: 768px) { + .wm-layout { + flex-direction: column; + } + .wm-sidebar { + width: 100%; + min-width: 100%; + flex-direction: row; + overflow-x: auto; + padding: 8px; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + .wm-folder-list { + display: flex; + gap: 4px; + } + .wm-folder-item { + white-space: nowrap; + padding: 6px 12px; + border-radius: 8px; + } + .wm-sidebar-divider, .wm-sidebar-nav { + display: none; + } + .wm-compose-btn { + margin: 0 4px 0 0; + padding: 6px 14px; + white-space: nowrap; + } + .wm-message-list { + width: 100%; + min-width: 100%; + max-height: 40vh; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + .wm-detail-pane { + min-height: 50vh; + } + .wm-field-row { + flex-direction: column; + } +} diff --git a/webmail/static/webmail/webmail.js b/webmail/static/webmail/webmail.js new file mode 100644 index 000000000..5592ec501 --- /dev/null +++ b/webmail/static/webmail/webmail.js @@ -0,0 +1,746 @@ +/* CyberPanel Webmail - AngularJS Controller */ + +app.filter('fileSize', function() { + return function(bytes) { + if (!bytes || bytes === 0) return '0 B'; + var k = 1024; + var sizes = ['B', 'KB', 'MB', 'GB']; + var i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; +}); + +app.filter('wmDate', function() { + return function(dateStr) { + if (!dateStr) return ''; + try { + var d = new Date(dateStr); + var now = new Date(); + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); + } + if (d.getFullYear() === now.getFullYear()) { + return d.toLocaleDateString([], {month: 'short', day: 'numeric'}); + } + return d.toLocaleDateString([], {year: 'numeric', month: 'short', day: 'numeric'}); + } catch(e) { + return dateStr; + } + }; +}); + +app.filter('trustHtml', ['$sce', function($sce) { + return function(html) { + return $sce.trustAsHtml(html); + }; +}]); + +app.directive('wmAutocomplete', ['$http', function($http) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + var dropdown = null; + var debounce = null; + + element.on('input', function() { + var val = element.val(); + var lastComma = val.lastIndexOf(','); + var query = lastComma >= 0 ? val.substring(lastComma + 1).trim() : val.trim(); + + if (query.length < 2) { + hideDropdown(); + return; + } + + clearTimeout(debounce); + debounce = setTimeout(function() { + $http.post('/webmail/api/searchContacts', {query: query}, { + headers: {'X-CSRFToken': getCookie('csrftoken')} + }).then(function(resp) { + if (resp.data.status === 1 && resp.data.contacts.length > 0) { + showDropdown(resp.data.contacts, val, lastComma); + } else { + hideDropdown(); + } + }); + }, 300); + }); + + function showDropdown(contacts, currentVal, lastComma) { + hideDropdown(); + dropdown = document.createElement('div'); + dropdown.className = 'wm-autocomplete-dropdown'; + contacts.forEach(function(c) { + var item = document.createElement('div'); + item.className = 'wm-autocomplete-item'; + item.textContent = c.display_name ? c.display_name + ' <' + c.email_address + '>' : c.email_address; + item.addEventListener('click', function() { + var prefix = lastComma >= 0 ? currentVal.substring(0, lastComma + 1) + ' ' : ''; + var newVal = prefix + c.email_address + ', '; + element.val(newVal); + element.triggerHandler('input'); + scope.$apply(function() { + scope.$eval(attrs.ngModel + ' = "' + newVal.replace(/"/g, '\\"') + '"'); + }); + hideDropdown(); + }); + dropdown.appendChild(item); + }); + element[0].parentNode.style.position = 'relative'; + element[0].parentNode.appendChild(dropdown); + } + + function hideDropdown() { + if (dropdown && dropdown.parentNode) { + dropdown.parentNode.removeChild(dropdown); + } + dropdown = null; + } + + element.on('blur', function() { + setTimeout(hideDropdown, 200); + }); + } + }; +}]); + +app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($scope, $http, $sce, $timeout) { + + // ── State ──────────────────────────────────────────────── + $scope.currentEmail = ''; + $scope.managedAccounts = []; + $scope.switchEmail = ''; + $scope.folders = []; + $scope.currentFolder = 'INBOX'; + $scope.messages = []; + $scope.currentPage = 1; + $scope.totalPages = 1; + $scope.totalMessages = 0; + $scope.perPage = 25; + $scope.openMsg = null; + $scope.trustedBody = ''; + $scope.viewMode = 'list'; // list, read, compose, contacts, rules, settings + $scope.loading = false; + $scope.sending = false; + $scope.searchQuery = ''; + $scope.selectAll = false; + $scope.showMoveDropdown = false; + $scope.moveTarget = ''; + $scope.showBcc = false; + + // Compose + $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; + + // Contacts + $scope.contacts = []; + $scope.filteredContacts = []; + $scope.contactSearch = ''; + $scope.editingContact = null; + + // Rules + $scope.sieveRules = []; + $scope.editingRule = null; + + // Settings + $scope.wmSettings = {}; + + // Draft auto-save + var draftTimer = null; + + // ── Helper ─────────────────────────────────────────────── + function apiCall(url, data, callback) { + var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}}; + $http.post(url, data || {}, config).then(function(resp) { + if (callback) callback(resp.data); + }, function(err) { + console.error('API error:', url, err); + }); + } + + function notify(msg, type) { + new PNotify({title: type === 'error' ? 'Error' : 'Webmail', text: msg, type: type || 'success'}); + } + + // ── Init ───────────────────────────────────────────────── + $scope.init = function() { + // Try SSO first + apiCall('/webmail/api/sso', {}, function(data) { + if (data.status === 1) { + $scope.currentEmail = data.email; + $scope.managedAccounts = data.accounts || []; + $scope.switchEmail = data.email; + $scope.loadFolders(); + $scope.loadSettings(); + } + }); + }; + + // ── Account Switching ──────────────────────────────────── + $scope.switchAccount = function() { + if (!$scope.switchEmail || $scope.switchEmail === $scope.currentEmail) return; + apiCall('/webmail/api/switchAccount', {email: $scope.switchEmail}, function(data) { + if (data.status === 1) { + $scope.currentEmail = data.email; + $scope.currentFolder = 'INBOX'; + $scope.currentPage = 1; + $scope.openMsg = null; + $scope.viewMode = 'list'; + $scope.loadFolders(); + $scope.loadSettings(); + } else { + notify(data.error_message, 'error'); + } + }); + }; + + // ── Folders ────────────────────────────────────────────── + $scope.loadFolders = function() { + apiCall('/webmail/api/listFolders', {}, function(data) { + if (data.status === 1) { + $scope.folders = data.folders; + $scope.loadMessages(); + } + }); + }; + + $scope.selectFolder = function(name) { + $scope.currentFolder = name; + $scope.currentPage = 1; + $scope.openMsg = null; + $scope.viewMode = 'list'; + $scope.searchQuery = ''; + $scope.loadMessages(); + }; + + $scope.getFolderIcon = function(name) { + var n = name.toLowerCase(); + if (n === 'inbox') return 'fa-inbox'; + if (n === 'sent' || n.indexOf('sent') >= 0) return 'fa-paper-plane'; + if (n === 'drafts' || n.indexOf('draft') >= 0) return 'fa-file'; + if (n === 'trash' || n.indexOf('trash') >= 0) return 'fa-trash'; + if (n === 'junk' || n === 'spam' || n.indexOf('junk') >= 0) return 'fa-ban'; + if (n.indexOf('archive') >= 0) return 'fa-box-archive'; + return 'fa-folder'; + }; + + $scope.createFolder = function() { + var name = prompt('Folder name:'); + if (!name) return; + apiCall('/webmail/api/createFolder', {name: name}, function(data) { + if (data.status === 1) { + $scope.loadFolders(); + notify('Folder created.'); + } else { + notify(data.error_message, 'error'); + } + }); + }; + + // ── Messages ───────────────────────────────────────────── + $scope.loadMessages = function() { + $scope.loading = true; + apiCall('/webmail/api/listMessages', { + folder: $scope.currentFolder, + page: $scope.currentPage, + perPage: $scope.perPage + }, function(data) { + $scope.loading = false; + if (data.status === 1) { + $scope.messages = data.messages; + $scope.totalMessages = data.total; + $scope.totalPages = data.pages; + $scope.selectAll = false; + } + }); + }; + + $scope.prevPage = function() { + if ($scope.currentPage > 1) { + $scope.currentPage--; + $scope.loadMessages(); + } + }; + + $scope.nextPage = function() { + if ($scope.currentPage < $scope.totalPages) { + $scope.currentPage++; + $scope.loadMessages(); + } + }; + + $scope.searchMessages = function() { + if (!$scope.searchQuery) { + $scope.loadMessages(); + return; + } + $scope.loading = true; + apiCall('/webmail/api/searchMessages', { + folder: $scope.currentFolder, + query: $scope.searchQuery + }, function(data) { + $scope.loading = false; + if (data.status === 1) { + // Re-fetch with found UIDs (simplified: reload) + $scope.loadMessages(); + } + }); + }; + + // ── Open/Read Message ──────────────────────────────────── + $scope.openMessage = function(msg) { + apiCall('/webmail/api/getMessage', { + folder: $scope.currentFolder, + uid: msg.uid + }, function(data) { + if (data.status === 1) { + $scope.openMsg = data.message; + $scope.trustedBody = $sce.trustAsHtml(data.message.body_html || ('
' + (data.message.body_text || '') + '
')); + $scope.viewMode = 'read'; + msg.is_read = true; + // Update folder unread count + $scope.folders.forEach(function(f) { + if (f.name === $scope.currentFolder && f.unread_count > 0) { + f.unread_count--; + } + }); + } + }); + }; + + // ── Compose ────────────────────────────────────────────── + $scope.composeNew = function() { + $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; + $scope.viewMode = 'compose'; + $scope.showBcc = false; + $timeout(function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + editor.innerHTML = ''; + // Add signature if available + if ($scope.wmSettings.signatureHtml) { + editor.innerHTML = '

--
' + $scope.wmSettings.signatureHtml + '
'; + } + } + }, 100); + startDraftAutoSave(); + }; + + $scope.replyTo = function() { + if (!$scope.openMsg) return; + $scope.compose = { + to: $scope.openMsg.from, + cc: '', + bcc: '', + subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject, + body: '', + files: [], + inReplyTo: $scope.openMsg.message_id || '', + references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim() + }; + $scope.viewMode = 'compose'; + $timeout(function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + var sig = $scope.wmSettings.signatureHtml ? '

--
' + $scope.wmSettings.signatureHtml + '
' : ''; + editor.innerHTML = '
' + sig + '
On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
'; + } + }, 100); + startDraftAutoSave(); + }; + + $scope.replyAll = function() { + if (!$scope.openMsg) return; + var cc = []; + if ($scope.openMsg.to) cc.push($scope.openMsg.to); + if ($scope.openMsg.cc) cc.push($scope.openMsg.cc); + $scope.compose = { + to: $scope.openMsg.from, + cc: cc.join(', '), + bcc: '', + subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject, + body: '', + files: [], + inReplyTo: $scope.openMsg.message_id || '', + references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim() + }; + $scope.viewMode = 'compose'; + $timeout(function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + editor.innerHTML = '

On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:
' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
'; + } + }, 100); + startDraftAutoSave(); + }; + + $scope.forwardMsg = function() { + if (!$scope.openMsg) return; + $scope.compose = { + to: '', + cc: '', + bcc: '', + subject: ($scope.openMsg.subject.match(/^Fwd:/i) ? '' : 'Fwd: ') + $scope.openMsg.subject, + body: '', + files: [], + inReplyTo: '', + references: '' + }; + $scope.viewMode = 'compose'; + $timeout(function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + editor.innerHTML = '

---------- Forwarded message ----------
From: ' + $scope.openMsg.from + '
Date: ' + $scope.openMsg.date + '
Subject: ' + $scope.openMsg.subject + '
To: ' + $scope.openMsg.to + '

' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '
'; + } + }, 100); + startDraftAutoSave(); + }; + + $scope.updateComposeBody = function() { + var editor = document.getElementById('wm-compose-body'); + if (editor) { + $scope.compose.body = editor.innerHTML; + } + }; + + $scope.execCmd = function(cmd) { + document.execCommand(cmd, false, null); + }; + + $scope.insertLink = function() { + var url = prompt('Enter URL:'); + if (url) { + document.execCommand('createLink', false, url); + } + }; + + $scope.addFiles = function(files) { + $scope.$apply(function() { + for (var i = 0; i < files.length; i++) { + $scope.compose.files.push(files[i]); + } + }); + }; + + $scope.removeFile = function(index) { + $scope.compose.files.splice(index, 1); + }; + + $scope.sendMessage = function() { + $scope.updateComposeBody(); + $scope.sending = true; + stopDraftAutoSave(); + + var fd = new FormData(); + fd.append('to', $scope.compose.to); + fd.append('cc', $scope.compose.cc || ''); + fd.append('bcc', $scope.compose.bcc || ''); + fd.append('subject', $scope.compose.subject); + fd.append('body', $scope.compose.body); + fd.append('inReplyTo', $scope.compose.inReplyTo || ''); + fd.append('references', $scope.compose.references || ''); + for (var i = 0; i < $scope.compose.files.length; i++) { + fd.append('attachment_' + i, $scope.compose.files[i]); + } + + $http.post('/webmail/api/sendMessage', fd, { + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': undefined + }, + transformRequest: angular.identity + }).then(function(resp) { + $scope.sending = false; + if (resp.data.status === 1) { + notify('Message sent.'); + $scope.viewMode = 'list'; + $scope.loadMessages(); + } else { + notify(resp.data.error_message, 'error'); + } + }, function() { + $scope.sending = false; + notify('Failed to send message.', 'error'); + }); + }; + + $scope.saveDraft = function() { + $scope.updateComposeBody(); + apiCall('/webmail/api/saveDraft', { + to: $scope.compose.to, + subject: $scope.compose.subject, + body: $scope.compose.body + }, function(data) { + if (data.status === 1) { + notify('Draft saved.'); + } + }); + }; + + $scope.discardDraft = function() { + stopDraftAutoSave(); + $scope.viewMode = 'list'; + $scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; + }; + + function startDraftAutoSave() { + stopDraftAutoSave(); + draftTimer = setInterval(function() { + $scope.updateComposeBody(); + if ($scope.compose.subject || $scope.compose.body || $scope.compose.to) { + apiCall('/webmail/api/saveDraft', { + to: $scope.compose.to, + subject: $scope.compose.subject, + body: $scope.compose.body + }); + } + }, 60000); // Auto-save every 60 seconds + } + + function stopDraftAutoSave() { + if (draftTimer) { + clearInterval(draftTimer); + draftTimer = null; + } + } + + // ── Bulk Actions ───────────────────────────────────────── + $scope.toggleSelectAll = function() { + $scope.messages.forEach(function(m) { m.selected = $scope.selectAll; }); + }; + + function getSelectedUids() { + return $scope.messages.filter(function(m) { return m.selected; }).map(function(m) { return m.uid; }); + } + + $scope.bulkDelete = function() { + var uids = getSelectedUids(); + if (uids.length === 0) return; + apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: uids}, function(data) { + if (data.status === 1) { + $scope.loadMessages(); + $scope.loadFolders(); + } + }); + }; + + $scope.bulkMarkRead = function() { + var uids = getSelectedUids(); + if (uids.length === 0) return; + apiCall('/webmail/api/markRead', {folder: $scope.currentFolder, uids: uids}, function() { + $scope.loadMessages(); + $scope.loadFolders(); + }); + }; + + $scope.bulkMarkUnread = function() { + var uids = getSelectedUids(); + if (uids.length === 0) return; + apiCall('/webmail/api/markUnread', {folder: $scope.currentFolder, uids: uids}, function() { + $scope.loadMessages(); + $scope.loadFolders(); + }); + }; + + $scope.bulkMove = function() { + var uids = getSelectedUids(); + if (uids.length === 0 || !$scope.moveTarget) return; + apiCall('/webmail/api/moveMessages', { + folder: $scope.currentFolder, + uids: uids, + targetFolder: $scope.moveTarget.name || $scope.moveTarget + }, function(data) { + if (data.status === 1) { + $scope.showMoveDropdown = false; + $scope.moveTarget = ''; + $scope.loadMessages(); + $scope.loadFolders(); + } + }); + }; + + $scope.toggleFlag = function(msg) { + apiCall('/webmail/api/markFlagged', {folder: $scope.currentFolder, uids: [msg.uid]}, function() { + msg.is_flagged = !msg.is_flagged; + }); + }; + + $scope.deleteMsg = function(msg) { + apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: [msg.uid]}, function(data) { + if (data.status === 1) { + $scope.openMsg = null; + $scope.viewMode = 'list'; + $scope.loadMessages(); + $scope.loadFolders(); + } + }); + }; + + // ── Attachments ────────────────────────────────────────── + $scope.downloadAttachment = function(att) { + var form = document.createElement('form'); + form.method = 'POST'; + form.action = '/webmail/api/getAttachment'; + form.target = '_blank'; + var fields = {folder: $scope.currentFolder, uid: $scope.openMsg.uid, partId: att.part_id}; + fields['csrfmiddlewaretoken'] = getCookie('csrftoken'); + for (var key in fields) { + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = fields[key]; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + }; + + // ── View Mode ──────────────────────────────────────────── + $scope.setView = function(mode) { + $scope.viewMode = mode; + $scope.openMsg = null; + if (mode === 'contacts') $scope.loadContacts(); + if (mode === 'rules') $scope.loadRules(); + if (mode === 'settings') $scope.loadSettings(); + }; + + // ── Contacts ───────────────────────────────────────────── + $scope.loadContacts = function() { + apiCall('/webmail/api/listContacts', {}, function(data) { + if (data.status === 1) { + $scope.contacts = data.contacts; + $scope.filteredContacts = data.contacts; + } + }); + }; + + $scope.filterContacts = function() { + var q = ($scope.contactSearch || '').toLowerCase(); + $scope.filteredContacts = $scope.contacts.filter(function(c) { + return (c.display_name || '').toLowerCase().indexOf(q) >= 0 || + (c.email_address || '').toLowerCase().indexOf(q) >= 0; + }); + }; + + $scope.newContact = function() { + $scope.editingContact = {display_name: '', email_address: '', phone: '', organization: '', notes: ''}; + }; + + $scope.editContact = function(c) { + $scope.editingContact = angular.copy(c); + }; + + $scope.saveContact = function() { + var c = $scope.editingContact; + var url = c.id ? '/webmail/api/updateContact' : '/webmail/api/createContact'; + apiCall(url, { + id: c.id, + displayName: c.display_name, + emailAddress: c.email_address, + phone: c.phone, + organization: c.organization, + notes: c.notes + }, function(data) { + if (data.status === 1) { + $scope.editingContact = null; + $scope.loadContacts(); + notify('Contact saved.'); + } else { + notify(data.error_message, 'error'); + } + }); + }; + + $scope.removeContact = function(c) { + if (!confirm('Delete contact ' + (c.display_name || c.email_address) + '?')) return; + apiCall('/webmail/api/deleteContact', {id: c.id}, function(data) { + if (data.status === 1) { + $scope.loadContacts(); + } + }); + }; + + $scope.composeToContact = function(c) { + $scope.compose = {to: c.email_address, cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''}; + $scope.viewMode = 'compose'; + }; + + // ── Sieve Rules ────────────────────────────────────────── + $scope.loadRules = function() { + apiCall('/webmail/api/listRules', {}, function(data) { + if (data.status === 1) { + $scope.sieveRules = data.rules; + } + }); + }; + + $scope.newRule = function() { + $scope.editingRule = { + name: '', priority: 0, conditionField: 'from', + conditionType: 'contains', conditionValue: '', + actionType: 'move', actionValue: '' + }; + }; + + $scope.editRule = function(rule) { + $scope.editingRule = { + id: rule.id, + name: rule.name, + priority: rule.priority, + conditionField: rule.condition_field, + conditionType: rule.condition_type, + conditionValue: rule.condition_value, + actionType: rule.action_type, + actionValue: rule.action_value + }; + }; + + $scope.saveRule = function() { + var r = $scope.editingRule; + var url = r.id ? '/webmail/api/updateRule' : '/webmail/api/createRule'; + apiCall(url, r, function(data) { + if (data.status === 1) { + $scope.editingRule = null; + $scope.loadRules(); + notify('Rule saved.'); + } else { + notify(data.error_message, 'error'); + } + }); + }; + + $scope.removeRule = function(rule) { + if (!confirm('Delete rule "' + rule.name + '"?')) return; + apiCall('/webmail/api/deleteRule', {id: rule.id}, function(data) { + if (data.status === 1) { + $scope.loadRules(); + } + }); + }; + + // ── Settings ───────────────────────────────────────────── + $scope.loadSettings = function() { + apiCall('/webmail/api/getSettings', {}, function(data) { + if (data.status === 1) { + $scope.wmSettings = data.settings; + if ($scope.wmSettings.messagesPerPage) { + $scope.perPage = parseInt($scope.wmSettings.messagesPerPage); + } + } + }); + }; + + $scope.saveSettings = function() { + apiCall('/webmail/api/saveSettings', $scope.wmSettings, function(data) { + if (data.status === 1) { + notify('Settings saved.'); + if ($scope.wmSettings.messagesPerPage) { + $scope.perPage = parseInt($scope.wmSettings.messagesPerPage); + } + } else { + notify(data.error_message, 'error'); + } + }); + }; + +}]); diff --git a/webmail/templates/webmail/index.html b/webmail/templates/webmail/index.html new file mode 100644 index 000000000..28bee4abb --- /dev/null +++ b/webmail/templates/webmail/index.html @@ -0,0 +1,440 @@ +{% extends "baseTemplate/index.html" %} +{% load i18n %} +{% load static %} +{% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %} +{% block content %} + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + {$ folder.name $} + {$ folder.unread_count $} +
+
+ +
+ + + +
+ +
+ + +
+ + + + +
+ + + + +
+ +
+ + {$ currentPage $} / {$ totalPages $} + + +
+ + +
+
+ + +
{$ msg.from | limitTo:30 $}
+
{$ msg.subject | limitTo:60 $}
+
{$ msg.date | wmDate $}
+
+
+ +

{% trans "No messages" %}

+
+
+ {% trans "Loading..." %} +
+
+
+ + +
+ + +
+
+ + + + +
+
+

{$ openMsg.subject $}

+
+
{% trans "From" %}: {$ openMsg.from $}
+
{% trans "To" %}: {$ openMsg.to $}
+
{% trans "Cc" %}: {$ openMsg.cc $}
+
{% trans "Date" %}: {$ openMsg.date $}
+
+
+ +
+
+ + +
+
+

{% trans "Compose" %}

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + + + + +
+
+
+ + +
+ + {$ f.name $} ({$ f.size | fileSize $}) + + +
+
+
+ + + +
+
+
+ + +
+
+

{% trans "Contacts" %}

+ +
+ +
+
+
+
{$ c.display_name || c.email_address $}
+
{$ c.email_address $}
+
+
+ + + +
+
+
+ + +
+

{$ editingContact.id ? '{% trans "Edit Contact" %}' : '{% trans "New Contact" %}' $}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

{% trans "Mail Filter Rules" %}

+ +
+
+
+
+ {$ rule.name $} + + If {$ rule.condition_field $} {$ rule.condition_type $} "{$ rule.condition_value $}" + → {$ rule.action_type $} {$ rule.action_value $} + +
+
+ + +
+
+
+ + +
+

{$ editingRule.id ? '{% trans "Edit Rule" %}' : '{% trans "New Rule" %}' $}

+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

{% trans "Settings" %}

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+ +

{% trans "Select a message to read" %}

+
+ +
+
+
+ + + +{% endblock %} diff --git a/webmail/templates/webmail/login.html b/webmail/templates/webmail/login.html new file mode 100644 index 000000000..7eb4aca9b --- /dev/null +++ b/webmail/templates/webmail/login.html @@ -0,0 +1,181 @@ +{% load i18n %} +{% load static %} + + + + + + {% trans "Webmail Login - CyberPanel" %} + + + + + + + +
+ + +
+ {$ errorMsg $} +
+ +
+ + + +
+
+ + + + diff --git a/webmail/urls.py b/webmail/urls.py new file mode 100644 index 000000000..78ffa2b1c --- /dev/null +++ b/webmail/urls.py @@ -0,0 +1,60 @@ +from django.urls import re_path +from . import views + +urlpatterns = [ + # Pages + re_path(r'^$', views.loadWebmail, name='loadWebmail'), + re_path(r'^login$', views.loadLogin, name='loadWebmailLogin'), + + # Auth + re_path(r'^api/login$', views.apiLogin, name='wmApiLogin'), + re_path(r'^api/logout$', views.apiLogout, name='wmApiLogout'), + re_path(r'^api/sso$', views.apiSSO, name='wmApiSSO'), + re_path(r'^api/listAccounts$', views.apiListAccounts, name='wmApiListAccounts'), + re_path(r'^api/switchAccount$', views.apiSwitchAccount, name='wmApiSwitchAccount'), + + # Folders + re_path(r'^api/listFolders$', views.apiListFolders, name='wmApiListFolders'), + re_path(r'^api/createFolder$', views.apiCreateFolder, name='wmApiCreateFolder'), + re_path(r'^api/renameFolder$', views.apiRenameFolder, name='wmApiRenameFolder'), + re_path(r'^api/deleteFolder$', views.apiDeleteFolder, name='wmApiDeleteFolder'), + + # Messages + re_path(r'^api/listMessages$', views.apiListMessages, name='wmApiListMessages'), + re_path(r'^api/searchMessages$', views.apiSearchMessages, name='wmApiSearchMessages'), + re_path(r'^api/getMessage$', views.apiGetMessage, name='wmApiGetMessage'), + re_path(r'^api/getAttachment$', views.apiGetAttachment, name='wmApiGetAttachment'), + + # Actions + re_path(r'^api/sendMessage$', views.apiSendMessage, name='wmApiSendMessage'), + re_path(r'^api/saveDraft$', views.apiSaveDraft, name='wmApiSaveDraft'), + re_path(r'^api/deleteMessages$', views.apiDeleteMessages, name='wmApiDeleteMessages'), + re_path(r'^api/moveMessages$', views.apiMoveMessages, name='wmApiMoveMessages'), + re_path(r'^api/markRead$', views.apiMarkRead, name='wmApiMarkRead'), + re_path(r'^api/markUnread$', views.apiMarkUnread, name='wmApiMarkUnread'), + re_path(r'^api/markFlagged$', views.apiMarkFlagged, name='wmApiMarkFlagged'), + + # Contacts + re_path(r'^api/listContacts$', views.apiListContacts, name='wmApiListContacts'), + re_path(r'^api/createContact$', views.apiCreateContact, name='wmApiCreateContact'), + re_path(r'^api/updateContact$', views.apiUpdateContact, name='wmApiUpdateContact'), + re_path(r'^api/deleteContact$', views.apiDeleteContact, name='wmApiDeleteContact'), + re_path(r'^api/searchContacts$', views.apiSearchContacts, name='wmApiSearchContacts'), + re_path(r'^api/listContactGroups$', views.apiListContactGroups, name='wmApiListContactGroups'), + re_path(r'^api/createContactGroup$', views.apiCreateContactGroup, name='wmApiCreateContactGroup'), + re_path(r'^api/deleteContactGroup$', views.apiDeleteContactGroup, name='wmApiDeleteContactGroup'), + + # Sieve Rules + re_path(r'^api/listRules$', views.apiListRules, name='wmApiListRules'), + re_path(r'^api/createRule$', views.apiCreateRule, name='wmApiCreateRule'), + re_path(r'^api/updateRule$', views.apiUpdateRule, name='wmApiUpdateRule'), + re_path(r'^api/deleteRule$', views.apiDeleteRule, name='wmApiDeleteRule'), + re_path(r'^api/activateRules$', views.apiActivateRules, name='wmApiActivateRules'), + + # Settings + re_path(r'^api/getSettings$', views.apiGetSettings, name='wmApiGetSettings'), + re_path(r'^api/saveSettings$', views.apiSaveSettings, name='wmApiSaveSettings'), + + # Image Proxy + re_path(r'^api/proxyImage$', views.apiProxyImage, name='wmApiProxyImage'), +] diff --git a/webmail/views.py b/webmail/views.py new file mode 100644 index 000000000..94d333dc0 --- /dev/null +++ b/webmail/views.py @@ -0,0 +1,397 @@ +import json +from django.shortcuts import redirect +from django.http import HttpResponse +from loginSystem.views import loadLoginPage +from .webmailManager import WebmailManager + + +# ── Page Views ──────────────────────────────────────────────── + +def loadWebmail(request): + try: + wm = WebmailManager(request) + return wm.loadWebmail() + except KeyError: + return redirect(loadLoginPage) + + +def loadLogin(request): + wm = WebmailManager(request) + return wm.loadLogin() + + +# ── Auth APIs ───────────────────────────────────────────────── + +def apiLogin(request): + try: + wm = WebmailManager(request) + return wm.apiLogin() + except Exception as e: + return _error_response(e) + + +def apiLogout(request): + try: + wm = WebmailManager(request) + return wm.apiLogout() + except Exception as e: + return _error_response(e) + + +def apiSSO(request): + try: + wm = WebmailManager(request) + return wm.apiSSO() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiListAccounts(request): + try: + wm = WebmailManager(request) + return wm.apiListAccounts() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiSwitchAccount(request): + try: + wm = WebmailManager(request) + return wm.apiSwitchAccount() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Folder APIs ─────────────────────────────────────────────── + +def apiListFolders(request): + try: + wm = WebmailManager(request) + return wm.apiListFolders() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiCreateFolder(request): + try: + wm = WebmailManager(request) + return wm.apiCreateFolder() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiRenameFolder(request): + try: + wm = WebmailManager(request) + return wm.apiRenameFolder() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiDeleteFolder(request): + try: + wm = WebmailManager(request) + return wm.apiDeleteFolder() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Message APIs ────────────────────────────────────────────── + +def apiListMessages(request): + try: + wm = WebmailManager(request) + return wm.apiListMessages() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiSearchMessages(request): + try: + wm = WebmailManager(request) + return wm.apiSearchMessages() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiGetMessage(request): + try: + wm = WebmailManager(request) + return wm.apiGetMessage() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiGetAttachment(request): + try: + wm = WebmailManager(request) + return wm.apiGetAttachment() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Action APIs ─────────────────────────────────────────────── + +def apiSendMessage(request): + try: + wm = WebmailManager(request) + return wm.apiSendMessage() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiSaveDraft(request): + try: + wm = WebmailManager(request) + return wm.apiSaveDraft() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiDeleteMessages(request): + try: + wm = WebmailManager(request) + return wm.apiDeleteMessages() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiMoveMessages(request): + try: + wm = WebmailManager(request) + return wm.apiMoveMessages() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiMarkRead(request): + try: + wm = WebmailManager(request) + return wm.apiMarkRead() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiMarkUnread(request): + try: + wm = WebmailManager(request) + return wm.apiMarkUnread() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiMarkFlagged(request): + try: + wm = WebmailManager(request) + return wm.apiMarkFlagged() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Contact APIs ────────────────────────────────────────────── + +def apiListContacts(request): + try: + wm = WebmailManager(request) + return wm.apiListContacts() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiCreateContact(request): + try: + wm = WebmailManager(request) + return wm.apiCreateContact() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiUpdateContact(request): + try: + wm = WebmailManager(request) + return wm.apiUpdateContact() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiDeleteContact(request): + try: + wm = WebmailManager(request) + return wm.apiDeleteContact() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiSearchContacts(request): + try: + wm = WebmailManager(request) + return wm.apiSearchContacts() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiListContactGroups(request): + try: + wm = WebmailManager(request) + return wm.apiListContactGroups() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiCreateContactGroup(request): + try: + wm = WebmailManager(request) + return wm.apiCreateContactGroup() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiDeleteContactGroup(request): + try: + wm = WebmailManager(request) + return wm.apiDeleteContactGroup() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Sieve Rule APIs ────────────────────────────────────────── + +def apiListRules(request): + try: + wm = WebmailManager(request) + return wm.apiListRules() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiCreateRule(request): + try: + wm = WebmailManager(request) + return wm.apiCreateRule() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiUpdateRule(request): + try: + wm = WebmailManager(request) + return wm.apiUpdateRule() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiDeleteRule(request): + try: + wm = WebmailManager(request) + return wm.apiDeleteRule() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiActivateRules(request): + try: + wm = WebmailManager(request) + return wm.apiActivateRules() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Settings APIs ───────────────────────────────────────────── + +def apiGetSettings(request): + try: + wm = WebmailManager(request) + return wm.apiGetSettings() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +def apiSaveSettings(request): + try: + wm = WebmailManager(request) + return wm.apiSaveSettings() + except KeyError: + return redirect(loadLoginPage) + except Exception as e: + return _error_response(e) + + +# ── Image Proxy ─────────────────────────────────────────────── + +def apiProxyImage(request): + try: + wm = WebmailManager(request) + return wm.apiProxyImage() + except Exception as e: + return _error_response(e) + + +# ── Helpers ─────────────────────────────────────────────────── + +def _error_response(e): + data = {'status': 0, 'error_message': str(e)} + return HttpResponse(json.dumps(data), content_type='application/json') diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py new file mode 100644 index 000000000..9727f9957 --- /dev/null +++ b/webmail/webmailManager.py @@ -0,0 +1,755 @@ +import json +import os +import base64 + +from django.http import HttpResponse +from django.shortcuts import render, redirect + +from .models import Contact, ContactGroup, ContactGroupMembership, WebmailSettings, SieveRule +from .services.imap_client import IMAPClient +from .services.smtp_client import SMTPClient +from .services.email_composer import EmailComposer +from .services.sieve_client import SieveClient + +import plogical.CyberCPLogFileWriter as logging + +WEBMAIL_CONF = '/etc/cyberpanel/webmail.conf' + + +class WebmailManager: + + def __init__(self, request): + self.request = request + + # ── Helpers ──────────────────────────────────────────────── + + @staticmethod + def _json_response(data): + return HttpResponse(json.dumps(data), content_type='application/json') + + @staticmethod + def _error(msg): + return HttpResponse(json.dumps({'status': 0, 'error_message': str(msg)}), + content_type='application/json') + + @staticmethod + def _success(extra=None): + data = {'status': 1} + if extra: + data.update(extra) + return HttpResponse(json.dumps(data), content_type='application/json') + + def _get_post_data(self): + try: + return json.loads(self.request.body) + except Exception: + return self.request.POST.dict() + + def _get_email(self): + return self.request.session.get('webmail_email') + + def _get_master_config(self): + """Read master user config from /etc/cyberpanel/webmail.conf""" + try: + with open(WEBMAIL_CONF, 'r') as f: + config = json.load(f) + return config.get('master_user'), config.get('master_password') + except Exception: + return None, None + + def _get_imap(self, email_addr=None): + """Create IMAP client, preferring master user auth for SSO sessions.""" + addr = email_addr or self._get_email() + if not addr: + raise Exception('No email account selected') + + master_user, master_pass = self._get_master_config() + if master_user and master_pass: + return IMAPClient(addr, '', master_user=master_user, master_password=master_pass) + + # Fallback: standalone login with stored password + password = self.request.session.get('webmail_password', '') + return IMAPClient(addr, password) + + def _get_smtp(self): + addr = self._get_email() + if not addr: + raise Exception('No email account selected') + password = self.request.session.get('webmail_password', '') + return SMTPClient(addr, password) + + def _get_sieve(self, email_addr=None): + addr = email_addr or self._get_email() + if not addr: + raise Exception('No email account selected') + + master_user, master_pass = self._get_master_config() + if master_user and master_pass: + return SieveClient(addr, '', master_user=master_user, master_password=master_pass) + + password = self.request.session.get('webmail_password', '') + return SieveClient(addr, password) + + def _get_managed_accounts(self): + """Get email accounts the current CyberPanel user can access.""" + try: + from plogical.acl import ACLManager + from loginSystem.models import Administrator + from mailServer.models import Domains, EUsers + + userID = self.request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + websites = ACLManager.findAllSites(currentACL, userID) + websites = websites + ACLManager.findChildDomains(websites) + + accounts = [] + for site in websites: + try: + domain = Domains.objects.get(domain=site) + for eu in EUsers.objects.filter(emailOwner=domain): + accounts.append(eu.email) + except Exception: + continue + return accounts + except Exception: + return [] + + # ── Page Renders ────────────────────────────────────────── + + def loadWebmail(self): + from plogical.httpProc import httpProc + email = self._get_email() + accounts = self._get_managed_accounts() + + if not email and accounts: + if len(accounts) == 1: + self.request.session['webmail_email'] = accounts[0] + email = accounts[0] + else: + # Multiple accounts - render picker + proc = httpProc(self.request, 'webmail/index.html', + {'accounts': json.dumps(accounts), 'show_picker': True}, + 'listEmails') + return proc.render() + + proc = httpProc(self.request, 'webmail/index.html', + {'email': email or '', + 'accounts': json.dumps(accounts), + 'show_picker': False}, + 'listEmails') + return proc.render() + + def loadLogin(self): + return render(self.request, 'webmail/login.html') + + # ── Auth APIs ───────────────────────────────────────────── + + def apiLogin(self): + data = self._get_post_data() + email_addr = data.get('email', '') + password = data.get('password', '') + + if not email_addr or not password: + return self._error('Email and password are required.') + + try: + client = IMAPClient(email_addr, password) + client.close() + except Exception as e: + return self._error('Login failed: %s' % str(e)) + + self.request.session['webmail_email'] = email_addr + self.request.session['webmail_password'] = password + self.request.session['webmail_standalone'] = True + return self._success() + + def apiLogout(self): + for key in ['webmail_email', 'webmail_password', 'webmail_standalone']: + self.request.session.pop(key, None) + return self._success() + + def apiSSO(self): + """Auto-login for CyberPanel users.""" + accounts = self._get_managed_accounts() + if not accounts: + return self._error('No email accounts found for your user.') + email = accounts[0] + self.request.session['webmail_email'] = email + return self._success({'email': email, 'accounts': accounts}) + + def apiListAccounts(self): + accounts = self._get_managed_accounts() + return self._success({'accounts': accounts}) + + def apiSwitchAccount(self): + data = self._get_post_data() + email = data.get('email', '') + accounts = self._get_managed_accounts() + if email not in accounts: + return self._error('You do not have access to this account.') + self.request.session['webmail_email'] = email + return self._success({'email': email}) + + # ── Folder APIs ─────────────────────────────────────────── + + def apiListFolders(self): + try: + with self._get_imap() as imap: + folders = imap.list_folders() + return self._success({'folders': folders}) + except Exception as e: + return self._error(str(e)) + + def apiCreateFolder(self): + data = self._get_post_data() + name = data.get('name', '') + if not name: + return self._error('Folder name is required.') + try: + with self._get_imap() as imap: + if imap.create_folder(name): + return self._success() + return self._error('Failed to create folder.') + except Exception as e: + return self._error(str(e)) + + def apiRenameFolder(self): + data = self._get_post_data() + old_name = data.get('oldName', '') + new_name = data.get('newName', '') + if not old_name or not new_name: + return self._error('Both old and new folder names are required.') + try: + with self._get_imap() as imap: + if imap.rename_folder(old_name, new_name): + return self._success() + return self._error('Failed to rename folder.') + except Exception as e: + return self._error(str(e)) + + def apiDeleteFolder(self): + data = self._get_post_data() + name = data.get('name', '') + if not name: + return self._error('Folder name is required.') + protected = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam'] + if name in protected: + return self._error('Cannot delete system folder.') + try: + with self._get_imap() as imap: + if imap.delete_folder(name): + return self._success() + return self._error('Failed to delete folder.') + except Exception as e: + return self._error(str(e)) + + # ── Message APIs ────────────────────────────────────────── + + def apiListMessages(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + page = int(data.get('page', 1)) + per_page = int(data.get('perPage', 25)) + try: + with self._get_imap() as imap: + result = imap.list_messages(folder, page, per_page) + return self._success(result) + except Exception as e: + return self._error(str(e)) + + def apiSearchMessages(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + query = data.get('query', '') + try: + with self._get_imap() as imap: + uids = imap.search_messages(folder, query) + uids = [u.decode() if isinstance(u, bytes) else str(u) for u in uids] + return self._success({'uids': uids}) + except Exception as e: + return self._error(str(e)) + + def apiGetMessage(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uid = data.get('uid', '') + if not uid: + return self._error('Message UID is required.') + try: + with self._get_imap() as imap: + msg = imap.get_message(folder, uid) + if msg is None: + return self._error('Message not found.') + imap.mark_read(folder, [uid]) + return self._success({'message': msg}) + except Exception as e: + return self._error(str(e)) + + def apiGetAttachment(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uid = data.get('uid', '') + part_id = data.get('partId', '') + try: + with self._get_imap() as imap: + result = imap.get_attachment(folder, uid, part_id) + if result is None: + return self._error('Attachment not found.') + filename, content_type, payload = result + response = HttpResponse(payload, content_type=content_type) + response['Content-Disposition'] = 'attachment; filename="%s"' % filename + return response + except Exception as e: + return self._error(str(e)) + + # ── Action APIs ─────────────────────────────────────────── + + def apiSendMessage(self): + try: + email_addr = self._get_email() + if not email_addr: + return self._error('Not logged in.') + + # Handle multipart form data for attachments + if self.request.content_type and 'multipart' in self.request.content_type: + to = self.request.POST.get('to', '') + cc = self.request.POST.get('cc', '') + bcc = self.request.POST.get('bcc', '') + subject = self.request.POST.get('subject', '') + body_html = self.request.POST.get('body', '') + in_reply_to = self.request.POST.get('inReplyTo', '') + references = self.request.POST.get('references', '') + + attachments = [] + for key in self.request.FILES: + f = self.request.FILES[key] + attachments.append((f.name, f.content_type, f.read())) + else: + data = self._get_post_data() + to = data.get('to', '') + cc = data.get('cc', '') + bcc = data.get('bcc', '') + subject = data.get('subject', '') + body_html = data.get('body', '') + in_reply_to = data.get('inReplyTo', '') + references = data.get('references', '') + attachments = None + + if not to: + return self._error('At least one recipient is required.') + + mime_msg = EmailComposer.compose( + from_addr=email_addr, + to_addrs=to, + subject=subject, + body_html=body_html, + cc_addrs=cc, + bcc_addrs=bcc, + attachments=attachments, + in_reply_to=in_reply_to, + references=references, + ) + + smtp = self._get_smtp() + result = smtp.send_message(mime_msg) + + if not result['success']: + return self._error(result.get('error', 'Failed to send.')) + + # Save to Sent folder + try: + with self._get_imap() as imap: + raw = mime_msg.as_bytes() + smtp.save_to_sent(imap, raw) + except Exception: + pass + + # Auto-collect contacts + try: + settings = WebmailSettings.objects.filter(email_account=email_addr).first() + if settings is None or settings.auto_collect_contacts: + self._auto_collect(email_addr, to, cc) + except Exception: + pass + + return self._success({'messageId': result['message_id']}) + except Exception as e: + return self._error(str(e)) + + def _auto_collect(self, owner, to_addrs, cc_addrs=''): + """Auto-save recipients as contacts.""" + import re + all_addrs = '%s,%s' % (to_addrs, cc_addrs) if cc_addrs else to_addrs + emails = re.findall(r'[\w.+-]+@[\w-]+\.[\w.-]+', all_addrs) + for addr in emails: + if addr == owner: + continue + Contact.objects.get_or_create( + owner_email=owner, + email_address=addr, + defaults={'is_auto_collected': True, 'display_name': addr.split('@')[0]}, + ) + + def apiSaveDraft(self): + try: + email_addr = self._get_email() + data = self._get_post_data() + to = data.get('to', '') + subject = data.get('subject', '') + body_html = data.get('body', '') + + mime_msg = EmailComposer.compose( + from_addr=email_addr, + to_addrs=to, + subject=subject, + body_html=body_html, + ) + + with self._get_imap() as imap: + draft_folders = ['Drafts', 'INBOX.Drafts', 'Draft'] + saved = False + for folder in draft_folders: + try: + if imap.append_message(folder, mime_msg.as_bytes(), '\\Draft \\Seen'): + saved = True + break + except Exception: + continue + if not saved: + imap.create_folder('Drafts') + imap.append_message('Drafts', mime_msg.as_bytes(), '\\Draft \\Seen') + + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiDeleteMessages(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uids = data.get('uids', []) + if not uids: + return self._error('No messages selected.') + try: + with self._get_imap() as imap: + imap.delete_messages(folder, uids) + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiMoveMessages(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uids = data.get('uids', []) + target = data.get('targetFolder', '') + if not uids or not target: + return self._error('Messages and target folder are required.') + try: + with self._get_imap() as imap: + imap.move_messages(folder, uids, target) + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiMarkRead(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uids = data.get('uids', []) + try: + with self._get_imap() as imap: + imap.mark_read(folder, uids) + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiMarkUnread(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uids = data.get('uids', []) + try: + with self._get_imap() as imap: + imap.mark_unread(folder, uids) + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiMarkFlagged(self): + data = self._get_post_data() + folder = data.get('folder', 'INBOX') + uids = data.get('uids', []) + try: + with self._get_imap() as imap: + imap.mark_flagged(folder, uids) + return self._success() + except Exception as e: + return self._error(str(e)) + + # ── Contact APIs ────────────────────────────────────────── + + def apiListContacts(self): + email = self._get_email() + try: + contacts = list(Contact.objects.filter(owner_email=email).values( + 'id', 'display_name', 'email_address', 'phone', 'organization', 'notes', 'is_auto_collected' + )) + return self._success({'contacts': contacts}) + except Exception as e: + return self._error(str(e)) + + def apiCreateContact(self): + email = self._get_email() + data = self._get_post_data() + try: + contact = Contact.objects.create( + owner_email=email, + display_name=data.get('displayName', ''), + email_address=data.get('emailAddress', ''), + phone=data.get('phone', ''), + organization=data.get('organization', ''), + notes=data.get('notes', ''), + ) + return self._success({'id': contact.id}) + except Exception as e: + return self._error(str(e)) + + def apiUpdateContact(self): + email = self._get_email() + data = self._get_post_data() + contact_id = data.get('id') + try: + contact = Contact.objects.get(id=contact_id, owner_email=email) + for field in ['display_name', 'email_address', 'phone', 'organization', 'notes']: + camel = field.replace('_', ' ').title().replace(' ', '') + camel = camel[0].lower() + camel[1:] + if camel in data: + setattr(contact, field, data[camel]) + contact.save() + return self._success() + except Contact.DoesNotExist: + return self._error('Contact not found.') + except Exception as e: + return self._error(str(e)) + + def apiDeleteContact(self): + email = self._get_email() + data = self._get_post_data() + contact_id = data.get('id') + try: + Contact.objects.filter(id=contact_id, owner_email=email).delete() + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiSearchContacts(self): + email = self._get_email() + data = self._get_post_data() + query = data.get('query', '') + try: + from django.db.models import Q + contacts = Contact.objects.filter( + owner_email=email + ).filter( + Q(display_name__icontains=query) | Q(email_address__icontains=query) + ).values('id', 'display_name', 'email_address')[:20] + return self._success({'contacts': list(contacts)}) + except Exception as e: + return self._error(str(e)) + + def apiListContactGroups(self): + email = self._get_email() + try: + groups = list(ContactGroup.objects.filter(owner_email=email).values('id', 'name')) + return self._success({'groups': groups}) + except Exception as e: + return self._error(str(e)) + + def apiCreateContactGroup(self): + email = self._get_email() + data = self._get_post_data() + name = data.get('name', '') + if not name: + return self._error('Group name is required.') + try: + group = ContactGroup.objects.create(owner_email=email, name=name) + return self._success({'id': group.id}) + except Exception as e: + return self._error(str(e)) + + def apiDeleteContactGroup(self): + email = self._get_email() + data = self._get_post_data() + group_id = data.get('id') + try: + ContactGroup.objects.filter(id=group_id, owner_email=email).delete() + return self._success() + except Exception as e: + return self._error(str(e)) + + # ── Sieve Rule APIs ─────────────────────────────────────── + + def apiListRules(self): + email = self._get_email() + try: + rules = list(SieveRule.objects.filter(email_account=email).values( + 'id', 'name', 'priority', 'is_active', + 'condition_field', 'condition_type', 'condition_value', + 'action_type', 'action_value', + )) + return self._success({'rules': rules}) + except Exception as e: + return self._error(str(e)) + + def apiCreateRule(self): + email = self._get_email() + data = self._get_post_data() + try: + rule = SieveRule.objects.create( + email_account=email, + name=data.get('name', 'New Rule'), + priority=data.get('priority', 0), + is_active=data.get('isActive', True), + condition_field=data.get('conditionField', 'from'), + condition_type=data.get('conditionType', 'contains'), + condition_value=data.get('conditionValue', ''), + action_type=data.get('actionType', 'move'), + action_value=data.get('actionValue', ''), + ) + self._sync_sieve_rules(email) + return self._success({'id': rule.id}) + except Exception as e: + return self._error(str(e)) + + def apiUpdateRule(self): + email = self._get_email() + data = self._get_post_data() + rule_id = data.get('id') + try: + rule = SieveRule.objects.get(id=rule_id, email_account=email) + for field in ['name', 'priority', 'is_active', 'condition_field', + 'condition_type', 'condition_value', 'action_type', 'action_value']: + camel = field.replace('_', ' ').title().replace(' ', '') + camel = camel[0].lower() + camel[1:] + if camel in data: + val = data[camel] + if field == 'is_active': + val = bool(val) + elif field == 'priority': + val = int(val) + setattr(rule, field, val) + rule.save() + self._sync_sieve_rules(email) + return self._success() + except SieveRule.DoesNotExist: + return self._error('Rule not found.') + except Exception as e: + return self._error(str(e)) + + def apiDeleteRule(self): + email = self._get_email() + data = self._get_post_data() + rule_id = data.get('id') + try: + SieveRule.objects.filter(id=rule_id, email_account=email).delete() + self._sync_sieve_rules(email) + return self._success() + except Exception as e: + return self._error(str(e)) + + def apiActivateRules(self): + email = self._get_email() + try: + self._sync_sieve_rules(email) + return self._success() + except Exception as e: + return self._error(str(e)) + + def _sync_sieve_rules(self, email): + """Generate sieve script from DB rules and upload to Dovecot.""" + rules = SieveRule.objects.filter(email_account=email, is_active=True).order_by('priority') + rule_dicts = [] + for r in rules: + rule_dicts.append({ + 'name': r.name, + 'condition_field': r.condition_field, + 'condition_type': r.condition_type, + 'condition_value': r.condition_value, + 'action_type': r.action_type, + 'action_value': r.action_value, + }) + + script = SieveClient.rules_to_sieve(rule_dicts) + + try: + with self._get_sieve(email) as sieve: + sieve.put_script('cyberpanel', script) + sieve.activate_script('cyberpanel') + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile('Sieve sync failed for %s: %s' % (email, str(e))) + + # ── Settings APIs ───────────────────────────────────────── + + def apiGetSettings(self): + email = self._get_email() + try: + settings, created = WebmailSettings.objects.get_or_create(email_account=email) + return self._success({ + 'settings': { + 'displayName': settings.display_name, + 'signatureHtml': settings.signature_html, + 'messagesPerPage': settings.messages_per_page, + 'defaultReplyBehavior': settings.default_reply_behavior, + 'themePreference': settings.theme_preference, + 'autoCollectContacts': settings.auto_collect_contacts, + } + }) + except Exception as e: + return self._error(str(e)) + + def apiSaveSettings(self): + email = self._get_email() + data = self._get_post_data() + try: + settings, created = WebmailSettings.objects.get_or_create(email_account=email) + if 'displayName' in data: + settings.display_name = data['displayName'] + if 'signatureHtml' in data: + settings.signature_html = data['signatureHtml'] + if 'messagesPerPage' in data: + settings.messages_per_page = int(data['messagesPerPage']) + if 'defaultReplyBehavior' in data: + settings.default_reply_behavior = data['defaultReplyBehavior'] + if 'themePreference' in data: + settings.theme_preference = data['themePreference'] + if 'autoCollectContacts' in data: + settings.auto_collect_contacts = bool(data['autoCollectContacts']) + settings.save() + return self._success() + except Exception as e: + return self._error(str(e)) + + # ── Image Proxy ─────────────────────────────────────────── + + def apiProxyImage(self): + """Proxy external images to prevent tracking and mixed content.""" + url_b64 = self.request.GET.get('url', '') or self.request.POST.get('url', '') + try: + url = base64.urlsafe_b64decode(url_b64).decode('utf-8') + except Exception: + return self._error('Invalid URL.') + + if not url.startswith(('http://', 'https://')): + return self._error('Invalid URL scheme.') + + try: + import urllib.request + req = urllib.request.Request(url, headers={ + 'User-Agent': 'CyberPanel-Webmail-Proxy/1.0', + }) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get('Content-Type', 'image/png') + if not content_type.startswith('image/'): + return self._error('Not an image.') + data = resp.read(5 * 1024 * 1024) # 5MB max + return HttpResponse(data, content_type=content_type) + except Exception as e: + return self._error(str(e))