"""
Email service for sending invoices to Dext via SMTP.
This service handles:
- SMTP connection and authentication
- Sending HTML emails with attachments
- Testing SMTP configuration
- Generating formatted HTML emails for Dext submission
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import logging
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails via SMTP"""
def __init__(self, settings):
"""Initialize email service with kitchen settings
Args:
settings: KitchenSettings object with SMTP configuration
"""
self.host = settings.smtp_host
self.port = settings.smtp_port
self.username = settings.smtp_username
self.password = settings.smtp_password
self.use_tls = settings.smtp_use_tls
self.from_email = settings.smtp_from_email
self.from_name = settings.smtp_from_name or "Kitchen Invoice System"
def send_email(
self,
to_email: str,
subject: str,
html_body: str,
plain_body: str = None,
attachments: list[tuple[str, bytes]] = None
) -> bool:
"""Send email with HTML and plain text versions plus optional attachments
Args:
to_email: Recipient email address
subject: Email subject line
html_body: HTML email body
plain_body: Plain text email body (optional, for clients that prefer plain text)
attachments: List of (filename, file_bytes) tuples
Returns:
True if sent successfully, False otherwise
"""
try:
msg = MIMEMultipart('mixed')
msg['From'] = f"{self.from_name} <{self.from_email}>"
msg['To'] = to_email
msg['Subject'] = subject
# Create alternative part for plain text and HTML
if plain_body:
alt_part = MIMEMultipart('alternative')
plain_part = MIMEText(plain_body, 'plain')
html_part = MIMEText(html_body, 'html')
alt_part.attach(plain_part)
alt_part.attach(html_part)
msg.attach(alt_part)
else:
# Just HTML body
html_part = MIMEText(html_body, 'html')
msg.attach(html_part)
# Attach files
if attachments:
for filename, file_bytes in attachments:
attachment = MIMEApplication(file_bytes)
attachment.add_header(
'Content-Disposition',
'attachment',
filename=filename
)
msg.attach(attachment)
# Connect and send
# Port 465 uses implicit SSL, port 587 uses STARTTLS
if self.port == 465:
# Use SMTP_SSL for implicit SSL/TLS (port 465)
with smtplib.SMTP_SSL(self.host, self.port, timeout=30) as server:
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
else:
# Use SMTP with STARTTLS for port 587 or others
with smtplib.SMTP(self.host, self.port, timeout=30) as server:
if self.use_tls:
server.starttls()
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email: {e}")
return False
def test_connection(self) -> tuple[bool, str]:
"""Test SMTP connection and authentication
Returns:
(success, message) tuple
"""
try:
# Port 465 uses implicit SSL, port 587 uses STARTTLS
if self.port == 465:
# Use SMTP_SSL for implicit SSL/TLS (port 465)
with smtplib.SMTP_SSL(self.host, self.port, timeout=10) as server:
server.ehlo()
if self.username and self.password:
server.login(self.username, self.password)
else:
# Use SMTP with STARTTLS for port 587 or others
with smtplib.SMTP(self.host, self.port, timeout=10) as server:
server.ehlo()
if self.use_tls:
server.starttls()
server.ehlo()
if self.username and self.password:
server.login(self.username, self.password)
return (True, "SMTP connection successful")
except smtplib.SMTPAuthenticationError:
return (False, "Authentication failed - check username/password")
except smtplib.SMTPException as e:
return (False, f"SMTP error: {str(e)}")
except Exception as e:
return (False, f"Connection error: {str(e)}")
def generate_dext_email_plain(
invoice,
supplier_name: str,
line_items: list,
notes: str | None,
include_notes: bool,
include_non_stock: bool
) -> str:
"""Generate plain text email body for Dext submission
Args:
invoice: Invoice object with invoice_number, invoice_date, total
supplier_name: Supplier name
line_items: List of LineItem objects
notes: Invoice notes
include_notes: Whether to include notes section
include_non_stock: Whether to include non-stock items table
Returns:
Plain text string
"""
lines = []
lines.append("INVOICE SUBMISSION TO DEXT")
lines.append("=" * 40)
lines.append("")
lines.append("INVOICE DETAILS")
lines.append("-" * 20)
lines.append(f"Invoice Number: {invoice.invoice_number or 'N/A'}")
lines.append(f"Supplier: {supplier_name or 'Unknown'}")
lines.append(f"Date: {invoice.invoice_date.strftime('%d/%m/%Y') if invoice.invoice_date else 'N/A'}")
lines.append(f"Total Amount: £{(invoice.total if invoice.total else 0.0):.2f}")
lines.append("")
# Notes section (if enabled and notes exist)
if include_notes and notes:
lines.append("INVOICE NOTES")
lines.append("-" * 20)
lines.append(notes)
lines.append("")
# Non-stock items table (if enabled and non-stock items exist)
if include_non_stock:
non_stock_items = [item for item in line_items if item.is_non_stock]
if non_stock_items:
lines.append("NOTE: The attached PDF has yellow highlights on all non-stock items.")
lines.append("")
lines.append("NON-STOCK ITEMS")
lines.append("-" * 20)
lines.append(f"{'Code':<15} {'Description':<30} {'Qty':<8} {'Price':<10} {'Amount':<10}")
lines.append("-" * 73)
for item in non_stock_items:
code = (item.product_code or '')[:15]
desc = (item.description or '')[:30]
qty = str(item.quantity or '')[:8]
price = f"£{(item.unit_price if item.unit_price else 0.0):.2f}"
amount = f"£{(item.amount if item.amount else 0.0):.2f}"
lines.append(f"{code:<15} {desc:<30} {qty:<8} {price:<10} {amount:<10}")
lines.append("")
return "\n".join(lines)
def generate_dext_email_html(
invoice,
supplier_name: str,
line_items: list,
notes: str | None,
include_notes: bool,
include_non_stock: bool
) -> str:
"""Generate HTML email body for Dext submission
Args:
invoice: Invoice object with invoice_number, invoice_date, total
supplier_name: Supplier name
line_items: List of LineItem objects
notes: Invoice notes
include_notes: Whether to include notes section
include_non_stock: Whether to include non-stock items table
Returns:
HTML string
"""
# Invoice metadata
html = f"""
Invoice Details
Invoice Number: {invoice.invoice_number or 'N/A'}
Supplier: {supplier_name or 'Unknown'}
Date: {invoice.invoice_date.strftime('%d/%m/%Y') if invoice.invoice_date else 'N/A'}
Total Amount: £{(invoice.total if invoice.total else 0.0):.2f}
"""
# Notes section (if enabled and notes exist)
if include_notes and notes:
# Escape HTML in notes and convert newlines to
escaped_notes = notes.replace('&', '&').replace('<', '<').replace('>', '>')
escaped_notes = escaped_notes.replace('\n', '
')
html += f"""
Invoice Notes:
{escaped_notes}
"""
# Non-stock items table (if enabled and non-stock items exist)
if include_non_stock:
non_stock_items = [item for item in line_items if item.is_non_stock]
if non_stock_items:
# Add PDF highlight notification
html += """
📄 The attached PDF has been enhanced with
yellow highlights
on all non-stock items for easy identification.
"""
html += """
Non-Stock Items:
| Code |
Description |
Qty |
Unit Price |
Amount |
"""
for item in non_stock_items:
html += f"""
| {item.product_code or ''} |
{item.description or ''} |
{item.quantity or ''} |
£{(item.unit_price if item.unit_price else 0.0):.2f} |
£{(item.amount if item.amount else 0.0):.2f} |
"""
html += """
"""
html += """
"""
return html