Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SMTPService and Email helper #110

Merged
merged 17 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ def mock_configuration(mocker):
"verify_ssl": False,
},
"redis_url": "redis://redis:@localhost:6379/",
"smtp": {
"host": "testserver",
"port": 12345,
"username": None,
"password": None,
},
},
"setup": {
"codecov_url": "https://codecov.io",
Expand Down
14 changes: 14 additions & 0 deletions helpers/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from email.message import EmailMessage


class Email:
def __init__(
self, to_addr=None, from_addr=None, subject=None, text=None, html=None
):
self.message = EmailMessage()
self.message["To"] = to_addr
self.message["From"] = from_addr
self.message["Subject"] = subject
self.message.set_content(text)
if html:
self.message.add_alternative(html, "text/html")
131 changes: 131 additions & 0 deletions services/smtp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import logging
import smtplib
import ssl

from shared.config import get_config

from helpers.email import Email

log = logging.getLogger(__name__)


class SMTPServiceError(Exception):
...


class SMTPService:
connection = None
matt-codecov marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def active(cls):
return cls.connection is not None

@property
def extra_dict(self):
return {"host": self.host, "port": self.port, "username": self.username}

def _load_config(self):
if get_config("services", "smtp", default={}) == {}:
return False
self.host = get_config("services", "smtp", "host", default="mailhog")
giovanni-guidini marked this conversation as resolved.
Show resolved Hide resolved
self.port = get_config("services", "smtp", "port", default=1025)
self.username = get_config("services", "smtp", "username", default=None)
self.password = get_config("services", "smtp", "password", default=None)
self.ssl_context = ssl.create_default_context()
return True

def tls_and_auth(self):
self.try_starttls()
if self.username and self.password:
self.try_login()

def try_starttls(self):
# only necessary if SMTP server supports TLS and authentication,
# for example mailhog does not need these two steps
try:
SMTPService.connection.starttls(context=self.ssl_context)
except smtplib.SMTPNotSupportedError:
log.warning(
"Server does not support TLS, continuing initialization of SMTP connection",
extra=dict(
host=self.host,
port=self.port,
username=self.username,
password=self.password,
),
)
except smtplib.SMTPResponseException as exc:
log.warning("Error doing STARTTLS command on SMTP", extra=self.extra_dict)
raise SMTPServiceError("Error doing STARTTLS command on SMTP") from exc

def try_login(self):
try:
SMTPService.connection.login(self.username, self.password)
except smtplib.SMTPNotSupportedError:
log.warning(
"Server does not support AUTH, continuing initialization of SMTP connection",
extra=self.extra_dict,
)
except smtplib.SMTPAuthenticationError as exc:
log.warning(
"SMTP server did not accept username/password combination",
extra=self.extra_dict,
)
raise SMTPServiceError(
"SMTP server did not accept username/password combination"
) from exc

def make_connection(self):
try:
SMTPService.connection.connect(self.host, self.port)
except smtplib.SMTPConnectError as exc:
raise SMTPServiceError("Error starting connection for SMTPService") from exc
self.tls_and_auth()

def __init__(self):
if not self._load_config():
log.warning("Unable to load SMTP config")
return
if SMTPService.connection is None:
try:
SMTPService.connection = smtplib.SMTP(
host=self.host,
port=self.port,
)

except smtplib.SMTPConnectError as exc:
raise SMTPServiceError(
"Error starting connection for SMTPService"
) from exc

self.tls_and_auth()

def send(self, email: Email):
giovanni-guidini marked this conversation as resolved.
Show resolved Hide resolved
if not SMTPService.connection:
self.make_connection()

Check warning on line 105 in services/smtp.py

View check run for this annotation

Codecov Public QA / codecov/patch

services/smtp.py#L105

Added line #L105 was not covered by tests

Check warning on line 105 in services/smtp.py

View check run for this annotation

Codecov / codecov/patch

services/smtp.py#L105

Added line #L105 was not covered by tests

Check warning on line 105 in services/smtp.py

View check run for this annotation

Codecov - Staging / codecov/patch

services/smtp.py#L105

Added line #L105 was not covered by tests
else:
try:
SMTPService.connection.noop()
except smtplib.SMTPServerDisconnected:
self.make_connection() # reconnect if disconnected
try:
errs = SMTPService.connection.send_message(
email.message,
)
if len(errs) != 0:
err_msg = " ".join(
list(map(lambda err_tuple: f"{err_tuple[0]} {err_tuple[1]}", errs))
)
log.warning(f"Error sending email message: {err_msg}")
raise SMTPServiceError(f"Error sending email message: {err_msg}")
except smtplib.SMTPRecipientsRefused as exc:
log.warning("All recipients were refused", extra=self.extra_dict)
raise SMTPServiceError("All recipients were refused") from exc
except smtplib.SMTPSenderRefused as exc:
log.warning("Sender was refused", extra=self.extra_dict)
raise SMTPServiceError("Sender was refused") from exc
except smtplib.SMTPDataError as exc:
log.warning(
"The SMTP server did not accept the data", extra=self.extra_dict
)
raise SMTPServiceError("The SMTP server did not accept the data") from exc
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you could save some lines with the err_msg approach you used earlier

if not SMTPService.connection:
    // this one is returning right now which is inconsistent with raising the exception in the other error cases
    err_msg = "Connection was not initialized"
try:
    ....
except smtplib.SMTPReceipientsRefused:
    err_msg = "..."
except ...:
    err_msg = "..."

if err_msg:
    log.warning(err_msg)
    raise SMTPServiceError(err_msg)

Loading
Loading