Skip to content

Commit 30a1004

Browse files
authored
Merge pull request #191 from grillazz/171-simple-and-fast-smtp-client
171 simple and fast smtp client
2 parents 92965f2 + abb6eae commit 30a1004

File tree

4 files changed

+125
-20
lines changed

4 files changed

+125
-20
lines changed

app/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class SMTPConfig(BaseModel):
1010
port: int = os.getenv("EMAIL_PORT", 587)
1111
username: str = os.getenv("EMAIL_HOST_USER", "smtp_user")
1212
password: str = os.getenv("EMAIL_HOST_PASSWORD", "smtp_password")
13+
template_path: str = os.getenv("EMAIL_TEMPLATE_PATH", "templates")
1314

1415

1516
class Settings(BaseSettings):

app/services/scheduler.py

-1
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,3 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
4545
await self.app(scope, receive, send)
4646
else:
4747
await self.app(scope, receive, send)
48-

app/services/smtp.py

+123-18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from attrs import define, field
12
import smtplib
23
from email.mime.multipart import MIMEMultipart
34
from email.mime.text import MIMEText
@@ -15,40 +16,144 @@
1516
logger = AppLogger().get_logger()
1617

1718

19+
@define
1820
class SMTPEmailService(metaclass=SingletonMetaNoArgs):
19-
def __init__(self):
20-
self.server = smtplib.SMTP(
21-
global_settings.smtp.server, global_settings.smtp.port
22-
)
23-
self.server.starttls()
24-
self.server.login(global_settings.smtp.username, global_settings.smtp.password)
25-
self.templates = Jinja2Templates("templates")
21+
"""
22+
SMTPEmailService provides a reusable interface to send emails via an SMTP server.
2623
27-
def send_email(
24+
This service supports plaintext and HTML emails, and also allows
25+
sending template-based emails using the Jinja2 template engine.
26+
27+
It is implemented as a singleton to ensure that only one SMTP connection is maintained
28+
throughout the application lifecycle, optimizing resource usage.
29+
30+
Attributes:
31+
server_host (str): SMTP server hostname or IP address.
32+
server_port (int): Port number for the SMTP connection.
33+
username (str): SMTP username for authentication.
34+
password (str): SMTP password for authentication.
35+
templates (Jinja2Templates): Jinja2Templates instance for loading and rendering email templates.
36+
server (smtplib.SMTP): An SMTP object for sending emails, initialized after object creation.
37+
"""
38+
39+
# SMTP configuration
40+
server_host: str = field(default=global_settings.smtp.server)
41+
server_port: int = field(default=global_settings.smtp.port)
42+
username: str = field(default=global_settings.smtp.username)
43+
password: str = field(default=global_settings.smtp.password)
44+
45+
# Dependencies
46+
templates: Jinja2Templates = field(
47+
factory=lambda: Jinja2Templates(global_settings.smtp.template_path)
48+
)
49+
server: smtplib.SMTP = field(init=False) # Deferred initialization in post-init
50+
51+
def __attrs_post_init__(self):
52+
"""
53+
Initializes the SMTP server connection after the object is created.
54+
55+
This method sets up a secure connection to the SMTP server, including STARTTLS encryption
56+
and logs in using the provided credentials.
57+
"""
58+
self.server = smtplib.SMTP(self.server_host, self.server_port)
59+
self.server.starttls() # Upgrade the connection to secure TLS
60+
self.server.login(self.username, self.password)
61+
logger.info("SMTPEmailService initialized successfully and connected to SMTP server.")
62+
63+
def _prepare_email(
2864
self,
2965
sender: EmailStr,
3066
recipients: list[EmailStr],
3167
subject: str,
32-
body_text: str = "",
33-
body_html=None,
34-
):
68+
body_text: str,
69+
body_html: str,
70+
) -> MIMEMultipart:
71+
"""
72+
Prepares a MIME email message with the given plaintext and HTML content.
73+
74+
Args:
75+
sender (EmailStr): The email address of the sender.
76+
recipients (list[EmailStr]): A list of recipient email addresses.
77+
subject (str): The subject line of the email.
78+
body_text (str): The plaintext content of the email.
79+
body_html (str): The HTML content of the email (optional).
80+
81+
Returns:
82+
MIMEMultipart: A MIME email object ready to be sent.
83+
"""
3584
msg = MIMEMultipart()
3685
msg["From"] = sender
3786
msg["To"] = ",".join(recipients)
3887
msg["Subject"] = subject
88+
# Add plain text and HTML content (if provided)
3989
msg.attach(MIMEText(body_text, "plain"))
4090
if body_html:
4191
msg.attach(MIMEText(body_html, "html"))
42-
self.server.sendmail(sender, recipients, msg.as_string())
92+
logger.debug(f"Prepared email from {sender} to {recipients}.")
93+
return msg
94+
95+
def send_email(
96+
self,
97+
sender: EmailStr,
98+
recipients: list[EmailStr],
99+
subject: str,
100+
body_text: str = "",
101+
body_html: str = None,
102+
):
103+
"""
104+
Sends an email to the specified recipients.
105+
106+
Supports plaintext and HTML email content. This method constructs
107+
the email message using `_prepare_email` and sends it using the SMTP server.
108+
109+
Args:
110+
sender (EmailStr): The email address of the sender.
111+
recipients (list[EmailStr]): A list of recipient email addresses.
112+
subject (str): The subject line of the email.
113+
body_text (str): The plaintext content of the email.
114+
body_html (str): The HTML content of the email (optional).
115+
116+
Raises:
117+
smtplib.SMTPException: If the email cannot be sent.
118+
"""
119+
try:
120+
msg = self._prepare_email(sender, recipients, subject, body_text, body_html)
121+
self.server.sendmail(sender, recipients, msg.as_string())
122+
logger.info(f"Email sent successfully to {recipients} from {sender}.")
123+
except smtplib.SMTPException as e:
124+
logger.error("Failed to send email", exc_info=e)
125+
raise
43126

44127
def send_template_email(
45128
self,
46129
recipients: list[EmailStr],
47130
subject: str,
48-
template: str = None,
49-
context: dict = None,
50-
sender: EmailStr = global_settings.smtp.from_email,
131+
template: str,
132+
context: dict,
133+
sender: EmailStr,
51134
):
52-
template_str = self.templates.get_template(template)
53-
body_html = template_str.render(context)
54-
self.send_email(sender, recipients, subject, body_html=body_html)
135+
"""
136+
Sends an email using a Jinja2 template.
137+
138+
This method renders the template with the provided context and sends it
139+
to the specified recipients.
140+
141+
Args:
142+
recipients (list[EmailStr]): A list of recipient email addresses.
143+
subject (str): The subject line of the email.
144+
template (str): The name of the template file in the templates directory.
145+
context (dict): A dictionary of values to render the template with.
146+
sender (EmailStr): The email address of the sender.
147+
148+
Raises:
149+
jinja2.TemplateNotFound: If the specified template is not found.
150+
smtplib.SMTPException: If the email cannot be sent.
151+
"""
152+
try:
153+
template_str = self.templates.get_template(template)
154+
body_html = template_str.render(context) # Render the HTML using context variables
155+
self.send_email(sender, recipients, subject, body_html=body_html)
156+
logger.info(f"Template email sent successfully to {recipients} using template {template}.")
157+
except Exception as e:
158+
logger.error("Failed to send template email", exc_info=e)
159+
raise

app/utils/singleton.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,4 @@ def __call__(cls):
3333
if cls not in cls._instances:
3434
instance = super().__call__()
3535
cls._instances[cls] = instance
36-
return cls._instances[cls]
36+
return cls._instances[cls]

0 commit comments

Comments
 (0)