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: 2 additions & 2 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 delivery is not configured, return the link for manual sharing.
if not (env.smtp.enabled or env.sendgrid.enabled):
return invite_link

html_content = html_template.format(
Expand Down
32 changes: 32 additions & 0 deletions api/oss/src/core/auth/supertokens/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
from urllib.parse import urlparse

from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.ingredients.emaildelivery.types import (
EmailDeliveryConfig,
SMTPSettings,
SMTPSettingsFrom,
)
from supertokens_python.recipe import (
emailpassword,
passwordless,
Expand Down Expand Up @@ -58,6 +63,32 @@ def get_supertokens_config() -> Dict[str, Any]:
}


def get_passwordless_email_delivery():
"""Route passwordless OTP emails through SMTP when SMTP is configured."""
if not env.smtp.enabled:
return None

if not env.smtp.use_ssl and not env.smtp.use_tls:
log.warning(
"Passwordless email delivery via SuperTokens may still attempt STARTTLS "
"even though SMTP_USE_TLS=false; set SMTP_USE_SSL=true or "
"SMTP_USE_TLS=true to avoid this difference."
)

return EmailDeliveryConfig(
service=passwordless.SMTPService(
SMTPSettings(
host=env.smtp.host,
port=env.smtp.port,
from_=SMTPSettingsFrom(name="Agenta", email=env.smtp.from_email),
username=env.smtp.username,
password=env.smtp.password,
secure=env.smtp.use_ssl,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
)


def get_app_info() -> InputAppInfo:
"""Get SuperTokens app info."""
# Extract domain from full URL (e.g., "http://localhost/api" -> "http://localhost")
Expand Down Expand Up @@ -402,6 +433,7 @@ def init_supertokens():
apis=override_passwordless_apis,
functions=override_passwordless_functions,
),
email_delivery=get_passwordless_email_delivery(),
)
)

Expand Down
95 changes: 90 additions & 5 deletions api/oss/src/services/email_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import asyncio
import os
import smtplib
import ssl
from email.message import EmailMessage

import sendgrid
from sendgrid.helpers.mail import Mail
Expand All @@ -10,7 +14,15 @@

log = get_logger(__name__)

# Initialize SendGrid only if enabled
# Initialize email providers only if enabled
if env.smtp.enabled:
log.info("✓ SMTP email enabled")
else:
if (env.smtp.host or env.smtp.port) and not env.smtp.from_email:
log.warn("✗ SMTP disabled: missing sender email address")
else:
log.warn("✗ SMTP disabled")
Comment on lines +21 to +24
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated — replaced the deprecated log.warn calls with log.warning.


if env.sendgrid.enabled:
sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
log.info("✓ SendGrid enabled")
Expand Down Expand Up @@ -54,11 +66,84 @@ 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}")
return True
if env.smtp.enabled:
return await _send_smtp_email(
to_email=to_email,
subject=subject,
html_content=html_content,
from_email=from_email,
)

if env.sendgrid.enabled:
return await _send_sendgrid_email(
to_email=to_email,
subject=subject,
html_content=html_content,
from_email=from_email,
)

log.info("[EMAIL] Email disabled - skipping email send")
return True


async def _send_smtp_email(
to_email: str, subject: str, html_content: str, from_email: str
) -> bool:
Comment on lines +89 to +110
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated — moved the blocking smtplib SMTP work into a synchronous helper and call it through asyncio.to_thread(...) from the async _send_smtp_email function, so SMTP connect/login/send no longer runs directly on the event loop.

try:
return await asyncio.to_thread(
_send_smtp_email_sync,
to_email=to_email,
subject=subject,
html_content=html_content,
from_email=from_email,
)
except Exception:
log.exception("Failed to send SMTP email")
raise HTTPException(
status_code=500,
detail="Failed to send email",
)


def _send_smtp_email_sync(
to_email: str, subject: str, html_content: str, from_email: str
) -> bool:
message = EmailMessage()
message["From"] = from_email or env.smtp.from_email
message["To"] = to_email
message["Subject"] = subject
message.set_content(html_content, subtype="html")

context = ssl.create_default_context()

if env.smtp.use_ssl:
with smtplib.SMTP_SSL(
env.smtp.host,
env.smtp.port,
context=context,
timeout=env.smtp.timeout,
) as smtp:
if env.smtp.username:
smtp.login(env.smtp.username, env.smtp.password)
smtp.send_message(message)
else:
with smtplib.SMTP(
env.smtp.host,
env.smtp.port,
timeout=env.smtp.timeout,
) as smtp:
if env.smtp.use_tls:
smtp.starttls(context=context)
if env.smtp.username:
smtp.login(env.smtp.username, env.smtp.password)
smtp.send_message(message)

return True


async def _send_sendgrid_email(
to_email: str, subject: str, html_content: str, from_email: str
) -> bool:
message = Mail(
from_email=from_email,
to_emails=to_email,
Expand Down
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 delivery is not configured, return the link for manual sharing.
if not (env.smtp.enabled or 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_email = env.smtp.from_email if env.smtp.enabled else env.sendgrid.from_address
if not from_email:
raise ValueError("Email delivery requires a sender email address to work.")

await email_service.send_email(
from_email=env.sendgrid.from_address,
from_email=from_email,
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 or 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_email = env.smtp.from_email if env.smtp.enabled else env.sendgrid.from_address
if not from_email:
raise ValueError("Email delivery requires a sender email address to work.")

await email_service.send_email(
from_email=env.sendgrid.from_address,
from_email=from_email,
to_email=user.email,
subject=f"{admin_user.username} requested a password reset for you in their workspace",
html_content=html_content,
Expand Down
48 changes: 38 additions & 10 deletions api/oss/src/utils/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ def _comma_set_optional(name: str, *legacy_names: str) -> set | None:
return s or None


def _parse_optional_int_env(name: str) -> int | None:
raw = os.getenv(name)
if raw is None or not raw.strip():
return None
try:
return int(raw)
except ValueError as e:
raise ValueError(f"{name} must be a valid integer") from e


# ---------------------------------------------------------------------------
# agenta.access — access controls.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -922,10 +932,35 @@ def enabled(self) -> bool:


# ---------------------------------------------------------------------------
# sendgrid
# email delivery
# ---------------------------------------------------------------------------


class SmtpConfig(BaseModel):
"""SMTP Email configuration"""

host: str | None = os.getenv("SMTP_HOST")
port: int | None = _parse_optional_int_env("SMTP_PORT")
username: str | None = os.getenv("SMTP_USERNAME")
password: str | None = os.getenv("SMTP_PASSWORD")
from_email: str | None = (
os.getenv("SMTP_FROM_EMAIL")
or os.getenv("AGENTA_AUTHN_EMAIL_FROM")
or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS")
or os.getenv("SENDGRID_FROM_ADDRESS")
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
use_tls: bool = (os.getenv("SMTP_USE_TLS") or "true").lower() in _TRUTHY
use_ssl: bool = (os.getenv("SMTP_USE_SSL") or "false").lower() in _TRUTHY
timeout: int = _parse_optional_int_env("SMTP_TIMEOUT") or 10

model_config = ConfigDict(extra="ignore")

@property
def enabled(self) -> bool:
"""SMTP enabled only if host, port, and sender are present"""
return bool(self.host and self.port and self.from_email)


class SendgridConfig(BaseModel):
"""SendGrid Email configuration"""

Expand Down Expand Up @@ -1037,15 +1072,7 @@ def email_method(self) -> str:
if env.agenta.access.email_disabled:
return ""

sendgrid_enabled = bool(
os.getenv("SENDGRID_API_KEY")
and (
os.getenv("SENDGRID_FROM_ADDRESS")
or os.getenv("AGENTA_AUTHN_EMAIL_FROM")
or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS")
)
)
return "otp" if sendgrid_enabled else "password"
return "otp" if env.smtp.enabled or env.sendgrid.enabled else "password"

@property
def email_enabled(self) -> bool:
Expand Down Expand Up @@ -1100,6 +1127,7 @@ class EnvironSettings(BaseModel):
postgres: PostgresConfig = PostgresConfig()
posthog: PostHogConfig = PostHogConfig()
redis: RedisConfig = RedisConfig()
smtp: SmtpConfig = SmtpConfig()
sendgrid: SendgridConfig = SendgridConfig()
stripe: StripeConfig = StripeConfig()
supertokens: SuperTokensConfig = SuperTokensConfig()
Expand Down
45 changes: 45 additions & 0 deletions api/oss/tests/pytest/unit/auth/test_supertokens_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from supertokens_python.ingredients.emaildelivery.types import EmailDeliveryConfig
from supertokens_python.recipe import passwordless

from oss.src.core.auth.supertokens import config
from oss.src.utils.env import env


def _disable_smtp(monkeypatch):
monkeypatch.setattr(env.smtp, "host", None)
monkeypatch.setattr(env.smtp, "port", None)
monkeypatch.setattr(env.smtp, "from_email", None)
monkeypatch.setattr(env.smtp, "username", None)
monkeypatch.setattr(env.smtp, "password", None)
monkeypatch.setattr(env.smtp, "use_ssl", False)


def _enable_smtp(monkeypatch):
monkeypatch.setattr(env.smtp, "host", "smtp.example.com")
monkeypatch.setattr(env.smtp, "port", 1025)
monkeypatch.setattr(env.smtp, "from_email", "smtp@example.com")
monkeypatch.setattr(env.smtp, "username", "smtp-user")
monkeypatch.setattr(env.smtp, "password", "smtp-secret")
monkeypatch.setattr(env.smtp, "use_ssl", False)


def test_passwordless_email_delivery_uses_smtp_when_smtp_is_enabled(monkeypatch):
_enable_smtp(monkeypatch)

email_delivery = config.get_passwordless_email_delivery()

assert isinstance(email_delivery, EmailDeliveryConfig)
assert isinstance(email_delivery.service, passwordless.SMTPService)
smtp_settings = email_delivery.service.transporter.smtp_settings
assert smtp_settings.host == "smtp.example.com"
assert smtp_settings.port == 1025
assert smtp_settings.from_.email == "smtp@example.com"
assert smtp_settings.username == "smtp-user"
assert smtp_settings.password == "smtp-secret"
assert smtp_settings.secure is False


def test_passwordless_email_delivery_is_unset_when_smtp_is_disabled(monkeypatch):
_disable_smtp(monkeypatch)

assert config.get_passwordless_email_delivery() is None
Loading