From 6ba6d23047fdf14f7f67f246b1c80cdc8af71ed7 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sat, 28 Dec 2024 08:10:09 +0100 Subject: [PATCH 1/2] refactor and black --- app/config.py | 1 + app/services/scheduler.py | 1 - app/services/smtp.py | 52 +++++++++++++++++++++++++++++---------- app/utils/singleton.py | 2 +- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/app/config.py b/app/config.py index 5c617d3..b0b2c7a 100644 --- a/app/config.py +++ b/app/config.py @@ -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): diff --git a/app/services/scheduler.py b/app/services/scheduler.py index 6a642d3..739e369 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -45,4 +45,3 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) else: await self.app(scope, receive, send) - diff --git a/app/services/smtp.py b/app/services/smtp.py index d4a2ce0..914d460 100644 --- a/app/services/smtp.py +++ b/app/services/smtp.py @@ -1,3 +1,4 @@ +from attrs import define, field import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -15,23 +16,35 @@ logger = AppLogger().get_logger() +@define class SMTPEmailService(metaclass=SingletonMetaNoArgs): - def __init__(self): - self.server = smtplib.SMTP( - global_settings.smtp.server, global_settings.smtp.port - ) + # 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.templates_dir) + ) + server: smtplib.SMTP = field(init=False) # Deferred initialization in post-init + + def __attrs_post_init__(self): + """Initialize the SMTP server connection after object creation.""" + self.server = smtplib.SMTP(self.server_host, self.server_port) self.server.starttls() - self.server.login(global_settings.smtp.username, global_settings.smtp.password) - self.templates = Jinja2Templates("templates") + self.server.login(self.username, self.password) - def send_email( + def _prepare_email( self, sender: EmailStr, recipients: list[EmailStr], subject: str, - body_text: str = "", - body_html=None, - ): + body_text: str, + body_html: str, + ) -> MIMEMultipart: + """Prepare the email message.""" msg = MIMEMultipart() msg["From"] = sender msg["To"] = ",".join(recipients) @@ -39,16 +52,29 @@ def send_email( msg.attach(MIMEText(body_text, "plain")) if body_html: msg.attach(MIMEText(body_html, "html")) + return msg + + def send_email( + self, + sender: EmailStr, + recipients: list[EmailStr], + subject: str, + body_text: str = "", + body_html: str = None, + ): + """Send a regular email (plain text or HTML).""" + msg = self._prepare_email(sender, recipients, subject, body_text, body_html) self.server.sendmail(sender, recipients, msg.as_string()) 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, ): + """Send an email using a template with the provided context.""" template_str = self.templates.get_template(template) body_html = template_str.render(context) self.send_email(sender, recipients, subject, body_html=body_html) diff --git a/app/utils/singleton.py b/app/utils/singleton.py index 85df17e..4bda944 100644 --- a/app/utils/singleton.py +++ b/app/utils/singleton.py @@ -33,4 +33,4 @@ def __call__(cls): if cls not in cls._instances: instance = super().__call__() cls._instances[cls] = instance - return cls._instances[cls] \ No newline at end of file + return cls._instances[cls] From abb6eae23abd18a5c6960e5fd2649ed8eb6ea233 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sat, 28 Dec 2024 08:51:07 +0100 Subject: [PATCH 2/2] docz and logz --- app/services/smtp.py | 101 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/app/services/smtp.py b/app/services/smtp.py index 914d460..d07cd03 100644 --- a/app/services/smtp.py +++ b/app/services/smtp.py @@ -18,6 +18,24 @@ @define class SMTPEmailService(metaclass=SingletonMetaNoArgs): + """ + 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) @@ -26,15 +44,21 @@ class SMTPEmailService(metaclass=SingletonMetaNoArgs): # Dependencies templates: Jinja2Templates = field( - factory=lambda: Jinja2Templates(global_settings.templates_dir) + factory=lambda: Jinja2Templates(global_settings.smtp.template_path) ) server: smtplib.SMTP = field(init=False) # Deferred initialization in post-init def __attrs_post_init__(self): - """Initialize the SMTP server connection after object creation.""" + """ + 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() + 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, @@ -44,14 +68,28 @@ def _prepare_email( body_text: str, body_html: str, ) -> MIMEMultipart: - """Prepare the email message.""" + """ + 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( @@ -62,9 +100,29 @@ def send_email( body_text: str = "", body_html: str = None, ): - """Send a regular email (plain text or HTML).""" - msg = self._prepare_email(sender, recipients, subject, body_text, body_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, @@ -74,7 +132,28 @@ def send_template_email( context: dict, sender: EmailStr, ): - """Send an email using a template with the provided context.""" - 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 \ No newline at end of file