Files
CyberPanel/webmail/services/email_composer.py
usmannasir 28113d97a7 Security fixes for webmail and emailDelivery apps
- Fix command injection in relay config: use shlex.quote() on all
  subprocess arguments passed to mailUtilities.py
- Fix XSS in email reply/forward: html.escape() on From/To/Date/Subject
  headers before embedding in quoted HTML
- Fix attachment filename traversal: use os.path.basename() and strip
  null bytes from attachment filenames
- Fix Sieve script name injection: sanitize names to alphanumeric chars
- Fix SSRF in image proxy: resolve hostname to IP and check against
  ipaddress.is_private/is_loopback/is_link_local/is_reserved
- Remove internal error details from user-facing responses
- Update Access Webmail link from /snappymail/ to /webmail/
2026-03-06 03:27:45 +05:00

181 lines
6.4 KiB
Python

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