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

Upgrade email functionality in Oppia to have email attachments #21380

Merged
merged 9 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
17 changes: 13 additions & 4 deletions core/domain/email_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,8 @@ def _send_email(
sender_email: str,
bcc_admin: bool = False,
sender_name: Optional[str] = None,
recipient_email: Optional[str] = None
recipient_email: Optional[str] = None,
attachments: Optional[List[Dict[str, str]]] = None
) -> None:
"""Sends an email to the given recipient.

Expand All @@ -513,6 +514,9 @@ def _send_email(
This should only be used when the user with user_id equal to
recipient_id does not exist or is deleted and their email cannot be
retrieved via get_email_from_user_id.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.
"""

if sender_name is None:
Expand Down Expand Up @@ -559,7 +563,8 @@ def _send_email_transactional() -> None:

email_services.send_mail(
sender_name_email, recipient_email_address, email_subject,
cleaned_plaintext_body, cleaned_html_body, bcc_admin=bcc_admin)
cleaned_plaintext_body, cleaned_html_body, bcc_admin=bcc_admin,
attachments=attachments)
email_models.SentEmailModel.create(
recipient_id, recipient_email_address, sender_id, sender_name_email,
intent, email_subject, cleaned_html_body, datetime.datetime.utcnow()
Expand All @@ -576,7 +581,8 @@ def _send_bulk_mail(
email_html_body: str,
sender_email: str,
sender_name: str,
instance_id: str
instance_id: str,
attachments: Optional[List[Dict[str, str]]] = None
) -> None:
"""Sends an email to all given recipients.

Expand All @@ -590,6 +596,9 @@ def _send_bulk_mail(
sender_name: str. The name to be shown in the "sender" field of the
email.
instance_id: str. The ID of the BulkEmailModel entity instance.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.
"""
require_sender_id_is_valid(intent, sender_id)

Expand Down Expand Up @@ -621,7 +630,7 @@ def _send_bulk_mail_transactional(instance_id: str) -> None:

email_services.send_bulk_mail(
sender_name_email, recipient_emails, email_subject,
cleaned_plaintext_body, cleaned_html_body)
cleaned_plaintext_body, cleaned_html_body, attachments)

email_models.BulkEmailModel.create(
instance_id, sender_id, sender_name_email, intent,
Expand Down
44 changes: 35 additions & 9 deletions core/domain/email_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def send_mail(
subject: str,
plaintext_body: str,
html_body: str,
bcc_admin: bool = False
bcc_admin: bool = False,
attachments: Optional[List[Dict[str, str]]] = None
) -> None:
"""Sends an email.

Expand All @@ -103,6 +104,9 @@ def send_mail(
html_body: str. The HTML body of the email. Must fit in a datastore
entity. Format must be utf-8.
bcc_admin: bool. Whether to bcc feconf.ADMIN_EMAIL_ADDRESS on the email.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.

Raises:
Exception. The configuration in feconf.py forbids emails from being
Expand Down Expand Up @@ -131,7 +135,7 @@ def send_mail(
bcc = [feconf.ADMIN_EMAIL_ADDRESS] if bcc_admin else None
response = email_services.send_email_to_recipients(
sender_email, [recipient_email], subject,
plaintext_body, html_body, bcc, '', None)
plaintext_body, html_body, bcc, '', None, attachments)

if not response:
raise Exception((
Expand All @@ -145,7 +149,8 @@ def send_bulk_mail(
recipient_emails: List[str],
subject: str,
plaintext_body: str,
html_body: str
html_body: str,
attachments: Optional[List[Dict[str, str]]] = None
) -> None:
"""Sends emails to all recipients in recipient_emails.

Expand All @@ -163,6 +168,9 @@ def send_bulk_mail(
utf-8.
html_body: str. The HTML body of the email. Must fit in a datastore
entity. Format must be utf-8.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.

Raises:
Exception. The configuration in feconf.py forbids emails from being
Expand Down Expand Up @@ -191,7 +199,8 @@ def send_bulk_mail(
'Malformed sender email address: %s' % sender_email)

response = email_services.send_email_to_recipients(
sender_email, recipient_emails, subject, plaintext_body, html_body)
sender_email, recipient_emails, subject,
plaintext_body, html_body, attachments=attachments)

if not response:
raise Exception(
Expand All @@ -208,7 +217,8 @@ def convert_email_to_loggable_string(
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
recipient_variables: Optional[
Dict[str, Dict[str, Union[str, float]]]] = None
Dict[str, Dict[str, Union[str, float]]]] = None,
attachments: Optional[List[Dict[str, str]]] = None
) -> str:
"""Generates a loggable email which can be printed to console in order
to model sending an email in non production mode.
Expand Down Expand Up @@ -239,8 +249,11 @@ def convert_email_to_loggable_string(
"alice@example.com": {"first":"Alice", "id":2}}
subject = 'Hey, %recipient.first%'
More info about this format at:
https://documentation.mailgun.com/en/
latest/user_manual.html#batch-sending
https://documentation.mailgun.com/en/latest/user_manual.html
#batch-sending.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.

Returns:
str. The loggable email string.
Expand All @@ -253,6 +266,11 @@ def convert_email_to_loggable_string(
recipient_email_list_str += (
'... Total: %s emails.' % (str(len(recipient_emails))))

filenames = []
if attachments:
for attachment in attachments:
filenames.append(attachment['filename'])

# Show the first 3 emails in bcc email list.
if bcc:
bcc_email_list_str = ' '.join(
Expand Down Expand Up @@ -286,6 +304,14 @@ def convert_email_to_loggable_string(
bcc_email_list_str if bcc else 'None',
reply_to if reply_to else 'None',
len(recipient_variables) if recipient_variables else 0))
loggable_msg = textwrap.dedent(msg) + textwrap.dedent(
optional_msg_description)
attachments_msg_description = (
"""
Attachments: %s
""" % (', '.join(filenames) if len(filenames) > 0 else 'None')
)
loggable_msg = (
textwrap.dedent(msg) +
textwrap.dedent(optional_msg_description) +
textwrap.dedent(attachments_msg_description)
)
return loggable_msg
4 changes: 3 additions & 1 deletion core/domain/email_services_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ def test_unsuccessful_status_codes_raises_exception(self) -> None:
' contact us to report a bug at https://www.oppia.org/contact.')
swap_send_email_to_recipients = self.swap(
platform_email_services, 'send_email_to_recipients',
lambda *_: False)
lambda *_, **__: False)
recipients = [feconf.ADMIN_EMAIL_ADDRESS]

with email_exception, swap_send_email_to_recipients:
Expand Down Expand Up @@ -250,6 +250,8 @@ def test_loggable_email_string_generation(self) -> None:
Reply_to: None
Recipient Variables:
Length: 0

Attachments: None
""" % (
feconf.SYSTEM_EMAIL_ADDRESS, feconf.ADMIN_EMAIL_ADDRESS,
'subject', 4, 4))
Expand Down
3 changes: 2 additions & 1 deletion core/domain/user_query_services_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ def test_send_email_to_qualified_users(self) -> None:
[self.NEW_USER_EMAIL],
'subject',
'body',
'body'
'body',
None
)]
)

Expand Down
8 changes: 6 additions & 2 deletions core/platform/email/dev_mode_email_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ def send_email_to_recipients(
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
recipient_variables: Optional[
Dict[str, Dict[str, Union[str, float]]]] = None
Dict[str, Dict[str, Union[str, float]]]] = None,
attachments: Optional[List[Dict[str, str]]] = None
) -> bool:
"""Prints information about sent emails to the terminal console, in order
to model sending an email in development mode.
Expand Down Expand Up @@ -67,14 +68,17 @@ def send_email_to_recipients(
More info about this format at:
https://documentation.mailgun.com/en/
latest/user_manual.html#batch-sending
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.

Returns:
bool. Whether the emails are "sent" successfully.
"""
logging.info(
email_services.convert_email_to_loggable_string(
sender_email, recipient_emails, subject, plaintext_body, html_body,
bcc, reply_to, recipient_variables
bcc, reply_to, recipient_variables, attachments
)
)
logging.info(
Expand Down
12 changes: 10 additions & 2 deletions core/platform/email/dev_mode_email_services_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def _mock_logging_function(msg: str, *args: str) -> None:
Reply_to: None
Recipient Variables:
Length: 0

Attachments: None
""" % (
feconf.SYSTEM_EMAIL_ADDRESS, feconf.ADMIN_EMAIL_ADDRESS,
'subject', 4, 4))
Expand Down Expand Up @@ -119,10 +121,12 @@ def _mock_logging_function(msg: str, *args: str) -> None:
Reply_to: %s
Recipient Variables:
Length: %d

Attachments: %s
""" % (
feconf.SYSTEM_EMAIL_ADDRESS, recipient_email_list_str,
'subject', 4, 4, bcc_email_list_str, '123',
len(recipient_variables)))
len(recipient_variables), 'attachment.txt'))
logging_info_email_body = textwrap.dedent(msg_body)
logging_info_notification = (
'You are not currently sending out real emails since this is a '
Expand All @@ -136,7 +140,11 @@ def _mock_logging_function(msg: str, *args: str) -> None:
'subject', 'body', 'html',
bcc=['e@e.com', 'f@f.com', 'g@g.com', 'h@h.com'],
reply_to='123',
recipient_variables=recipient_variables)
recipient_variables=recipient_variables,
attachments=[{
'filename': 'attachment.txt',
'path': '/dummypath'
}])
self.assertEqual(len(observed_log_messages), 2)
self.assertEqual(
observed_log_messages,
Expand Down
77 changes: 46 additions & 31 deletions core/platform/email/mailgun_email_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@

from __future__ import annotations

import base64
import urllib
import logging

from core import feconf
from core import utils
from core.domain import email_services
from core.platform import models

import requests
from typing import Dict, List, Optional, Union

MYPY = False
Expand All @@ -34,6 +33,9 @@

secrets_services = models.Registry.import_secrets_services()

# Timeout in seconds for mailgun requests.
TIMEOUT_SECS = 60


def send_email_to_recipients(
sender_email: str,
Expand All @@ -44,7 +46,8 @@ def send_email_to_recipients(
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
recipient_variables: Optional[
Dict[str, Dict[str, Union[str, float]]]] = None
Dict[str, Dict[str, Union[str, float]]]] = None,
attachments: Optional[List[Dict[str, str]]] = None
) -> bool:
"""Send POST HTTP request to mailgun api. This method is adopted from
the requests library's post method.
Expand All @@ -71,10 +74,13 @@ def send_email_to_recipients(
recipient_variables =
{"bob@example.com": {"first":"Bob", "id":1},
"alice@example.com": {"first":"Alice", "id":2}}
subject = 'Hey, %recipient.first%
subject = 'Hey, %recipient.first%'
More info about this format at:
https://documentation.mailgun.com/en/
latest/user_manual.html#batch-sending.
https://documentation.mailgun.com/en/latest/user_manual.html
#batch-sending.
attachments: list(dict)|None. Optional argument. A list of
dictionaries, where each dictionary includes the keys `filename`
and `path` with their corresponding values.

Raises:
Exception. The mailgun api key is not stored in
Expand Down Expand Up @@ -108,16 +114,17 @@ def send_email_to_recipients(
# To send bulk emails we pass list of recipients in 'to' paarameter of
# post data. Maximum limit of recipients per request is 1000.
# For more detail check following link:
# https://documentation.mailgun.com/user_manual.html#batch-sending
# https://documentation.mailgun.com/docs/mailgun/user-manual/
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated documentation link.

# sending-messages/#batch-sending.
recipient_email_lists = [
recipient_emails[i:i + 1000]
for i in range(0, len(recipient_emails), 1000)]
for email_list in recipient_email_lists:
data = {
'from': sender_email,
'subject': subject.encode('utf-8'),
Copy link
Member Author

@Nik-09 Nik-09 Dec 6, 2024

Choose a reason for hiding this comment

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

No need to encode manually because the request library will handle the encoding intrinsically.

'text': plaintext_body.encode('utf-8'),
'html': html_body.encode('utf-8'),
'subject': subject,
'text': plaintext_body,
'html': html_body,
'to': email_list[0] if len(email_list) == 1 else email_list
}

Expand All @@ -131,28 +138,36 @@ def send_email_to_recipients(
# email to each recipient (This is intended to be a workaround for
# sending individual emails).
data['recipient_variables'] = recipient_variables or {}

# The b64encode accepts and returns bytes, so we first need to encode
# the MAILGUN_API_KEY to bytes, then decode the returned bytes back
# to string.
base64_mailgun_api_key = base64.b64encode(
b'api:%b' % mailgun_api_key.encode('utf-8')
).strip().decode('utf-8')
auth_str = 'Basic %s' % base64_mailgun_api_key
header = {'Authorization': auth_str}
server = 'https://api.mailgun.net/v3/%s/messages' % (
feconf.MAILGUN_DOMAIN_NAME
)
# The 'ascii' is used here, because only ASCII char are allowed in url,
# also the docs recommend this approach:
# https://docs.python.org/3.7/library/urllib.request.html#urllib-examples
encoded_url = urllib.parse.urlencode(data).encode('ascii')
req = urllib.request.Request(server, encoded_url, header)
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated the implementation from urllib to the requests library as it simplifies attaching files in multipart/form-data and provides built-in support for this format.

resp = utils.url_open(req)
# The function url_open returns a file_like object that can be queried
# for the status code of the url query. If it is not 200, the mail query
# failed so we return False (this function did not complete
# successfully).
if resp.getcode() != 200:

# Adding attachments to the email.
files = [(
'attachment',
(attachment['filename'], open(attachment['path'], 'rb')))
for attachment in attachments
] if attachments else None

try:
response = requests.post(
server,
auth=('api', mailgun_api_key),
data=data,
files=files,
timeout=TIMEOUT_SECS
)

if files:
for _, (_, file_obj) in files:
file_obj.close()

if response.status_code != 200:
logging.error(
'Failed to send email: %s - %s.'
% (response.status_code, response.text))
return False
except requests.RequestException as e:
logging.error('Failed to send email: %s.' % e)
return False
return True
Loading
Loading