Skip to content

Commit 8b303aa

Browse files
author
ci bot
committed
Merge branch 'tg-964-email-service' into 'enterprise'
feat(notifications): Adding basic email sending functionality See merge request dkinternal/testgen/dataops-testgen!341
2 parents 73b31c0 + 2842a4c commit 8b303aa

4 files changed

Lines changed: 188 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ dependencies = [
6060
"streamlit-pydantic==0.6.0",
6161
"cron-converter==1.2.1",
6262
"cron-descriptor==2.0.5",
63+
"pybars3==0.9.7",
6364

6465
# Pinned to match the manually compiled libs or for security
6566
"pyarrow==18.1.0",

testgen/common/email.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import logging
2+
import smtplib
3+
import ssl
4+
from collections.abc import Mapping
5+
from email.mime.multipart import MIMEMultipart
6+
from email.mime.text import MIMEText
7+
8+
from pybars import Compiler
9+
10+
from testgen import settings
11+
12+
LOG = logging.getLogger(__name__)
13+
14+
MANDATORY_SETTINGS = (
15+
"EMAIL_FROM_ADDRESS",
16+
"SMTP_ENDPOINT",
17+
"SMTP_PORT",
18+
"SMTP_USERNAME",
19+
"SMTP_PASSWORD",
20+
)
21+
22+
23+
class EmailTemplateException(Exception):
24+
pass
25+
26+
27+
class BaseEmailTemplate:
28+
29+
def __init__(self):
30+
compiler = Compiler()
31+
self.compiled_subject = compiler.compile(self.get_subject_template())
32+
self.compiled_body = compiler.compile(self.get_body_template())
33+
34+
def validate_settings(self):
35+
missing_settings = [
36+
f"TG_{setting_name}"
37+
for setting_name in MANDATORY_SETTINGS
38+
if getattr(settings, setting_name) is None
39+
]
40+
41+
if missing_settings:
42+
LOG.error(
43+
"Template '%s' can not send emails because the following settings are missing: %s",
44+
self.__class__.__name__,
45+
", ".join(missing_settings),
46+
)
47+
48+
raise EmailTemplateException("Invalid or insufficient email/SMTP settings")
49+
50+
def get_subject_template(self) -> str:
51+
raise NotImplementedError
52+
53+
def get_body_template(self) -> str:
54+
raise NotImplementedError
55+
56+
def get_message(self, recipients: list[str], context: Mapping | None) -> MIMEMultipart:
57+
subject = self.compiled_subject(context)
58+
body = self.compiled_body(context)
59+
60+
message = MIMEMultipart("alternative")
61+
message["Subject"] = subject
62+
message["To"] = ", ".join(recipients)
63+
message["From"] = settings.EMAIL_FROM_ADDRESS
64+
message.attach(MIMEText(body, "html"))
65+
return message
66+
67+
def send_mime_message(self, recipients: list[str], message: MIMEMultipart) -> dict:
68+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
69+
try:
70+
with smtplib.SMTP_SSL(settings.SMTP_ENDPOINT, settings.SMTP_PORT, context=ssl_context) as smtp_server:
71+
smtp_server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
72+
response = smtp_server.sendmail(settings.EMAIL_FROM_ADDRESS, recipients, message.as_string())
73+
except Exception as e:
74+
LOG.error("Template '%s' failed to send email with: %s", self.__class__.__name__, e) # noqa: TRY400
75+
else:
76+
return response
77+
78+
def send(self, recipients: list[str], context: Mapping | None) -> dict:
79+
self.validate_settings()
80+
mime_message = self.get_message(recipients, context)
81+
response = self.send_mime_message(recipients, mime_message)
82+
83+
LOG.info(
84+
"Template '%s' successfully sent email to %d recipients -- %d failed.",
85+
self.__class__.__name__,
86+
len(recipients) - len(response),
87+
len(response)
88+
)
89+
90+
return response

testgen/settings.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@
374374

375375
OBSERVABILITY_VERIFY_SSL: bool = os.getenv("TG_EXPORT_TO_OBSERVABILITY_VERIFY_SSL", "yes").lower() in ["yes", "true"]
376376
"""
377-
When False, exporting events to your instance of Observabilty will skip
377+
When False, exporting events to your instance of Observability will skip
378378
SSL verification.
379379
380380
from env variable: `TG_EXPORT_TO_OBSERVABILITY_VERIFY_SSL`
@@ -383,7 +383,7 @@
383383

384384
OBSERVABILITY_EXPORT_LIMIT: int = int(os.getenv("TG_OBSERVABILITY_EXPORT_MAX_QTY", "5000"))
385385
"""
386-
When exporting to your instance of Observabilty, the maximum number of
386+
When exporting to your instance of Observability, the maximum number of
387387
events that will be sent to the events API on a single export.
388388
389389
from env variable: `TG_OBSERVABILITY_EXPORT_MAX_QTY`
@@ -392,7 +392,7 @@
392392

393393
OBSERVABILITY_DEFAULT_COMPONENT_TYPE: str = os.getenv("OBSERVABILITY_DEFAULT_COMPONENT_TYPE", "dataset")
394394
"""
395-
When exporting to your instance of Observabilty, the type of event that
395+
When exporting to your instance of Observability, the type of event that
396396
will be sent to the events API.
397397
398398
from env variable: `OBSERVABILITY_DEFAULT_COMPONENT_TYPE`
@@ -401,7 +401,7 @@
401401

402402
OBSERVABILITY_DEFAULT_COMPONENT_KEY: str = os.getenv("OBSERVABILITY_DEFAULT_COMPONENT_KEY", "default")
403403
"""
404-
When exporting to your instance of Observabilty, the key sent to the
404+
When exporting to your instance of Observability, the key sent to the
405405
events API to identify the components.
406406
407407
from env variable: `OBSERVABILITY_DEFAULT_COMPONENT_KEY`
@@ -475,3 +475,28 @@
475475
"""
476476
Limit the number of records used to generate the PDF with test results and hygiene issue reports.
477477
"""
478+
479+
EMAIL_FROM_ADDRESS: str | None = os.getenv("TG_EMAIL_FROM_ADDRESS")
480+
"""
481+
Email: Sender address
482+
"""
483+
484+
SMTP_ENDPOINT: str | None = os.getenv("TG_SMTP_ENDPOINT")
485+
"""
486+
Email: SMTP endpoint
487+
"""
488+
489+
SMTP_PORT: int | None = int(os.getenv("TG_SMTP_PORT", 0)) or None
490+
"""
491+
Email: SMTP port
492+
"""
493+
494+
SMTP_USERNAME: str | None = os.getenv("TG_SMTP_USERNAME")
495+
"""
496+
Email: SMTP username
497+
"""
498+
499+
SMTP_PASSWORD: str | None = os.getenv("TG_SMTP_PASSWORD")
500+
"""
501+
Email: SMTP password
502+
"""

tests/unit/test_common_email.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from unittest.mock import ANY, call, patch
2+
3+
import pytest
4+
5+
from testgen.common.email import BaseEmailTemplate, EmailTemplateException
6+
7+
8+
class TestEmailTemplate(BaseEmailTemplate):
9+
10+
def get_subject_template(self) -> str:
11+
return "{{project}}: Test execution finished"
12+
13+
def get_body_template(self) -> str:
14+
return "<html><body><h1>DataKitchen TestGen</h1><p>Hi, {{user}}!</p></body></html>"
15+
16+
17+
@pytest.fixture
18+
def smtp_mock():
19+
with patch("testgen.common.email.smtplib.SMTP_SSL") as mock:
20+
yield mock
21+
22+
23+
@pytest.fixture
24+
def def_settings():
25+
with patch("testgen.common.email.settings") as mock:
26+
mock.EMAIL_FROM_ADDRESS = "from@email"
27+
mock.SMTP_ENDPOINT = "smtp-endpoint"
28+
mock.SMTP_PORT = 333
29+
mock.SMTP_USERNAME = "smtp-user"
30+
mock.SMTP_PASSWORD = "smtp-pass" # noqa: S105
31+
yield mock
32+
33+
34+
@pytest.fixture
35+
def template(smtp_mock, def_settings):
36+
yield TestEmailTemplate()
37+
38+
39+
@pytest.fixture
40+
def send_args():
41+
return ["test@data.kitchen"], {"project": "Test Project", "user": "Test user"}
42+
43+
44+
def test_send_email(smtp_mock, template, send_args, def_settings):
45+
template.send(*send_args)
46+
47+
smtp_mock.assert_has_calls(
48+
[
49+
call("smtp-endpoint", 333, context=ANY),
50+
call().__enter__().login("smtp-user", "smtp-pass"),
51+
call().__enter__().sendmail("from@email", ["test@data.kitchen"], ANY)
52+
],
53+
any_order=True,
54+
)
55+
email_body = smtp_mock().__enter__().sendmail.call_args_list[0][0][2]
56+
assert "<h1>DataKitchen TestGen</h1>" in email_body
57+
assert "Subject: Test Project: Test execution finished" in email_body
58+
assert "<p>Hi, Test user!</p>" in email_body
59+
60+
61+
@pytest.mark.parametrize(
62+
"missing",
63+
("EMAIL_FROM_ADDRESS", "SMTP_ENDPOINT", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD")
64+
)
65+
def test_settings_validation(missing, template, def_settings, send_args):
66+
setattr(def_settings, missing, None)
67+
with pytest.raises(EmailTemplateException, match="Invalid or insufficient email/SMTP settings"):
68+
template.send(*send_args)

0 commit comments

Comments
 (0)