diff --git a/backend/api.py b/backend/api.py index 5617530c..6f07e4d8 100644 --- a/backend/api.py +++ b/backend/api.py @@ -22,6 +22,8 @@ from services import billing as billing_api from services import transcription as transcription_api from services.mcp_custom import discover_custom_tools import sys +from services import email_api + load_dotenv() @@ -136,6 +138,8 @@ app.include_router(mcp_api.router, prefix="/api") app.include_router(transcription_api.router, prefix="/api") +app.include_router(email_api.router, prefix="/api") + @app.get("/api/health") async def health_check(): """Health check endpoint to verify API is working.""" diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml index 7c3d63a7..add788ee 100644 --- a/backend/docker-compose.prod.yml +++ b/backend/docker-compose.prod.yml @@ -10,7 +10,7 @@ services: memory: 32G worker: - command: python -m dramatiq --processes 20 --threads 16 run_agent_background + command: python -m dramatiq --processes 10 --threads 32 run_agent_background deploy: resources: limits: diff --git a/backend/poetry.lock b/backend/poetry.lock index dbc6585c..c459e04d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -543,6 +543,27 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + [[package]] name = "dramatiq" version = "1.17.1" @@ -605,6 +626,22 @@ attrs = ">=21.3.0" e2b = ">=1.3.1,<2.0.0" httpx = ">=0.20.0,<1.0.0" +[[package]] +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "entrypoints" version = "0.4" @@ -1297,6 +1334,21 @@ tokenizers = "*" extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"] proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.7)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"] +[[package]] +name = "mailtrap" +version = "2.1.0" +description = "Official mailtrap.io API client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mailtrap-2.1.0-py3-none-any.whl", hash = "sha256:6cef8dc02734e3e3a16161e38d184ea6971e925673c731c8ac968b88556f069e"}, + {file = "mailtrap-2.1.0.tar.gz", hash = "sha256:22fccf3cd912a7e47d4a1bb86865cf0f0587d59dc73bc78d9e77d596767f5b85"}, +] + +[package.dependencies] +requests = ">=2.26.0" + [[package]] name = "markupsafe" version = "3.0.2" @@ -3719,4 +3771,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "ed0ccb92ccc81ecff536968c883a2ea96d2ee8a6c06a18d0023b0cd4185f8a28" +content-hash = "70ef4a9be6ddd82debb9e9e377d9f47f6f6917b43b75a688e404adeef9e61018" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a62ed1ef..5afdabde 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -56,6 +56,10 @@ langfuse = "^2.60.5" Pillow = "^10.0.0" mcp = "^1.0.0" sentry-sdk = {extras = ["fastapi"], version = "^2.29.1"} +httpx = "^0.28.0" +aiohttp = "^3.9.0" +email-validator = "^2.0.0" +mailtrap = "^2.0.1" [tool.poetry.scripts] agentpress = "agentpress.cli:main" diff --git a/backend/requirements.txt b/backend/requirements.txt index ac805f9b..cc58b88b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -38,4 +38,6 @@ Pillow>=10.0.0 sentry-sdk[fastapi]>=2.29.1 mcp>=1.0.0 mcp_use>=1.0.0 -aiohttp>=3.9.0 \ No newline at end of file +aiohttp>=3.9.0 +email-validator>=2.0.0 +mailtrap>=2.0.1 diff --git a/backend/run_agent_background.py b/backend/run_agent_background.py index b36c5e9b..8f4edead 100644 --- a/backend/run_agent_background.py +++ b/backend/run_agent_background.py @@ -132,6 +132,8 @@ async def run_agent_background( final_status = "running" error_message = None + pending_redis_operations = [] + async for response in agent_gen: if stop_signal_received: logger.info(f"Agent run {agent_run_id} stopped by signal.") @@ -141,8 +143,8 @@ async def run_agent_background( # Store response in Redis list and publish notification response_json = json.dumps(response) - asyncio.create_task(redis.rpush(response_list_key, response_json)) - asyncio.create_task(redis.publish(response_channel, "new")) + pending_redis_operations.append(asyncio.create_task(redis.rpush(response_list_key, response_json))) + pending_redis_operations.append(asyncio.create_task(redis.publish(response_channel, "new"))) total_responses += 1 # Check for agent-signaled completion or error @@ -239,8 +241,11 @@ async def run_agent_background( # Remove the instance-specific active run key await _cleanup_redis_instance_key(agent_run_id) - # Wait for 5 seconds for any pending redis operations to complete - await asyncio.sleep(5) + # Wait for all pending redis operations to complete, with timeout + try: + await asyncio.wait_for(asyncio.gather(*pending_redis_operations), timeout=5.0) + except asyncio.TimeoutError: + logger.warning(f"Timeout waiting for pending Redis operations for {agent_run_id}") logger.info(f"Agent run background task fully completed for: {agent_run_id} (Instance: {instance_id}) with final status: {final_status}") diff --git a/backend/services/email.py b/backend/services/email.py new file mode 100644 index 00000000..9e9230df --- /dev/null +++ b/backend/services/email.py @@ -0,0 +1,188 @@ +import os +import logging +from typing import Optional +import mailtrap as mt +from utils.config import config + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + self.api_token = os.getenv('MAILTRAP_API_TOKEN') + self.sender_email = os.getenv('MAILTRAP_SENDER_EMAIL', 'dom@kortix.ai') + self.sender_name = os.getenv('MAILTRAP_SENDER_NAME', 'Suna Team') + + if not self.api_token: + logger.warning("MAILTRAP_API_TOKEN not found in environment variables") + self.client = None + else: + self.client = mt.MailtrapClient(token=self.api_token) + + def send_welcome_email(self, user_email: str, user_name: Optional[str] = None) -> bool: + if not self.client: + logger.error("Cannot send email: MAILTRAP_API_TOKEN not configured") + return False + + if not user_name: + user_name = user_email.split('@')[0].title() + + subject = "🎉 Welcome to Suna — Let's Get Started " + html_content = self._get_welcome_email_template(user_name) + text_content = self._get_welcome_email_text(user_name) + + return self._send_email( + to_email=user_email, + to_name=user_name, + subject=subject, + html_content=html_content, + text_content=text_content + ) + + def _send_email( + self, + to_email: str, + to_name: str, + subject: str, + html_content: str, + text_content: str + ) -> bool: + try: + mail = mt.Mail( + sender=mt.Address(email=self.sender_email, name=self.sender_name), + to=[mt.Address(email=to_email, name=to_name)], + subject=subject, + text=text_content, + html=html_content, + category="welcome" + ) + + response = self.client.send(mail) + + logger.info(f"Welcome email sent to {to_email}. Response: {response}") + return True + + except Exception as e: + logger.error(f"Error sending email to {to_email}: {str(e)}") + return False + + def _get_welcome_email_template(self, user_name: str) -> str: + return f""" + +
+ + +Hi {user_name},
+ +Welcome to Kortix Suna — we're excited to have you on board!
+ +To get started, we'd like to get to know you better: fill out this short form!
+ +To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month):
+ +🎁 Use code WELCOME15 at checkout.
+ +Let us know if you need help getting started or have questions — we're always here, and join our Discord community.
+ +Thanks again, and welcome to the Suna community 🌞
+ +— The Suna Team
+ + Go to the platform +