Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions api/ee/src/services/db_manager_ee.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import uuid
from datetime import datetime, timezone

import sendgrid
from fastapi import HTTPException

from sqlalchemy import delete, func, update
Expand Down Expand Up @@ -60,9 +59,6 @@
from oss.src.utils.env import env


# Initialize sendgrid api client
sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)

log = get_module_logger(__name__)


Expand Down
12 changes: 8 additions & 4 deletions api/ee/src/services/organization_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ async def send_invitation_email(
f"&project_id={project_param}"
)

# If Sendgrid is not configured, return the link for manual sharing (URL-based invitation)
if not env.sendgrid.enabled:
# If email is not configured, return the link for manual sharing (URL-based invitation)
if not env.smtp.enabled and not env.sendgrid.enabled:
return invite_link

html_content = html_template.format(
Expand All @@ -118,8 +118,10 @@ async def send_invitation_email(
),
)

from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai"

await email_service.send_email(
from_email="account@hello.agenta.ai",
from_email=from_address,
to_email=email,
subject=f"{user.username} invited you to join {organization.name}",
html_content=html_content,
Expand Down Expand Up @@ -152,10 +154,12 @@ async def notify_org_admin_invitation(workspace: WorkspaceDB, user: UserDB) -> b
),
)

from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai"

workspace_admins = await db_manager_ee.get_workspace_administrators(workspace)
for workspace_admin in workspace_admins:
await email_service.send_email(
from_email="account@hello.agenta.ai",
from_email=from_address,
to_email=workspace_admin.email,
subject=f"New Member Joined {organization.name}",
html_content=html_content,
Expand Down
115 changes: 104 additions & 11 deletions api/oss/src/core/auth/supertokens/config.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
from typing import Dict, List, Any
from typing import Any, Dict, List
from urllib.parse import urlparse

from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python import InputAppInfo, SupertokensConfig, init
from supertokens_python.ingredients.emaildelivery.types import EmailDeliveryConfig
from supertokens_python.recipe import (
emailpassword,
passwordless,
session,
thirdparty,
)
from supertokens_python.recipe.emailpassword.types import InputFormField
from supertokens_python.recipe.emailpassword.utils import (
InputSignUpFeature,
InputOverrideConfig as EmailPasswordInputOverrideConfig,
)
from supertokens_python.recipe.emailpassword.types import (
InputFormField,
InputSignUpFeature,
)
from supertokens_python.recipe.passwordless import (
ContactEmailOnlyConfig,
InputOverrideConfig as PasswordlessInputOverrideConfig,
)
from supertokens_python.recipe.thirdparty import (
ProviderInput,
ProviderConfig,
ProviderClientConfig,
InputOverrideConfig as ThirdPartyInputOverrideConfig,
from supertokens_python.recipe.passwordless.types import (
PasswordlessLoginEmailTemplateVars,
)
from supertokens_python.recipe.session import (
InputOverrideConfig as SessionInputOverrideConfig,
)
from supertokens_python.recipe.thirdparty import (
InputOverrideConfig as ThirdPartyInputOverrideConfig,
ProviderClientConfig,
ProviderConfig,
ProviderInput,
)

from oss.src.utils.env import env
from oss.src.utils.common import is_ee
Expand All @@ -50,6 +52,96 @@
log = get_module_logger(__name__)


# ---------------------------------------------------------------------------
# Passwordless OTP email delivery — delegates to the shared email service so
# that SMTP_USE_TLS / SendGrid behaviour is identical for OTP and app emails.
# ---------------------------------------------------------------------------


class PasswordlessEmailService:
"""Custom SuperTokens email delivery for passwordless OTP emails.

Implements ``EmailDeliveryInterface[PasswordlessLoginEmailTemplateVars]``
by delegating to the shared ``email_service`` so TLS/SSL settings are
honoured consistently.
"""

async def send_email(
self,
template_vars: PasswordlessLoginEmailTemplateVars,
user_context: Dict[str, Any],
) -> None:
from oss.src.services import email_service

otp = template_vars.user_input_code or ""
code_lifetime = template_vars.code_life_time
minutes = max(1, code_lifetime // 60)

html_content = (
f"<p>Hello,</p>"
f"<p>Your one-time login code is:</p>"
f"<h2 style=\"font-family:monospace;letter-spacing:0.15em\">{otp}</h2>"
f"<p>This code expires in {minutes} minute(s).</p>"
f"<p>If you did not request this code, you can safely ignore this email.</p>"
f"<p>Thank you for using Agenta!</p>"
)

from_address = env.smtp.from_address or env.sendgrid.from_address
if not from_address:
log.warning(
"Passwordless OTP email skipped — no sender address configured "
"(set SMTP_FROM_ADDRESS or SENDGRID_FROM_ADDRESS)"
)
return

if not email_service._USE_SMTP and not email_service._USE_SENDGRID:
log.info(
"Passwordless OTP email delivery disabled — no email provider configured"
)
return

try:
if email_service._USE_SMTP:
email_service._send_via_smtp(
to_email=template_vars.email,
subject="Your Agenta login code",
html_content=html_content,
from_email=from_address,
)
else:
email_service._send_via_sendgrid(
to_email=template_vars.email,
subject="Your Agenta login code",
html_content=html_content,
from_email=from_address,
)
log.info("Passwordless OTP email sent to %s", template_vars.email)
except Exception:
log.error(
"Failed to send passwordless OTP email to %s",
template_vars.email,
exc_info=True,
)

Comment on lines +119 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow OTP delivery failures.

At Line 119-Line 124, all exceptions are logged and then suppressed. That can make passwordless login appear successful while the OTP email was never delivered. Re-raise after logging so callers can surface a failure state.


def get_passwordless_email_delivery() -> EmailDeliveryConfig | None:
"""Return email delivery config for the passwordless recipe.

Routes OTP emails through the shared ``email_service`` so that
``SMTP_USE_TLS`` and the SendGrid fallback behave identically to
application emails (invitations, password resets, etc.).

Returns ``None`` when no email provider is configured — SuperTokens
will fall back to its default behaviour.
"""
from oss.src.services import email_service

if not email_service._USE_SMTP and not email_service._USE_SENDGRID:
return None

return EmailDeliveryConfig(service=PasswordlessEmailService())


def get_supertokens_config() -> Dict[str, Any]:
"""Get SuperTokens configuration from environment."""
return {
Expand Down Expand Up @@ -398,6 +490,7 @@ def init_supertokens():
passwordless.init(
flow_type="USER_INPUT_CODE",
contact_config=ContactEmailOnlyConfig(),
email_delivery=get_passwordless_email_delivery(),
override=PasswordlessInputOverrideConfig(
apis=override_passwordless_apis,
functions=override_passwordless_functions,
Expand Down
86 changes: 65 additions & 21 deletions api/oss/src/services/email_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

import sendgrid
from sendgrid.helpers.mail import Mail
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

from fastapi import HTTPException

Expand All @@ -10,16 +10,25 @@

log = get_logger(__name__)

# Initialize SendGrid only if enabled
if env.sendgrid.enabled:
sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
log.info("✓ SendGrid enabled")
# Determine which email backend to use (SMTP > SendGrid > no-op)
_USE_SMTP = env.smtp.enabled
_USE_SENDGRID = not _USE_SMTP and env.sendgrid.enabled

if _USE_SMTP:
log.info(
"✓ Email enabled via SMTP (%s:%s)", env.smtp.host, env.smtp.port
)
elif _USE_SENDGRID:
import sendgrid

_sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
log.info("✓ Email enabled via SendGrid (legacy)")
else:
sg = None
_sg = None
if env.sendgrid.api_key and not env.sendgrid.from_address:
log.warn("✗ SendGrid disabled: missing sender email address")
log.warn("✗ Email disabled: missing sender email address")
else:
log.warn("✗ SendGrid disabled")
log.warn("✗ Email disabled")


def read_email_template(template_file_path):
Expand All @@ -35,6 +44,46 @@ def read_email_template(template_file_path):
return template_file.read()


def _send_via_smtp(to_email: str, subject: str, html_content: str, from_email: str) -> None:
"""Send email using SMTP."""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_email
msg["To"] = to_email
msg.attach(MIMEText(html_content, "html"))

smtp_host = env.smtp.host
smtp_port = env.smtp.port

if env.smtp.use_tls:
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
server.starttls()
server.ehlo()
else:
server = smtplib.SMTP(smtp_host, smtp_port)

Comment on lines +58 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add implicit SSL SMTP mode support.

Line 58-Line 65 only switches between STARTTLS and plain smtplib.SMTP. There is no implicit SSL path (SMTP_SSL), so SMTPS-only providers (commonly port 465) will fail even when SMTP is otherwise configured.

try:
if env.smtp.username and env.smtp.password:
server.login(env.smtp.username, env.smtp.password)
server.sendmail(from_email, [to_email], msg.as_string())
finally:
server.quit()
Comment on lines +59 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move SMTP connection setup inside a context-managed try scope.

At Line 59-Line 63, connection/handshake exceptions can occur before the finally block at Line 70-Line 71, which risks leaving sockets unclosed. Wrap client lifecycle with with smtplib.SMTP(...) as server: (or include setup in the try) to guarantee cleanup.



def _send_via_sendgrid(to_email: str, subject: str, html_content: str, from_email: str) -> None:
"""Send email using SendGrid (legacy fallback)."""
from sendgrid.helpers.mail import Mail

message = Mail(
from_email=from_email,
to_emails=to_email,
subject=subject,
html_content=html_content,
)
_sg.send(message)


async def send_email(
to_email: str, subject: str, html_content: str, from_email: str
) -> bool:
Expand All @@ -54,20 +103,15 @@ async def send_email(
HTTPException: If there is an error sending the email.
"""

# No-op if SendGrid is disabled
if not env.sendgrid.enabled:
log.info(f"[SENDGRID] Email disabled - would send '{subject}' to {to_email}")
if not _USE_SMTP and not _USE_SENDGRID:
log.info(f"[EMAIL] Email disabled - would send '{subject}' to {to_email}")
return True

message = Mail(
from_email=from_email,
to_emails=to_email,
subject=subject,
html_content=html_content,
)

try:
sg.send(message)
if _USE_SMTP:
_send_via_smtp(to_email, subject, html_content, from_email)
else:
_send_via_sendgrid(to_email, subject, html_content, from_email)
return True
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
11 changes: 6 additions & 5 deletions api/oss/src/services/organization_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ async def send_invitation_email(
f"&project_id={project_param}"
)

# If Sendgrid is not configured, return the link for manual sharing (URL-based invitation)
if not env.sendgrid.enabled:
# If email is not configured, return the link for manual sharing (URL-based invitation)
if not env.smtp.enabled and not env.sendgrid.enabled:
return invite_link

html_template = email_service.read_email_template("./templates/send_email.html")
Expand All @@ -169,11 +169,12 @@ async def send_invitation_email(
),
)

if not env.sendgrid.from_address:
raise ValueError("Sendgrid requires a sender email address to work.")
from_address = env.smtp.from_address or env.sendgrid.from_address
if not from_address:
raise ValueError("Email requires a sender email address to work.")

await email_service.send_email(
from_email=env.sendgrid.from_address,
from_email=from_address,
to_email=email,
subject=f"{user.username} invited you to join their organization",
html_content=html_content,
Expand Down
9 changes: 5 additions & 4 deletions api/oss/src/services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async def generate_user_password_reset_link(user_id: str, admin_user_id: str):
email=user.email,
)

if not env.sendgrid.api_key:
if not env.smtp.enabled and not env.sendgrid.enabled:
return password_reset_link

html_template = email_service.read_email_template("./templates/send_email.html")
Expand All @@ -159,11 +159,12 @@ async def generate_user_password_reset_link(user_id: str, admin_user_id: str):
call_to_action=f"""<p>Click the link below to reset your password:</p><br><a href="{password_reset_link}">Reset Password</a>""",
)

if not env.sendgrid.from_address:
raise ValueError("Sendgrid requires a sender email address to work.")
from_address = env.smtp.from_address or env.sendgrid.from_address
if not from_address:
raise ValueError("Email requires a sender email address to work.")

await email_service.send_email(
from_email=env.sendgrid.from_address,
from_email=from_address,
to_email=user.email,
subject=f"{admin_user.username} requested a password reset for you in their workspace",
html_content=html_content,
Expand Down
Loading