From ffdef177635e08635de6c50bd4518f12cbfe5e80 Mon Sep 17 00:00:00 2001 From: techwithdunamix Date: Fri, 20 Feb 2026 23:54:13 +0100 Subject: [PATCH] fix(tasks): reorder function parameters and add mail dependency --- nexios_contrib/mail/README.md | 470 ++++++++++++++++++++++++++++++ nexios_contrib/mail/__init__.py | 132 +++++++++ nexios_contrib/mail/client.py | 400 +++++++++++++++++++++++++ nexios_contrib/mail/config.py | 166 +++++++++++ nexios_contrib/mail/dependency.py | 117 ++++++++ nexios_contrib/mail/models.py | 256 ++++++++++++++++ nexios_contrib/mail/tasks.py | 364 +++++++++++++++++++++++ nexios_contrib/tasks/__init__.py | 2 +- pyproject.toml | 7 +- 9 files changed, 1911 insertions(+), 3 deletions(-) create mode 100644 nexios_contrib/mail/README.md create mode 100644 nexios_contrib/mail/__init__.py create mode 100644 nexios_contrib/mail/client.py create mode 100644 nexios_contrib/mail/config.py create mode 100644 nexios_contrib/mail/dependency.py create mode 100644 nexios_contrib/mail/models.py create mode 100644 nexios_contrib/mail/tasks.py diff --git a/nexios_contrib/mail/README.md b/nexios_contrib/mail/README.md new file mode 100644 index 0000000..a627929 --- /dev/null +++ b/nexios_contrib/mail/README.md @@ -0,0 +1,470 @@ +# Nexios Mail + +A powerful and easy-to-use email sending solution for Nexios applications with SMTP support, template integration, and background task processing. + +## Features + +- **SMTP Support**: Full SMTP configuration with TLS/SSL support +- **Template Integration**: Jinja2-based HTML email templates +- **Background Tasks**: Async email sending with nexios-contrib tasks +- **Dependency Injection**: Easy integration with Nexios applications +- **Multiple Providers**: Pre-configured settings for Gmail, Outlook, SendGrid +- **Attachments**: Support for file attachments and inline images +- **Error Handling**: Comprehensive error reporting and logging +- **Testing**: Full test coverage with mocking support + +## Installation + +```bash +# Basic installation +pip install nexios-contrib[mail] + +# With template support +pip install nexios-contrib[mail,templating] + +# With all features +pip install nexios-contrib[all] +``` + +## Quick Start + +### Basic Setup + +```python +from nexios import NexiosApp +from nexios_contrib.mail import setup_mail, MailConfig + +app = NexiosApp() + +# Setup with environment variables +mail_client = setup_mail(app) + +# Or with custom configuration +config = MailConfig( + smtp_host="smtp.gmail.com", + smtp_port=587, + smtp_username="your-email@gmail.com", + smtp_password="your-app-password", + use_tls=True, + default_from="Your Name " +) +mail_client = setup_mail(app, config=config) +``` + +### Environment Variables + +```bash +# SMTP Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_USE_TLS=true +SMTP_USE_SSL=false + +# Email Defaults +MAIL_DEFAULT_FROM=Your Name +MAIL_DEFAULT_REPLY_TO=support@yourcompany.com + +# Template Directory +MAIL_TEMPLATE_DIR=templates/emails + +# Debug Settings +MAIL_DEBUG=false +MAIL_SUPPRESS_SEND=false +``` + +## Usage Examples + +### Sending Basic Emails + +```python +from nexios import Request +from nexios_contrib.mail import MailDepend + +@app.post("/send-email") +async def send_email( + request: Request, + mail_client: MailClient = MailDepend() +): + result = await mail_client.send_email( + to="user@example.com", + subject="Welcome to Our Service", + body="Thank you for joining our platform!", + html_body="

Welcome!

Thank you for joining our platform!

" + ) + + return { + "success": result.success, + "message_id": result.message_id, + "sent_at": result.sent_at.isoformat() + } +``` + +### Using Email Templates + +Create templates in your `templates/emails/` directory: + +**templates/emails/welcome.html** +```html + + + + Welcome {{ name }}! + + +

Welcome {{ name }}!

+

Thank you for joining {{ company_name }}.

+

Your account has been created with email: {{ email }}

+ Activate Your Account + + +``` + +**templates/emails/welcome.txt** +```text +Welcome {{ name }}! + +Thank you for joining {{ company_name }}. +Your account has been created with email: {{ email }}. + +Activate Your Account: {{ activation_url }} +``` + +Send template emails: + +```python +@app.post("/send-welcome") +async def send_welcome_email( + request: Request, + mail_client: MailClient = MailDepend() +): + result = await mail_client.send_template_email( + to="newuser@example.com", + subject="Welcome to Our Platform!", + template_name="welcome", + context={ + "name": "John Doe", + "email": "newuser@example.com", + "company_name": "Acme Corp", + "activation_url": "https://example.com/activate/12345" + } + ) + + return {"success": result.success, "message_id": result.message_id} +``` + +### Sending Emails with Attachments + +```python +@app.post("/send-with-attachment") +async def send_with_attachment( + request: Request, + mail_client: MailClient = MailDepend() +): + # Add file attachments + result = await mail_client.send_email( + to="user@example.com", + subject="Your Document", + body="Please find your document attached.", + attachments=[ + { + "filename": "document.pdf", + "content": b"PDF content here", + "content_type": "application/pdf" + }, + { + "filename": "image.png", + "content": "path/to/image.png", # File path also works + "content_id": "logo" # For inline images + } + ] + ) + + return {"success": result.success} +``` + +### Background Email Sending + +Send emails asynchronously without blocking your API responses: + +```python +from nexios_contrib.mail import send_email_async + +@app.post("/send-async") +async def send_async_email(request: Request): + task = await send_email_async( + request=request, + to="user@example.com", + subject="Processing Your Request", + body="We're processing your request and will notify you when complete." + ) + + return { + "message": "Email queued for sending", + "task_id": task.id if task else None + } +``` + +### Bulk Email Sending + +```python +@app.post("/send-bulk") +async def send_bulk_emails( + request: Request, + mail_client: MailClient = MailDepend() +): + emails = [ + { + "to": "user1@example.com", + "subject": "Newsletter #1", + "body": "Latest news...", + "html_body": "

Latest News

..." + }, + { + "to": "user2@example.com", + "subject": "Newsletter #1", + "body": "Latest news...", + "html_body": "

Latest News

..." + } + ] + + tasks = await mail_client.tasks.send_bulk_emails_async(emails) + + return { + "queued_emails": len(tasks), + "task_ids": [task.id for task in tasks if task] + } +``` + +## Configuration + +### MailConfig Options + +```python +from nexios_contrib.mail import MailConfig + +config = MailConfig( + # SMTP Settings + smtp_host="smtp.gmail.com", + smtp_port=587, + smtp_username="your-email@gmail.com", + smtp_password="your-app-password", + use_tls=True, + use_ssl=False, + + # Email Defaults + default_from="Your Name ", + default_reply_to="support@yourcompany.com", + default_cc=["admin@yourcompany.com"], + default_bcc=["backup@yourcompany.com"], + + # Connection Settings + smtp_timeout=30.0, + max_connections=10, + + # Template Settings + template_directory="templates/emails", + template_auto_escape=True, + + # Background Task Settings + use_background_tasks=True, + task_timeout=300.0, + + # Debug Settings + debug=False, + suppress_send=False +) +``` + +### Provider-Specific Configurations + +#### Gmail +```python +config = MailConfig.for_gmail( + username="your-email@gmail.com", + password="your-app-password", # Use app password, not regular password + default_from="Your Name " +) +``` + +#### Outlook/Office 365 +```python +config = MailConfig.for_outlook( + username="your-email@outlook.com", + password="your-password", + default_from="Your Name " +) +``` + +#### SendGrid +```python +config = MailConfig.for_sendgrid( + api_key="your-sendgrid-api-key", + default_from="your-email@yourdomain.com" +) +``` + +## Advanced Usage + +### Custom Email Messages + +```python +from nexios_contrib.mail import EmailMessage + +# Create detailed email message +message = EmailMessage( + to="recipient@example.com", + subject="Custom Email", + body="Plain text content", + html_body="

HTML Content

", + cc="manager@example.com", + bcc="archive@example.com", + priority=1 # High priority +) + +# Add custom headers +message.add_header("X-Campaign-ID", "summer-2024") +message.add_header("X-Mailer", "Nexios Mail") + +# Add attachments +message.add_attachment("report.pdf", b"PDF content", "application/pdf") + +# Send the message +result = await mail_client.send_message(message) +``` + +### Template Custom Filters + +```python +# Add custom Jinja2 filters +def format_currency(value, currency="USD"): + return f"{value:.2f} {currency}" + +# In your mail client setup +mail_client._template_env.filters["currency"] = format_currency + +# Use in templates +{{ price | currency }} +``` + +### Error Handling + +```python +try: + result = await mail_client.send_email( + to="user@example.com", + subject="Test Email", + body="Test content" + ) + + if result.success: + print(f"Email sent: {result.message_id}") + else: + print(f"Email failed: {result.error}") + +except Exception as e: + print(f"Mail client error: {e}") +``` + +### Testing + +```python +# Use suppress_send for testing +test_config = MailConfig( + suppress_send=True, # Don't actually send emails + debug=True +) + +mail_client = MailClient(config=test_config) +await mail_client.start() + +# Emails will be logged but not sent +result = await mail_client.send_email( + to="test@example.com", + subject="Test", + body="This won't be sent" +) + +assert result.success is True +``` + +## API Reference + +### MailClient + +The main mail client class for sending emails. + +#### Methods + +- `send_email(to, subject, body=None, html_body=None, **kwargs)` - Send an email +- `send_message(message)` - Send an EmailMessage object +- `send_template_email(to, subject, template_name, context=None, **kwargs)` - Send template email +- `create_message(to, subject, **kwargs)` - Create EmailMessage object + +### EmailMessage + +Represents an email message with all its components. + +#### Properties + +- `to` - Recipient email addresses +- `subject` - Email subject +- `body` - Plain text body +- `html_body` - HTML body +- `attachments` - List of attachments +- `headers` - Custom headers + +#### Methods + +- `add_attachment(filename, content, content_type=None, content_id=None)` - Add attachment +- `set_template(template_name, context=None)` - Set template +- `add_header(name, value)` - Add custom header + +### MailConfig + +Configuration for the mail client. + +#### Class Methods + +- `for_gmail(username, password, **kwargs)` - Gmail configuration +- `for_outlook(username, password, **kwargs)` - Outlook configuration +- `for_sendgrid(api_key, **kwargs)` - SendGrid configuration + +## Troubleshooting + +### Common Issues + +1. **Authentication Failed** + - Check SMTP credentials + - For Gmail, use an App Password instead of your regular password + - Verify 2FA settings + +2. **Connection Timeout** + - Check SMTP host and port + - Verify firewall settings + - Increase `smtp_timeout` value + +3. **Template Not Found** + - Verify template directory path + - Check template file names and extensions + - Ensure template files exist + +4. **Background Tasks Not Working** + - Install nexios-contrib tasks: `pip install nexios-contrib[tasks]` + - Setup tasks in your app: `setup_tasks(app)` + +### Debug Mode + +Enable debug mode to see SMTP communication: + +```python +config = MailConfig( + debug=True, # Enables SMTP debug logging + suppress_send=True # Test mode - don't actually send +) +``` + +## License + +This project is licensed under the BSD-3-Clause License. diff --git a/nexios_contrib/mail/__init__.py b/nexios_contrib/mail/__init__.py new file mode 100644 index 0000000..94e5b91 --- /dev/null +++ b/nexios_contrib/mail/__init__.py @@ -0,0 +1,132 @@ +""" +Nexios Mail - Email Sending with Background Task Support + +This module provides a robust and easy-to-use email sending solution for Nexios applications. +It includes features like SMTP configuration, template-based HTML emails, background task integration, +and dependency injection support. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +from nexios import NexiosApp +from nexios.http import Request + +from .client import MailClient +from .config import MailConfig +from .dependency import MailDepend, get_mail_client +from .models import EmailMessage, EmailResult +from .tasks import MailTaskManager, add_task_support, send_email_async, send_template_email_async + +__all__ = [ + # Main classes + 'MailClient', + 'MailConfig', + 'EmailMessage', + 'EmailResult', + + # Dependency injection + 'MailDepend', + 'get_mail_client', + + # Background tasks + 'MailTaskManager', + 'add_task_support', + 'send_email_async', + 'send_template_email_async', + + # Setup functions + 'setup_mail', + 'get_mail_from_request', +] + +def setup_mail( + app: NexiosApp, + config: Optional[MailConfig] = None +) -> MailClient: + """Set up the mail client for a Nexios application. + + This function initializes the mail client and registers it with the Nexios app. + It should be called during application startup. + + Args: + app: The Nexios application instance. + config: Optional configuration for the mail client. + + Returns: + The initialized MailClient instance. + + Example: + ```python + from nexios import NexiosApp + from nexios_contrib.mail import setup_mail, MailConfig + + app = NexiosApp() + + # Initialize with default configuration + mail_client = setup_mail(app) + + # Or with custom configuration + config = MailConfig( + smtp_host="smtp.gmail.com", + smtp_port=587, + smtp_username="your-email@gmail.com", + smtp_password="your-app-password", + use_tls=True + ) + mail_client = setup_mail(app, config=config) + ``` + """ + if not hasattr(app, 'mail_client'): + mail_client = MailClient(config=config) + app.mail_client = mail_client + app.on_startup(mail_client.start) + app.on_shutdown(mail_client.stop) + + # Add background task support if available + try: + add_task_support(mail_client) + except Exception: + # Tasks not available, continue without them + pass + + return app.mail_client + +def get_mail_from_request(request: Request) -> MailClient: + """Get the mail client from a request. + + This is a convenience function to get the mail client instance + from a request object. + + Args: + request: The current request object. + + Returns: + The MailClient instance. + + Raises: + AttributeError: If the mail client is not initialized. + + Example: + ```python + from nexios import Request + from nexios_contrib.mail import get_mail_from_request + + @app.post("/send-email") + async def send_email_endpoint(request: Request): + mail_client = get_mail_from_request(request) + result = await mail_client.send_email( + to="recipient@example.com", + subject="Hello", + body="This is a test email" + ) + return {"status": "sent", "message_id": result.message_id} + ``` + """ + mail_client = getattr(request.base_app, 'mail_client', None) + if mail_client is None: + raise AttributeError( + "Mail client not initialized. Call setup_mail(app) during application startup." + ) + return mail_client diff --git a/nexios_contrib/mail/client.py b/nexios_contrib/mail/client.py new file mode 100644 index 0000000..1c73ac7 --- /dev/null +++ b/nexios_contrib/mail/client.py @@ -0,0 +1,400 @@ +""" +Mail Client Module + +This module provides the main mail client implementation with SMTP support, +template rendering, and background task integration. +""" + +from __future__ import annotations + +import asyncio +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +try: + import jinja2 + JINJA2_AVAILABLE = True +except ImportError: + JINJA2_AVAILABLE = False + +from .config import MailConfig +from .models import EmailMessage, EmailResult, EmailError + +logger = logging.getLogger(__name__) + + +class MailClient: + """Main mail client for sending emails with SMTP and template support. + + This client provides a high-level interface for sending emails through SMTP + servers, with support for HTML templates, attachments, and background tasks. + + Example: + ```python + from nexios_contrib.mail import MailClient, MailConfig + + config = MailConfig( + smtp_host="smtp.gmail.com", + smtp_port=587, + smtp_username="your-email@gmail.com", + smtp_password="your-app-password", + use_tls=True + ) + + mail_client = MailClient(config=config) + await mail_client.start() + + result = await mail_client.send_email( + to="recipient@example.com", + subject="Hello World", + body="This is a test email" + ) + ``` + """ + + def __init__(self, config: Optional[MailConfig] = None) -> None: + """Initialize the mail client. + + Args: + config: Optional mail configuration. If not provided, uses default config. + """ + self.config = config or MailConfig() + self._smtp_pool: Optional[smtplib.SMTP] = None + self._template_env: Optional[jinja2.Environment] = None + self._is_started = False + + # Setup template environment if Jinja2 is available + if JINJA2_AVAILABLE and self.config.template_directory: + self._setup_template_environment() + + def _setup_template_environment(self) -> None: + """Setup the Jinja2 template environment.""" + if not self.config.template_directory: + return + + template_path = Path(self.config.template_directory) + if not template_path.exists(): + logger.warning(f"Template directory not found: {template_path}") + return + + loader = jinja2.FileSystemLoader(str(template_path)) + self._template_env = jinja2.Environment( + loader=loader, + autoescape=self.config.template_auto_escape, + trim_blocks=True, + lstrip_blocks=True + ) + + # Add custom filters + self._template_env.filters["format_date"] = self._format_date_filter + + def _format_date_filter(self, value: Any, format_str: str = "%Y-%m-%d") -> str: + """Jinja2 filter for formatting dates. + + Args: + value: Date value to format. + format_str: Date format string. + + Returns: + Formatted date string. + """ + if hasattr(value, "strftime"): + return value.strftime(format_str) + return str(value) + + async def start(self) -> None: + """Start the mail client and initialize SMTP connection.""" + if self._is_started: + return + + try: + if not self.config.suppress_send: + await self._create_smtp_connection() + self._is_started = True + logger.info("Mail client started successfully") + except Exception as e: + logger.error(f"Failed to start mail client: {e}") + raise + + async def stop(self) -> None: + """Stop the mail client and close SMTP connection.""" + if not self._is_started: + return + + try: + if self._smtp_pool: + self._smtp_pool.quit() + self._smtp_pool = None + self._is_started = False + logger.info("Mail client stopped successfully") + except Exception as e: + logger.error(f"Error stopping mail client: {e}") + + async def _create_smtp_connection(self) -> None: + """Create and configure SMTP connection.""" + if self.config.use_ssl: + self._smtp_pool = smtplib.SMTP_SSL( + self.config.smtp_host, + self.config.smtp_port, + timeout=self.config.smtp_timeout + ) + else: + self._smtp_pool = smtplib.SMTP( + self.config.smtp_host, + self.config.smtp_port, + timeout=self.config.smtp_timeout + ) + + if self.config.use_tls: + self._smtp_pool.starttls() + + # Enable debug mode if configured + if self.config.debug: + self._smtp_pool.set_debuglevel(1) + + # Authenticate if credentials are provided + if self.config.smtp_username and self.config.smtp_password: + try: + self._smtp_pool.login( + self.config.smtp_username, + self.config.smtp_password + ) + logger.info(f"SMTP authentication successful for {self.config.smtp_username}") + except Exception as e: + logger.error(f"SMTP authentication failed: {e}") + raise + + async def send_email( + self, + to: Union[str, List[str]], + subject: str, + body: Optional[str] = None, + html_body: Optional[str] = None, + from_email: Optional[str] = None, + reply_to: Optional[Union[str, List[str]]] = None, + cc: Optional[Union[str, List[str]]] = None, + bcc: Optional[Union[str, List[str]]] = None, + attachments: Optional[List[Any]] = None, + template_name: Optional[str] = None, + template_context: Optional[Dict[str, Any]] = None, + **kwargs: Any + ) -> EmailResult: + """Send an email. + + Args: + to: Recipient email address(es). + subject: Email subject. + body: Plain text email body. + html_body: HTML email body. + from_email: Sender email address. + reply_to: Reply-to email address(es). + cc: CC recipient(s). + bcc: BCC recipient(s). + attachments: List of file attachments. + template_name: Name of the template to use. + template_context: Context variables for the template. + **kwargs: Additional email parameters. + + Returns: + EmailResult indicating success or failure. + """ + # Create email message + message = EmailMessage( + to=to, + subject=subject, + body=body, + html_body=html_body, + from_email=from_email, + reply_to=reply_to, + cc=cc, + bcc=bcc, + template_name=template_name, + template_context=template_context, + **kwargs + ) + + # Add attachments if provided + if attachments: + for attachment in attachments: + if isinstance(attachment, dict): + message.add_attachment(**attachment) + else: + message.add_attachment(attachment) + + return await self.send_message(message) + + async def send_message(self, message: EmailMessage) -> EmailResult: + """Send an EmailMessage. + + Args: + message: The EmailMessage to send. + + Returns: + EmailResult indicating success or failure. + """ + try: + # Render template if specified + if message.template_name and self._template_env: + await self._render_template(message) + + # Use default from email if not specified + from_email = message.from_email or self.config.default_from + if not from_email: + raise ValueError("No 'from' email address specified") + + # Create MIME message + mime_message = message.to_mime_message(from_email) + + # Add default CC/BCC if not specified + if self.config.default_cc and not message.cc: + mime_message["Cc"] = ", ".join(self.config.default_cc) + message.cc.extend(self.config.default_cc) + + if self.config.default_bcc and not message.bcc: + message.bcc.extend(self.config.default_bcc) + + # Prepare recipient list + recipients = list(message.to) + if message.cc: + recipients.extend(message.cc) + if message.bcc: + recipients.extend(message.bcc) + + # Send email + if self.config.suppress_send: + logger.info(f"Email sending suppressed: {message.subject} to {recipients}") + return EmailResult( + success=True, + message_id=message.message_id, + to=recipients, + subject=message.subject, + provider_response={"suppressed": True} + ) + + # Send in a thread pool to avoid blocking + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + self._send_mime_message, + mime_message, + recipients + ) + + logger.info(f"Email sent successfully: {message.message_id} to {recipients}") + + return EmailResult( + success=True, + message_id=message.message_id, + to=recipients, + subject=message.subject + ) + + except Exception as e: + error_msg = str(e) + logger.error(f"Failed to send email: {error_msg}") + + return EmailResult( + success=False, + message_id=message.message_id, + to=list(message.to), + subject=message.subject, + error=error_msg + ) + + def _send_mime_message(self, mime_message: MIMEMultipart, recipients: List[str]) -> None: + """Send MIME message using SMTP. + + Args: + mime_message: The MIME message to send. + recipients: List of recipient email addresses. + """ + if not self._smtp_pool: + raise RuntimeError("SMTP connection not established") + + self._smtp_pool.sendmail( + mime_message["From"], + recipients, + mime_message.as_string() + ) + + async def _render_template(self, message: EmailMessage) -> None: + """Render email template. + + Args: + message: The email message to render template for. + """ + if not self._template_env or not message.template_name: + return + + try: + # Try to render HTML template + html_template = self._template_env.get_template(f"{message.template_name}.html") + message.html_body = html_template.render(**message.template_context) + + # Try to render text template + try: + text_template = self._template_env.get_template(f"{message.template_name}.txt") + message.body = text_template.render(**message.template_context) + except jinja2.TemplateNotFound: + # Text template is optional + pass + + except jinja2.TemplateNotFound as e: + logger.error(f"Template not found: {e}") + raise + except jinja2.TemplateError as e: + logger.error(f"Template rendering error: {e}") + raise + + async def send_template_email( + self, + to: Union[str, List[str]], + subject: str, + template_name: str, + context: Optional[Dict[str, Any]] = None, + from_email: Optional[str] = None, + **kwargs: Any + ) -> EmailResult: + """Send an email using a template. + + Args: + to: Recipient email address(es). + subject: Email subject. + template_name: Name of the template to use. + context: Template context variables. + from_email: Sender email address. + **kwargs: Additional email parameters. + + Returns: + EmailResult indicating success or failure. + """ + return await self.send_email( + to=to, + subject=subject, + template_name=template_name, + template_context=context, + from_email=from_email, + **kwargs + ) + + def create_message( + self, + to: Union[str, List[str]], + subject: str, + **kwargs: Any + ) -> EmailMessage: + """Create an EmailMessage object. + + Args: + to: Recipient email address(es). + subject: Email subject. + **kwargs: Additional message parameters. + + Returns: + EmailMessage instance. + """ + return EmailMessage(to=to, subject=subject, **kwargs) diff --git a/nexios_contrib/mail/config.py b/nexios_contrib/mail/config.py new file mode 100644 index 0000000..8910ad0 --- /dev/null +++ b/nexios_contrib/mail/config.py @@ -0,0 +1,166 @@ +""" +Mail Configuration Module + +This module provides configuration classes for the mail client, +including SMTP settings and template configuration. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Union + + +@dataclass +class MailConfig: + """Configuration for the mail client. + + This class contains all the settings needed to configure + the SMTP connection and email sending behavior. + """ + + # SMTP Configuration + smtp_host: str = field(default_factory=lambda: os.getenv("SMTP_HOST", "localhost")) + smtp_port: int = field(default_factory=lambda: int(os.getenv("SMTP_PORT", "587"))) + smtp_username: Optional[str] = field(default_factory=lambda: os.getenv("SMTP_USERNAME")) + smtp_password: Optional[str] = field(default_factory=lambda: os.getenv("SMTP_PASSWORD")) + use_tls: bool = field(default_factory=lambda: os.getenv("SMTP_USE_TLS", "true").lower() == "true") + use_ssl: bool = field(default_factory=lambda: os.getenv("SMTP_USE_SSL", "false").lower() == "true") + + # Email defaults + default_from: Optional[str] = field(default_factory=lambda: os.getenv("MAIL_DEFAULT_FROM")) + default_reply_to: Optional[str] = field(default_factory=lambda: os.getenv("MAIL_DEFAULT_REPLY_TO")) + default_cc: Optional[List[str]] = None + default_bcc: Optional[List[str]] = None + + # Connection settings + smtp_timeout: float = field(default_factory=lambda: float(os.getenv("SMTP_TIMEOUT", "30"))) + max_connections: int = field(default_factory=lambda: int(os.getenv("SMTP_MAX_CONNECTIONS", "10"))) + + # Template settings + template_directory: Optional[str] = field(default_factory=lambda: os.getenv("MAIL_TEMPLATE_DIR")) + template_auto_escape: bool = True + + # Background task settings + use_background_tasks: bool = True + task_timeout: Optional[float] = field(default_factory=lambda: float(os.getenv("MAIL_TASK_TIMEOUT", "300"))) + + # Debug settings + debug: bool = field(default_factory=lambda: os.getenv("MAIL_DEBUG", "false").lower() == "true") + suppress_send: bool = field(default_factory=lambda: os.getenv("MAIL_SUPPRESS_SEND", "false").lower() == "true") + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + if self.use_ssl and self.use_tls: + raise ValueError("Cannot use both SSL and TLS. Choose one.") + + if self.smtp_port == 465 and not self.use_ssl: + # Port 465 is typically used for SSL + self.use_ssl = True + self.use_tls = False + elif self.smtp_port == 587 and not self.use_tls: + # Port 587 is typically used for TLS + self.use_tls = True + self.use_ssl = False + + def to_dict(self) -> Dict[str, Any]: + """Convert configuration to dictionary. + + Returns: + Dictionary representation of the configuration. + """ + return { + "smtp_host": self.smtp_host, + "smtp_port": self.smtp_port, + "smtp_username": self.smtp_username, + "smtp_password": "***" if self.smtp_password else None, + "use_tls": self.use_tls, + "use_ssl": self.use_ssl, + "default_from": self.default_from, + "default_reply_to": self.default_reply_to, + "default_cc": self.default_cc, + "default_bcc": self.default_bcc, + "smtp_timeout": self.smtp_timeout, + "max_connections": self.max_connections, + "template_directory": self.template_directory, + "template_auto_escape": self.template_auto_escape, + "use_background_tasks": self.use_background_tasks, + "task_timeout": self.task_timeout, + "debug": self.debug, + "suppress_send": self.suppress_send, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> MailConfig: + """Create configuration from dictionary. + + Args: + data: Dictionary containing configuration values. + + Returns: + MailConfig instance. + """ + return cls(**data) + + @classmethod + def for_gmail(cls, username: str, password: str, **kwargs: Any) -> MailConfig: + """Create configuration for Gmail SMTP. + + Args: + username: Gmail username or email address. + password: Gmail app password (not regular password). + **kwargs: Additional configuration options. + + Returns: + MailConfig configured for Gmail. + """ + return cls( + smtp_host="smtp.gmail.com", + smtp_port=587, + smtp_username=username, + smtp_password=password, + use_tls=True, + **kwargs + ) + + @classmethod + def for_outlook(cls, username: str, password: str, **kwargs: Any) -> MailConfig: + """Create configuration for Outlook/Office 365 SMTP. + + Args: + username: Outlook username or email address. + password: Outlook password. + **kwargs: Additional configuration options. + + Returns: + MailConfig configured for Outlook. + """ + return cls( + smtp_host="smtp-mail.outlook.com", + smtp_port=587, + smtp_username=username, + smtp_password=password, + use_tls=True, + **kwargs + ) + + @classmethod + def for_sendgrid(cls, api_key: str, **kwargs: Any) -> MailConfig: + """Create configuration for SendGrid SMTP. + + Args: + api_key: SendGrid API key. + **kwargs: Additional configuration options. + + Returns: + MailConfig configured for SendGrid. + """ + return cls( + smtp_host="smtp.sendgrid.net", + smtp_port=587, + smtp_username="apikey", + smtp_password=api_key, + use_tls=True, + **kwargs + ) diff --git a/nexios_contrib/mail/dependency.py b/nexios_contrib/mail/dependency.py new file mode 100644 index 0000000..3ae0eda --- /dev/null +++ b/nexios_contrib/mail/dependency.py @@ -0,0 +1,117 @@ +""" +Mail Dependency Injection Module + +This module provides dependency injection support for the mail client, +allowing it to be easily integrated with Nexios applications. +""" + +from __future__ import annotations + +from typing import Optional, TypeVar, cast + +from nexios.dependencies import Depend, current_context +from nexios.http import Request + +from .client import MailClient + +T = TypeVar("T") + + +class MailDepend(Depend[MailClient]): + """Dependency provider for the mail client. + + This class provides a dependency injection wrapper for the mail client, + allowing it to be easily injected into route handlers and other components. + + Example: + ```python + from nexios_contrib.mail import MailDepend + + @app.post("/send-email") + async def send_email( + mail_client: MailClient = MailDepend() + ): + result = await mail_client.send_email( + to="user@example.com", + subject="Hello", + body="This is a test email" + ) + return {"status": "sent", "message_id": result.message_id} + ``` + """ + + def __init__(self) -> None: + """Initialize the mail dependency.""" + super().__init__(self._get_mail_client) + + async def _get_mail_client(self) -> MailClient: + """Get the mail client from the current context. + + Returns: + The MailClient instance from the current request context. + + Raises: + RuntimeError: If no mail client is found in the context. + """ + try: + ctx = current_context.get() + if ctx and ctx.request: + return get_mail_from_request(ctx.request) + except LookupError: + pass + + raise RuntimeError( + "Mail client not found in current context. " + "Make sure setup_mail(app) was called during application startup." + ) + + +def get_mail_client(request: Request) -> MailClient: + """Get the mail client from a request. + + This is a convenience function that retrieves the mail client + from the Nexios application instance attached to the request. + + Args: + request: The current request object. + + Returns: + The MailClient instance. + + Raises: + AttributeError: If the mail client is not initialized. + + Example: + ```python + from nexios import Request + from nexios_contrib.mail import get_mail_client + + @app.post("/send-email") + async def send_email(request: Request): + mail_client = get_mail_client(request) + result = await mail_client.send_email( + to="user@example.com", + subject="Hello", + body="This is a test email" + ) + return {"status": "sent", "message_id": result.message_id} + ``` + """ + mail_client = getattr(request.base_app, 'mail_client', None) + if mail_client is None: + raise AttributeError( + "Mail client not initialized. Call setup_mail(app) during application startup." + ) + return cast(MailClient, mail_client) + + +def get_mail_from_request(request: Request) -> MailClient: + """Alias for get_mail_client for backward compatibility. + + Args: + request: The current request object. + + Returns: + The MailClient instance. + """ + return get_mail_client(request) diff --git a/nexios_contrib/mail/models.py b/nexios_contrib/mail/models.py new file mode 100644 index 0000000..a0bf588 --- /dev/null +++ b/nexios_contrib/mail/models.py @@ -0,0 +1,256 @@ +""" +Mail Models Module + +This module contains data models for email messages and results. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + + +@dataclass +class EmailAttachment: + """Represents an email attachment.""" + + filename: str + content: Union[bytes, str] + content_type: Optional[str] = None + content_id: Optional[str] = None # For inline images + + def __post_init__(self) -> None: + """Validate and process attachment after initialization.""" + if isinstance(self.content, str): + # If content is a string, assume it's a file path + path = Path(self.content) + if path.exists(): + self.content = path.read_bytes() + if not self.content_type: + # Guess content type from file extension + import mimetypes + self.content_type, _ = mimetypes.guess_type(str(path)) + else: + raise FileNotFoundError(f"Attachment file not found: {self.content}") + + if not self.content_type: + self.content_type = "application/octet-stream" + + +@dataclass +class EmailMessage: + """Represents an email message.""" + + # Required fields + to: Union[str, List[str]] + subject: str + + # Content fields + body: Optional[str] = None + html_body: Optional[str] = None + template_name: Optional[str] = None + template_context: Optional[Dict[str, Any]] = None + + # Optional fields + from_email: Optional[str] = None + reply_to: Optional[Union[str, List[str]]] = None + cc: Optional[Union[str, List[str]]] = None + bcc: Optional[Union[str, List[str]]] = None + attachments: Optional[List[EmailAttachment]] = None + + # Metadata + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) + headers: Optional[Dict[str, str]] = None + priority: Optional[int] = None # 1 (high), 3 (normal), 5 (low) + + def __post_init__(self) -> None: + """Normalize and validate email addresses after initialization.""" + # Normalize single addresses to lists + if isinstance(self.to, str): + self.to = [self.to] + + if isinstance(self.cc, str): + self.cc = [self.cc] + elif self.cc is None: + self.cc = [] + + if isinstance(self.bcc, str): + self.bcc = [self.bcc] + elif self.bcc is None: + self.bcc = [] + + if isinstance(self.reply_to, str): + self.reply_to = [self.reply_to] + elif self.reply_to is None: + self.reply_to = [] + + # Initialize empty lists/dicts if None + if self.attachments is None: + self.attachments = [] + if self.headers is None: + self.headers = {} + if self.template_context is None: + self.template_context = {} + + def add_attachment( + self, + filename: str, + content: Union[bytes, str], + content_type: Optional[str] = None, + content_id: Optional[str] = None + ) -> None: + """Add an attachment to the email. + + Args: + filename: Name of the attachment file. + content: File content as bytes or file path string. + content_type: MIME content type. + content_id: Content ID for inline images. + """ + if self.attachments is None: + self.attachments = [] + + attachment = EmailAttachment( + filename=filename, + content=content, + content_type=content_type, + content_id=content_id + ) + self.attachments.append(attachment) + + def set_template(self, template_name: str, context: Optional[Dict[str, Any]] = None) -> None: + """Set the template for the email. + + Args: + template_name: Name of the template file. + context: Template context variables. + """ + self.template_name = template_name + if context: + self.template_context = {**self.template_context, **context} + + def add_header(self, name: str, value: str) -> None: + """Add a custom header to the email. + + Args: + name: Header name. + value: Header value. + """ + if self.headers is None: + self.headers = {} + self.headers[name] = value + + def to_mime_message(self, from_email: Optional[str] = None) -> MIMEMultipart: + """Convert the email message to a MIME message. + + Args: + from_email: Override the from email address. + + Returns: + MIMEMultipart message ready for sending. + """ + # Create the main message + msg = MIMEMultipart("alternative") + + # Set headers + msg["Subject"] = self.subject + msg["To"] = ", ".join(self.to) + msg["From"] = from_email or self.from_email or "" + msg["Message-ID"] = self.message_id + + # Set optional headers + if self.cc: + msg["Cc"] = ", ".join(self.cc) + + if self.reply_to: + msg["Reply-To"] = ", ".join(self.reply_to) + + if self.priority: + priority_map = {1: "High", 3: "Normal", 5: "Low"} + msg["X-Priority"] = str(self.priority) + msg["Priority"] = priority_map.get(self.priority, "Normal") + + # Add custom headers + if self.headers: + for name, value in self.headers.items(): + msg[name] = value + + # Add body parts + if self.body: + text_part = MIMEText(self.body, "plain", "utf-8") + msg.attach(text_part) + + if self.html_body: + html_part = MIMEText(self.html_body, "html", "utf-8") + msg.attach(html_part) + + # Add attachments + if self.attachments: + for attachment in self.attachments: + part = MIMEBase(*attachment.content_type.split("/", 1)) + part.set_payload(attachment.content) + import email.encoders + email.encoders.encode_base64(part) + + part.add_header( + "Content-Disposition", + f"attachment; filename={attachment.filename}" + ) + + if attachment.content_id: + part.add_header("Content-ID", f"<{attachment.content_id}>") + + msg.attach(part) + + return msg + + +@dataclass +class EmailResult: + """Represents the result of sending an email.""" + + success: bool + message_id: str + to: List[str] + subject: str + sent_at: datetime = field(default_factory=datetime.utcnow) + error: Optional[str] = None + provider_response: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert result to dictionary. + + Returns: + Dictionary representation of the result. + """ + return { + "success": self.success, + "message_id": self.message_id, + "to": self.to, + "subject": self.subject, + "sent_at": self.sent_at.isoformat(), + "error": self.error, + "provider_response": self.provider_response, + } + + +@dataclass +class EmailError: + """Represents an email sending error.""" + + message: str + error_code: Optional[str] = None + provider: Optional[str] = None + details: Optional[Dict[str, Any]] = None + + def __str__(self) -> str: + """String representation of the error.""" + if self.error_code: + return f"{self.provider} Error [{self.error_code}]: {self.message}" + return self.message diff --git a/nexios_contrib/mail/tasks.py b/nexios_contrib/mail/tasks.py new file mode 100644 index 0000000..3ceb742 --- /dev/null +++ b/nexios_contrib/mail/tasks.py @@ -0,0 +1,364 @@ +""" +Mail Background Tasks Module + +This module provides background task integration for email sending, +allowing emails to be sent asynchronously without blocking the main application. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Union + +from nexios.http import Request + +try: + from ..tasks import create_task, Task + TASKS_AVAILABLE = True +except ImportError: + TASKS_AVAILABLE = False + +from .client import MailClient +from .models import EmailMessage, EmailResult + +logger = logging.getLogger(__name__) + + +class MailTaskManager: + """Manager for email background tasks. + + This class provides methods to send emails in the background + using the nexios-contrib tasks system. + """ + + def __init__(self, mail_client: MailClient) -> None: + """Initialize the mail task manager. + + Args: + mail_client: The mail client instance. + """ + self.mail_client = mail_client + + async def send_email_async( + self, + to: Union[str, List[str]], + subject: str, + body: Optional[str] = None, + html_body: Optional[str] = None, + from_email: Optional[str] = None, + reply_to: Optional[Union[str, List[str]]] = None, + cc: Optional[Union[str, List[str]]] = None, + bcc: Optional[Union[str, List[str]]] = None, + attachments: Optional[List[Any]] = None, + template_name: Optional[str] = None, + template_context: Optional[Dict[str, Any]] = None, + priority: str = "normal", + timeout: Optional[float] = None, + **kwargs: Any + ) -> Optional[Task]: + """Send an email in the background. + + Args: + to: Recipient email address(es). + subject: Email subject. + body: Plain text email body. + html_body: HTML email body. + from_email: Sender email address. + reply_to: Reply-to email address(es). + cc: CC recipient(s). + bcc: BCC recipient(s). + attachments: List of file attachments. + template_name: Name of the template to use. + template_context: Context variables for the template. + priority: Task priority ("low", "normal", "high"). + timeout: Task timeout in seconds. + **kwargs: Additional email parameters. + + Returns: + Task instance if tasks are available, None otherwise. + """ + if not TASKS_AVAILABLE: + logger.warning("Background tasks not available, sending email synchronously") + await self.mail_client.send_email( + to=to, + subject=subject, + body=body, + html_body=html_body, + from_email=from_email, + reply_to=reply_to, + cc=cc, + bcc=bcc, + attachments=attachments, + template_name=template_name, + template_context=template_context, + **kwargs + ) + return None + + # Create the background task + task = await create_task( + self._send_email_task, + to=to, + subject=subject, + body=body, + html_body=html_body, + from_email=from_email, + reply_to=reply_to, + cc=cc, + bcc=bcc, + attachments=attachments, + template_name=template_name, + template_context=template_context, + name=f"send_email_{subject}", + timeout=timeout or self.mail_client.config.task_timeout + ) + + logger.info(f"Email task created: {task.id} for {subject}") + return task + + async def send_message_async( + self, + message: EmailMessage, + priority: str = "normal", + timeout: Optional[float] = None + ) -> Optional[Task]: + """Send an EmailMessage in the background. + + Args: + message: The EmailMessage to send. + priority: Task priority ("low", "normal", "high"). + timeout: Task timeout in seconds. + + Returns: + Task instance if tasks are available, None otherwise. + """ + if not TASKS_AVAILABLE: + logger.warning("Background tasks not available, sending message synchronously") + await self.mail_client.send_message(message) + return None + + # Create the background task + task = await create_task( + self._send_message_task, + message, + name=f"send_message_{message.subject}", + timeout=timeout or self.mail_client.config.task_timeout + ) + + logger.info(f"Email message task created: {task.id} for {message.subject}") + return task + + async def send_template_email_async( + self, + to: Union[str, List[str]], + subject: str, + template_name: str, + context: Optional[Dict[str, Any]] = None, + from_email: Optional[str] = None, + priority: str = "normal", + timeout: Optional[float] = None, + **kwargs: Any + ) -> Optional[Task]: + """Send a template email in the background. + + Args: + to: Recipient email address(es). + subject: Email subject. + template_name: Name of the template to use. + context: Template context variables. + from_email: Sender email address. + priority: Task priority ("low", "normal", "high"). + timeout: Task timeout in seconds. + **kwargs: Additional email parameters. + + Returns: + Task instance if tasks are available, None otherwise. + """ + if not TASKS_AVAILABLE: + logger.warning("Background tasks not available, sending template email synchronously") + await self.mail_client.send_template_email( + to=to, + subject=subject, + template_name=template_name, + context=context, + from_email=from_email, + **kwargs + ) + return None + + # Create the background task + task = await create_task( + self._send_template_email_task, + to=to, + subject=subject, + template_name=template_name, + context=context, + from_email=from_email, + name=f"send_template_email_{subject}", + timeout=timeout or self.mail_client.config.task_timeout, + **kwargs + ) + + logger.info(f"Template email task created: {task.id} for {subject}") + return task + + async def _send_email_task(self, *args: Any, **kwargs: Any) -> EmailResult: + """Background task for sending emails. + + Args: + *args: Positional arguments for send_email. + **kwargs: Keyword arguments for send_email. + + Returns: + EmailResult from the mail client. + """ + try: + result = await self.mail_client.send_email(*args, **kwargs) + if result.success: + logger.info(f"Background email sent successfully: {result.message_id}") + else: + logger.error(f"Background email failed: {result.error}") + return result + except Exception as e: + logger.error(f"Background email task error: {e}") + raise + + async def _send_message_task(self, message: EmailMessage) -> EmailResult: + """Background task for sending EmailMessage. + + Args: + message: The EmailMessage to send. + + Returns: + EmailResult from the mail client. + """ + try: + result = await self.mail_client.send_message(message) + if result.success: + logger.info(f"Background message sent successfully: {result.message_id}") + else: + logger.error(f"Background message failed: {result.error}") + return result + except Exception as e: + logger.error(f"Background message task error: {e}") + raise + + async def _send_template_email_task( + self, + to: Union[str, List[str]], + subject: str, + template_name: str, + context: Optional[Dict[str, Any]] = None, + from_email: Optional[str] = None, + **kwargs: Any + ) -> EmailResult: + """Background task for sending template emails. + + Args: + to: Recipient email address(es). + subject: Email subject. + template_name: Name of the template to use. + context: Template context variables. + from_email: Sender email address. + **kwargs: Additional email parameters. + + Returns: + EmailResult from the mail client. + """ + try: + result = await self.mail_client.send_template_email( + to=to, + subject=subject, + template_name=template_name, + context=context, + from_email=from_email, + **kwargs + ) + if result.success: + logger.info(f"Background template email sent successfully: {result.message_id}") + else: + logger.error(f"Background template email failed: {result.error}") + return result + except Exception as e: + logger.error(f"Background template email task error: {e}") + raise + + +# Add task manager to mail client +def add_task_support(mail_client: MailClient) -> MailTaskManager: + """Add background task support to a mail client. + + Args: + mail_client: The mail client to extend. + + Returns: + MailTaskManager instance. + """ + task_manager = MailTaskManager(mail_client) + mail_client.tasks = task_manager + return task_manager + + +# Convenience functions for background email sending +async def send_email_async( + request: Request, + to: Union[str, List[str]], + subject: str, + **kwargs: Any +) -> Optional[Task]: + """Send an email in the background from a request context. + + Args: + request: The current request object. + to: Recipient email address(es). + subject: Email subject. + **kwargs: Additional email parameters. + + Returns: + Task instance if tasks are available, None otherwise. + """ + from . import get_mail_from_request + + mail_client = get_mail_from_request(request) + + if not hasattr(mail_client, 'tasks'): + add_task_support(mail_client) + + return await mail_client.tasks.send_email_async(to=to, subject=subject, **kwargs) + + +async def send_template_email_async( + request: Request, + to: Union[str, List[str]], + subject: str, + template_name: str, + context: Optional[Dict[str, Any]] = None, + **kwargs: Any +) -> Optional[Task]: + """Send a template email in the background from a request context. + + Args: + request: The current request object. + to: Recipient email address(es). + subject: Email subject. + template_name: Name of the template to use. + context: Template context variables. + **kwargs: Additional email parameters. + + Returns: + Task instance if tasks are available, None otherwise. + """ + from . import get_mail_from_request + + mail_client = get_mail_from_request(request) + + if not hasattr(mail_client, 'tasks'): + add_task_support(mail_client) + + return await mail_client.tasks.send_template_email_async( + to=to, + subject=subject, + template_name=template_name, + context=context, + **kwargs + ) diff --git a/nexios_contrib/tasks/__init__.py b/nexios_contrib/tasks/__init__.py index ef4091e..2203466 100644 --- a/nexios_contrib/tasks/__init__.py +++ b/nexios_contrib/tasks/__init__.py @@ -123,9 +123,9 @@ async def get_task_status(request: Request): def create_task( request_or_func: Union[Request, TaskCallback], func_or_arg: Optional[Union[TaskCallback, Any]] = None, - *args: Any, name: Optional[str] = None, timeout: Optional[float] = None, + *args: Any, **kwargs: Any ) -> Task: """Create and schedule a new background task. diff --git a/pyproject.toml b/pyproject.toml index e05f803..717e36b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ redis = ["redis>=4.0.0"] tortoise = ["tortoise-orm>=0.20.0", "aiosqlite<0.20.0"] graphql = ["strawberry-graphql>=0.219.0"] scalar = ["scalar_docs>=0.1.0"] +mail = ["aiosmtplib>=3.0.0"] @@ -62,7 +63,8 @@ all = [ "granian>=1.2.0", "click>=8.1.3", "strawberry-graphql>=0.219.0", - "scalar_docs>=0.1.0" + "scalar_docs>=0.1.0", + "aiosmtplib>=3.0.0" ] dev = [ "nexios>=3.2.0", @@ -88,7 +90,8 @@ dev = [ "tortoise-orm>=0.20.0", "aiosqlite<0.20.0", "strawberry-graphql>=0.219.0", - "scalar_docs>=0.1.0" + "scalar_docs>=0.1.0", + "aiosmtplib>=3.0.0" ]