Files
CyberPanel/webmail/services/smtp_client.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

91 lines
3.6 KiB
Python

import smtplib
import ssl
class SMTPClient:
"""Wrapper around smtplib.SMTP for sending mail via Postfix.
Supports two modes:
1. Authenticated (port 587 + STARTTLS) for standalone login sessions.
2. Local relay (port 25, no auth) for SSO sessions using master user.
Postfix accepts relay from localhost (permit_mynetworks in main.cf)
"""
def __init__(self, email_address, password, host='localhost', port=587,
use_local_relay=False):
self.email_address = email_address
self.password = password
# Postfix on AlmaLinux/CyberPanel often sets mynetworks=127.0.0.0/8 only.
# Python resolves "localhost" to ::1 first → SMTP is not treated as mynetworks
# → 554 Relay access denied. Force IPv4 loopback for predictable relay.
self.host = self._smtp_host_ipv4_loopback(host)
self.port = port
self.use_local_relay = use_local_relay
@staticmethod
def _smtp_host_ipv4_loopback(host):
if not host:
return '127.0.0.1'
h = str(host).strip().lower()
if h in ('localhost', '::1', '[::1]'):
return '127.0.0.1'
return host
def send_message(self, mime_message):
"""Send a composed email via SMTP.
Returns:
dict: {success: bool, message_id: str or None, error: str or None}
"""
try:
# Bind outbound socket to IPv4 so Postfix sees 127.0.0.1 (mynetworks), not ::1.
src = ('127.0.0.1', 0)
if self.use_local_relay:
# SSO mode: send via port 25 without auth
# Postfix permits relay from localhost (permit_mynetworks)
smtp = smtplib.SMTP(self.host, 25, source_address=src)
smtp.ehlo()
smtp.send_message(mime_message)
smtp.quit()
else:
# Standalone mode: authenticated via port 587 + STARTTLS
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
smtp = smtplib.SMTP(self.host, self.port, source_address=src)
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.
CyberPanel's Dovecot uses INBOX.Sent as the Sent folder.
"""
# Try CyberPanel's actual folder name first, then fallbacks
sent_folders = ['INBOX.Sent', '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('INBOX.Sent')
return imap_client.append_message('INBOX.Sent', raw_message, '\\Seen')
except Exception:
return False