Skip to content

Commit

Permalink
chore: email treasury report zip file (#399)
Browse files Browse the repository at this point in the history
* chore: email treasury report

* chore: reorganizes email-lambda and provides ses permission (#405)

* chore: reorganizes email-lambda and provides ses permission

* chore: remove the extra configuration related to emails

* Minor changes

* Update poetry.lock

* Get email html to work

* fix lint

* chore: Remove duplicate import

* Update typing, naming and error handling

---------

Co-authored-by: Victor Shia <[email protected]>
Co-authored-by: aditya <[email protected]>
  • Loading branch information
3 people authored Sep 16, 2024
1 parent a59f6e7 commit ed30f13
Show file tree
Hide file tree
Showing 11 changed files with 1,612 additions and 670 deletions.
1,383 changes: 717 additions & 666 deletions python/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pydantic = "^2.6.4"
aws-lambda-typing = "^2.19.0"
boto3 = "^1.34.70"
structlog = "^24.1.0"
chevron="^0.14.0"
boto3-stubs = {extras = ["essential"], version = "^1.34.72"}

[tool.poetry.group.dev.dependencies]
Expand Down
142 changes: 142 additions & 0 deletions python/src/functions/generate_presigned_url_and_send_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import os

import boto3
import chevron
import structlog
from aws_lambda_typing.context import Context
from pydantic import BaseModel
from typing import Optional, Tuple

from src.lib.logging import get_logger, reset_contextvars
from src.lib.s3_helper import get_presigned_url
from src.lib.treasury_generation_common import (
OrganizationObj,
UserObj,
)
from src.lib.email import send_email


treasury_email_html = """
Your treasury report can be downloaded <a href={url}>here</a>.
"""

treasury_email_text = """
Hello,
Your treasury report can be downloaded here: {url}.
"""

class SendTreasuryEmailLambdaPayload(BaseModel):
organization: OrganizationObj
user: UserObj


@reset_contextvars
def handle(event: SendTreasuryEmailLambdaPayload, context: Context):
"""Lambda handler for emailing Treasury reports
Given a user and organization object- send an email to the user that
contains a pre-signed URL to the following S3 object if it exists:
treasuryreports/{organization.id}/{organization.preferences.current_reporting_period_id}/report.zip
If the object does not exist then raise an exception.
Args:
event: S3 Lambda event of type `s3:ObjectCreated:*`
context: Lambda context
"""
structlog.contextvars.bind_contextvars(lambda_event={"step_function": event})
logger = get_logger()
logger.info("received new invocation event from step function")

try:
payload = SendTreasuryEmailLambdaPayload.model_validate(event)
except Exception:
logger.exception("Exception parsing Send Treasury Email event payload")
return {"statusCode": 400, "body": "Bad Request"}

try:
process_event(payload, logger)
except Exception:
logger.exception("Exception processing sending treasury report email")
return {"statusCode": 500, "body": "Internal Server Error"}

return {"statusCode": 200, "body": "Success"}


def generate_email(
user: UserObj,
logger: structlog.stdlib.BoundLogger,
presigned_url: str = "",
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
try:
with open("src/static/email_templates/formatted_body.html") as g:
email_body = chevron.render(g, {
"body_title": 'Hello,',
"body_detail": treasury_email_html.format(
url = presigned_url
),
})
with open("src/static/email_templates/base.html") as f:
email_html = chevron.render(f, {
"tool_name": "CPF",
"title": "CPF Treasury Report",
"preheader": False,
"webview_available": False,
"base_url_safe": "",
"usdr_logo_url": 'https://grants.usdigitalresponse.org/usdr_logo_transparent.png',
"presigned_url": presigned_url,
"notifications_url_safe": False,
"email_body": email_body,
},
partials_dict = {
"email_body": email_body,
})
email_text = treasury_email_text.format(url=presigned_url)
subject = "USDR CPF Treasury Report"
return email_html, email_text, subject
except Exception as e:
logger.error(f"Failed to generate treasury email: {e}")
return None, None, None


def process_event(
payload: SendTreasuryEmailLambdaPayload,
logger: structlog.stdlib.BoundLogger,
):
"""
This function is structured as followed:
1) Check to see if the s3 object exists:
treasuryreports/{organization.id}/{organization.preferences.current_reporting_period_id}/report.zip
2) If it does not, raise an exception and quit
3) Generate a pre-signed URL with an expiration date of 1 hour
4) Generate an email
5) Send email to the user
"""
s3_client = boto3.client("s3")
user = payload.user
organization = payload.organization

presigned_url = get_presigned_url(
s3_client = s3_client,
bucket = os.getenv("REPORTING_DATA_BUCKET_NAME"),
key = f"treasuryreports/{organization.id}/{organization.preferences.current_reporting_period_id}/report.zip",
expiration_time = 60 * 60, # 1 hour
)
if presigned_url is None:
raise Exception('Failed to generate signed-URL or file not found')

email_html, email_text, subject = generate_email(
user = user,
presigned_url = presigned_url,
logger = logger,
)
if not email_html:
return False

send_email(
dest_email = user.email,
email_html = email_html,
email_text = email_text,
subject = subject,
logger = logger,
)
return True
52 changes: 52 additions & 0 deletions python/src/lib/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import os
import boto3
from botocore.exceptions import ClientError

CHARSET = "UTF-8"


def send_email(
dest_email: str,
email_html: str,
email_text: str,
subject: str,
logger
) -> bool:
# Email user
email_client = boto3.client("ses")

# Try to send the email.
try:
#Provide the contents of the email.
response = email_client.send_email(
Destination={
"ToAddresses": [
dest_email,
],
},
Message={
"Body": {
"Html": {
"Charset": CHARSET,
"Data": email_html,
},
"Text": {
"Charset": CHARSET,
"Data": email_text,
},
},
"Subject": {
"Charset": CHARSET,
"Data": subject,
},
},
Source=os.getenv("NOTIFICATIONS_EMAIL"),
)
# Display an error if something goes wrong.
except ClientError as e:
logger.info(e.response["Error"]["Message"])
return False
else:
logger.info("Email sent! Message ID:"),
logger.info(response["MessageId"])
return True
40 changes: 36 additions & 4 deletions python/src/lib/s3_helper.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import tempfile
from typing import IO, Union
from urllib.parse import unquote_plus

from botocore.exceptions import ClientError
from mypy_boto3_s3.client import S3Client
from typing import IO, Optional, Union
from urllib.parse import unquote_plus
import tempfile

from src.lib.logging import get_logger

Expand Down Expand Up @@ -56,3 +56,35 @@ def upload_generated_file_to_s3(
raise

logger.info("successfully uploaded file to s3")


def get_presigned_url(
s3_client: S3Client,
bucket: str,
key: str,
expiration_time: int = 60 * 60, # 1 hour
) -> Optional[str]:
logger = get_logger()
try:
response = s3_client.head_object(
bucket = bucket,
key = key,
)
except ClientError as e:
logger.error(e)
return None

try:
response = s3_client.generate_presigned_url(
"get_object",
Params={
"Bucket": bucket,
"Key": key,
},
ExpiresIn=expiration_time
)
except ClientError as e:
logger.error(e)
return None

return response
Loading

0 comments on commit ed30f13

Please sign in to comment.