Skip to content
14 changes: 14 additions & 0 deletions .github/workflows/base-lambdas-reusable-deploy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -824,3 +824,17 @@ jobs:
lambda_layer_names: "core_lambda_layer,reports_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}

deploy_ses_feedback_monitor_lambda:
name: Deploy SES Feedback Monitor
uses: ./.github/workflows/base-lambdas-reusable-deploy.yml
with:
environment: ${{ inputs.environment }}
python_version: ${{ inputs.python_version }}
build_branch: ${{ inputs.build_branch }}
sandbox: ${{ inputs.sandbox }}
lambda_handler_name: ses_feedback_monitor_handler
lambda_aws_name: sesFeedbackMonitor
lambda_layer_names: "core_lambda_layer,reports_lambda_layer"
secrets:
AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }}
6 changes: 5 additions & 1 deletion lambdas/handlers/report_distribution_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"CONTACT_TABLE_NAME",
"PRM_MAILBOX_EMAIL",
"SES_FROM_ADDRESS",
"SES_CONFIGURATION_SET",
]
)
@override_error_check
Expand All @@ -35,9 +36,12 @@ def lambda_handler(event, context) -> Dict[str, Any]:
prm_mailbox = os.environ["PRM_MAILBOX_EMAIL"]
from_address = os.environ["SES_FROM_ADDRESS"]

configuration_set = os.environ["SES_CONFIGURATION_SET"]

s3_service = S3Service()
contact_repo = ReportContactRepository(contact_table)
email_service = EmailService()

email_service = EmailService(default_configuration_set=configuration_set)

service = ReportDistributionService(
s3_service=s3_service,
Expand Down
37 changes: 37 additions & 0 deletions lambdas/handlers/ses_feedback_monitor_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os
from typing import Any, Dict

from services.base.s3_service import S3Service
from services.email_service import EmailService
from services.ses_feedback_monitor_service import (
SesFeedbackMonitorConfig,
SesFeedbackMonitorService,
)
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions
from utils.decorators.override_error_check import override_error_check
from utils.decorators.set_audit_arg import set_request_context_for_logging


def parse_alert_types(configured: str) -> set[str]:
return {s.strip().upper() for s in configured.split(",") if s.strip()}

@override_error_check
@handle_lambda_exceptions
@set_request_context_for_logging
def lambda_handler(event, context) -> Dict[str, Any]:
config = SesFeedbackMonitorConfig(
feedback_bucket=os.environ["SES_FEEDBACK_BUCKET_NAME"],
feedback_prefix=os.environ["SES_FEEDBACK_PREFIX"],
prm_mailbox=os.environ["PRM_MAILBOX_EMAIL"],
from_address=os.environ["SES_FROM_ADDRESS"],
alert_on_event_types=parse_alert_types(os.environ["ALERT_ON_EVENT_TYPES"]),
)

s3_service = S3Service()

service = SesFeedbackMonitorService(
s3_service=s3_service,
email_service=EmailService(),
config=config,
)
return service.process_ses_feedback_event(event)
16 changes: 10 additions & 6 deletions lambdas/repositories/reporting/reporting_dynamo_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
from boto3.dynamodb.conditions import Key
from services.base.dynamo_service import DynamoDBService
from utils.audit_logging_setup import LoggingService
from utils.utilities import utc_date_string, utc_date, utc_day_start_timestamp, utc_day_end_timestamp
from utils.utilities import (
utc_date,
utc_day_end_timestamp,
utc_day_start_timestamp,
)

logger = LoggingService(__name__)


class ReportingDynamoRepository:
def __init__(self, table_name: str):
self.table_name = table_name
Expand Down Expand Up @@ -37,10 +42,9 @@ def get_records_for_time_window(
effective_start_ts = max(start_timestamp, day_start_ts)
effective_end_ts = min(end_timestamp, day_end_ts)

key_condition = (
Key("Date").eq(current_date.isoformat())
& Key("Timestamp").between(effective_start_ts, effective_end_ts)
)
key_condition = Key("Date").eq(current_date.isoformat()) & Key(
"Timestamp"
).between(effective_start_ts, effective_end_ts)

records_for_day = self.dynamo_service.query_by_key_condition_expression(
table_name=self.table_name,
Expand All @@ -51,4 +55,4 @@ def get_records_for_time_window(
records_for_window.extend(records_for_day)
current_date += timedelta(days=1)

return records_for_window
return records_for_window
18 changes: 18 additions & 0 deletions lambdas/services/base/s3_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import json
from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Any, Mapping
Expand Down Expand Up @@ -27,6 +28,8 @@ def __new__(cls, *args, **kwargs):
def __init__(self, custom_aws_role=None):
if not self.initialised:
self.config = BotoConfig(
connect_timeout=3,
read_timeout=5,
retries={"max_attempts": 3, "mode": "standard"},
s3={"addressing_style": "virtual"},
signature_version="s3v4",
Expand All @@ -43,6 +46,21 @@ def __init__(self, custom_aws_role=None):
self.custom_aws_role, "s3", config=self.config
)

def put_json(
self,
bucket: str,
key: str,
payload: Mapping[str, Any],
*,
content_type: str = "application/json",
):
return self.client.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps(payload).encode("utf-8"),
ContentType=content_type,
)

# S3 Location should be a minimum of a s3_object_key but can also be a directory location in the form of
# {{directory}}/{{s3_object_key}}
def create_upload_presigned_url(self, s3_bucket_name: str, s3_object_location: str):
Expand Down
88 changes: 67 additions & 21 deletions lambdas/services/email_service.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import boto3
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from typing import Iterable, Optional, Dict, Any
from typing import Any, Dict, Iterable, Optional

import boto3
from utils.audit_logging_setup import LoggingService

logger = LoggingService(__name__)
Expand All @@ -15,8 +15,9 @@ class EmailService:
Higher-level methods prepare inputs and call send_email().
"""

def __init__(self):
def __init__(self, *, default_configuration_set: Optional[str] = None):
self.ses = boto3.client("ses")
self.default_configuration_set = default_configuration_set

def send_email(
self,
Expand All @@ -26,15 +27,23 @@ def send_email(
body_text: str,
from_address: str,
attachments: Optional[Iterable[str]] = None,
)->Dict[str, Any]:
configuration_set: Optional[str] = None,
tags: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
Sends an email using SES SendRawEmail.

If configuration_set is not provided, self.default_configuration_set is used (if set).
"""
msg = MIMEMultipart()
msg["Subject"] = subject
msg["To"] = to_address
msg["From"] = from_address

msg.attach(MIMEText(body_text, "plain"))

for attachment_path in attachments or []:
attachment_list = list(attachments or [])
for attachment_path in attachment_list:
with open(attachment_path, "rb") as f:
part = MIMEApplication(f.read())
part.add_header(
Expand All @@ -43,23 +52,54 @@ def send_email(
filename=attachment_path.split("/")[-1],
)
msg.attach(part)

effective_config_set = configuration_set or self.default_configuration_set

logger.info(
f"Sending email: from={from_address!r}, to={to_address!r}, subject={subject!r}, "
f"attachments={len(list(attachments or []))}"
f"attachments={len(attachment_list)}, configuration_set={effective_config_set!r}, tags={tags!r}"
)
return self._send_raw(msg, to_address)

def _send_raw(self, msg: MIMEMultipart, to_address: str)->Dict[str, Any]:
return self._send_raw(
msg=msg,
to_address=to_address,
configuration_set=effective_config_set,
tags=tags,
)

def _send_raw(
self,
*,
msg: MIMEMultipart,
to_address: str,
configuration_set: Optional[str] = None,
tags: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
subject = msg.get("Subject", "")
from_address = msg.get("From", "")
logger.info(f"Sending SES raw email: subject={subject!r}, from={from_address!r}, to={to_address!r}")
resp = self.ses.send_raw_email(
Source=from_address,
RawMessage={"Data": msg.as_string()},
Destinations=[to_address],

logger.info(
f"Sending SES raw email: subject={subject!r}, from={from_address!r}, to={to_address!r}, "
f"configuration_set={configuration_set!r}, tags={tags!r}"
)

logger.info(f"SES accepted email: subject={subject!r}, message_id={resp.get('MessageId')}")
kwargs: Dict[str, Any] = {
"Source": from_address,
"RawMessage": {"Data": msg.as_string()},
"Destinations": [to_address],
}

if configuration_set:
kwargs["ConfigurationSetName"] = configuration_set

if tags:
kwargs["Tags"] = [{"Name": k, "Value": v} for k, v in tags.items()]

resp = self.ses.send_raw_email(**kwargs)

logger.info(
f"SES accepted email: subject={subject!r}, message_id={resp.get('MessageId')}"
)
return resp

def send_report_email(
Expand All @@ -68,13 +108,15 @@ def send_report_email(
to_address: str,
from_address: str,
attachment_path: str,
):
self.send_email(
tags: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
return self.send_email(
to_address=to_address,
from_address=from_address,
subject="Daily Upload Report",
body_text="Please find your encrypted daily upload report attached.",
attachments=[attachment_path],
tags=tags,
)

def send_password_email(
Expand All @@ -83,12 +125,14 @@ def send_password_email(
to_address: str,
from_address: str,
password: str,
):
self.send_email(
tags: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
return self.send_email(
to_address=to_address,
from_address=from_address,
subject="Daily Upload Report Password",
body_text=f"Password for your report:\n\n{password}",
tags=tags,
)

def send_prm_missing_contact_email(
Expand All @@ -99,8 +143,9 @@ def send_prm_missing_contact_email(
ods_code: str,
attachment_path: str,
password: str,
):
self.send_email(
tags: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
return self.send_email(
to_address=prm_mailbox,
from_address=from_address,
subject=f"Missing contact for ODS {ods_code}",
Expand All @@ -110,4 +155,5 @@ def send_prm_missing_contact_email(
f"Please resolve the contact and forward the report."
),
attachments=[attachment_path],
tags=tags,
)
Loading