Merge pull request #191 from grillazz/171-simple-and-fast-smtp-client

171 simple and fast smtp client
This commit is contained in:
Jakub Miazek 2024-12-28 08:59:34 +01:00 committed by GitHub
commit 30a10046c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 129 additions and 24 deletions

View File

@ -10,6 +10,7 @@ class SMTPConfig(BaseModel):
port: int = os.getenv("EMAIL_PORT", 587)
username: str = os.getenv("EMAIL_HOST_USER", "smtp_user")
password: str = os.getenv("EMAIL_HOST_PASSWORD", "smtp_password")
template_path: str = os.getenv("EMAIL_TEMPLATE_PATH", "templates")
class Settings(BaseSettings):

View File

@ -45,4 +45,3 @@ class SchedulerMiddleware:
await self.app(scope, receive, send)
else:
await self.app(scope, receive, send)

View File

@ -1,3 +1,4 @@
from attrs import define, field
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@ -15,14 +16,81 @@ from app.utils.singleton import SingletonMetaNoArgs
logger = AppLogger().get_logger()
@define
class SMTPEmailService(metaclass=SingletonMetaNoArgs):
def __init__(self):
self.server = smtplib.SMTP(
global_settings.smtp.server, global_settings.smtp.port
)
self.server.starttls()
self.server.login(global_settings.smtp.username, global_settings.smtp.password)
self.templates = Jinja2Templates("templates")
"""
SMTPEmailService provides a reusable interface to send emails via an SMTP server.
This service supports plaintext and HTML emails, and also allows
sending template-based emails using the Jinja2 template engine.
It is implemented as a singleton to ensure that only one SMTP connection is maintained
throughout the application lifecycle, optimizing resource usage.
Attributes:
server_host (str): SMTP server hostname or IP address.
server_port (int): Port number for the SMTP connection.
username (str): SMTP username for authentication.
password (str): SMTP password for authentication.
templates (Jinja2Templates): Jinja2Templates instance for loading and rendering email templates.
server (smtplib.SMTP): An SMTP object for sending emails, initialized after object creation.
"""
# SMTP configuration
server_host: str = field(default=global_settings.smtp.server)
server_port: int = field(default=global_settings.smtp.port)
username: str = field(default=global_settings.smtp.username)
password: str = field(default=global_settings.smtp.password)
# Dependencies
templates: Jinja2Templates = field(
factory=lambda: Jinja2Templates(global_settings.smtp.template_path)
)
server: smtplib.SMTP = field(init=False) # Deferred initialization in post-init
def __attrs_post_init__(self):
"""
Initializes the SMTP server connection after the object is created.
This method sets up a secure connection to the SMTP server, including STARTTLS encryption
and logs in using the provided credentials.
"""
self.server = smtplib.SMTP(self.server_host, self.server_port)
self.server.starttls() # Upgrade the connection to secure TLS
self.server.login(self.username, self.password)
logger.info("SMTPEmailService initialized successfully and connected to SMTP server.")
def _prepare_email(
self,
sender: EmailStr,
recipients: list[EmailStr],
subject: str,
body_text: str,
body_html: str,
) -> MIMEMultipart:
"""
Prepares a MIME email message with the given plaintext and HTML content.
Args:
sender (EmailStr): The email address of the sender.
recipients (list[EmailStr]): A list of recipient email addresses.
subject (str): The subject line of the email.
body_text (str): The plaintext content of the email.
body_html (str): The HTML content of the email (optional).
Returns:
MIMEMultipart: A MIME email object ready to be sent.
"""
msg = MIMEMultipart()
msg["From"] = sender
msg["To"] = ",".join(recipients)
msg["Subject"] = subject
# Add plain text and HTML content (if provided)
msg.attach(MIMEText(body_text, "plain"))
if body_html:
msg.attach(MIMEText(body_html, "html"))
logger.debug(f"Prepared email from {sender} to {recipients}.")
return msg
def send_email(
self,
@ -30,25 +98,62 @@ class SMTPEmailService(metaclass=SingletonMetaNoArgs):
recipients: list[EmailStr],
subject: str,
body_text: str = "",
body_html=None,
body_html: str = None,
):
msg = MIMEMultipart()
msg["From"] = sender
msg["To"] = ",".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText(body_text, "plain"))
if body_html:
msg.attach(MIMEText(body_html, "html"))
self.server.sendmail(sender, recipients, msg.as_string())
"""
Sends an email to the specified recipients.
Supports plaintext and HTML email content. This method constructs
the email message using `_prepare_email` and sends it using the SMTP server.
Args:
sender (EmailStr): The email address of the sender.
recipients (list[EmailStr]): A list of recipient email addresses.
subject (str): The subject line of the email.
body_text (str): The plaintext content of the email.
body_html (str): The HTML content of the email (optional).
Raises:
smtplib.SMTPException: If the email cannot be sent.
"""
try:
msg = self._prepare_email(sender, recipients, subject, body_text, body_html)
self.server.sendmail(sender, recipients, msg.as_string())
logger.info(f"Email sent successfully to {recipients} from {sender}.")
except smtplib.SMTPException as e:
logger.error("Failed to send email", exc_info=e)
raise
def send_template_email(
self,
recipients: list[EmailStr],
subject: str,
template: str = None,
context: dict = None,
sender: EmailStr = global_settings.smtp.from_email,
template: str,
context: dict,
sender: EmailStr,
):
template_str = self.templates.get_template(template)
body_html = template_str.render(context)
self.send_email(sender, recipients, subject, body_html=body_html)
"""
Sends an email using a Jinja2 template.
This method renders the template with the provided context and sends it
to the specified recipients.
Args:
recipients (list[EmailStr]): A list of recipient email addresses.
subject (str): The subject line of the email.
template (str): The name of the template file in the templates directory.
context (dict): A dictionary of values to render the template with.
sender (EmailStr): The email address of the sender.
Raises:
jinja2.TemplateNotFound: If the specified template is not found.
smtplib.SMTPException: If the email cannot be sent.
"""
try:
template_str = self.templates.get_template(template)
body_html = template_str.render(context) # Render the HTML using context variables
self.send_email(sender, recipients, subject, body_html=body_html)
logger.info(f"Template email sent successfully to {recipients} using template {template}.")
except Exception as e:
logger.error("Failed to send template email", exc_info=e)
raise

View File

@ -33,4 +33,4 @@ class SingletonMetaNoArgs(type):
if cls not in cls._instances:
instance = super().__call__()
cls._instances[cls] = instance
return cls._instances[cls]
return cls._instances[cls]