-
Notifications
You must be signed in to change notification settings - Fork 164
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
362 additions
and
10 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.