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

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 

8 

9from html2text import html2text 

10 

11from mealie.services._base_service import BaseService 

12 

13SMTP_TIMEOUT = 10 

14"""Timeout in seconds for SMTP connection""" 

15 

16 

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 

25 

26 

27@dataclass(slots=True) 

28class SMTPResponse: 

29 success: bool 

30 message: str 

31 errors: typing.Any 

32 

33 

34@dataclass(slots=True) 

35class Message: 

36 subject: str 

37 html: str 

38 mail_from_name: str 

39 mail_from_address: str 

40 

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

49 

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}>" 

56 

57 msg["Message-ID"] = message_id 

58 msg["MIME-Version"] = "1.0" 

59 

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) 

64 

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) 

73 

74 return SMTPResponse(errors == {}, "Message Sent", errors=errors) 

75 

76 

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

80 

81 

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 """ 

88 

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.") 

92 

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 ) 

99 

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.") 

102 

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 ) 

109 

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 

114 

115 response = message.send(to=email_to, smtp=smtp_options) 

116 self.logger.debug(f"send email result: {response}") 

117 

118 if not response.success: 

119 self.logger.error(f"send email error: {response}") 

120 

121 return response.success