Files
CyberPanel/webmail/services/email_composer.py
master3395 3e8750ab58 webmail: v2.5.5-dev UI and backend improvements
- Resizable folder sidebar with persisted width; nested folder tree with expand/collapse
- Message search: scope all folders or single folder; listMessages honors UID filter
- Drag-and-drop messages onto folders to move (multi-select supported)
- SnappyMail import paths, folder settings store, wm DB migration and SQL install
- IMAP quoted mailbox, IPv4 SMTP relay, compose recipient handling
- Modal new/delete folder flows; dash-free UI copy; folder pills in search results
2026-03-25 23:18:54 +01:00

233 lines
8.2 KiB
Python

import email
import re
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, formataddr, parseaddr
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import mimetypes
class EmailComposer:
"""Construct MIME messages for sending."""
# Light validation after parseaddr (avoid empty / garbage tokens from trailing commas).
@staticmethod
def _valid_email(addr):
if not addr or '@' not in addr:
return False
local, _, domain = addr.partition('@')
if not local or not domain or '.' not in domain:
return False
if len(addr) > 254:
return False
return True
@classmethod
def normalize_address_list(cls, raw):
"""Split comma/semicolon-separated addresses; drop empties and obviously invalid tokens."""
if not raw:
return []
if isinstance(raw, (list, tuple)):
chunks = raw
else:
txt = str(raw).replace(';', ',')
chunks = txt.split(',')
out = []
seen = set()
for chunk in chunks:
part = (chunk or '').strip()
if not part:
continue
_name, addr = parseaddr(part)
addr = (addr or '').strip()
if not addr:
continue
key = addr.lower()
if key in seen:
continue
if not cls._valid_email(addr):
continue
seen.add(key)
out.append(addr)
return out
@staticmethod
def compose(from_addr, to_addrs, subject, body_html='', body_text='',
cc_addrs='', bcc_addrs='', attachments=None,
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'))
to_list = EmailComposer.normalize_address_list(to_addrs)
cc_list = EmailComposer.normalize_address_list(cc_addrs)
bcc_list = EmailComposer.normalize_address_list(bcc_addrs)
if not to_list and not cc_list and not bcc_list:
raise ValueError('No valid recipients after parsing To/Cc/Bcc.')
msg['From'] = from_addr
if to_list:
msg['To'] = ', '.join(to_list)
elif cc_list:
msg['To'] = ', '.join(cc_list)
else:
msg['To'] = 'undisclosed-recipients:;'
if cc_list and to_list:
msg['Cc'] = ', '.join(cc_list)
if bcc_list:
msg['Bcc'] = ', '.join(bcc_list)
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain=from_addr.split('@')[-1] if '@' in from_addr else 'localhost')
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
from html import escape as html_escape
orig_date = html_escape(original.get('date', ''))
orig_from = html_escape(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 html_escape(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
from html import escape as html_escape
orig_from = html_escape(original.get('from', ''))
orig_to = html_escape(original.get('to', ''))
orig_date = html_escape(original.get('date', ''))
orig_subject = html_escape(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 html_escape(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,
)