Skip to content

Commit

Permalink
Send email notifications with automated testing (GSI 9) (#4)
Browse files Browse the repository at this point in the history
* Add test server with authentication

Update config to include new params

Update dev reqs to include aiosmtpd

* Add more errors to Notifier port

* Create smtp client port

* Implement SMTP client adapter

* Update notifier to use smtp client

* Update tests to use new dummy server

* Silence mypy error on message_from_bytes

* Tweak host values in config

* Add random free port testing util

Update smtp client and dummy server with methods to set hostname/port

* Update example config

* Connect consumer to notifier

* Prefix record_email() with underscore

Co-authored-by: Kersten Breuer <[email protected]>

* Remove setters from DummyServer/make values public

Update use of record_email to _record_email

* Reorder tests so quicker ones run first

* Move failed auth test to own test function

DRY out parametrized notification

* Remove DummySmtpClient entirely

* Reflect in tests that HTML template is mandatory

* Add SmtpClientConfig

* Add NotifierConfig

* Make BadTemplateFormat error more specific

Remove condition for using html template

* Add test config with dynamic port

Remove port modification for dummy server/smtp client

* Expand error handling for other SMTP exceptions

---------

Co-authored-by: Byron Himes <[email protected]>
Co-authored-by: Kersten Breuer <[email protected]>
  • Loading branch information
3 people authored Apr 21, 2023
1 parent 3855120 commit dbecdbe
Show file tree
Hide file tree
Showing 19 changed files with 539 additions and 82 deletions.
9 changes: 7 additions & 2 deletions .devcontainer/.dev_config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Please only mention the non-default settings here:

notification_event_topic: "notifications"
notification_event_type: "notification"
notification_event_topic: notifications
notification_event_type: notification
service_instance_id: 001
kafka_servers: ["kafka:9092"]
plaintext_email_template: "Dear $recipient_name,\n\n$plaintext_body\n\nWarm regards,\n\nThe GHGA Team"
html_email_template: '<!DOCTYPE html><html><head></head><body style="color: #00393f;padding: 12px;"><h2>Dear $recipient_name,</h2><p>$plaintext_body</p><p>Warm regards,</p><h3>The GHGA Team</h3></body></html>'
smtp_host: 127.0.0.1
smtp_port: 587
login_user: "[email protected]"
login_password: test
from_address: "[email protected]"
82 changes: 65 additions & 17 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,63 @@
"description": "Modifies the orginal Settings class provided by the user",
"type": "object",
"properties": {
"plaintext_email_template": {
"title": "Plaintext Email Template",
"description": "The plaintext template to use for email notifications",
"env_names": [
"ns_plaintext_email_template"
],
"type": "string"
},
"html_email_template": {
"title": "Html Email Template",
"description": "The HTML template to use for email notifications",
"env_names": [
"ns_html_email_template"
],
"type": "string"
},
"from_address": {
"title": "From Address",
"description": "The sender's address.",
"env_names": [
"ns_from_address"
],
"type": "string",
"format": "email"
},
"smtp_host": {
"title": "Smtp Host",
"description": "The mail server host to connect to",
"env_names": [
"ns_smtp_host"
],
"type": "string"
},
"smtp_port": {
"title": "Smtp Port",
"description": "The port for the mail server connection",
"env_names": [
"ns_smtp_port"
],
"type": "integer"
},
"login_user": {
"title": "Login User",
"description": "The login username or email",
"env_names": [
"ns_login_user"
],
"type": "string"
},
"login_password": {
"title": "Login Password",
"description": "The login password",
"env_names": [
"ns_login_password"
],
"type": "string"
},
"notification_event_topic": {
"title": "Notification Event Topic",
"description": "Name of the event topic used to track notification events",
Expand Down Expand Up @@ -51,29 +108,20 @@
"items": {
"type": "string"
}
},
"plaintext_email_template": {
"title": "Plaintext Email Template",
"env_names": [
"ns_plaintext_email_template"
],
"type": "string"
},
"html_email_template": {
"title": "Html Email Template",
"env_names": [
"ns_html_email_template"
],
"type": "string"
}
},
"required": [
"plaintext_email_template",
"html_email_template",
"from_address",
"smtp_host",
"smtp_port",
"login_user",
"login_password",
"notification_event_topic",
"notification_event_type",
"service_instance_id",
"kafka_servers",
"plaintext_email_template",
"html_email_template"
"kafka_servers"
],
"additionalProperties": false
}
5 changes: 5 additions & 0 deletions example_config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from_address: [email protected]
html_email_template: '<!DOCTYPE html><html><head></head><body style="color: #00393f;padding:
12px;"><h2>Dear $recipient_name,</h2><p>$plaintext_body</p><p>Warm regards,</p><h3>The
GHGA Team</h3></body></html>'
kafka_servers:
- kafka:9092
login_password: test
login_user: [email protected]
notification_event_topic: notifications
notification_event_type: notification
plaintext_email_template: 'Dear $recipient_name,
Expand All @@ -17,3 +20,5 @@ plaintext_email_template: 'Dear $recipient_name,
The GHGA Team'
service_instance_id: '1'
service_name: ns
smtp_host: 127.0.0.1
smtp_port: 587
12 changes: 10 additions & 2 deletions ns/adapters/inbound/akafka.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
# limitations under the License.
#
"""Event subscriber details for notification events"""
import ghga_event_schemas.pydantic_ as event_schemas
from ghga_event_schemas.validation import get_validated_payload
from hexkit.custom_types import Ascii, JsonObject
from hexkit.protocols.eventsub import EventSubscriberProtocol
from pydantic import BaseSettings, Field

from ns.ports.inbound.notifier import NotifierPort


class EventSubTranslatorConfig(BaseSettings):
"""Config for the event subscriber"""
Expand All @@ -37,14 +41,18 @@ class EventSubTranslatorConfig(BaseSettings):
class EventSubTranslator(EventSubscriberProtocol):
"""A translator that can consume Notification events"""

def __init__(self, *, config: EventSubTranslatorConfig):
def __init__(self, *, config: EventSubTranslatorConfig, notifier: NotifierPort):
self.topics_of_interest = [config.notification_event_topic]
self.types_of_interest = [config.notification_event_type]
self._config = config
self._notifier = notifier

async def _send_notification(self, *, payload: JsonObject):
"""Validates the schema, then makes a call to the notifier with the payload"""
raise NotImplementedError()
validated_payload = get_validated_payload(
payload=payload, schema=event_schemas.Notification
)
await self._notifier.send_notification(notification=validated_payload)

async def _consume_validated(
self, *, payload: JsonObject, type_: Ascii, topic: Ascii
Expand Down
15 changes: 15 additions & 0 deletions ns/adapters/outbound/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
# for the German Human Genome-Phenome Archive (GHGA)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
62 changes: 62 additions & 0 deletions ns/adapters/outbound/smtp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
# for the German Human Genome-Phenome Archive (GHGA)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Contains the smtp client adapter"""
import smtplib
import ssl
from email.message import EmailMessage

from pydantic import BaseSettings, Field

from ns.ports.outbound.smtp_client import SmtpClientPort


class SmtpClientConfig(BaseSettings):
"""Configuration details for the SmtpClient"""

smtp_host: str = Field(..., description="The mail server host to connect to")
smtp_port: int = Field(..., description="The port for the mail server connection")
login_user: str = Field(..., description="The login username or email")
login_password: str = Field(..., description="The login password")


class SmtpClient(SmtpClientPort):
"""Concrete implementation of an SmtpClientPort"""

def __init__(self, config: SmtpClientConfig, debugging: bool = False):
"""Assign config, which should contain all needed info"""
self._config = config
self._debugging = debugging

def send_email_message(self, message: EmailMessage):
# create ssl security context per Python's Security considerations
context = ssl.create_default_context()

try:
with smtplib.SMTP(self._config.smtp_host, self._config.smtp_port) as server:
if not self._debugging:
server.starttls(context=context)
try:
server.login(self._config.login_user, self._config.login_password)
except smtplib.SMTPAuthenticationError as err:
raise self.FailedLoginError() from err

# check for a connection
if server.noop()[0] != 250:
raise self.ConnectionError()
server.send_message(msg=message)
except smtplib.SMTPException as exc:
raise self.GeneralSmtpException(error_info=exc.args[0])
9 changes: 3 additions & 6 deletions ns/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,12 @@
from hexkit.providers.akafka import KafkaConfig

from ns.adapters.inbound.akafka import EventSubTranslatorConfig
from ns.adapters.outbound.smtp_client import SmtpClientConfig
from ns.core.notifier import NotifierConfig


@config_from_yaml(prefix="ns")
class Config(KafkaConfig, EventSubTranslatorConfig):
class Config(KafkaConfig, EventSubTranslatorConfig, SmtpClientConfig, NotifierConfig):
"""Config parameters and their defaults."""

service_name: str = "ns"
plaintext_email_template: str
html_email_template: str


CONFIG = Config()
10 changes: 8 additions & 2 deletions ns/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from hexkit.providers.akafka.provider import KafkaEventSubscriber

from ns.adapters.inbound.akafka import EventSubTranslator
from ns.adapters.outbound.smtp_client import SmtpClient
from ns.config import Config
from ns.core.notifier import Notifier

Expand All @@ -27,11 +28,16 @@ class Container(ContainerBase):

config = get_configurator(Config)

# outbound translator
smtp_client = get_constructor(SmtpClient, config=config)

# domain components
notifier = get_constructor(Notifier, config=config)
notifier = get_constructor(Notifier, config=config, smtp_client=smtp_client)

# inbound translators
event_sub_translator = get_constructor(EventSubTranslator, config=config)
event_sub_translator = get_constructor(
EventSubTranslator, config=config, notifier=notifier
)

# inbound providers
kafka_event_subscriber = get_constructor(
Expand Down
34 changes: 27 additions & 7 deletions ns/core/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,41 @@
# limitations under the License.
#
"""Contains the concrete implementation of a NotifierPort"""

from email.message import EmailMessage
from string import Template

from ghga_event_schemas import pydantic_ as event_schemas
from pydantic import BaseSettings, EmailStr, Field

from ns.config import Config
from ns.ports.inbound.notifier import NotifierPort
from ns.ports.outbound.smtp_client import SmtpClientPort


class NotifierConfig(BaseSettings):
"""Config details for the notifier"""

plaintext_email_template: str = Field(
..., description="The plaintext template to use for email notifications"
)
html_email_template: str = Field(
..., description="The HTML template to use for email notifications"
)
from_address: EmailStr = Field(..., description="The sender's address.")


class Notifier(NotifierPort):
"""Implementation of the Notifier Port"""

def __init__(self, *, config: Config):
"""Initialize the Notifier with configured host, port, and so on"""
def __init__(self, *, config: NotifierConfig, smtp_client: SmtpClientPort):
"""Initialize the Notifier with configuration and smtp client"""
self._config = config
self._smtp_client = smtp_client

async def send_notification(self, *, notification: event_schemas.Notification):
"""Sends notifications based on the channel info provided (e.g. email addresses)"""
raise NotImplementedError
if len(notification.recipient_email) > 0:
message = self._construct_email(notification=notification)
self._smtp_client.send_email_message(message)

def _construct_email(
self, *, notification: event_schemas.Notification
Expand All @@ -44,6 +59,7 @@ def _construct_email(
message["Cc"] = notification.email_cc
message["Bcc"] = notification.email_bcc
message["Subject"] = notification.subject
message["From"] = self._config.from_address

payload_as_dict = notification.dict()

Expand All @@ -54,7 +70,9 @@ def _construct_email(
except KeyError as err:
raise self.VariableNotSuppliedError(variable=err.args[0]) from err
except ValueError as err:
raise self.BadTemplateFormat(problem=err.args[0]) from err
raise self.BadTemplateFormat(
template_type="plaintext", problem=err.args[0]
) from err

message.set_content(plaintext_email)

Expand All @@ -66,7 +84,9 @@ def _construct_email(
except KeyError as err:
raise self.VariableNotSuppliedError(variable=err.args[0]) from err
except ValueError as err:
raise self.BadTemplateFormat(problem=err.args[0]) from err
raise self.BadTemplateFormat(
template_type="html", problem=err.args[0]
) from err

# add the html version to the EmailMessage object
message.add_alternative(html_email, subtype="html")
Expand Down
Loading

0 comments on commit dbecdbe

Please sign in to comment.