Skip to content

Commit 7b29544

Browse files
author
GanJiaKouN16
committed
feat: migrate email from SendGrid to SMTP with SendGrid fallback
Replace the SendGrid-only email backend with SMTP support, keeping SendGrid as a legacy fallback for existing deployments. Changes: - email_service.py: use smtplib for SMTP (priority), SendGrid as fallback, no-op when neither is configured - env.py: add SmtpConfig (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_ADDRESS, SMTP_USE_TLS), keep SendgridConfig for backwards compatibility; update AuthFacade.email_method to check both - OSS/EE organization_service.py: use env.smtp.enabled || env.sendgrid .enabled; use configured from_address instead of hardcoded email - user_service.py: same email-enabled check update - db_manager_ee.py: remove dead sendgrid import and unused sg client - pyproject.toml: remove sendgrid dependency (imported lazily only when SENDGRID_API_KEY is set) - env example files: add SMTP vars, mark SendGrid as legacy - docs: add SMTP config table, mark SendGrid as legacy Closes #4536
1 parent 691f759 commit 7b29544

12 files changed

Lines changed: 174 additions & 55 deletions

File tree

api/ee/src/services/db_manager_ee.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import uuid
33
from datetime import datetime, timezone
44

5-
import sendgrid
65
from fastapi import HTTPException
76

87
from sqlalchemy import delete, func, update
@@ -60,9 +59,6 @@
6059
from oss.src.utils.env import env
6160

6261

63-
# Initialize sendgrid api client
64-
sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
65-
6662
log = get_module_logger(__name__)
6763

6864

api/ee/src/services/organization_service.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ async def send_invitation_email(
104104
f"&project_id={project_param}"
105105
)
106106

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

111111
html_content = html_template.format(
@@ -118,8 +118,10 @@ async def send_invitation_email(
118118
),
119119
)
120120

121+
from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai"
122+
121123
await email_service.send_email(
122-
from_email="account@hello.agenta.ai",
124+
from_email=from_address,
123125
to_email=email,
124126
subject=f"{user.username} invited you to join {organization.name}",
125127
html_content=html_content,
@@ -152,10 +154,12 @@ async def notify_org_admin_invitation(workspace: WorkspaceDB, user: UserDB) -> b
152154
),
153155
)
154156

157+
from_address = env.smtp.from_address or env.sendgrid.from_address or "account@hello.agenta.ai"
158+
155159
workspace_admins = await db_manager_ee.get_workspace_administrators(workspace)
156160
for workspace_admin in workspace_admins:
157161
await email_service.send_email(
158-
from_email="account@hello.agenta.ai",
162+
from_email=from_address,
159163
to_email=workspace_admin.email,
160164
subject=f"New Member Joined {organization.name}",
161165
html_content=html_content,
Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
2-
3-
import sendgrid
4-
from sendgrid.helpers.mail import Mail
2+
import smtplib
3+
from email.mime.multipart import MIMEMultipart
4+
from email.mime.text import MIMEText
55

66
from fastapi import HTTPException
77

@@ -10,16 +10,25 @@
1010

1111
log = get_logger(__name__)
1212

13-
# Initialize SendGrid only if enabled
14-
if env.sendgrid.enabled:
15-
sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
16-
log.info("✓ SendGrid enabled")
13+
# Determine which email backend to use (SMTP > SendGrid > no-op)
14+
_USE_SMTP = env.smtp.enabled
15+
_USE_SENDGRID = not _USE_SMTP and env.sendgrid.enabled
16+
17+
if _USE_SMTP:
18+
log.info(
19+
"✓ Email enabled via SMTP (%s:%s)", env.smtp.host, env.smtp.port
20+
)
21+
elif _USE_SENDGRID:
22+
import sendgrid
23+
24+
_sg = sendgrid.SendGridAPIClient(api_key=env.sendgrid.api_key)
25+
log.info("✓ Email enabled via SendGrid (legacy)")
1726
else:
18-
sg = None
27+
_sg = None
1928
if env.sendgrid.api_key and not env.sendgrid.from_address:
20-
log.warn("✗ SendGrid disabled: missing sender email address")
29+
log.warn("✗ Email disabled: missing sender email address")
2130
else:
22-
log.warn("✗ SendGrid disabled")
31+
log.warn("✗ Email disabled")
2332

2433

2534
def read_email_template(template_file_path):
@@ -35,6 +44,46 @@ def read_email_template(template_file_path):
3544
return template_file.read()
3645

3746

47+
def _send_via_smtp(to_email: str, subject: str, html_content: str, from_email: str) -> None:
48+
"""Send email using SMTP."""
49+
msg = MIMEMultipart("alternative")
50+
msg["Subject"] = subject
51+
msg["From"] = from_email
52+
msg["To"] = to_email
53+
msg.attach(MIMEText(html_content, "html"))
54+
55+
smtp_host = env.smtp.host
56+
smtp_port = env.smtp.port
57+
58+
if env.smtp.use_tls:
59+
server = smtplib.SMTP(smtp_host, smtp_port)
60+
server.ehlo()
61+
server.starttls()
62+
server.ehlo()
63+
else:
64+
server = smtplib.SMTP(smtp_host, smtp_port)
65+
66+
try:
67+
if env.smtp.username and env.smtp.password:
68+
server.login(env.smtp.username, env.smtp.password)
69+
server.sendmail(from_email, [to_email], msg.as_string())
70+
finally:
71+
server.quit()
72+
73+
74+
def _send_via_sendgrid(to_email: str, subject: str, html_content: str, from_email: str) -> None:
75+
"""Send email using SendGrid (legacy fallback)."""
76+
from sendgrid.helpers.mail import Mail
77+
78+
message = Mail(
79+
from_email=from_email,
80+
to_emails=to_email,
81+
subject=subject,
82+
html_content=html_content,
83+
)
84+
_sg.send(message)
85+
86+
3887
async def send_email(
3988
to_email: str, subject: str, html_content: str, from_email: str
4089
) -> bool:
@@ -54,20 +103,15 @@ async def send_email(
54103
HTTPException: If there is an error sending the email.
55104
"""
56105

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

62-
message = Mail(
63-
from_email=from_email,
64-
to_emails=to_email,
65-
subject=subject,
66-
html_content=html_content,
67-
)
68-
69110
try:
70-
sg.send(message)
111+
if _USE_SMTP:
112+
_send_via_smtp(to_email, subject, html_content, from_email)
113+
else:
114+
_send_via_sendgrid(to_email, subject, html_content, from_email)
71115
return True
72116
except Exception as e:
73117
raise HTTPException(status_code=500, detail=str(e))

api/oss/src/services/organization_service.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ async def send_invitation_email(
154154
f"&project_id={project_param}"
155155
)
156156

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

161161
html_template = email_service.read_email_template("./templates/send_email.html")
@@ -169,11 +169,12 @@ async def send_invitation_email(
169169
),
170170
)
171171

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

175176
await email_service.send_email(
176-
from_email=env.sendgrid.from_address,
177+
from_email=from_address,
177178
to_email=email,
178179
subject=f"{user.username} invited you to join their organization",
179180
html_content=html_content,

api/oss/src/services/user_service.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async def generate_user_password_reset_link(user_id: str, admin_user_id: str):
148148
email=user.email,
149149
)
150150

151-
if not env.sendgrid.api_key:
151+
if not env.smtp.enabled and not env.sendgrid.enabled:
152152
return password_reset_link
153153

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

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

165166
await email_service.send_email(
166-
from_email=env.sendgrid.from_address,
167+
from_email=from_address,
167168
to_email=user.email,
168169
subject=f"{admin_user.username} requested a password reset for you in their workspace",
169170
html_content=html_content,

api/oss/src/utils/env.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -922,12 +922,40 @@ def enabled(self) -> bool:
922922

923923

924924
# ---------------------------------------------------------------------------
925-
# sendgrid
925+
# smtp
926+
# ---------------------------------------------------------------------------
927+
928+
929+
class SmtpConfig(BaseModel):
930+
"""SMTP Email configuration"""
931+
932+
host: str | None = os.getenv("SMTP_HOST")
933+
port: int = int(os.getenv("SMTP_PORT", "587"))
934+
username: str | None = os.getenv("SMTP_USERNAME")
935+
password: str | None = os.getenv("SMTP_PASSWORD")
936+
from_address: str | None = (
937+
os.getenv("SMTP_FROM_ADDRESS")
938+
or os.getenv("SENDGRID_FROM_ADDRESS")
939+
or os.getenv("AGENTA_AUTHN_EMAIL_FROM")
940+
or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS")
941+
)
942+
use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes")
943+
944+
model_config = ConfigDict(extra="ignore")
945+
946+
@property
947+
def enabled(self) -> bool:
948+
"""SMTP enabled if host and from address are present"""
949+
return bool(self.host and self.from_address)
950+
951+
952+
# ---------------------------------------------------------------------------
953+
# sendgrid (legacy — kept for backwards compatibility)
926954
# ---------------------------------------------------------------------------
927955

928956

929957
class SendgridConfig(BaseModel):
930-
"""SendGrid Email configuration"""
958+
"""SendGrid Email configuration (legacy)"""
931959

932960
api_key: str | None = os.getenv("SENDGRID_API_KEY")
933961
from_address: str | None = (
@@ -1037,15 +1065,9 @@ def email_method(self) -> str:
10371065
if env.agenta.access.email_disabled:
10381066
return ""
10391067

1040-
sendgrid_enabled = bool(
1041-
os.getenv("SENDGRID_API_KEY")
1042-
and (
1043-
os.getenv("SENDGRID_FROM_ADDRESS")
1044-
or os.getenv("AGENTA_AUTHN_EMAIL_FROM")
1045-
or os.getenv("AGENTA_SEND_EMAIL_FROM_ADDRESS")
1046-
)
1047-
)
1048-
return "otp" if sendgrid_enabled else "password"
1068+
# SMTP takes priority, then SendGrid fallback
1069+
email_configured = env.smtp.enabled or env.sendgrid.enabled
1070+
return "otp" if email_configured else "password"
10491071

10501072
@property
10511073
def email_enabled(self) -> bool:
@@ -1101,6 +1123,7 @@ class EnvironSettings(BaseModel):
11011123
posthog: PostHogConfig = PostHogConfig()
11021124
redis: RedisConfig = RedisConfig()
11031125
sendgrid: SendgridConfig = SendgridConfig()
1126+
smtp: SmtpConfig = SmtpConfig()
11041127
stripe: StripeConfig = StripeConfig()
11051128
supertokens: SuperTokensConfig = SuperTokensConfig()
11061129

api/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ dependencies = [
3434
"cachetools>=7,<8",
3535
"supertokens-python>=0.31,<0.32",
3636
"openai>=2,<3",
37-
"sendgrid>=6,<7",
3837
"stripe>=15,<16",
3938
"posthog>=7,<8",
4039
"newrelic>=13,<14",

docs/docs/self-host/02-configuration.mdx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,18 @@ This key has no env-var or `env.py` equivalent.
254254
| `REDIS_URI_DURABLE` | `redis.uri_durable` | `redis.uriDurable` |
255255
| `REDIS_URI_VOLATILE` | `redis.uri_volatile` | `redis.uriVolatile` |
256256

257-
## sendgrid
257+
## smtp
258+
259+
| Env var | env.py path | values.yaml path |
260+
|---|---|---|
261+
| `SMTP_HOST` | `smtp.host` | `smtp.host` |
262+
| `SMTP_PORT` | `smtp.port` | `smtp.port` |
263+
| `SMTP_USERNAME` | `smtp.username` | `smtp.username` |
264+
| `SMTP_PASSWORD` | `smtp.password` | `smtp.password` |
265+
| `SMTP_FROM_ADDRESS` | `smtp.from_address` | `smtp.fromAddress` |
266+
| `SMTP_USE_TLS` | `smtp.use_tls` | `smtp.useTls` |
267+
268+
## sendgrid (legacy)
258269

259270
| Env var | env.py path | values.yaml path |
260271
|---|---|---|

hosting/docker-compose/ee/env.ee.dev.example

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp
215215
# REDIS_URI_VOLATILE=redis://redis-volatile:6379/0
216216

217217
# ================================================================== #
218-
# sendgrid
218+
# smtp
219+
# ================================================================== #
220+
# SMTP_HOST=
221+
# SMTP_PORT=587
222+
# SMTP_USERNAME=
223+
# SMTP_PASSWORD=
224+
# SMTP_FROM_ADDRESS=
225+
# SMTP_USE_TLS=true
226+
227+
# ================================================================== #
228+
# sendgrid (legacy — use SMTP instead)
219229
# ================================================================== #
220230
# SENDGRID_API_KEY=
221231
# SENDGRID_FROM_ADDRESS=

hosting/docker-compose/ee/env.ee.gh.example

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,17 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp
215215
# REDIS_URI_VOLATILE=redis://redis-volatile:6379/0
216216

217217
# ================================================================== #
218-
# sendgrid
218+
# smtp
219+
# ================================================================== #
220+
# SMTP_HOST=
221+
# SMTP_PORT=587
222+
# SMTP_USERNAME=
223+
# SMTP_PASSWORD=
224+
# SMTP_FROM_ADDRESS=
225+
# SMTP_USE_TLS=true
226+
227+
# ================================================================== #
228+
# sendgrid (legacy — use SMTP instead)
219229
# ================================================================== #
220230
# SENDGRID_API_KEY=
221231
# SENDGRID_FROM_ADDRESS=

0 commit comments

Comments
 (0)