Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use msgraph SDK for mail #25

Merged
merged 4 commits into from
Sep 13, 2024
Merged
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
91 changes: 63 additions & 28 deletions notify/mail.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import base64
import logging
import os
Expand All @@ -6,6 +7,16 @@
import pandas as pd

from notify.msgraph import Graph

from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.message import Message
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.recipient import Recipient
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import SendMailPostRequestBody
from msgraph.generated.models.o_data_errors.o_data_error import ODataError

from msgraph.generated.models.file_attachment import FileAttachment
from notify.utils import check_environment_variables, dataframe_to_html


Expand Down Expand Up @@ -72,24 +83,37 @@ def send_email(self):
-------
response: requests.Response
"""
endpoint = f"https://graph.microsoft.com/v1.0/users/{self.sender}/sendMail"

msg = {
"Message": {
"Subject": self.subject,
"Body": {"ContentType": "HTML", "Content": self.message},
"ToRecipients": [{"EmailAddress": {"Address": to.strip()}} for to in self.to.split(",")],
},
"SaveToSentItems": "true",
}

sender = EmailAddress(address=self.sender)
sender_recipient = Recipient(email_address=sender)
recipients = []
tos = self.to.split(",")
for to in tos:
to_email = EmailAddress(address=to)
to_recipient = Recipient(email_address=to_email)
recipients.append(to_recipient)
message = Message(
to_recipients=recipients, subject=self.subject, sender=sender_recipient, from_=sender_recipient
)
# CC if present
if self.cc:
msg["Message"]["CcRecipients"] = [{"EmailAddress": {"Address": cc.strip()}} for cc in self.cc.split(",")]
ccs = self.cc.split(",")
cc_recipients = []
for cc in ccs:
cc_email = EmailAddress(address=cc)
cc_recipient = Recipient(email_address=cc_email)
cc_recipients.append(cc_recipient)
message.cc_recipients = cc_recipients
# BCC if present
if self.bcc:
msg["Message"]["BccRecipients"] = [
{"EmailAddress": {"Address": bcc.strip()}} for bcc in self.bcc.split(",")
]

bccs = self.bcc.split(",")
bcc_recipients = []
for bcc in bccs:
bcc_email = EmailAddress(address=bcc)
bcc_recipient = Recipient(email_address=bcc_email)
bcc_recipients.append(bcc_recipient)
message.bcc_recipients = bcc_recipients
email_body = ItemBody(content=self.message, content_type=BodyType.Html)
# add html table (if table less than 30 records)
if self.df.shape[0] in range(1, 31):
html_table = dataframe_to_html(df=self.df)
Expand All @@ -99,22 +123,33 @@ def send_email(self):
else:
html_table = "" # no data in dataframe (0 records)

msg["Message"]["Body"]["Content"] += html_table

email_body.content += html_table
message.body = email_body
if self.files:
# There might be a more safe way to check if a string is an url, but for our purposes, this suffices.
attachments = list()
for name, path in self.files.items():
content = self.read_file_content(path)
attachments.append(
{
"@odata.type": "#microsoft.graph.fileAttachment",
"ContentBytes": content.decode("utf-8"),
"Name": name,
}
attachment = FileAttachment(
odata_type="#microsoft.graph.fileAttachment",
name=name,
content_bytes=base64.urlsafe_b64decode(content),
)

msg["Message"]["Attachments"] = attachments

response = self.graph.app_client.post(endpoint, json=msg)
return response
attachments.append(attachment)
message.attachments = attachments
request_body = SendMailPostRequestBody(message=message, save_to_sent_items=True)
success = asyncio.run(self.get_mail_response(request_body))
return success

async def get_mail_response(self, request_body):
try:
await self.graph.app_client.users.by_user_id(self.sender).send_mail.post(request_body)
except ODataError as e:
# Handle Microsoft Graph API errors
raise ODataError(f"Error sending email: {e.message}")

except Exception as e:
# Catch any other exceptions
raise Exception(f"An unexpected error occurred: {str(e)}")

return True
10 changes: 5 additions & 5 deletions notify/msgraph.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import os

from azure.identity import ClientSecretCredential
from msgraph.core import GraphClient
from msgraph import GraphServiceClient


class Graph:
user_client: GraphClient
user_client: GraphServiceClient
client_credential: ClientSecretCredential
app_client: GraphClient
app_client: GraphServiceClient

def ensure_graph_for_app_only_auth(self):
if not hasattr(self, "client_credential"):
Expand All @@ -18,6 +18,6 @@ def ensure_graph_for_app_only_auth(self):
self.client_credential = ClientSecretCredential(tenant_id, client_id, client_secret)

if not hasattr(self, "app_client"):
self.app_client = GraphClient(
credential=self.client_credential, scopes=["https://graph.microsoft.com/.default"]
self.app_client = GraphServiceClient(
credentials=self.client_credential, scopes=["https://graph.microsoft.com/.default"]
)
30 changes: 17 additions & 13 deletions notify/tests/test_email.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import os
import time

import pytest
from keyvault import secrets_to_environment

from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from notify import NotifyMail, format_numbers
from notify.tests import PDF_STORAGE_LINK, import_sample_dfs

Expand All @@ -15,7 +14,7 @@ def test_send_single_email():
response = NotifyMail(
to=f"{os.environ.get('TEST_EMAIL_1')}", subject="Test Notify single email", message=message
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


Expand All @@ -29,7 +28,7 @@ def test_send_email_with_table():
message=message,
df=df,
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


Expand All @@ -43,20 +42,25 @@ def test_send_email_with_formatted_table():
message=message,
df=df,
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


@pytest.mark.parametrize("sep", [",", ";"])
def test_send_multiple_emails(sep: str):
message = "This is a test from notify"
to = sep.join([os.environ.get("TEST_EMAIL_1"), os.environ.get("TEST_EMAIL_2")])
to = sep.join(
[
os.environ.get("TEST_EMAIL_1"),
os.environ.get("TEST_EMAIL_2"),
]
)
response = NotifyMail(
to=to,
subject="Test Notify multiple emails",
message=message,
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


Expand All @@ -71,7 +75,7 @@ def test_send_file():
message=message,
files={file_name: file_path},
).send_email()
assert response.status_code == 202
assert response


def test_send_file_from_storage():
Expand All @@ -83,7 +87,7 @@ def test_send_file_from_storage():
message=message,
files={"testpdf.pdf": PDF_STORAGE_LINK},
).send_email()
assert response.status_code == 202
assert response


def test_send_cc():
Expand All @@ -94,7 +98,7 @@ def test_send_cc():
subject="Test Notify cc emails",
message=message,
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


Expand All @@ -106,12 +110,12 @@ def test_send_bcc():
subject="Test Notify bcc emails",
message=message,
).send_email()
assert response.status_code == 202
assert response
time.sleep(2)


def test_wrong_user():
mail = NotifyMail(to=os.environ.get("TEST_EMAIL_1"), subject="Test wrong sender", message="Test")
mail.sender = "[email protected]"
response = mail.send_email()
assert response.status_code == 404
with pytest.raises(ODataError):
mail.send_email()
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
azure-identity==1.7.1
azure-identity>=1.16.1
Babel>=2.14.0
msgraph-core==0.2.2
msgraph-core>=1.1.3
msgraph-sdk~=1.5.4
pandas>=2.2.2
tabulate>=0.8.10
5 changes: 3 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ classifiers =
packages = notify
python_requires = >=3.9
install_requires =
azure-identity==1.7.1
azure-identity>=1.16.1
Babel>=2.14.0
msgraph-core==0.2.2
msgraph-core>=1.1.3
pandas>=2.2.2
tabulate>=0.8.10
msgraph-sdk~=1.5.4
Loading