Merge branch 'development-smtp-solution' into 'main'

when in development log SMTP errors & email

Closes #3

See merge request engineering/remarkbox/remarkbox!4
This commit is contained in:
Russell Ballestrini 2025-01-09 14:08:19 +00:00
commit 84fabcd1c0
4 changed files with 84 additions and 44 deletions

View file

@ -29,7 +29,3 @@ serve: dev
http: dev http: dev
# In the second shell, run a "mock" simple HTTP webserver to serve index.html: # In the second shell, run a "mock" simple HTTP webserver to serve index.html:
env/bin/python -m http.server 8000 env/bin/python -m http.server 8000
smtp: dev
# In the third shell, run a "mock" SMTP server to catch log in emails:
sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25

View file

@ -70,7 +70,7 @@ We utilize a ``Makefile`` to capture targets for building a local Remarkbox envi
Functional testing environment Functional testing environment
------------------------------- -------------------------------
To setup a "functional testing" environment on your personal workstation, open three terminal shells. To setup a "functional testing" environment on your personal workstation, open two terminal shells.
In the first shell, run a copy of Remarkbox using: In the first shell, run a copy of Remarkbox using:
@ -84,14 +84,11 @@ In the second shell, run a "mock" simple HTTP web server to serve index.html:
make http make http
In the third shell, run a "mock" SMTP server to catch log in emails: Now browse to http://127.0.0.1:8000 and index.html will load.
This has an embedded copy of Remarkbox which is also running on localhost.
.. code-block:: bash If you attempt to log in, a verification one-time-password code will be sent over SMTP to log in!
If you do not have an SMTP server the socket error will log email to console when in development.
make smtp
Now browse to http://127.0.0.1:8000 and index.html will load and will have an embedded copy of Remarkbox which running on localhost.
If you attempt to log in, your third shell will capture the email and you can copy / paste the verification link to log in!
New Environments New Environments

View file

@ -192,6 +192,12 @@ def main(global_config, **settings):
# compile the email validator regex outside of the functions. # compile the email validator regex outside of the functions.
_email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$") _email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$")
def add_debug_mode(request):
"""Return True if debug toolbar is enabled."""
return "pyramid_debugtoolbar" in request.registry.settings.get(
"pyramid.includes", ""
)
''' '''
def add_redis(request): def add_redis(request):
"""Return Redis Connection""" """Return Redis Connection"""
@ -490,6 +496,7 @@ def main(global_config, **settings):
# each request instance will run these functions and attach results. # each request instance will run these functions and attach results.
# cache result with `reify=True` to prevent multiple db lookups. # cache result with `reify=True` to prevent multiple db lookups.
# config.add_request_method(add_redis, "redis", reify=True) # config.add_request_method(add_redis, "redis", reify=True)
config.add_request_method(add_debug_mode, "debug_mode", reify=True)
config.add_request_method(add_user, "user", reify=True) config.add_request_method(add_user, "user", reify=True)
config.add_request_method(add_csrf_token, "csrf_token", reify=True) config.add_request_method(add_csrf_token, "csrf_token", reify=True)
config.add_request_method(add_email, "email", reify=True) config.add_request_method(add_email, "email", reify=True)

View file

@ -26,6 +26,10 @@ from email.mime.text import MIMEText
# catch socket errors when postfix isn't running... # catch socket errors when postfix isn't running...
from socket import error as socket_error from socket import error as socket_error
import logging
log = logging.getLogger(__name__)
from jinja2 import Environment, PackageLoader, select_autoescape from jinja2 import Environment, PackageLoader, select_autoescape
jinja2_env = Environment( jinja2_env = Environment(
@ -44,12 +48,11 @@ def send_email(
dkim_private_key_path="", dkim_private_key_path="",
dkim_selector="", dkim_selector="",
dkim_signature_algorithm="ed25519-sha256", dkim_signature_algorithm="ed25519-sha256",
debug_mode=False,
): ):
# the `email` library assumes it is working with string objects. # the `email` library assumes it is working with string objects.
# the `dkim` library assumes it is working with byte objects. # the `dkim` library assumes it is working with byte objects.
# this function performs the acrobatics to make them both happy. # this function performs the acrobatics to make them both happy.
if isinstance(message_text, bytes): if isinstance(message_text, bytes):
# needed for Python 3. # needed for Python 3.
message_text = message_text.decode() message_text = message_text.decode()
@ -74,47 +77,83 @@ def send_email(
msg_data = msg.as_string() msg_data = msg.as_string()
if dkim_private_key_path and dkim_selector: if dkim_private_key_path and dkim_selector:
# the dkim library uses regex on byte strings so everything
# needs to be encoded from strings to bytes.
with open(dkim_private_key_path) as fh:
dkim_private_key = fh.read()
headers = [b"To", b"From", b"Subject"]
sig = dkim.sign(
message=msg_data,
selector=str(dkim_selector).encode(),
domain=sender_domain.encode(),
privkey=dkim_private_key.encode(),
include_headers=headers,
signature_algorithm=dkim_signature_algorithm.encode(),
)
# add the dkim signature to the email message headers.
# decode the signature back to string_type because later on
# the call to msg.as_string() performs it's own bytes encoding...
msg["DKIM-Signature"] = sig[len("DKIM-Signature: ") :].decode()
try: try:
# Python 3 libraries expect bytes. # the dkim library uses regex on byte strings so everything
msg_data = msg.as_bytes() # needs to be encoded from strings to bytes.
except: with open(dkim_private_key_path) as fh:
# Python 2 libraries expect strings. dkim_private_key = fh.read()
msg_data = msg.as_string() headers = [b"To", b"From", b"Subject"]
sig = dkim.sign(
message=msg_data,
selector=str(dkim_selector).encode(),
domain=sender_domain.encode(),
privkey=dkim_private_key.encode(),
include_headers=headers,
signature_algorithm=dkim_signature_algorithm.encode(),
)
# add the dkim signature to the email message headers.
# decode the signature back to string_type because later on
# the call to msg.as_string() performs it's own bytes encoding...
msg["DKIM-Signature"] = sig[len("DKIM-Signature: ") :].decode()
# TODO: react if connecting to relay (localhost postfix) is a socket error. try:
s = smtplib.SMTP(relay) # Python 3 libraries expect bytes.
s.sendmail(sender_email, [to_email], msg_data) msg_data = msg.as_bytes()
s.quit() except:
return msg # Python 2 libraries expect strings.
msg_data = msg.as_string()
except Exception as e:
if debug_mode:
log.error(f"DKIM signing failed: {str(e)}")
raise
try:
s = smtplib.SMTP(relay)
s.sendmail(sender_email, [to_email], msg_data)
s.quit()
return msg
except (socket_error, smtplib.SMTPException) as e:
error_msg = f"Failed to send email: {str(e)}"
if debug_mode:
# Log the error first for quick scanning
log.error(error_msg)
# Then log the email details
log.info(
f"""
Email Contents:
To: {to_email}
From: {sender_email}
Subject: {subject}
Text Content:
{message_text}
HTML Content:
{message_html}
"""
)
if not debug_mode:
raise
return None
def send_pyramid_email(request, to_email, subject, message_text, message_html): def send_pyramid_email(request, to_email, subject, message_text, message_html):
"""Thin wrapper around `send_email` to customise settings using request object.""" """Thin wrapper around `send_email` to customise settings using request object."""
default_sender = "no-reply@{}".format(request.domain) default_sender = "no-reply@{}".format(request.domain)
sender_email = request.app.get("email.sender", default_sender) sender_email = request.app.get("email.sender", default_sender)
subject = "{} | {}".format(subject, request.app.get("email.subject_postfix", request.domain)) subject = "{} | {}".format(
subject, request.app.get("email.subject_postfix", request.domain)
)
relay = request.app.get("email.relay", "localhost") relay = request.app.get("email.relay", "localhost")
dkim_private_key_path = request.app.get("email.dkim_private_key_path", "") dkim_private_key_path = request.app.get("email.dkim_private_key_path", "")
dkim_selector = request.app.get("email.dkim_selector", "") dkim_selector = request.app.get("email.dkim_selector", "")
dkim_signature_algorithm = request.app.get("email.dkim_signature_algorithm", "ed25519-sha256") dkim_signature_algorithm = request.app.get(
"email.dkim_signature_algorithm", "ed25519-sha256"
)
send_email( send_email(
to_email, to_email,
@ -126,6 +165,7 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html):
dkim_private_key_path, dkim_private_key_path,
dkim_selector, dkim_selector,
dkim_signature_algorithm, dkim_signature_algorithm,
request.debug_mode,
) )