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