Coverage for opt/mealie/lib/python3.12/site-packages/mealie/services/email/email_senders.py: 39%
76 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1import smtplib
2import typing
3from abc import ABC, abstractmethod
4from dataclasses import dataclass
5from email import message
6from email.utils import formatdate
7from uuid import uuid4
9from html2text import html2text
11from mealie.services._base_service import BaseService
13SMTP_TIMEOUT = 10
14"""Timeout in seconds for SMTP connection"""
17@dataclass(slots=True)
18class EmailOptions:
19 host: str
20 port: int
21 username: str | None = None
22 password: str | None = None
23 tls: bool = False
24 ssl: bool = False
27@dataclass(slots=True)
28class SMTPResponse:
29 success: bool
30 message: str
31 errors: typing.Any
34@dataclass(slots=True)
35class Message:
36 subject: str
37 html: str
38 mail_from_name: str
39 mail_from_address: str
41 def send(self, to: str, smtp: EmailOptions) -> SMTPResponse:
42 msg = message.EmailMessage()
43 msg["Subject"] = self.subject
44 msg["From"] = f"{self.mail_from_name} <{self.mail_from_address}>"
45 msg["To"] = to
46 msg["Date"] = formatdate(localtime=True)
47 msg.add_alternative(html2text(self.html), subtype="plain")
48 msg.add_alternative(self.html, subtype="html")
50 try:
51 message_id = f"<{uuid4()}@{self.mail_from_address.split('@')[1]}>"
52 except IndexError:
53 # this should never happen with a valid email address,
54 # but we let the SMTP server handle it instead of raising it here
55 message_id = f"<{uuid4()}@{self.mail_from_address}>"
57 msg["Message-ID"] = message_id
58 msg["MIME-Version"] = "1.0"
60 if smtp.ssl:
61 with smtplib.SMTP_SSL(smtp.host, smtp.port, timeout=SMTP_TIMEOUT) as server:
62 if smtp.username and smtp.password:
63 server.login(smtp.username, smtp.password)
65 errors = server.send_message(msg)
66 else:
67 with smtplib.SMTP(smtp.host, smtp.port, timeout=SMTP_TIMEOUT) as server:
68 if smtp.tls:
69 server.starttls()
70 if smtp.username and smtp.password:
71 server.login(smtp.username, smtp.password)
72 errors = server.send_message(msg)
74 return SMTPResponse(errors == {}, "Message Sent", errors=errors)
77class ABCEmailSender(ABC):
78 @abstractmethod
79 def send(self, email_to: str, subject: str, html: str) -> bool: ... 79 ↛ exitline 79 didn't return from function 'send' because
82class DefaultEmailSender(ABCEmailSender, BaseService):
83 """
84 DefaultEmailSender is the default email sender for Mealie. It uses the SMTP settings
85 from the config file to send emails via the python standard library. It supports
86 both TLS and SSL connections.
87 """
89 def send(self, email_to: str, subject: str, html: str) -> bool:
90 if self.settings.SMTP_FROM_EMAIL is None or self.settings.SMTP_FROM_NAME is None:
91 raise ValueError("SMTP_FROM_EMAIL and SMTP_FROM_NAME must be set in the config file.")
93 message = Message(
94 subject=subject,
95 html=html,
96 mail_from_name=self.settings.SMTP_FROM_NAME,
97 mail_from_address=self.settings.SMTP_FROM_EMAIL,
98 )
100 if self.settings.SMTP_HOST is None or self.settings.SMTP_PORT is None:
101 raise ValueError("SMTP_HOST, SMTP_PORT must be set in the config file.")
103 smtp_options = EmailOptions(
104 self.settings.SMTP_HOST,
105 int(self.settings.SMTP_PORT),
106 tls=self.settings.SMTP_AUTH_STRATEGY.upper() == "TLS" if self.settings.SMTP_AUTH_STRATEGY else False,
107 ssl=self.settings.SMTP_AUTH_STRATEGY.upper() == "SSL" if self.settings.SMTP_AUTH_STRATEGY else False,
108 )
110 if self.settings.SMTP_USER:
111 smtp_options.username = self.settings.SMTP_USER
112 if self.settings.SMTP_PASSWORD:
113 smtp_options.password = self.settings.SMTP_PASSWORD
115 response = message.send(to=email_to, smtp=smtp_options)
116 self.logger.debug(f"send email result: {response}")
118 if not response.success:
119 self.logger.error(f"send email error: {response}")
121 return response.success