diff --git a/.devcontainer/.dev_config.yaml b/.devcontainer/.dev_config.yaml
index f07f870..85959dd 100644
--- a/.devcontainer/.dev_config.yaml
+++ b/.devcontainer/.dev_config.yaml
@@ -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: '
Dear $recipient_name,
$plaintext_body
Warm regards,
The GHGA Team
'
+smtp_host: 127.0.0.1
+smtp_port: 587
+login_user: "test@test.com"
+login_password: test
+from_address: "test@test.com"
diff --git a/config_schema.json b/config_schema.json
index 76a864a..4d5b453 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -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",
@@ -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
}
\ No newline at end of file
diff --git a/example_config.yaml b/example_config.yaml
index d232ee3..4bb6316 100644
--- a/example_config.yaml
+++ b/example_config.yaml
@@ -1,8 +1,11 @@
+from_address: test@test.com
html_email_template: 'Dear $recipient_name,
$plaintext_body
Warm regards,
The
GHGA Team
'
kafka_servers:
- kafka:9092
+login_password: test
+login_user: test@test.com
notification_event_topic: notifications
notification_event_type: notification
plaintext_email_template: 'Dear $recipient_name,
@@ -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
diff --git a/ns/adapters/inbound/akafka.py b/ns/adapters/inbound/akafka.py
index f0570ec..d2f4cda 100644
--- a/ns/adapters/inbound/akafka.py
+++ b/ns/adapters/inbound/akafka.py
@@ -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"""
@@ -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
diff --git a/ns/adapters/outbound/__init__.py b/ns/adapters/outbound/__init__.py
new file mode 100644
index 0000000..a25a8e1
--- /dev/null
+++ b/ns/adapters/outbound/__init__.py
@@ -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.
+#
diff --git a/ns/adapters/outbound/smtp_client.py b/ns/adapters/outbound/smtp_client.py
new file mode 100644
index 0000000..05d3109
--- /dev/null
+++ b/ns/adapters/outbound/smtp_client.py
@@ -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])
diff --git a/ns/config.py b/ns/config.py
index 671f727..3561c8f 100644
--- a/ns/config.py
+++ b/ns/config.py
@@ -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()
diff --git a/ns/container.py b/ns/container.py
index 10067ad..804835c 100644
--- a/ns/container.py
+++ b/ns/container.py
@@ -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
@@ -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(
diff --git a/ns/core/notifier.py b/ns/core/notifier.py
index bf73e23..7d6297d 100644
--- a/ns/core/notifier.py
+++ b/ns/core/notifier.py
@@ -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
@@ -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()
@@ -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)
@@ -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")
diff --git a/ns/ports/inbound/notifier.py b/ns/ports/inbound/notifier.py
index 65c3c97..ecfc6ad 100644
--- a/ns/ports/inbound/notifier.py
+++ b/ns/ports/inbound/notifier.py
@@ -12,7 +12,7 @@
# 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 a port for the notifier"""
from abc import ABC, abstractmethod
@@ -23,17 +23,17 @@ class NotifierPort(ABC):
"""Describes a notifier service in basic detail"""
class VariableNotSuppliedError(KeyError):
- """Raised when an html template contains a variable that isn't supplied"""
+ """Raised when a template references a variable that isn't supplied"""
def __init__(self, *, variable: str):
message = f"Nothing supplied for template variable {variable}"
super().__init__(message)
class BadTemplateFormat(ValueError):
- """Raised when the html template contains improperly formatted content"""
+ """Raised when the template contains improperly formatted content"""
- def __init__(self, *, problem: str):
- message = f"Problem with HTML template: {problem}"
+ def __init__(self, *, template_type: str, problem: str):
+ message = f"Problem with the {template_type} template: {problem}"
super().__init__(message)
@abstractmethod
diff --git a/ns/ports/outbound/__init__.py b/ns/ports/outbound/__init__.py
new file mode 100644
index 0000000..a25a8e1
--- /dev/null
+++ b/ns/ports/outbound/__init__.py
@@ -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.
+#
diff --git a/ns/ports/outbound/smtp_client.py b/ns/ports/outbound/smtp_client.py
new file mode 100644
index 0000000..86a5eb5
--- /dev/null
+++ b/ns/ports/outbound/smtp_client.py
@@ -0,0 +1,50 @@
+# 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 port"""
+from abc import ABC, abstractmethod
+from email.message import EmailMessage
+
+
+class SmtpClientPort(ABC):
+ """Abstract description of an SMTP client that can send email"""
+
+ class ConnectionError(RuntimeError):
+ """To be raised when testing the connection fails"""
+
+ def __init__(self):
+ message = "Did not receive 250 status from connection test check."
+ super().__init__(message)
+
+ class FailedLoginError(RuntimeError):
+ """Raised when we fail to log in"""
+
+ def __init__(self):
+ message = "Failed to log in."
+ super().__init__(message)
+
+ class GeneralSmtpException(Exception):
+ """Raised by other errors (not failed connection or failed login)"""
+
+ def __init__(self, error_info: str):
+ message = (
+ f"Encountered an issue while attempting to send email: {error_info}"
+ )
+ super().__init__(message)
+
+ @abstractmethod
+ def send_email_message(self, message: EmailMessage):
+ """Sends an email message"""
+ ...
diff --git a/requirements-dev.txt b/requirements-dev.txt
index c022918..cb4d2e3 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -4,3 +4,4 @@
# additional requirements can be listed here
testcontainers[kafka]==3.4.1
+aiosmtpd==1.4.4.post2
diff --git a/tests/fixtures/config.py b/tests/fixtures/config.py
index 0e49d74..b6caad1 100644
--- a/tests/fixtures/config.py
+++ b/tests/fixtures/config.py
@@ -20,8 +20,9 @@
from pydantic.env_settings import BaseSettings
+from ns.adapters.outbound.smtp_client import SmtpClientConfig
from ns.config import Config
-from tests.fixtures.utils import BASE_DIR
+from tests.fixtures.utils import BASE_DIR, get_free_port
TEST_CONFIG_YAML = BASE_DIR / "test_config.yaml"
@@ -41,4 +42,9 @@ def get_config(
return Config(config_yaml=default_config_yaml, **sources_dict) # type: ignore
-DEFAULT_CONFIG = get_config()
+SMTP_TEST_CONFIG = SmtpClientConfig(
+ smtp_host="127.0.0.1",
+ smtp_port=get_free_port(),
+ login_user="test@example.com",
+ login_password="test123",
+)
diff --git a/tests/fixtures/joint.py b/tests/fixtures/joint.py
index b6b9d8a..1a420ac 100644
--- a/tests/fixtures/joint.py
+++ b/tests/fixtures/joint.py
@@ -39,7 +39,7 @@ class JointFixture:
async def joint_fixture(
kafka_fixture: KafkaFixture, # noqa: F811
) -> AsyncGenerator[JointFixture, None]:
- """A fixture that embeds all other fixtures for API-level integration testing"""
+ """A fixture that embeds all other fixtures for integration testing"""
# merge configs from different sources with the default one:
config = get_config(sources=[kafka_fixture.config])
diff --git a/tests/fixtures/server.py b/tests/fixtures/server.py
new file mode 100644
index 0000000..1c9a884
--- /dev/null
+++ b/tests/fixtures/server.py
@@ -0,0 +1,137 @@
+# 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 a limited test server and testing functionality for local email verification."""
+from contextlib import asynccontextmanager
+from email import message_from_bytes
+from email.message import EmailMessage
+
+from aiosmtpd.controller import Controller
+from aiosmtpd.handlers import Sink
+from aiosmtpd.smtp import AuthResult, Envelope
+
+from ns.config import Config
+
+
+class Authenticator:
+ """Basic authenticator so we can test error handling for failed authentication"""
+
+ def __init__(self, user: str, password: str):
+ self._user = user
+ self._password = password
+
+ def __call__(self, server, session, envelope, mechanism, auth_data):
+ login = str(auth_data.login, encoding="utf-8")
+ password = str(auth_data.password, encoding="utf-8")
+
+ if login == self._user and password == self._password:
+ return AuthResult(success=True)
+
+ return AuthResult(success=False, handled=False)
+
+
+class CustomHandler(Sink):
+ """Single use handler"""
+
+ def __init__(self):
+ self.email_received: Envelope
+ super().__init__()
+
+ async def handle_DATA(self, server, session, envelope):
+ """Handler function for email message which closes controller upon use"""
+ self.email_received = envelope
+
+ return "250 Ok"
+
+
+def check_emails(received: Envelope, expected: EmailMessage):
+ """Compares two emails"""
+ message_received = message_from_bytes(received.content) # type: ignore
+ assert message_received["To"] == expected["To"]
+ assert message_received["Cc"] == expected["Cc"]
+ assert message_received["From"] == expected["From"]
+ assert message_received["Subject"] == expected["Subject"]
+ assert message_received.preamble == expected.preamble
+ assert (
+ message_received.get_content_disposition() == expected.get_content_disposition()
+ )
+ assert expected.is_multipart() == message_received.is_multipart()
+ expected_payload = expected.get_payload()
+ received_payload = message_received.get_payload()
+ # I don't think we'll have an issue with non-unique content types.
+ # If we do, you could look for something like the content-id as a UID
+ for part in expected_payload:
+ content_type = part.get_content_type()
+ for corresponding in received_payload:
+ if corresponding.get_content_type() == content_type:
+ assert part.as_bytes() == corresponding.as_bytes()
+
+
+class EmailRecorder:
+ """Listens for one email"""
+
+ def __init__(self, *, expected_email: EmailMessage, controller: Controller):
+ self._expected_email = expected_email
+ self._controller = controller
+
+ async def __aenter__(self):
+ try:
+ self._controller.start()
+
+ except RuntimeError as err:
+ self._controller.stop()
+ raise RuntimeError(err.args[0]) from err
+
+ async def __aexit__(self, *args):
+ if self._controller.loop.is_running():
+ self._controller.stop()
+
+
+class DummyServer:
+ """Test server for making sure emails are received as intended"""
+
+ def __init__(self, *, config: Config):
+ """assign config"""
+ self._config = config
+ self.login = self._config.login_user
+ self.password = self._config.login_password
+
+ def _record_email(
+ self, *, expected_email: EmailMessage, controller: Controller
+ ) -> EmailRecorder:
+ """Returns an async context manager with custom controller/handler"""
+ return EmailRecorder(expected_email=expected_email, controller=controller)
+
+ @asynccontextmanager
+ async def expect_email(self, expected_email: EmailMessage):
+ """Yields an async context manager with a single-use SMTP message handler,
+ and compares the received message envelope with the original EmailMessage"""
+ handler = CustomHandler()
+ controller = Controller(
+ handler,
+ self._config.smtp_host,
+ self._config.smtp_port,
+ auth_require_tls=False,
+ authenticator=Authenticator(self.login, self.password),
+ )
+
+ async with self._record_email(
+ expected_email=expected_email, controller=controller
+ ) as email_recorder:
+ yield email_recorder
+
+ check_emails(received=handler.email_received, expected=expected_email)
+ if controller.loop.is_running():
+ controller.stop()
diff --git a/tests/fixtures/test_config.yaml b/tests/fixtures/test_config.yaml
index 2c23dac..452e76e 100644
--- a/tests/fixtures/test_config.yaml
+++ b/tests/fixtures/test_config.yaml
@@ -14,9 +14,14 @@
# limitations under the License.
#
-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: 'Dear $recipient_name,
$plaintext_body
Warm regards,
The GHGA Team
'
+smtp_host: 127.0.0.1
+smtp_port: 587
+login_user: "test@test.com"
+login_password: test
+from_address: "test@test.com"
diff --git a/tests/fixtures/utils.py b/tests/fixtures/utils.py
index cbed094..231ef5b 100644
--- a/tests/fixtures/utils.py
+++ b/tests/fixtures/utils.py
@@ -15,6 +15,23 @@
"""Utils for Fixture handling"""
+import socket
from pathlib import Path
+from ghga_event_schemas import pydantic_ as event_schemas
+from ghga_event_schemas.validation import get_validated_payload
+from hexkit.custom_types import JsonObject
+
BASE_DIR = Path(__file__).parent.resolve()
+
+
+def make_notification(payload: JsonObject):
+ """Convenience method to produce a validated notification object"""
+ return get_validated_payload(payload=payload, schema=event_schemas.Notification)
+
+
+def get_free_port() -> int:
+ """Finds and returns a free port on localhost."""
+ sock = socket.socket()
+ sock.bind(("", 0))
+ return int(sock.getsockname()[1])
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 4004e1a..4a5df9c 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -14,63 +14,52 @@
# limitations under the License.
"""Test basic event consumption"""
+import asyncio
+
import pytest
-from ghga_event_schemas import pydantic_ as event_schemas
from hexkit.providers.akafka.testutils import kafka_fixture # noqa: F401
+from ns.adapters.outbound.smtp_client import SmtpClient
from ns.core.notifier import Notifier
-from tests.fixtures.config import get_config
+from tests.fixtures.config import SMTP_TEST_CONFIG, get_config
from tests.fixtures.joint import JointFixture, joint_fixture # noqa: F401
+from tests.fixtures.server import DummyServer
+from tests.fixtures.utils import make_notification
-
-@pytest.mark.asyncio
-async def test_basic_path(joint_fixture: JointFixture): # noqa: F811
- """Verify that the event is correctly translated into a basic email object"""
- await joint_fixture.kafka.publish_event(
- payload={
- "recipient_email": "test@example.com",
- "email_cc": [],
- "email_bcc": [],
- "subject": "Test123",
- "recipient_name": "Yolanda Martinez",
- "plaintext_body": "Where are you, where are you, Yolanda?",
- },
- type_=joint_fixture.config.notification_event_type,
- topic=joint_fixture.config.notification_event_topic,
- )
-
- event_subscriber = await joint_fixture.container.kafka_event_subscriber()
- with pytest.raises(NotImplementedError):
- await event_subscriber.run(forever=False)
+sample_notification = {
+ "recipient_email": "test@example.com",
+ "email_cc": ["test2@test.com", "test3@test.com"],
+ "email_bcc": ["test4@test.com", "test5@test.com"],
+ "subject": "Test123",
+ "recipient_name": "Yolanda Martinez",
+ "plaintext_body": "Where are you, where are you, Yolanda?",
+}
-@pytest.mark.asyncio
-async def test_email_construction():
+@pytest.mark.parametrize(
+ "notification_details",
+ [sample_notification],
+)
+def test_email_construction(notification_details):
"""Verify that the email is getting constructed properly from the template."""
- notification = event_schemas.Notification(
- recipient_email="test@example.com",
- email_cc=[],
- email_bcc=[],
- subject="Test123",
- recipient_name="Yolanda Martinez",
- plaintext_body="Where are you, where are you, Yolanda?",
- )
-
- notifier = Notifier(config=get_config())
+ config = get_config([SMTP_TEST_CONFIG])
+ notification = make_notification(notification_details)
+ smtp_client = SmtpClient(config=config)
+ notifier = Notifier(config=config, smtp_client=smtp_client)
msg = notifier._construct_email(
notification=notification
) # pylint: disable=protected-access
assert msg is not None
- plaintext_body = msg.get_body(preferencelist=("plain"))
+ plaintext_body = msg.get_body(preferencelist="plain")
assert plaintext_body is not None
plaintext_content = plaintext_body.get_content() # type: ignore[attr-defined]
expected_plaintext = "Dear Yolanda Martinez,\n\nWhere are you, where are you, Yolanda?\n\nWarm regards,\n\nThe GHGA Team"
assert plaintext_content.strip() == expected_plaintext
- html_body = msg.get_body(preferencelist=("html"))
+ html_body = msg.get_body(preferencelist="html")
assert html_body is not None
html_content = html_body.get_content() # type: ignore[attr-defined]
@@ -78,3 +67,74 @@ async def test_email_construction():
expected_html = 'Dear Yolanda Martinez,
Where are you, where are you, Yolanda?
Warm regards,
The GHGA Team
'
assert html_content.strip() == expected_html
+
+
+@pytest.mark.parametrize(
+ "notification_details",
+ [sample_notification],
+)
+@pytest.mark.asyncio
+async def test_transmission(notification_details):
+ """Test that the email that the test server gets is what we expect"""
+ config = get_config([SMTP_TEST_CONFIG])
+ notification = make_notification(notification_details)
+
+ smtp_client = SmtpClient(config=config, debugging=True)
+ server = DummyServer(config=config)
+
+ notifier = Notifier(config=config, smtp_client=smtp_client)
+
+ # send the notification so it gets intercepted by the dummy client
+ expected_email = notifier._construct_email(
+ notification=notification
+ ) # pylint: disable=protected-access
+
+ # tell the smtp client to send the message and compare that with what is received
+ async with server.expect_email(expected_email=expected_email):
+ await notifier.send_notification(notification=notification)
+ asyncio.get_running_loop().stop()
+
+
+@pytest.mark.asyncio
+async def test_failed_authentication():
+ """Change login credentials so authentication fails."""
+ config = get_config([SMTP_TEST_CONFIG])
+ server = DummyServer(config=config)
+ server.login = "bob@bobswebsite.com"
+ server.password = "notCorrect"
+ notification = make_notification(sample_notification)
+ smtp_client = SmtpClient(config=config, debugging=True)
+ notifier = Notifier(config=config, smtp_client=smtp_client)
+ expected_email = notifier._construct_email(
+ notification=notification
+ ) # pylint: disable=protected-access
+
+ # send the notification so it gets intercepted by the dummy client
+ with pytest.raises(SmtpClient.FailedLoginError):
+ async with server.expect_email(expected_email=expected_email):
+ await notifier.send_notification(notification=notification)
+ asyncio.get_running_loop().stop()
+
+
+@pytest.mark.asyncio
+async def test_consume_thru_send(joint_fixture: JointFixture): # noqa: F811
+ """Verify that the event is correctly translated into a basic email object"""
+ await joint_fixture.kafka.publish_event(
+ payload={
+ "recipient_email": "test@example.com",
+ "email_cc": [],
+ "email_bcc": [],
+ "subject": "Test123",
+ "recipient_name": "Yolanda Martinez",
+ "plaintext_body": "Where are you, where are you, Yolanda?",
+ },
+ type_=joint_fixture.config.notification_event_type,
+ topic=joint_fixture.config.notification_event_topic,
+ )
+
+ event_subscriber = await joint_fixture.container.kafka_event_subscriber()
+ with pytest.raises(ConnectionRefusedError):
+ # the connection error tells us that the smtp_client tried to connect, which
+ # means that the consumer successfully passed the event through the notifier
+ # and on to the client for emailing.
+ await event_subscriber.run(forever=False)