Skip to content

Commit

Permalink
fix: Implement simple email sender to simplify dependencies (#4815)
Browse files Browse the repository at this point in the history
* Implement simple email sender to simplify dependencies

* Clean up type annotations

* Send emails with secure method fallback
  • Loading branch information
anticorrelator authored Oct 1, 2024
1 parent 80c23ad commit f56cdb8
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 31 deletions.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ dependencies = [
"arize-phoenix-evals>=0.13.1",
"arize-phoenix-otel>=0.5.1",
"fastapi",
"fastapi-mail",
"pydantic>=1.0,!=2.0.*,<3", # exclude 2.0.* since it does not support the `json_encoders` configuration setting
"authlib",
]
Expand Down
99 changes: 85 additions & 14 deletions src/phoenix/server/email/sender.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,97 @@
import asyncio
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path
from typing import Literal

from fastapi_mail import ConnectionConfig, FastMail, MessageSchema
from jinja2 import Environment, FileSystemLoader, select_autoescape

EMAIL_TEMPLATE_FOLDER = Path(__file__).parent / "templates"


class FastMailSender:
def __init__(self, conf: ConnectionConfig) -> None:
self._fm = FastMail(conf)
class SimpleEmailSender:
def __init__(
self,
smtp_server: str,
smtp_port: int,
username: str,
password: str,
sender_email: str,
connection_method: Literal["STARTTLS", "SSL", "PLAIN"] = "STARTTLS",
validate_certs: bool = True,
) -> None:
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.username = username
self.password = password
self.sender_email = sender_email
self.connection_method = connection_method.upper()
self.validate_certs = validate_certs

self.env = Environment(
loader=FileSystemLoader(EMAIL_TEMPLATE_FOLDER),
autoescape=select_autoescape(["html", "xml"]),
)

async def send_password_reset_email(
self,
email: str,
reset_url: str,
) -> None:
message = MessageSchema(
subject="[Phoenix] Password Reset Request",
recipients=[email],
template_body=dict(reset_url=reset_url),
subtype="html",
)
await self._fm.send_message(
message,
template_name="password_reset.html",
)
subject = "[Phoenix] Password Reset Request"
template_name = "password_reset.html"

template = self.env.get_template(template_name)
html_content = template.render(reset_url=reset_url)

msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = self.sender_email
msg["To"] = email
msg.set_content(html_content, subtype="html")

def send_email() -> None:
context: ssl.SSLContext
if self.validate_certs:
context = ssl.create_default_context()
else:
context = ssl._create_unverified_context()

methods_to_try = [self.connection_method]
# add secure method fallbacks
if self.connection_method != "PLAIN":
if self.connection_method != "STARTTLS":
methods_to_try.append("STARTTLS")
elif self.connection_method != "SSL":
methods_to_try.append("SSL")

for method in methods_to_try:
try:
if method == "STARTTLS":
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
server.ehlo()
server.starttls(context=context)
server.ehlo()
elif method == "SSL":
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context)
server.ehlo()
elif method == "PLAIN":
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
server.ehlo()
else:
continue # Unsupported method

if self.username and self.password:
server.login(self.username, self.password)

server.send_message(msg)
server.quit()
break # Success
except Exception as e:
print(f"Failed to send email using {method}: {e}")
continue
else:
raise Exception("All connection methods failed")

await asyncio.to_thread(send_email)
26 changes: 10 additions & 16 deletions src/phoenix/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from typing import List, Optional
from urllib.parse import urljoin

from fastapi_mail import ConnectionConfig
from jinja2 import BaseLoader, Environment
from uvicorn import Config, Server

Expand Down Expand Up @@ -59,7 +58,7 @@
create_engine_and_run_migrations,
instrument_engine_if_enabled,
)
from phoenix.server.email.sender import EMAIL_TEMPLATE_FOLDER, FastMailSender
from phoenix.server.email.sender import SimpleEmailSender
from phoenix.server.types import DbSessionFactory
from phoenix.settings import Settings
from phoenix.trace.fixtures import (
Expand Down Expand Up @@ -368,20 +367,15 @@ def main() -> None:
if mail_sever := get_env_smtp_hostname():
assert (mail_username := get_env_smtp_username()), "SMTP username is required"
assert (mail_password := get_env_smtp_password()), "SMTP password is required"
assert (mail_from := get_env_smtp_mail_from()), "SMTP mail_from is required"
email_sender = FastMailSender(
ConnectionConfig(
MAIL_USERNAME=mail_username,
MAIL_PASSWORD=mail_password,
MAIL_FROM=mail_from,
MAIL_SERVER=mail_sever,
MAIL_PORT=get_env_smtp_port(),
VALIDATE_CERTS=get_env_smtp_validate_certs(),
USE_CREDENTIALS=True,
MAIL_STARTTLS=True,
MAIL_SSL_TLS=False,
TEMPLATE_FOLDER=EMAIL_TEMPLATE_FOLDER,
)
assert (sender_email := get_env_smtp_mail_from()), "SMTP mail_from is required"
email_sender = SimpleEmailSender(
smtp_server=mail_sever,
smtp_port=get_env_smtp_port(),
username=mail_username,
password=mail_password,
sender_email=sender_email,
connection_method="STARTTLS",
validate_certs=get_env_smtp_validate_certs(),
)
app = create_app(
db=factory,
Expand Down

0 comments on commit f56cdb8

Please sign in to comment.