diff --git a/.env.example b/.env.example index 7cd11d9f..9f9c8c15 100644 --- a/.env.example +++ b/.env.example @@ -34,9 +34,22 @@ JWT_EXPIRATION_TIME=3600 # Encryption key for API keys ENCRYPTION_KEY="your-encryption-key" +# Email provider settings +EMAIL_PROVIDER="sendgrid" + # SendGrid SENDGRID_API_KEY="your-sendgrid-api-key" EMAIL_FROM="noreply@yourdomain.com" + +# SMTP settings +SMTP_HOST="your-smtp-host" +SMTP_FROM="noreply-smtp@yourdomain.com" +SMTP_USER="your-smtp-username" +SMTP_PASSWORD="your-smtp-password" +SMTP_PORT=587 +SMTP_USE_TLS=true +SMTP_USE_SSL=false + APP_URL="https://yourdomain.com" LANGFUSE_PUBLIC_KEY="your-langfuse-public-key" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 69dd5489..ed1509c9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -7,8 +7,10 @@ on: branches: ["main", "develop"] env: - REGISTRY: ghcr.io + GHCR_REGISTRY: ghcr.io + DOCKERHUB_REGISTRY: docker.io IMAGE_NAME: ${{ github.repository }} + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} jobs: build-and-push: @@ -21,18 +23,27 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Log in to the Container registry + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKERHUB_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: | + ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} + ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE }} tags: | type=raw,value=develop,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c632d49e..8aaee5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve context management in agent execution - Add file support for A2A protocol (Agent-to-Agent) endpoints - Implement multimodal content processing in A2A messages +- Add SMTP email provider support as alternative to SendGrid ## [0.0.9] - 2025-05-13 diff --git a/README.md b/README.md index 2e805739..87bac869 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Authorization: Bearer your-token-jwt - **Uvicorn**: ASGI server - **Redis**: Cache and session management - **JWT**: Secure token authentication -- **SendGrid**: Email service for notifications +- **SendGrid/SMTP**: Email service for notifications (configurable) - **Jinja2**: Template engine for email rendering - **Bcrypt**: Password hashing and security - **LangGraph**: Framework for building stateful, multi-agent workflows @@ -469,7 +469,9 @@ You'll also need the following accounts/API keys: - Python 3.10+ - PostgreSQL - Redis -- SendGrid Account (for email sending) +- Email provider: + - SendGrid Account (if using SendGrid email provider) + - SMTP Server (if using SMTP email provider) ## 🔧 Installation @@ -566,11 +568,23 @@ JWT_SECRET_KEY="your-jwt-secret-key" JWT_ALGORITHM="HS256" JWT_EXPIRATION_TIME=30 # In seconds -# SendGrid for emails +# Email provider configuration +EMAIL_PROVIDER="sendgrid" # Options: "sendgrid" or "smtp" + +# SendGrid (if EMAIL_PROVIDER=sendgrid) SENDGRID_API_KEY="your-sendgrid-api-key" EMAIL_FROM="noreply@yourdomain.com" APP_URL="https://yourdomain.com" +# SMTP (if EMAIL_PROVIDER=smtp) +SMTP_FROM="noreply-smtp@yourdomain.com" +SMTP_USER="your-smtp-username" +SMTP_PASSWORD="your-smtp-password" +SMTP_HOST="your-smtp-host" +SMTP_PORT=587 +SMTP_USE_TLS=true +SMTP_USE_SSL=false + # Encryption for API keys ENCRYPTION_KEY="your-encryption-key" ``` @@ -787,8 +801,12 @@ The main environment variables used by the API container: - `POSTGRES_CONNECTION_STRING`: PostgreSQL connection string - `REDIS_HOST`: Redis host (use "redis" when running with Docker) - `JWT_SECRET_KEY`: Secret key for JWT token generation -- `SENDGRID_API_KEY`: SendGrid API key for sending emails -- `EMAIL_FROM`: Email used as sender +- `EMAIL_PROVIDER`: Email provider to use ("sendgrid" or "smtp") +- `SENDGRID_API_KEY`: SendGrid API key (if using SendGrid) +- `EMAIL_FROM`: Email used as sender (for SendGrid) +- `SMTP_FROM`: Email used as sender (for SMTP) +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`: SMTP server configuration +- `SMTP_USE_TLS`, `SMTP_USE_SSL`: SMTP security settings - `APP_URL`: Base URL of the application ## 🔒 Secure API Key Management diff --git a/src/config/settings.py b/src/config/settings.py index eb885e73..1dbb8440 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -81,9 +81,22 @@ class Settings(BaseSettings): # Encryption settings ENCRYPTION_KEY: str = os.getenv("ENCRYPTION_KEY", secrets.token_urlsafe(32)) + # Email provider settings + EMAIL_PROVIDER: str = os.getenv("EMAIL_PROVIDER", "sendgrid") + # SendGrid settings SENDGRID_API_KEY: str = os.getenv("SENDGRID_API_KEY", "") EMAIL_FROM: str = os.getenv("EMAIL_FROM", "noreply@yourdomain.com") + + # SMTP settings + SMTP_HOST: str = os.getenv("SMTP_HOST", "") + SMTP_PORT: int = int(os.getenv("SMTP_PORT", 587)) + SMTP_USER: str = os.getenv("SMTP_USER", "") + SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + SMTP_USE_TLS: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + SMTP_USE_SSL: bool = os.getenv("SMTP_USE_SSL", "false").lower() == "true" + SMTP_FROM: str = os.getenv("SMTP_FROM", "") + APP_URL: str = os.getenv("APP_URL", "http://localhost:8000") # Server settings diff --git a/src/services/email_service.py b/src/services/email_service.py index 543978c8..bf234ab7 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -33,7 +33,11 @@ import logging from datetime import datetime from jinja2 import Environment, FileSystemLoader, select_autoescape import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart from pathlib import Path +from config.settings import settings logger = logging.getLogger(__name__) @@ -67,6 +71,110 @@ def _render_template(template_name: str, context: dict) -> str: return f"
Could not display email content. Please access {context.get('verification_link', '') or context.get('reset_link', '')}
" +def _send_email_sendgrid(to_email: str, subject: str, html_content: str) -> bool: + """ + Send an email using SendGrid provider + + Args: + to_email: Recipient's email + subject: Email subject + html_content: HTML content of the email + + Returns: + bool: True if the email was sent successfully, False otherwise + """ + try: + sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY) + from_email = Email(settings.EMAIL_FROM) + to_email = To(to_email) + content = Content("text/html", html_content) + + mail = Mail(from_email, to_email, subject, content) + response = sg.client.mail.send.post(request_body=mail.get()) + + if response.status_code >= 200 and response.status_code < 300: + logger.info(f"Email sent via SendGrid to {to_email}") + return True + else: + logger.error( + f"Failed to send email via SendGrid to {to_email}. Status: {response.status_code}" + ) + return False + + except Exception as e: + logger.error(f"Error sending email via SendGrid to {to_email}: {str(e)}") + return False + + +def _send_email_smtp(to_email: str, subject: str, html_content: str) -> bool: + """ + Send an email using SMTP provider + + Args: + to_email: Recipient's email + subject: Email subject + html_content: HTML content of the email + + Returns: + bool: True if the email was sent successfully, False otherwise + """ + try: + # Create message container + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = settings.SMTP_FROM or settings.EMAIL_FROM + msg['To'] = to_email + + # Attach HTML content + part = MIMEText(html_content, 'html') + msg.attach(part) + + # Setup SMTP server + if settings.SMTP_USE_SSL: + server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) + else: + server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) + if settings.SMTP_USE_TLS: + server.starttls() + + # Login if credentials are provided + if settings.SMTP_USER and settings.SMTP_PASSWORD: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + + # Send email + server.sendmail( + settings.SMTP_FROM or settings.EMAIL_FROM, + to_email, + msg.as_string() + ) + server.quit() + + logger.info(f"Email sent via SMTP to {to_email}") + return True + + except Exception as e: + logger.error(f"Error sending email via SMTP to {to_email}: {str(e)}") + return False + + +def send_email(to_email: str, subject: str, html_content: str) -> bool: + """ + Send an email using the configured provider + + Args: + to_email: Recipient's email + subject: Email subject + html_content: HTML content of the email + + Returns: + bool: True if the email was sent successfully, False otherwise + """ + if settings.EMAIL_PROVIDER.lower() == "smtp": + return _send_email_smtp(to_email, subject, html_content) + else: # Default to SendGrid + return _send_email_sendgrid(to_email, subject, html_content) + + def send_verification_email(email: str, token: str) -> bool: """ Send a verification email to the user @@ -79,40 +187,22 @@ def send_verification_email(email: str, token: str) -> bool: bool: True if the email was sent successfully, False otherwise """ try: - sg = sendgrid.SendGridAPIClient(api_key=os.getenv("SENDGRID_API_KEY")) - from_email = Email(os.getenv("EMAIL_FROM")) - to_email = To(email) subject = "Email Verification - Evo AI" - - verification_link = f"{os.getenv('APP_URL')}/security/verify-email?code={token}" + verification_link = f"{settings.APP_URL}/security/verify-email?code={token}" html_content = _render_template( "verification_email", { "verification_link": verification_link, - "user_name": email.split("@")[ - 0 - ], # Use part of the email as temporary name + "user_name": email.split("@")[0], # Use part of the email as temporary name "current_year": datetime.now().year, }, ) - content = Content("text/html", html_content) - - mail = Mail(from_email, to_email, subject, content) - response = sg.client.mail.send.post(request_body=mail.get()) - - if response.status_code >= 200 and response.status_code < 300: - logger.info(f"Verification email sent to {email}") - return True - else: - logger.error( - f"Failed to send verification email to {email}. Status: {response.status_code}" - ) - return False + return send_email(email, subject, html_content) except Exception as e: - logger.error(f"Error sending verification email to {email}: {str(e)}") + logger.error(f"Error preparing verification email to {email}: {str(e)}") return False @@ -128,40 +218,22 @@ def send_password_reset_email(email: str, token: str) -> bool: bool: True if the email was sent successfully, False otherwise """ try: - sg = sendgrid.SendGridAPIClient(api_key=os.getenv("SENDGRID_API_KEY")) - from_email = Email(os.getenv("EMAIL_FROM")) - to_email = To(email) subject = "Password Reset - Evo AI" - - reset_link = f"{os.getenv('APP_URL')}/security/reset-password?token={token}" + reset_link = f"{settings.APP_URL}/security/reset-password?token={token}" html_content = _render_template( "password_reset", { "reset_link": reset_link, - "user_name": email.split("@")[ - 0 - ], # Use part of the email as temporary name + "user_name": email.split("@")[0], # Use part of the email as temporary name "current_year": datetime.now().year, }, ) - content = Content("text/html", html_content) - - mail = Mail(from_email, to_email, subject, content) - response = sg.client.mail.send.post(request_body=mail.get()) - - if response.status_code >= 200 and response.status_code < 300: - logger.info(f"Password reset email sent to {email}") - return True - else: - logger.error( - f"Failed to send password reset email to {email}. Status: {response.status_code}" - ) - return False + return send_email(email, subject, html_content) except Exception as e: - logger.error(f"Error sending password reset email to {email}: {str(e)}") + logger.error(f"Error preparing password reset email to {email}: {str(e)}") return False @@ -177,12 +249,8 @@ def send_welcome_email(email: str, user_name: str = None) -> bool: bool: True if the email was sent successfully, False otherwise """ try: - sg = sendgrid.SendGridAPIClient(api_key=os.getenv("SENDGRID_API_KEY")) - from_email = Email(os.getenv("EMAIL_FROM")) - to_email = To(email) subject = "Welcome to Evo AI" - - dashboard_link = f"{os.getenv('APP_URL')}/dashboard" + dashboard_link = f"{settings.APP_URL}/dashboard" html_content = _render_template( "welcome_email", @@ -193,22 +261,10 @@ def send_welcome_email(email: str, user_name: str = None) -> bool: }, ) - content = Content("text/html", html_content) - - mail = Mail(from_email, to_email, subject, content) - response = sg.client.mail.send.post(request_body=mail.get()) - - if response.status_code >= 200 and response.status_code < 300: - logger.info(f"Welcome email sent to {email}") - return True - else: - logger.error( - f"Failed to send welcome email to {email}. Status: {response.status_code}" - ) - return False + return send_email(email, subject, html_content) except Exception as e: - logger.error(f"Error sending welcome email to {email}: {str(e)}") + logger.error(f"Error preparing welcome email to {email}: {str(e)}") return False @@ -228,14 +284,8 @@ def send_account_locked_email( bool: True if the email was sent successfully, False otherwise """ try: - sg = sendgrid.SendGridAPIClient(api_key=os.getenv("SENDGRID_API_KEY")) - from_email = Email(os.getenv("EMAIL_FROM")) - to_email = To(email) subject = "Security Alert - Account Locked" - - reset_link = ( - f"{os.getenv('APP_URL')}/security/reset-password?token={reset_token}" - ) + reset_link = f"{settings.APP_URL}/security/reset-password?token={reset_token}" html_content = _render_template( "account_locked", @@ -248,20 +298,8 @@ def send_account_locked_email( }, ) - content = Content("text/html", html_content) - - mail = Mail(from_email, to_email, subject, content) - response = sg.client.mail.send.post(request_body=mail.get()) - - if response.status_code >= 200 and response.status_code < 300: - logger.info(f"Account locked email sent to {email}") - return True - else: - logger.error( - f"Failed to send account locked email to {email}. Status: {response.status_code}" - ) - return False + return send_email(email, subject, html_content) except Exception as e: - logger.error(f"Error sending account locked email to {email}: {str(e)}") + logger.error(f"Error preparing account locked email to {email}: {str(e)}") return False