Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

171 simple and fast smtp client #191

Merged
merged 2 commits into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 0 additions & 1 deletion app/services/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

141 changes: 123 additions & 18 deletions app/services/smtp.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from attrs import define, field
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
Expand All @@ -15,40 +16,144 @@
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.

def send_email(
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=None,
):
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"))
self.server.sendmail(sender, recipients, msg.as_string())
logger.debug(f"Prepared email from {sender} to {recipients}.")
return msg

def send_email(
self,
sender: EmailStr,
recipients: list[EmailStr],
subject: str,
body_text: str = "",
body_html: str = None,
Copy link
Preview

Copilot AI Dec 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The body_html argument should be initialized as an empty string instead of None to avoid potential issues in _prepare_email method.

Suggested change
body_html: str = None,
body_html: str = "",

Copilot is powered by AI, so mistakes are possible. Review output carefully before use.

Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
):
"""
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
2 changes: 1 addition & 1 deletion app/utils/singleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ def __call__(cls):
if cls not in cls._instances:
instance = super().__call__()
cls._instances[cls] = instance
return cls._instances[cls]
return cls._instances[cls]
Loading