Skip to content

Commit 6d5b698

Browse files
rkuo-danswerRichard Kuo (Onyx)
andauthored
add sendgrid as option (onyx-dot-app#4639)
* add sendgrid as option * code review * mypy --------- Co-authored-by: Richard Kuo (Onyx) <rkuo@onyx.app>
1 parent 0093ba5 commit 6d5b698

File tree

5 files changed

+162
-38
lines changed

5 files changed

+162
-38
lines changed

backend/onyx/auth/email_utils.py

Lines changed: 114 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import smtplib
23
from datetime import datetime
34
from email.mime.image import MIMEImage
@@ -6,8 +7,21 @@
67
from email.utils import formatdate
78
from email.utils import make_msgid
89

10+
import sendgrid # type: ignore
11+
from sendgrid.helpers.mail import Attachment # type: ignore
12+
from sendgrid.helpers.mail import Content
13+
from sendgrid.helpers.mail import ContentId
14+
from sendgrid.helpers.mail import Disposition
15+
from sendgrid.helpers.mail import Email
16+
from sendgrid.helpers.mail import FileContent
17+
from sendgrid.helpers.mail import FileName
18+
from sendgrid.helpers.mail import FileType
19+
from sendgrid.helpers.mail import Mail
20+
from sendgrid.helpers.mail import To
21+
922
from onyx.configs.app_configs import EMAIL_CONFIGURED
1023
from onyx.configs.app_configs import EMAIL_FROM
24+
from onyx.configs.app_configs import SENDGRID_API_KEY
1125
from onyx.configs.app_configs import SMTP_PASS
1226
from onyx.configs.app_configs import SMTP_PORT
1327
from onyx.configs.app_configs import SMTP_SERVER
@@ -18,11 +32,12 @@
1832
from onyx.configs.constants import ONYX_SLACK_URL
1933
from onyx.db.models import User
2034
from onyx.server.runtime.onyx_runtime import OnyxRuntime
21-
from onyx.utils.file import FileWithMimeType
35+
from onyx.utils.logger import setup_logger
2236
from onyx.utils.url import add_url_params
2337
from onyx.utils.variable_functionality import fetch_versioned_implementation
2438
from shared_configs.configs import MULTI_TENANT
2539

40+
logger = setup_logger()
2641

2742
HTML_EMAIL_TEMPLATE = """\
2843
<!DOCTYPE html>
@@ -176,6 +191,70 @@ def send_email(
176191
if not EMAIL_CONFIGURED:
177192
raise ValueError("Email is not configured.")
178193

194+
if SENDGRID_API_KEY:
195+
send_email_with_sendgrid(
196+
user_email, subject, html_body, text_body, mail_from, inline_png
197+
)
198+
return
199+
200+
send_email_with_smtplib(
201+
user_email, subject, html_body, text_body, mail_from, inline_png
202+
)
203+
204+
205+
def send_email_with_sendgrid(
206+
user_email: str,
207+
subject: str,
208+
html_body: str,
209+
text_body: str,
210+
mail_from: str = EMAIL_FROM,
211+
inline_png: tuple[str, bytes] | None = None,
212+
) -> None:
213+
from_email = Email(mail_from) if mail_from else Email("noreply@onyx.app")
214+
to_email = To(user_email)
215+
216+
mail = Mail(
217+
from_email=from_email,
218+
to_emails=to_email,
219+
subject=subject,
220+
plain_text_content=Content("text/plain", text_body),
221+
)
222+
223+
# Add HTML content
224+
mail.add_content(Content("text/html", html_body))
225+
226+
if inline_png:
227+
image_name, image_data = inline_png
228+
229+
# Create attachment
230+
encoded_image = base64.b64encode(image_data).decode()
231+
attachment = Attachment()
232+
attachment.file_content = FileContent(encoded_image)
233+
attachment.file_name = FileName(image_name)
234+
attachment.file_type = FileType("image/png")
235+
attachment.disposition = Disposition("inline")
236+
attachment.content_id = ContentId(image_name)
237+
238+
mail.add_attachment(attachment)
239+
240+
# Get a JSON-ready representation of the Mail object
241+
mail_json = mail.get()
242+
243+
sg = sendgrid.SendGridAPIClient(api_key=SENDGRID_API_KEY)
244+
response = sg.client.mail.send.post(request_body=mail_json) # can raise
245+
if response.status_code != 202:
246+
logger.warning(f"Unexpected status code {response.status_code}")
247+
248+
249+
def send_email_with_smtplib(
250+
user_email: str,
251+
subject: str,
252+
html_body: str,
253+
text_body: str,
254+
mail_from: str = EMAIL_FROM,
255+
inline_png: tuple[str, bytes] | None = None,
256+
) -> None:
257+
179258
# Create a multipart/alternative message - this indicates these are alternative versions of the same content
180259
msg = MIMEMultipart("alternative")
181260
msg["Subject"] = subject
@@ -210,13 +289,10 @@ def send_email(
210289
html_part = MIMEText(html_body, "html")
211290
msg.attach(html_part)
212291

213-
try:
214-
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as s:
215-
s.starttls()
216-
s.login(SMTP_USER, SMTP_PASS)
217-
s.send_message(msg)
218-
except Exception as e:
219-
raise e
292+
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as s:
293+
s.starttls()
294+
s.login(SMTP_USER, SMTP_PASS)
295+
s.send_message(msg)
220296

221297

222298
def send_subscription_cancellation_email(user_email: str) -> None:
@@ -264,27 +340,13 @@ def send_subscription_cancellation_email(user_email: str) -> None:
264340
)
265341

266342

267-
def send_user_email_invite(
268-
user_email: str, current_user: User, auth_type: AuthType
269-
) -> None:
270-
onyx_file: FileWithMimeType | None = None
271-
272-
try:
273-
load_runtime_settings_fn = fetch_versioned_implementation(
274-
"onyx.server.enterprise_settings.store", "load_runtime_settings"
275-
)
276-
settings = load_runtime_settings_fn()
277-
application_name = settings.application_name
278-
except ModuleNotFoundError:
279-
application_name = ONYX_DEFAULT_APPLICATION_NAME
280-
281-
onyx_file = OnyxRuntime.get_emailable_logo()
282-
283-
subject = f"Invitation to Join {application_name} Organization"
343+
def build_user_email_invite(
344+
from_email: str, to_email: str, application_name: str, auth_type: AuthType
345+
) -> tuple[str, str]:
284346
heading = "You've Been Invited!"
285347

286348
# the exact action taken by the user, and thus the message, depends on the auth type
287-
message = f"<p>You have been invited by {current_user.email} to join an organization on {application_name}.</p>"
349+
message = f"<p>You have been invited by {from_email} to join an organization on {application_name}.</p>"
288350
if auth_type == AuthType.CLOUD:
289351
message += (
290352
"<p>To join the organization, please click the button below to set a password "
@@ -309,7 +371,7 @@ def send_user_email_invite(
309371
raise ValueError(f"Invalid auth type: {auth_type}")
310372

311373
cta_text = "Join Organization"
312-
cta_link = f"{WEB_DOMAIN}/auth/signup?email={user_email}"
374+
cta_link = f"{WEB_DOMAIN}/auth/signup?email={to_email}"
313375

314376
html_content = build_html_email(
315377
application_name,
@@ -322,13 +384,36 @@ def send_user_email_invite(
322384
# text content is the fallback for clients that don't support HTML
323385
# not as critical, so not having special cases for each auth type
324386
text_content = (
325-
f"You have been invited by {current_user.email} to join an organization on {application_name}.\n"
387+
f"You have been invited by {from_email} to join an organization on {application_name}.\n"
326388
"To join the organization, please visit the following link:\n"
327-
f"{WEB_DOMAIN}/auth/signup?email={user_email}\n"
389+
f"{WEB_DOMAIN}/auth/signup?email={to_email}\n"
328390
)
329391
if auth_type == AuthType.CLOUD:
330392
text_content += "You'll be asked to set a password or login with Google to complete your registration."
331393

394+
return text_content, html_content
395+
396+
397+
def send_user_email_invite(
398+
user_email: str, current_user: User, auth_type: AuthType
399+
) -> None:
400+
try:
401+
load_runtime_settings_fn = fetch_versioned_implementation(
402+
"onyx.server.enterprise_settings.store", "load_runtime_settings"
403+
)
404+
settings = load_runtime_settings_fn()
405+
application_name = settings.application_name
406+
except ModuleNotFoundError:
407+
application_name = ONYX_DEFAULT_APPLICATION_NAME
408+
409+
onyx_file = OnyxRuntime.get_emailable_logo()
410+
411+
subject = f"Invitation to Join {application_name} Organization"
412+
413+
text_content, html_content = build_user_email_invite(
414+
current_user.email, user_email, application_name, auth_type
415+
)
416+
332417
send_email(
333418
user_email,
334419
subject,

backend/onyx/chat/process_message.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,13 @@ def _handle_search_tool_response_summary(
211211
user_files: list[UserFile] | None = None,
212212
loaded_user_files: list[InMemoryChatFile] | None = None,
213213
) -> tuple[QADocsResponse, list[DbSearchDoc], list[int] | None]:
214-
response_sumary = cast(SearchResponseSummary, packet.response)
214+
response_summary = cast(SearchResponseSummary, packet.response)
215215

216216
is_extended = isinstance(packet, ExtendedToolResponse)
217217
dropped_inds = None
218218

219219
if not selected_search_docs:
220-
top_docs = chunks_or_sections_to_search_docs(response_sumary.top_sections)
220+
top_docs = chunks_or_sections_to_search_docs(response_summary.top_sections)
221221

222222
deduped_docs = top_docs
223223
if (
@@ -264,13 +264,13 @@ def _handle_search_tool_response_summary(
264264
level, question_num = packet.level, packet.level_question_num
265265
return (
266266
QADocsResponse(
267-
rephrased_query=response_sumary.rephrased_query,
267+
rephrased_query=response_summary.rephrased_query,
268268
top_documents=response_docs,
269-
predicted_flow=response_sumary.predicted_flow,
270-
predicted_search=response_sumary.predicted_search,
271-
applied_source_filters=response_sumary.final_filters.source_type,
272-
applied_time_cutoff=response_sumary.final_filters.time_cutoff,
273-
recency_bias_multiplier=response_sumary.recency_bias_multiplier,
269+
predicted_flow=response_summary.predicted_flow,
270+
predicted_search=response_summary.predicted_search,
271+
applied_source_filters=response_summary.final_filters.source_type,
272+
applied_time_cutoff=response_summary.final_filters.time_cutoff,
273+
recency_bias_multiplier=response_summary.recency_bias_multiplier,
274274
level=level,
275275
level_question_num=question_num,
276276
),

backend/onyx/configs/app_configs.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,11 @@
120120
SMTP_PORT = int(os.environ.get("SMTP_PORT") or "587")
121121
SMTP_USER = os.environ.get("SMTP_USER", "your-email@gmail.com")
122122
SMTP_PASS = os.environ.get("SMTP_PASS", "your-gmail-password")
123-
EMAIL_CONFIGURED = all([SMTP_SERVER, SMTP_USER, SMTP_PASS])
124123
EMAIL_FROM = os.environ.get("EMAIL_FROM") or SMTP_USER
125124

125+
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") or ""
126+
EMAIL_CONFIGURED = all([SMTP_SERVER, SMTP_USER, SMTP_PASS]) or SENDGRID_API_KEY
127+
126128
# If set, Onyx will listen to the `expires_at` returned by the identity
127129
# provider (e.g. Okta, Google, etc.) and force the user to re-authenticate
128130
# after this time has elapsed. Disabled since by default many auth providers

backend/requirements/default.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,4 @@ sentry-sdk==2.14.0
9999
prometheus_client==0.21.0
100100
fastapi-limiter==0.1.6
101101
prometheus_fastapi_instrumentator==7.1.0
102+
sendgrid==6.11.0
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import pytest
2+
3+
from onyx.auth.email_utils import build_user_email_invite
4+
from onyx.auth.email_utils import send_email
5+
from onyx.configs.constants import AuthType
6+
from onyx.configs.constants import ONYX_DEFAULT_APPLICATION_NAME
7+
from onyx.db.engine import SqlEngine
8+
from onyx.server.runtime.onyx_runtime import OnyxRuntime
9+
10+
11+
@pytest.mark.skip(
12+
reason="This sends real emails, so only run when you really want to test this!"
13+
)
14+
def test_send_user_email_invite() -> None:
15+
SqlEngine.init_engine(pool_size=20, max_overflow=5)
16+
17+
application_name = ONYX_DEFAULT_APPLICATION_NAME
18+
19+
onyx_file = OnyxRuntime.get_emailable_logo()
20+
21+
subject = f"Invitation to Join {application_name} Organization"
22+
23+
FROM_EMAIL = "noreply@onyx.app"
24+
TO_EMAIL = "support@onyx.app"
25+
text_content, html_content = build_user_email_invite(
26+
FROM_EMAIL, TO_EMAIL, ONYX_DEFAULT_APPLICATION_NAME, AuthType.CLOUD
27+
)
28+
29+
send_email(
30+
TO_EMAIL,
31+
subject,
32+
html_content,
33+
text_content,
34+
mail_from=FROM_EMAIL,
35+
inline_png=("logo.png", onyx_file.data),
36+
)

0 commit comments

Comments
 (0)