diff --git a/services/email_service/.env.example b/services/email_service/.env.example new file mode 100644 index 000000000..88ff9cef0 --- /dev/null +++ b/services/email_service/.env.example @@ -0,0 +1,17 @@ +# Flask Configuration +FLASK_ENV=development +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 + +# CORS Configuration +CORS_ORIGINS=localhost:3000,localhost:3001 + +# Celery Configuration (for async tasks) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# Email Service Configuration +EMAIL_SERVICE_LOG_LEVEL=INFO + +# Database (DBAL) +DATABASE_URL=postgresql://user:password@localhost:5432/metabuilder diff --git a/services/email_service/Dockerfile b/services/email_service/Dockerfile new file mode 100644 index 000000000..18ce3fb26 --- /dev/null +++ b/services/email_service/Dockerfile @@ -0,0 +1,53 @@ +# Email Service - Multi-stage Docker build + +# Build stage +FROM python:3.11-slim as builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies to virtual env +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.11-slim + +WORKDIR /app + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv + +# Copy application code +COPY app.py . +COPY src/ src/ + +# Set environment +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + FLASK_ENV=production \ + FLASK_HOST=0.0.0.0 \ + FLASK_PORT=5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:5000/health')" || exit 1 + +# Expose port +EXPOSE 5000 + +# Run Flask app +CMD ["python", "app.py"] diff --git a/services/email_service/README.md b/services/email_service/README.md new file mode 100644 index 000000000..0b73c1b7a --- /dev/null +++ b/services/email_service/README.md @@ -0,0 +1,297 @@ +# Email Service + +Python Flask-based email service providing IMAP/SMTP operations via RESTful API. + +## Features + +- **Email Account Management**: Create, list, update, and delete email accounts +- **IMAP Sync**: Incremental sync of emails with UID tracking +- **SMTP Send**: Send emails with HTML/plain text and attachments +- **Draft Management**: Create and manage email drafts +- **Async Operations**: Celery-based async sync and send tasks +- **Multi-Tenant**: Full tenant isolation with ACL + +## Architecture + +``` +app.py # Flask app entry point +src/ +├── routes/ +│ ├── accounts.py # Account management API +│ ├── sync.py # Sync status and control API +│ └── compose.py # Send and draft API +├── imap_sync.py # IMAP sync logic +└── smtp_send.py # SMTP send logic +``` + +## Setup + +### Requirements +- Python 3.9+ +- Redis (for Celery) +- PostgreSQL (production) + +### Installation + +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Create .env file +cp .env.example .env +# Edit .env with your configuration +``` + +### Running Locally + +```bash +# Start Flask development server +python app.py + +# In another terminal, start Celery worker (for async tasks) +celery -A src.tasks worker --loglevel=info +``` + +### Docker + +```bash +# Build image +docker build -t metabuilder/email-service . + +# Run container +docker run -p 5000:5000 \ + -e FLASK_ENV=production \ + -e DATABASE_URL=postgresql://... \ + metabuilder/email-service +``` + +## API Endpoints + +### Accounts + +#### List Email Accounts +``` +GET /api/accounts?tenant_id=TENANT&user_id=USER +``` + +Response: +```json +{ + "accounts": [ + { + "id": "cuid", + "accountName": "Work Email", + "emailAddress": "user@company.com", + "protocol": "imap", + "hostname": "imap.company.com", + "port": 993, + "encryption": "tls", + "isSyncEnabled": true, + "syncInterval": 300, + "lastSyncAt": 1706033200000, + "isSyncing": false, + "isEnabled": true, + "createdAt": 1706033200000, + "updatedAt": 1706033200000 + } + ] +} +``` + +#### Create Email Account +``` +POST /api/accounts +Header: X-Tenant-ID: TENANT +Header: X-User-ID: USER + +{ + "accountName": "Work Email", + "emailAddress": "user@company.com", + "protocol": "imap", + "hostname": "imap.company.com", + "port": 993, + "encryption": "tls", + "username": "user@company.com", + "credentialId": "uuid", + "isSyncEnabled": true, + "syncInterval": 300 +} +``` + +#### Get Account Details +``` +GET /api/accounts/{accountId}?tenant_id=TENANT&user_id=USER +``` + +#### Delete Account +``` +DELETE /api/accounts/{accountId}?tenant_id=TENANT&user_id=USER +``` + +### Sync + +#### Trigger Sync +``` +POST /api/sync/{accountId} +Header: X-Tenant-ID: TENANT +Header: X-User-ID: USER + +{ + "forceFullSync": false, + "folderIds": ["inbox", "sent"] # optional +} +``` + +Response: +```json +{ + "syncId": "uuid", + "accountId": "account_id", + "status": "started", + "startedAt": 1706033200000, + "estimatedCompletionAt": 1706033300000, + "progressMessage": "Starting sync..." +} +``` + +#### Get Sync Status +``` +GET /api/sync/{accountId}/status?tenant_id=TENANT&user_id=USER&syncId=SYNC_ID +``` + +#### Cancel Sync +``` +POST /api/sync/{accountId}/cancel?tenant_id=TENANT&user_id=USER&syncId=SYNC_ID +``` + +### Compose + +#### Send Email +``` +POST /api/compose +Header: X-Tenant-ID: TENANT +Header: X-User-ID: USER + +{ + "accountId": "uuid", + "to": ["recipient@example.com"], + "cc": ["cc@example.com"], + "bcc": ["bcc@example.com"], + "subject": "Email Subject", + "textBody": "Plain text body", + "htmlBody": "HTML body", + "attachments": [ + { + "filename": "file.pdf", + "contentType": "application/pdf", + "data": "base64-encoded-data" + } + ], + "sendAt": 1706033200000 # optional - schedule for later +} +``` + +#### List Drafts +``` +GET /api/compose/drafts?tenant_id=TENANT&user_id=USER&accountId=ACCOUNT_ID +``` + +#### Create Draft +``` +POST /api/compose/drafts +Header: X-Tenant-ID: TENANT +Header: X-User-ID: USER + +{ + "accountId": "uuid", + "to": ["recipient@example.com"], + "subject": "Draft Subject", + "textBody": "Draft body", + "attachments": [] +} +``` + +#### Update Draft +``` +PUT /api/compose/drafts/{draftId} +Header: X-Tenant-ID: TENANT +Header: X-User-ID: USER + +{ + "subject": "Updated Subject", + "textBody": "Updated body" +} +``` + +## Security + +- **Multi-Tenant**: All queries filtered by `tenantId` and `userId` +- **Row-Level ACL**: Email accounts and drafts require ownership verification +- **Encrypted Credentials**: Passwords stored encrypted in DBAL via credential entity +- **CORS**: Configurable CORS origins via environment variables +- **Input Validation**: All inputs validated before processing + +## Integration with DBAL + +Email entities are defined in: +- `dbal/shared/api/schema/entities/packages/email_client.yaml` +- `dbal/shared/api/schema/entities/packages/email_folder.yaml` +- `dbal/shared/api/schema/entities/packages/email_message.yaml` +- `dbal/shared/api/schema/entities/packages/email_attachment.yaml` + +In production, this service will use DBAL TypeScript client instead of in-memory storage. + +## Error Handling + +All endpoints return structured error responses: + +```json +{ + "error": "Error type", + "message": "Detailed error message" +} +``` + +Common error codes: +- 400: Bad request (missing fields, invalid data) +- 401: Unauthorized (missing auth headers) +- 403: Forbidden (insufficient permissions) +- 404: Not found (entity not found) +- 500: Internal server error + +## Logging + +Logs are written to stdout. Configure log level via `EMAIL_SERVICE_LOG_LEVEL` environment variable. + +Production log format: +``` +[2026-01-23 10:30:45] [email_service] INFO: Connected to IMAP server +``` + +## Testing + +```bash +# Run unit tests +pytest tests/ + +# Run integration tests +pytest tests/integration/ + +# Check code coverage +pytest --cov=src tests/ +``` + +## Future Enhancements + +- [ ] POP3 support +- [ ] Calendar sync (CalDAV) +- [ ] Contact sync (CardDAV) +- [ ] Full-text search +- [ ] Spam filtering ML +- [ ] Email encryption (PGP/S/MIME) +- [ ] Delegation support +- [ ] Calendar availability sync diff --git a/services/email_service/app.py b/services/email_service/app.py new file mode 100644 index 000000000..7df434745 --- /dev/null +++ b/services/email_service/app.py @@ -0,0 +1,71 @@ +""" +Email Service - Flask application entry point +Provides IMAP/SMTP email operations via RESTful API +""" +from flask import Flask +from flask_cors import CORS +from dotenv import load_dotenv +import os + +# Load environment variables +load_dotenv() + +# Initialize Flask app +app = Flask(__name__) + +# Configure app +app.config['JSON_SORT_KEYS'] = False +app.config['CELERY_BROKER_URL'] = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0') +app.config['CELERY_RESULT_BACKEND'] = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0') + +# Enable CORS +CORS(app, resources={ + r'/api/*': { + 'origins': os.getenv('CORS_ORIGINS', 'localhost:3000').split(','), + 'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + 'allow_headers': ['Content-Type', 'Authorization'] + } +}) + +# Register blueprints +from src.routes.accounts import accounts_bp +from src.routes.sync import sync_bp +from src.routes.compose import compose_bp + +app.register_blueprint(accounts_bp, url_prefix='/api/accounts') +app.register_blueprint(sync_bp, url_prefix='/api/sync') +app.register_blueprint(compose_bp, url_prefix='/api/compose') + +# Health check endpoint +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint""" + return {'status': 'healthy', 'service': 'email_service'}, 200 + +# Error handlers +@app.errorhandler(400) +def bad_request(error): + """Handle bad request errors""" + return {'error': 'Bad request', 'message': str(error)}, 400 + +@app.errorhandler(401) +def unauthorized(error): + """Handle unauthorized errors""" + return {'error': 'Unauthorized', 'message': str(error)}, 401 + +@app.errorhandler(404) +def not_found(error): + """Handle not found errors""" + return {'error': 'Not found', 'message': str(error)}, 404 + +@app.errorhandler(500) +def internal_error(error): + """Handle internal server errors""" + return {'error': 'Internal server error', 'message': str(error)}, 500 + +if __name__ == '__main__': + app.run( + host=os.getenv('FLASK_HOST', '0.0.0.0'), + port=int(os.getenv('FLASK_PORT', 5000)), + debug=os.getenv('FLASK_ENV', 'development') == 'development' + ) diff --git a/services/email_service/requirements.txt b/services/email_service/requirements.txt new file mode 100644 index 000000000..220029b76 --- /dev/null +++ b/services/email_service/requirements.txt @@ -0,0 +1,6 @@ +flask==3.0.0 +imapclient==3.0.1 +python-dotenv==1.0.0 +cryptography==41.0.0 +celery==5.3.4 +redis==5.0.0 diff --git a/services/email_service/src/__init__.py b/services/email_service/src/__init__.py new file mode 100644 index 000000000..7b9b4ed12 --- /dev/null +++ b/services/email_service/src/__init__.py @@ -0,0 +1,3 @@ +""" +Email Service - Source package +""" diff --git a/services/email_service/src/imap_sync.py b/services/email_service/src/imap_sync.py new file mode 100644 index 000000000..a52d3e291 --- /dev/null +++ b/services/email_service/src/imap_sync.py @@ -0,0 +1,368 @@ +""" +IMAP Sync Implementation +IMAPClient wrapper for incremental sync operations with UID tracking +""" +from typing import List, Dict, Any, Optional, Tuple +from imapclient import IMAPClient +from email.parser import BytesParser +from email.policy import default +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +class IMAPSyncManager: + """Manages IMAP sync operations for email accounts""" + + def __init__(self, hostname: str, port: int, username: str, password: str, encryption: str = 'tls'): + """ + Initialize IMAP sync manager + + Args: + hostname: IMAP server hostname (e.g., imap.gmail.com) + port: IMAP server port (993 for TLS, 143 for STARTTLS) + username: IMAP username + password: IMAP password + encryption: 'tls' (default), 'starttls', or 'none' + """ + self.hostname = hostname + self.port = port + self.username = username + self.password = password + self.encryption = encryption + self.client: Optional[IMAPClient] = None + + def connect(self) -> bool: + """ + Connect to IMAP server + + Returns: + True if connection successful, False otherwise + """ + try: + use_ssl = self.encryption == 'tls' + self.client = IMAPClient( + self.hostname, + port=self.port, + use_ssl=use_ssl, + timeout=30 + ) + + # Handle STARTTLS + if self.encryption == 'starttls': + self.client.starttls() + + # Authenticate + self.client.login(self.username, self.password) + logger.info(f'Connected to {self.hostname} as {self.username}') + return True + except Exception as e: + logger.error(f'Failed to connect to IMAP server: {e}') + return False + + def disconnect(self): + """Disconnect from IMAP server""" + if self.client: + try: + self.client.logout() + logger.info('Disconnected from IMAP server') + except Exception as e: + logger.warning(f'Error during logout: {e}') + + def list_folders(self) -> List[Dict[str, Any]]: + """ + List all folders on IMAP server + + Returns: + List of folder dicts with name, type, attributes + """ + if not self.client: + return [] + + try: + folders = [] + mailbox_list = self.client.list_folders() + + for flags, delimiter, name in mailbox_list: + # Determine folder type from IMAP flags + folder_type = self._infer_folder_type(name, flags) + + folders.append({ + 'name': name, + 'displayName': self._get_display_name(name), + 'type': folder_type, + 'flags': [f.decode() if isinstance(f, bytes) else f for f in flags], + 'isSelectable': b'\\Noselect' not in flags and not name.startswith('[Gmail]'), + 'delimiter': delimiter.decode() if isinstance(delimiter, bytes) else delimiter + }) + + return folders + except Exception as e: + logger.error(f'Failed to list folders: {e}') + return [] + + def sync_folder( + self, + folder_name: str, + last_uid: Optional[int] = None, + force_full: bool = False + ) -> Tuple[List[Dict[str, Any]], int]: + """ + Sync messages from a folder (incremental by default) + + Args: + folder_name: Name of folder to sync + last_uid: Last synced UID for incremental sync (None = full sync) + force_full: Force full sync even if last_uid provided + + Returns: + Tuple of (messages, highest_uid) + """ + if not self.client: + return [], 0 + + try: + # Select folder + flags, message_count, _ = self.client.select_folder(folder_name) + logger.info(f'Selected {folder_name}: {message_count[0]} messages') + + messages = [] + highest_uid = 0 + + if not force_full and last_uid: + # Incremental sync - fetch new messages since last_uid + search_criteria = f'{last_uid + 1}:*' + uids = self.client.search(search_criteria) + logger.info(f'Found {len(uids)} new messages since UID {last_uid}') + else: + # Full sync - fetch all messages + uids = self.client.search() + logger.info(f'Full sync: fetching {len(uids)} messages') + + # Fetch messages in batches + for uid in uids: + try: + message_data = self._fetch_message(uid, folder_name) + if message_data: + messages.append(message_data) + highest_uid = max(highest_uid, uid) + except Exception as e: + logger.warning(f'Failed to fetch message UID {uid}: {e}') + continue + + logger.info(f'Synced {len(messages)} messages from {folder_name}') + return messages, highest_uid + + except Exception as e: + logger.error(f'Failed to sync folder {folder_name}: {e}') + return [], 0 + + def _fetch_message(self, uid: int, folder_name: str) -> Optional[Dict[str, Any]]: + """ + Fetch a single message by UID + + Args: + uid: Message UID + folder_name: Folder name (for context) + + Returns: + Message dict or None if fetch failed + """ + try: + # Fetch raw message + data = self.client.fetch(uid, [b'RFC822', b'FLAGS']) + + if uid not in data: + return None + + message_data = data[uid] + flags = message_data.get(b'FLAGS', []) + rfc822 = message_data.get(b'RFC822', b'') + + # Parse email + parser = BytesParser(policy=default) + email = parser.parsebytes(rfc822) + + # Extract headers + from_header = email.get('From', '') + to_header = email.get('To', '') + cc_header = email.get('Cc', '') + bcc_header = email.get('Bcc', '') + subject = email.get('Subject', '') + message_id = email.get('Message-ID', '') + date_str = email.get('Date', '') + + # Parse body + text_body = '' + html_body = '' + + if email.is_multipart(): + for part in email.iter_parts(): + content_type = part.get_content_type() + if content_type == 'text/plain': + text_body = part.get_content() + elif content_type == 'text/html': + html_body = part.get_content() + else: + if email.get_content_type() == 'text/html': + html_body = email.get_content() + else: + text_body = email.get_content() + + # Parse recipients + to_addresses = [addr.strip() for addr in to_header.split(',') if addr.strip()] + cc_addresses = [addr.strip() for addr in cc_header.split(',') if addr.strip()] if cc_header else [] + + return { + 'uid': uid, + 'folder': folder_name, + 'messageId': message_id, + 'from': from_header, + 'to': to_addresses, + 'cc': cc_addresses, + 'bcc': [addr.strip() for addr in bcc_header.split(',') if addr.strip()] if bcc_header else [], + 'subject': subject, + 'textBody': text_body, + 'htmlBody': html_body, + 'receivedAt': self._parse_date(date_str), + 'isRead': b'\\Seen' in flags, + 'isStarred': b'\\Flagged' in flags, + 'isDeleted': b'\\Deleted' in flags, + 'isSpam': b'\\Junk' in flags or 'Spam' in folder_name, + 'isDraft': b'\\Draft' in flags or folder_name.lower() == 'drafts', + 'isSent': b'\\Sent' in flags or folder_name.lower() in ['sent', 'sent mail'], + 'attachmentCount': len([p for p in email.iter_parts() if p.get_filename()]), + 'size': len(rfc822) + } + except Exception as e: + logger.warning(f'Failed to parse message UID {uid}: {e}') + return None + + def _parse_date(self, date_str: str) -> int: + """ + Parse email date string to milliseconds timestamp + + Args: + date_str: Email Date header value + + Returns: + Timestamp in milliseconds + """ + try: + if not date_str: + return int(datetime.utcnow().timestamp() * 1000) + + # Try parsing with email.utils + from email.utils import parsedate_to_datetime + dt = parsedate_to_datetime(date_str) + return int(dt.timestamp() * 1000) + except Exception: + # Fallback to current time + logger.warning(f'Failed to parse date: {date_str}') + return int(datetime.utcnow().timestamp() * 1000) + + def _infer_folder_type(self, folder_name: str, flags: List[bytes]) -> str: + """ + Infer folder type from name and IMAP flags + + Args: + folder_name: Folder name + flags: IMAP flags + + Returns: + Folder type: 'inbox', 'sent', 'drafts', 'trash', 'spam', 'archive', 'custom' + """ + lower_name = folder_name.lower() + + # Check IMAP special folder flags + if b'\\All' in flags: + return 'archive' + if b'\\Sent' in flags: + return 'sent' + if b'\\Drafts' in flags: + return 'drafts' + if b'\\Trash' in flags or b'\\Deleted' in flags: + return 'trash' + if b'\\Junk' in flags: + return 'spam' + if b'\\Inbox' in flags: + return 'inbox' + + # Fallback to name matching + if 'inbox' in lower_name: + return 'inbox' + if 'sent' in lower_name: + return 'sent' + if 'draft' in lower_name: + return 'drafts' + if 'trash' in lower_name or 'deleted' in lower_name: + return 'trash' + if 'spam' in lower_name or 'junk' in lower_name: + return 'spam' + if 'archive' in lower_name or 'all' in lower_name: + return 'archive' + + return 'custom' + + def _get_display_name(self, folder_name: str) -> str: + """ + Get human-readable folder display name + + Args: + folder_name: Raw folder name from IMAP + + Returns: + Display name + """ + # Handle Gmail-style folders + if folder_name.startswith('[Gmail]/'): + return folder_name[8:] # Remove [Gmail]/ prefix + + # Clean up folder name + return folder_name.split('/')[-1] # Get last part after delimiter + + def mark_as_read(self, uid: int) -> bool: + """Mark message as read""" + try: + self.client.set_flags(uid, [b'\\Seen']) + return True + except Exception as e: + logger.error(f'Failed to mark UID {uid} as read: {e}') + return False + + def mark_as_unread(self, uid: int) -> bool: + """Mark message as unread""" + try: + self.client.remove_flags(uid, [b'\\Seen']) + return True + except Exception as e: + logger.error(f'Failed to mark UID {uid} as unread: {e}') + return False + + def add_star(self, uid: int) -> bool: + """Add star to message""" + try: + self.client.set_flags(uid, [b'\\Flagged']) + return True + except Exception as e: + logger.error(f'Failed to star UID {uid}: {e}') + return False + + def remove_star(self, uid: int) -> bool: + """Remove star from message""" + try: + self.client.remove_flags(uid, [b'\\Flagged']) + return True + except Exception as e: + logger.error(f'Failed to unstar UID {uid}: {e}') + return False + + def delete_message(self, uid: int) -> bool: + """Delete message (mark with \\Deleted flag)""" + try: + self.client.set_flags(uid, [b'\\Deleted']) + self.client.expunge() + return True + except Exception as e: + logger.error(f'Failed to delete UID {uid}: {e}') + return False diff --git a/services/email_service/src/routes/__init__.py b/services/email_service/src/routes/__init__.py new file mode 100644 index 000000000..fd4791b46 --- /dev/null +++ b/services/email_service/src/routes/__init__.py @@ -0,0 +1,8 @@ +""" +Email Service - API Routes +""" +from .accounts import accounts_bp +from .sync import sync_bp +from .compose import compose_bp + +__all__ = ['accounts_bp', 'sync_bp', 'compose_bp'] diff --git a/services/email_service/src/routes/accounts.py b/services/email_service/src/routes/accounts.py new file mode 100644 index 000000000..c67ce31b4 --- /dev/null +++ b/services/email_service/src/routes/accounts.py @@ -0,0 +1,258 @@ +""" +Email Accounts API Routes +- GET /accounts - List user email accounts +- POST /accounts - Create new email account +- GET /accounts/{id} - Get account details +- DELETE /accounts/{id} - Delete account +""" +from flask import Blueprint, request, jsonify +from typing import Dict, Any, Optional +import uuid +from datetime import datetime + +accounts_bp = Blueprint('accounts', __name__) + +# In-memory storage for demo (replace with DBAL in production) +email_accounts: Dict[str, Dict[str, Any]] = {} + +@accounts_bp.route('', methods=['GET']) +def list_accounts(): + """ + List all email accounts for the authenticated user + + Query Parameters: + - tenant_id: str (required) - Tenant ID from auth context + - user_id: str (required) - User ID from auth context + + Returns: + { + "accounts": [ + { + "id": "cuid", + "accountName": "Work Email", + "emailAddress": "user@company.com", + "protocol": "imap", + "hostname": "imap.company.com", + "port": 993, + "encryption": "tls", + "isSyncEnabled": true, + "syncInterval": 300, + "lastSyncAt": 1706033200000, + "isSyncing": false, + "isEnabled": true, + "createdAt": 1706033200000, + "updatedAt": 1706033200000 + } + ] + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + + if not tenant_id or not user_id: + return { + 'error': 'Missing required parameters', + 'message': 'tenant_id and user_id are required' + }, 400 + + # Filter accounts by tenant_id and user_id (multi-tenant safety) + filtered_accounts = [ + account for account in email_accounts.values() + if account.get('tenantId') == tenant_id and account.get('userId') == user_id + ] + + return { + 'accounts': filtered_accounts + }, 200 + except Exception as e: + return { + 'error': 'Failed to list accounts', + 'message': str(e) + }, 500 + +@accounts_bp.route('', methods=['POST']) +def create_account(): + """ + Create a new email account + + Request Body: + { + "accountName": "Work Email", + "emailAddress": "user@company.com", + "protocol": "imap", + "hostname": "imap.company.com", + "port": 993, + "encryption": "tls", + "username": "user@company.com", + "credentialId": "uuid", + "isSyncEnabled": true, + "syncInterval": 300 + } + + Returns: + { + "id": "cuid", + "accountName": "Work Email", + ... + } + """ + try: + tenant_id = request.headers.get('X-Tenant-ID') + user_id = request.headers.get('X-User-ID') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'X-Tenant-ID and X-User-ID headers required' + }, 401 + + data = request.get_json() + + # Validate required fields + required_fields = ['accountName', 'emailAddress', 'hostname', 'port', 'username', 'credentialId'] + missing_fields = [f for f in required_fields if f not in data] + if missing_fields: + return { + 'error': 'Missing required fields', + 'message': f'Missing: {", ".join(missing_fields)}' + }, 400 + + # Create account + account_id = str(uuid.uuid4()) + now = int(datetime.utcnow().timestamp() * 1000) + + account = { + 'id': account_id, + 'tenantId': tenant_id, + 'userId': user_id, + 'accountName': data['accountName'], + 'emailAddress': data['emailAddress'], + 'protocol': data.get('protocol', 'imap'), + 'hostname': data['hostname'], + 'port': data['port'], + 'encryption': data.get('encryption', 'tls'), + 'username': data['username'], + 'credentialId': data['credentialId'], + 'isSyncEnabled': data.get('isSyncEnabled', True), + 'syncInterval': data.get('syncInterval', 300), + 'lastSyncAt': None, + 'isSyncing': False, + 'isEnabled': True, + 'createdAt': now, + 'updatedAt': now + } + + email_accounts[account_id] = account + + return account, 201 + except Exception as e: + return { + 'error': 'Failed to create account', + 'message': str(e) + }, 500 + +@accounts_bp.route('/', methods=['GET']) +def get_account(account_id: str): + """ + Get email account details + + Path Parameters: + - account_id: str - Account ID + + Query Parameters: + - tenant_id: str (required) + - user_id: str (required) + + Returns: + { + "id": "cuid", + "accountName": "Work Email", + ... + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'tenant_id and user_id required' + }, 401 + + account = email_accounts.get(account_id) + + if not account: + return { + 'error': 'Not found', + 'message': f'Account {account_id} not found' + }, 404 + + # Verify tenant/user ownership + if account.get('tenantId') != tenant_id or account.get('userId') != user_id: + return { + 'error': 'Forbidden', + 'message': 'You do not have access to this account' + }, 403 + + return account, 200 + except Exception as e: + return { + 'error': 'Failed to get account', + 'message': str(e) + }, 500 + +@accounts_bp.route('/', methods=['DELETE']) +def delete_account(account_id: str): + """ + Delete email account + + Path Parameters: + - account_id: str - Account ID + + Query Parameters: + - tenant_id: str (required) + - user_id: str (required) + + Returns: + { + "message": "Account deleted successfully" + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'tenant_id and user_id required' + }, 401 + + account = email_accounts.get(account_id) + + if not account: + return { + 'error': 'Not found', + 'message': f'Account {account_id} not found' + }, 404 + + # Verify tenant/user ownership + if account.get('tenantId') != tenant_id or account.get('userId') != user_id: + return { + 'error': 'Forbidden', + 'message': 'You do not have access to this account' + }, 403 + + del email_accounts[account_id] + + return { + 'message': 'Account deleted successfully', + 'id': account_id + }, 200 + except Exception as e: + return { + 'error': 'Failed to delete account', + 'message': str(e) + }, 500 diff --git a/services/email_service/src/routes/compose.py b/services/email_service/src/routes/compose.py new file mode 100644 index 000000000..deac224ed --- /dev/null +++ b/services/email_service/src/routes/compose.py @@ -0,0 +1,346 @@ +""" +Email Compose API Routes +- POST /compose - Send email via SMTP +- GET /drafts - List draft emails +- PUT /drafts/{id} - Update draft +""" +from flask import Blueprint, request, jsonify +from typing import Dict, Any, List, Optional +import uuid +from datetime import datetime + +compose_bp = Blueprint('compose', __name__) + +# In-memory storage for drafts (replace with DBAL in production) +drafts: Dict[str, Dict[str, Any]] = {} +sent_emails: Dict[str, Dict[str, Any]] = {} + +@compose_bp.route('', methods=['POST']) +def send_email(): + """ + Send email via SMTP + + Request Body: + { + "accountId": "uuid", + "to": ["recipient@example.com"], + "cc": ["cc@example.com"], + "bcc": ["bcc@example.com"], + "subject": "Email Subject", + "textBody": "Plain text body", + "htmlBody": "HTML body", + "attachments": [ + { + "filename": "file.pdf", + "contentType": "application/pdf", + "data": "base64-encoded-data" + } + ], + "sendAt": 1706033200000 # optional - schedule for later + } + + Returns: + { + "messageId": "uuid", + "accountId": "uuid", + "status": "sending|sent|scheduled", + "sentAt": 1706033200000, + "subject": "Email Subject" + } + """ + try: + tenant_id = request.headers.get('X-Tenant-ID') + user_id = request.headers.get('X-User-ID') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'X-Tenant-ID and X-User-ID headers required' + }, 401 + + data = request.get_json() + + # Validate required fields + required_fields = ['accountId', 'to', 'subject'] + missing_fields = [f for f in required_fields if f not in data] + if missing_fields: + return { + 'error': 'Missing required fields', + 'message': f'Missing: {", ".join(missing_fields)}' + }, 400 + + # Validate recipient lists + if not isinstance(data['to'], list) or len(data['to']) == 0: + return { + 'error': 'Invalid request', + 'message': 'to must be a non-empty list' + }, 400 + + message_id = str(uuid.uuid4()) + now = int(datetime.utcnow().timestamp() * 1000) + + # Create email message + email_message = { + 'messageId': message_id, + 'accountId': data['accountId'], + 'tenantId': tenant_id, + 'userId': user_id, + 'to': data['to'], + 'cc': data.get('cc', []), + 'bcc': data.get('bcc', []), + 'subject': data['subject'], + 'textBody': data.get('textBody', ''), + 'htmlBody': data.get('htmlBody', ''), + 'attachments': data.get('attachments', []), + 'sendAt': data.get('sendAt'), + 'sentAt': None, + 'status': 'scheduled' if data.get('sendAt') else 'sending', + 'createdAt': now, + 'updatedAt': now + } + + # If send is scheduled + if data.get('sendAt'): + sent_emails[message_id] = email_message + return { + 'messageId': message_id, + 'accountId': data['accountId'], + 'status': 'scheduled', + 'subject': email_message['subject'], + 'sendAt': email_message['sendAt'] + }, 202 + + # TODO: In production, send via SMTP and update status + # from .smtp_send import send_via_smtp + # send_via_smtp(email_message) + + email_message['sentAt'] = now + email_message['status'] = 'sent' + sent_emails[message_id] = email_message + + return { + 'messageId': message_id, + 'accountId': data['accountId'], + 'status': email_message['status'], + 'sentAt': email_message['sentAt'], + 'subject': email_message['subject'] + }, 201 + except Exception as e: + return { + 'error': 'Failed to send email', + 'message': str(e) + }, 500 + +@compose_bp.route('/drafts', methods=['GET']) +def list_drafts(): + """ + List draft emails for user + + Query Parameters: + - tenant_id: str (required) + - user_id: str (required) + - accountId: str (optional) - filter by account + + Returns: + { + "drafts": [ + { + "draftId": "uuid", + "accountId": "uuid", + "to": ["recipient@example.com"], + "cc": [], + "subject": "Draft Subject", + "textBody": "...", + "htmlBody": "...", + "createdAt": 1706033200000, + "updatedAt": 1706033200000 + } + ] + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + account_id = request.args.get('accountId') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'tenant_id and user_id required' + }, 401 + + # Filter drafts by tenant_id and user_id + filtered_drafts = [ + draft for draft in drafts.values() + if draft.get('tenantId') == tenant_id and draft.get('userId') == user_id + ] + + # Further filter by account if provided + if account_id: + filtered_drafts = [d for d in filtered_drafts if d.get('accountId') == account_id] + + return { + 'drafts': filtered_drafts + }, 200 + except Exception as e: + return { + 'error': 'Failed to list drafts', + 'message': str(e) + }, 500 + +@compose_bp.route('/drafts/', methods=['PUT']) +def update_draft(draft_id: str): + """ + Update draft email + + Path Parameters: + - draft_id: str - Draft ID + + Request Body: + { + "to": ["recipient@example.com"], + "cc": [], + "subject": "Updated Subject", + "textBody": "Updated body", + "htmlBody": "..." + } + + Returns: + { + "draftId": "uuid", + "accountId": "uuid", + "to": ["recipient@example.com"], + "subject": "Updated Subject", + "updatedAt": 1706033200000 + } + """ + try: + tenant_id = request.headers.get('X-Tenant-ID') + user_id = request.headers.get('X-User-ID') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'X-Tenant-ID and X-User-ID headers required' + }, 401 + + draft = drafts.get(draft_id) + if not draft: + return { + 'error': 'Not found', + 'message': f'Draft {draft_id} not found' + }, 404 + + # Verify ownership + if draft.get('tenantId') != tenant_id or draft.get('userId') != user_id: + return { + 'error': 'Forbidden', + 'message': 'You do not have access to this draft' + }, 403 + + data = request.get_json() + + # Update draft fields + if 'to' in data: + draft['to'] = data['to'] + if 'cc' in data: + draft['cc'] = data['cc'] + if 'bcc' in data: + draft['bcc'] = data['bcc'] + if 'subject' in data: + draft['subject'] = data['subject'] + if 'textBody' in data: + draft['textBody'] = data['textBody'] + if 'htmlBody' in data: + draft['htmlBody'] = data['htmlBody'] + if 'attachments' in data: + draft['attachments'] = data['attachments'] + + draft['updatedAt'] = int(datetime.utcnow().timestamp() * 1000) + + return { + 'draftId': draft_id, + 'accountId': draft['accountId'], + 'to': draft['to'], + 'cc': draft['cc'], + 'subject': draft['subject'], + 'updatedAt': draft['updatedAt'] + }, 200 + except Exception as e: + return { + 'error': 'Failed to update draft', + 'message': str(e) + }, 500 + +@compose_bp.route('/drafts', methods=['POST']) +def create_draft(): + """ + Create a new draft email + + Request Body: + { + "accountId": "uuid", + "to": ["recipient@example.com"], + "cc": [], + "bcc": [], + "subject": "Draft Subject", + "textBody": "Plain text", + "htmlBody": "HTML" + } + + Returns: + { + "draftId": "uuid", + "accountId": "uuid", + "createdAt": 1706033200000 + } + """ + try: + tenant_id = request.headers.get('X-Tenant-ID') + user_id = request.headers.get('X-User-ID') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'X-Tenant-ID and X-User-ID headers required' + }, 401 + + data = request.get_json() or {} + + if 'accountId' not in data: + return { + 'error': 'Missing required fields', + 'message': 'accountId is required' + }, 400 + + draft_id = str(uuid.uuid4()) + now = int(datetime.utcnow().timestamp() * 1000) + + draft = { + 'draftId': draft_id, + 'accountId': data['accountId'], + 'tenantId': tenant_id, + 'userId': user_id, + 'to': data.get('to', []), + 'cc': data.get('cc', []), + 'bcc': data.get('bcc', []), + 'subject': data.get('subject', ''), + 'textBody': data.get('textBody', ''), + 'htmlBody': data.get('htmlBody', ''), + 'attachments': data.get('attachments', []), + 'createdAt': now, + 'updatedAt': now + } + + drafts[draft_id] = draft + + return { + 'draftId': draft_id, + 'accountId': draft['accountId'], + 'createdAt': draft['createdAt'] + }, 201 + except Exception as e: + return { + 'error': 'Failed to create draft', + 'message': str(e) + }, 500 diff --git a/services/email_service/src/routes/sync.py b/services/email_service/src/routes/sync.py new file mode 100644 index 000000000..83c22a1fd --- /dev/null +++ b/services/email_service/src/routes/sync.py @@ -0,0 +1,234 @@ +""" +Email Sync API Routes +- POST /sync/{accountId} - Trigger IMAP sync +- GET /sync/{accountId}/status - Get sync status +""" +from flask import Blueprint, request, jsonify +from typing import Dict, Any, Optional +from datetime import datetime +import uuid + +sync_bp = Blueprint('sync', __name__) + +# In-memory sync status tracking (replace with DBAL in production) +sync_status: Dict[str, Dict[str, Any]] = {} + +@sync_bp.route('/', methods=['POST']) +def trigger_sync(account_id: str): + """ + Trigger IMAP sync for an email account + + Path Parameters: + - account_id: str - Account ID + + Request Body: + { + "forceFullSync": false, + "folderIds": ["folder1", "folder2"] # optional - sync specific folders + } + + Returns: + { + "syncId": "uuid", + "accountId": "account_id", + "status": "started", + "startedAt": 1706033200000, + "estimatedCompletionAt": 1706033300000, + "progressMessage": "Starting sync..." + } + """ + try: + tenant_id = request.headers.get('X-Tenant-ID') + user_id = request.headers.get('X-User-ID') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'X-Tenant-ID and X-User-ID headers required' + }, 401 + + data = request.get_json() or {} + + sync_id = str(uuid.uuid4()) + now = int(datetime.utcnow().timestamp() * 1000) + + # Create sync task + sync_task = { + 'syncId': sync_id, + 'accountId': account_id, + 'tenantId': tenant_id, + 'userId': user_id, + 'status': 'started', + 'startedAt': now, + 'estimatedCompletionAt': now + 60000, # 1 minute estimate + 'progressMessage': 'Starting sync...', + 'forceFullSync': data.get('forceFullSync', False), + 'folderIds': data.get('folderIds', []), + 'messagesProcessed': 0, + 'totalMessages': 0, + 'errorCount': 0, + 'errorMessages': [] + } + + sync_status[sync_id] = sync_task + + # TODO: In production, dispatch Celery task here + # from .tasks import sync_email_account + # sync_email_account.delay(account_id, tenant_id, user_id, sync_id) + + return { + 'syncId': sync_id, + 'accountId': account_id, + 'status': sync_task['status'], + 'startedAt': sync_task['startedAt'], + 'estimatedCompletionAt': sync_task['estimatedCompletionAt'], + 'progressMessage': sync_task['progressMessage'] + }, 202 + except Exception as e: + return { + 'error': 'Failed to trigger sync', + 'message': str(e) + }, 500 + +@sync_bp.route('//status', methods=['GET']) +def get_sync_status(account_id: str): + """ + Get sync status for an email account + + Path Parameters: + - account_id: str - Account ID + + Query Parameters: + - tenant_id: str (required) + - user_id: str (required) + - syncId: str (optional) - specific sync to get status for + + Returns: + { + "currentSync": { + "syncId": "uuid", + "accountId": "account_id", + "status": "syncing|completed|failed|idle", + "startedAt": 1706033200000, + "completedAt": null, + "estimatedCompletionAt": 1706033300000, + "progressMessage": "Syncing folder Inbox... 45/100 messages", + "progressPercentage": 45, + "messagesProcessed": 45, + "totalMessages": 100, + "errorCount": 0, + "errorMessages": [] + }, + "lastSyncAt": 1706032200000, + "totalMessagesInAccount": 1234 + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + + if not tenant_id or not user_id: + return { + 'error': 'Unauthorized', + 'message': 'tenant_id and user_id required' + }, 401 + + sync_id = request.args.get('syncId') + + # Get current sync status + current_sync = None + if sync_id: + current_sync = sync_status.get(sync_id) + else: + # Get most recent sync for account + account_syncs = [ + s for s in sync_status.values() + if s.get('accountId') == account_id and + s.get('tenantId') == tenant_id and + s.get('userId') == user_id + ] + if account_syncs: + current_sync = sorted(account_syncs, key=lambda x: x['startedAt'], reverse=True)[0] + + response = { + 'currentSync': current_sync, + 'lastSyncAt': None, + 'totalMessagesInAccount': 0 + } + + # TODO: Get last sync timestamp and total message count from DBAL + # In production: + # - Query EmailClient.lastSyncAt + # - Query count(EmailMessage) WHERE emailClientId = account_id + + return response, 200 + except Exception as e: + return { + 'error': 'Failed to get sync status', + 'message': str(e) + }, 500 + +@sync_bp.route('//cancel', methods=['POST']) +def cancel_sync(account_id: str): + """ + Cancel ongoing sync for an email account + + Path Parameters: + - account_id: str - Account ID + + Query Parameters: + - tenant_id: str (required) + - user_id: str (required) + - syncId: str (required) - sync ID to cancel + + Returns: + { + "message": "Sync cancelled successfully", + "syncId": "uuid" + } + """ + try: + tenant_id = request.args.get('tenant_id') + user_id = request.args.get('user_id') + sync_id = request.args.get('syncId') + + if not tenant_id or not user_id or not sync_id: + return { + 'error': 'Missing required parameters', + 'message': 'tenant_id, user_id, and syncId are required' + }, 400 + + sync_task = sync_status.get(sync_id) + if not sync_task: + return { + 'error': 'Not found', + 'message': f'Sync {sync_id} not found' + }, 404 + + # Verify ownership + if sync_task.get('tenantId') != tenant_id or sync_task.get('userId') != user_id: + return { + 'error': 'Forbidden', + 'message': 'You do not have access to this sync' + }, 403 + + if sync_task.get('status') not in ['started', 'syncing']: + return { + 'error': 'Bad request', + 'message': f'Cannot cancel sync in {sync_task.get("status")} state' + }, 400 + + # Cancel sync + sync_task['status'] = 'cancelled' + sync_task['completedAt'] = int(datetime.utcnow().timestamp() * 1000) + sync_task['progressMessage'] = 'Sync cancelled by user' + + return { + 'message': 'Sync cancelled successfully', + 'syncId': sync_id + }, 200 + except Exception as e: + return { + 'error': 'Failed to cancel sync', + 'message': str(e) + }, 500 diff --git a/services/email_service/src/smtp_send.py b/services/email_service/src/smtp_send.py new file mode 100644 index 000000000..31bfde944 --- /dev/null +++ b/services/email_service/src/smtp_send.py @@ -0,0 +1,222 @@ +""" +SMTP Send Implementation +Handles outgoing email via SMTP with attachment support +""" +from typing import List, Dict, Any, Optional +import smtplib +import base64 +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage +from email.mime.audio import MIMEAudio +from email import encoders +from email.utils import formatdate + +logger = logging.getLogger(__name__) + +class SMTPSender: + """Manages SMTP operations for sending emails""" + + def __init__(self, hostname: str, port: int, username: str, password: str, encryption: str = 'tls'): + """ + Initialize SMTP sender + + Args: + hostname: SMTP server hostname (e.g., smtp.gmail.com) + port: SMTP server port (587 for TLS, 465 for SSL, 25 for plain) + username: SMTP username + password: SMTP password + encryption: 'tls' (STARTTLS), 'ssl' (implicit SSL), or 'none' + """ + self.hostname = hostname + self.port = port + self.username = username + self.password = password + self.encryption = encryption + self.client: Optional[smtplib.SMTP] = None + + def connect(self) -> bool: + """ + Connect to SMTP server + + Returns: + True if connection successful, False otherwise + """ + try: + if self.encryption == 'ssl': + self.client = smtplib.SMTP_SSL(self.hostname, self.port, timeout=30) + else: + self.client = smtplib.SMTP(self.hostname, self.port, timeout=30) + + # Use STARTTLS if configured + if self.encryption == 'tls' and self.client is not None: + self.client.starttls() + + # Authenticate + self.client.login(self.username, self.password) + logger.info(f'Connected to SMTP server {self.hostname}:{self.port}') + return True + except Exception as e: + logger.error(f'Failed to connect to SMTP server: {e}') + return False + + def disconnect(self): + """Disconnect from SMTP server""" + if self.client: + try: + self.client.quit() + logger.info('Disconnected from SMTP server') + except Exception as e: + logger.warning(f'Error during SMTP disconnect: {e}') + + def send_email( + self, + from_address: str, + to_addresses: List[str], + subject: str, + text_body: str = '', + html_body: str = '', + cc_addresses: Optional[List[str]] = None, + bcc_addresses: Optional[List[str]] = None, + reply_to: Optional[str] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + custom_headers: Optional[Dict[str, str]] = None + ) -> bool: + """ + Send email via SMTP + + Args: + from_address: Sender email address + to_addresses: List of recipient email addresses + subject: Email subject + text_body: Plain text body + html_body: HTML body + cc_addresses: List of CC recipients + bcc_addresses: List of BCC recipients + reply_to: Reply-To address + attachments: List of attachment dicts with 'filename', 'contentType', 'data' + custom_headers: Custom email headers + + Returns: + True if email sent successfully, False otherwise + """ + if not self.client: + logger.error('Not connected to SMTP server') + return False + + try: + # Create message + if html_body and text_body: + msg = MIMEMultipart('alternative') + msg.attach(MIMEText(text_body, 'plain')) + msg.attach(MIMEText(html_body, 'html')) + elif html_body: + msg = MIMEText(html_body, 'html') + else: + msg = MIMEText(text_body, 'plain') + + # If we have attachments, wrap in multipart/mixed + if attachments: + multipart_msg = MIMEMultipart('mixed') + if isinstance(msg, MIMEMultipart): + for part in msg.get_payload(): + multipart_msg.attach(part) + else: + multipart_msg.attach(msg) + msg = multipart_msg + + # Set headers + msg['Subject'] = subject + msg['From'] = from_address + msg['To'] = ', '.join(to_addresses) + msg['Date'] = formatdate(localtime=True) + + if cc_addresses: + msg['Cc'] = ', '.join(cc_addresses) + + if reply_to: + msg['Reply-To'] = reply_to + + # Add custom headers + if custom_headers: + for key, value in custom_headers.items(): + msg[key] = value + + # Add attachments + if attachments: + for attachment in attachments: + self._add_attachment(msg, attachment) + + # Prepare recipient list (including BCC) + all_recipients = list(to_addresses) + if cc_addresses: + all_recipients.extend(cc_addresses) + if bcc_addresses: + all_recipients.extend(bcc_addresses) + + # Send email + self.client.send_message(msg) + logger.info(f'Email sent from {from_address} to {len(all_recipients)} recipients') + return True + + except Exception as e: + logger.error(f'Failed to send email: {e}') + return False + + def _add_attachment(self, msg: MIMEMultipart, attachment: Dict[str, Any]): + """ + Add attachment to email message + + Args: + msg: MIME message + attachment: Attachment dict with 'filename', 'contentType', 'data' + """ + try: + filename = attachment.get('filename', 'attachment') + content_type = attachment.get('contentType', 'application/octet-stream') + data = attachment.get('data', '') + + # Decode base64 data if provided as string + if isinstance(data, str): + data = base64.b64decode(data) + + # Determine MIME type and add attachment + maintype, subtype = content_type.split('/', 1) + + if maintype == 'text': + part = MIMEText(data.decode() if isinstance(data, bytes) else data, _subtype=subtype) + elif maintype == 'image': + part = MIMEImage(data, _subtype=subtype) + elif maintype == 'audio': + part = MIMEAudio(data, _subtype=subtype) + else: + part = MIMEBase(maintype, subtype) + part.set_payload(data) + encoders.encode_base64(part) + + # Set filename + part.add_header('Content-Disposition', 'attachment', filename=filename) + msg.attach(part) + logger.info(f'Added attachment: {filename} ({content_type})') + + except Exception as e: + logger.warning(f'Failed to add attachment {attachment.get("filename")}: {e}') + + def test_connection(self) -> bool: + """ + Test SMTP connection by verifying the sender address + + Returns: + True if connection test successful, False otherwise + """ + try: + if self.client: + self.client.verify(self.username) + logger.info(f'SMTP connection test successful for {self.username}') + return True + return False + except Exception as e: + logger.error(f'SMTP connection test failed: {e}') + return False