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
# In the second shell, run a "mock" simple HTTP webserver to serve index.html:
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
-------------------------------
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:
@ -84,14 +84,11 @@ In the second shell, run a "mock" simple HTTP web server to serve index.html:
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
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!
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.
New Environments

View file

@ -192,6 +192,12 @@ def main(global_config, **settings):
# compile the email validator regex outside of the functions.
_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):
"""Return Redis Connection"""
@ -490,6 +496,7 @@ def main(global_config, **settings):
# each request instance will run these functions and attach results.
# cache result with `reify=True` to prevent multiple db lookups.
# 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_csrf_token, "csrf_token", 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...
from socket import error as socket_error
import logging
log = logging.getLogger(__name__)
from jinja2 import Environment, PackageLoader, select_autoescape
jinja2_env = Environment(
@ -44,12 +48,11 @@ def send_email(
dkim_private_key_path="",
dkim_selector="",
dkim_signature_algorithm="ed25519-sha256",
debug_mode=False,
):
# the `email` library assumes it is working with string objects.
# the `dkim` library assumes it is working with byte objects.
# this function performs the acrobatics to make them both happy.
if isinstance(message_text, bytes):
# needed for Python 3.
message_text = message_text.decode()
@ -74,47 +77,83 @@ def send_email(
msg_data = msg.as_string()
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:
# Python 3 libraries expect bytes.
msg_data = msg.as_bytes()
except:
# Python 2 libraries expect strings.
msg_data = msg.as_string()
# 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()
# TODO: react if connecting to relay (localhost postfix) is a socket error.
s = smtplib.SMTP(relay)
s.sendmail(sender_email, [to_email], msg_data)
s.quit()
return msg
try:
# Python 3 libraries expect bytes.
msg_data = msg.as_bytes()
except:
# 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):
"""Thin wrapper around `send_email` to customise settings using request object."""
default_sender = "no-reply@{}".format(request.domain)
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")
dkim_private_key_path = request.app.get("email.dkim_private_key_path", "")
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(
to_email,
@ -126,6 +165,7 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html):
dkim_private_key_path,
dkim_selector,
dkim_signature_algorithm,
request.debug_mode,
)