Skip to content

Commit

Permalink
added mail module
Browse files Browse the repository at this point in the history
  • Loading branch information
anubrag committed Jan 18, 2024
1 parent c7c6719 commit d80e4c9
Show file tree
Hide file tree
Showing 11 changed files with 362 additions and 10 deletions.
4 changes: 0 additions & 4 deletions nextpy/backend/module/email/__init__.py

This file was deleted.

16 changes: 16 additions & 0 deletions nextpy/backend/module/mail/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""The email package."""

from .config import EmailConfig
from .message import EmailMessage
from .sender import EmailSender
from .email_template import EmailTemplateManager
from .exceptions import EmailConfigError, EmailSendError

__all__ = [
'EmailConfig',
'EmailSender',
'EmailMessage',
'EmailTemplateManager',
'EmailConfigError',
'EmailSendError'
]
19 changes: 19 additions & 0 deletions nextpy/backend/module/mail/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr


class EmailConfig(BaseModel):
"""Configuration class for the email module."""

MAIL_SERVER: str # SMTP server address
MAIL_PORT: int # SMTP server port
MAIL_USERNAME: str # SMTP username
MAIL_PASSWORD: str # SMTP password
MAIL_USE_TLS: bool = False # Whether to use TLS
MAIL_USE_SSL: bool = False # Whether to use SSL
MAIL_DEFAULT_SENDER: EmailStr # Default email sender
MAIL_TEMPLATE_FOLDER: str = None # Path to the email templates directory
MAIL_MAX_EMAILS: int = None # Maximum number of emails to send
MAIL_SUPPRESS_SEND: bool = False # Suppress sending emails for testing

class Config:
env_prefix = "EMAIL_" # Prefix for environment variable configuration
15 changes: 15 additions & 0 deletions nextpy/backend/module/mail/email_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape

from .config import EmailConfig


class EmailTemplateManager:
def __init__(self, config: EmailConfig):
self.env = Environment(
loader=FileSystemLoader(config.MAIL_TEMPLATE_FOLDER),
autoescape=select_autoescape(['html', 'xml'])
)

def render_template(self, template_name: str, context: dict) -> str:
template = self.env.get_template(template_name)
return template.render(context)
8 changes: 8 additions & 0 deletions nextpy/backend/module/mail/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class EmailError(Exception):
"""Base class for email errors."""

class EmailConfigError(EmailError):
"""Raised for configuration related errors."""

class EmailSendError(EmailError):
"""Raised when sending an email fails."""
61 changes: 61 additions & 0 deletions nextpy/backend/module/mail/message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import mimetypes
import os
from email import encoders
from email.mime.base import MIMEBase
from enum import Enum
from typing import Dict, List, Optional, Union

from pydantic import BaseModel, EmailStr, validator


class MessageType(str, Enum):
PLAIN = "plain"
HTML = "html"

class MultipartSubtypeEnum(str, Enum):
MIXED = "mixed"
DIGEST = "digest"
ALTERNATIVE = "alternative"
RELATED = "related"
REPORT = "report"
SIGNED = "signed"
ENCRYPTED = "encrypted"
FORM_DATA = "form-data"
MIXED_REPLACE = "x-mixed-replace"
BYTERANGE = "byterange"

class EmailMessage(BaseModel):
recipients: List[EmailStr]
subject: str
body: Optional[str] = None
html_body: Optional[str] = None
sender: Optional[EmailStr] = None
cc: List[EmailStr] = []
bcc: List[EmailStr] = []
attachments: List[str] = [] # List of file paths for attachments
template_body: Optional[Union[dict, list, str]] = None # Template context
subtype: MessageType = MessageType.PLAIN
multipart_subtype: MultipartSubtypeEnum = MultipartSubtypeEnum.MIXED
headers: Optional[Dict[str, str]] = None

@validator('attachments', each_item=True)
def validate_attachment(cls, v):
if isinstance(v, str) and os.path.isfile(v) and os.access(v, os.R_OK):
return v
raise ValueError("Attachment must be a readable file path")

def create_attachment(self, filepath: str) -> MIMEBase:
"""Creates a MIMEBase object for the given attachment file."""
ctype, encoding = mimetypes.guess_type(filepath)
if ctype is None or encoding is not None:
ctype = 'application/octet-stream'
maintype, subtype = ctype.split('/', 1)
with open(filepath, 'rb') as fp:
attachment = MIMEBase(maintype, subtype)
attachment.set_payload(fp.read())
encoders.encode_base64(attachment)
attachment.add_header('Content-Disposition', 'attachment', filename=os.path.basename(filepath))
return attachment

class Config:
arbitrary_types_allowed = True
70 changes: 70 additions & 0 deletions nextpy/backend/module/mail/sender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional

import aiosmtplib

from .config import EmailConfig
from .email_template import EmailTemplateManager
from .exceptions import EmailSendError
from .message import EmailMessage


class EmailSender:
def __init__(self, config: EmailConfig, template_manager: Optional[EmailTemplateManager] = None):
self.config = config
self.template_manager = template_manager

async def send_email(self, message: EmailMessage, template_name: Optional[str] = None) -> None:
"""Sends an email message."""
if self.config.MAIL_SUPPRESS_SEND:
return

smtp = aiosmtplib.SMTP()

try:
await smtp.connect(hostname=self.config.MAIL_SERVER, port=self.config.MAIL_PORT, use_tls=self.config.MAIL_USE_TLS)
if self.config.MAIL_USE_TLS or self.config.MAIL_USE_SSL:
await smtp.starttls()
if self.config.MAIL_USERNAME and self.config.MAIL_PASSWORD:
await smtp.login(self.config.MAIL_USERNAME, self.config.MAIL_PASSWORD)

mime_message = await self._create_mime_message(message, template_name)
await smtp.send_message(mime_message)
except Exception as error:
# Consider adding logging here
raise EmailSendError(f"Failed to send email: {error}")
finally:
if smtp.is_connected:
await smtp.quit()


async def _create_mime_message(
self, message: EmailMessage, template_name: Optional[str] = None
) -> MIMEMultipart:
"""Creates a MIME message from an EmailMessage object."""
mime_message = MIMEMultipart("mixed" if message.attachments else "alternative")
mime_message["Subject"] = message.subject
mime_message["From"] = (
message.sender if message.sender else self.config.MAIL_DEFAULT_SENDER
)
mime_message["To"] = ", ".join(message.recipients)

# If a template is provided, render the email content using the template
if template_name and self.template_manager:
rendered_content = self.template_manager.render_template(
template_name, message.template_body
)
mime_message.attach(MIMEText(rendered_content, "html"))
else:
if message.body:
mime_message.attach(MIMEText(message.body, "plain"))
if message.html_body:
mime_message.attach(MIMEText(message.html_body, "html"))

# Handling attachments
for attachment_path in message.attachments:
attachment = message.create_attachment(attachment_path)
mime_message.attach(attachment)

return mime_message
63 changes: 58 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ gunicorn = "^20.1.0"
httpx = ">=0.24.0,<0.26.0"
jinja2 = "^3.1.2"
psutil = "^5.9.4"
pydantic = "^1.10.2"
pydantic = {version = "^1.10.2", extras = ["email"]}
python-multipart = "^0.0.5"
python-socketio = "^5.7.0"
redis = "^4.3.5"
Expand Down Expand Up @@ -50,6 +50,7 @@ websockets = ">=10.4"
pyjokes = "^0.6.0"
pylint = "^3.0.3"
charset-normalizer = "^3.3.2"
aiosmtplib = "^3.0.1"

[tool.poetry.group.dev.dependencies]
pytest = "^7.1.2"
Expand Down
Loading

0 comments on commit d80e4c9

Please sign in to comment.