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

On %s, %s wrote:
%s
' % ( 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 = ( '

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

' '%s
' ) % (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, )