diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 23a145a..fb9e267 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -43,5 +43,10 @@ build: deploy: stage: deploy needs: [build] + rules: + - if: '$CI_COMMIT_BRANCH == "develop"' + when: on_success + - if: '$CI_COMMIT_BRANCH == "master"' + when: on_success trigger: include: .gitlab-ci.deploy.yml diff --git a/Makefile b/Makefile index 111abb8..bba312e 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,3 @@ test: dev serve: dev # In the first shell, run a copy of make_post_sell using: env/bin/pserve development.ini --reload - -smtp: env - # 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 - sudo env/bin/python -m aiosmtpd -l 127.0.0.1:8025 diff --git a/README.rst b/README.rst index 1a66c58..ce75847 100644 --- a/README.rst +++ b/README.rst @@ -28,12 +28,6 @@ Run a web application server with the project loaded: make serve -Open a 2nd shell and run a test SMTP server for intercepting OTP login uri's: - -.. code-block:: bash - - make smtp - In a web browser navagate to http://127.0.0.1:6501/ and you should see the app. @@ -128,3 +122,36 @@ For example, we needed to migrate production data using this script: # commit/close the database transaction to really make changes. request.tm.commit() + + +Contributing +=================== + +* Establish communication with Russell or another admin to bless your git.unturf.com gitlab account & put you into the proper roles. +* Russell should see your account request but due to spam you have to ask him directly for approval via email or some other means of comms. +* Clone repo & make commits +* Create merge requests, we automatically run the unit & headless functional tests on each commit +* On merge we release to the production site & see the change across users. + +Optionally, format your code. + +This is not set in stone, but if you want to use a formatter this is the path for now! + +**Python** + black (manual) + +**Jinja2** + None (not needed, neither is an HTML formatter) + +**JavaScript** + Prettier or biome (manual) + +**CSS** + Prettier or biome (manual) + + +Licence +===================== + +All code contributed goes into the public domain. + diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 676ab02..70e2dc8 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -7,7 +7,6 @@ except ImportError: # Python 3. from urllib.parse import quote_plus - from make_post_sell.lib.mail_messages import ( WELCOME_1_TEXT, WELCOME_1_HTML, @@ -19,17 +18,15 @@ from make_post_sell.lib.mail_messages import ( INVITE_1_HTML, ) - import dkim - import smtplib - from email.mime.multipart import MIMEMultipart - 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 +import logging + +log = logging.getLogger(__name__) def send_email( @@ -42,17 +39,18 @@ 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. + # 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. + # Needed for Python 3. message_text = message_text.decode() if isinstance(message_html, bytes): - # needed for Python 3. + # Needed for Python 3. message_html = message_html.decode() sender_domain = sender_email.split("@")[-1] @@ -71,40 +69,71 @@ 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 its 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 AttributeError: # For Python 2 compatibility + # 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.""" + """Thin wrapper around `send_email` to customize settings using request object.""" default_sender = f"no-reply@{request.domain}" sender_email = request.app.get("email.sender", default_sender) subject = f"{subject} | {request.app.get('email.subject_postfix', request.domain)}" @@ -125,6 +154,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, ) @@ -225,17 +255,17 @@ def send_sale_email(request, shop, products, total_cost): subject, shop.absolute_sales_url(request), product_text, f"{total_cost:.2f}" ) - # send a separate email for each shop owner. + # Send a separate email for each shop owner. for user in shop.users: send_pyramid_email(request, user.email, subject, message_text, message_html) def send_invite_email(request, to_email, user, shop): """ - Send an email to all shop owners regarding the sale. + Send an email to invite a user to join a shop. request - the request (of the successful log in attempt) + the request (of the invitation) to_email the email address to send the shop invitation to. diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index 40ff94f..a3aa0de 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -18,6 +18,12 @@ def includeme(config): # get the app_settings from the config file. app_settings = get_children_settings(config.get_settings(), "app") + def add_debug_mode(request): + """Return True if debug toolbar is enabled.""" + return "pyramid_debugtoolbar" in request.registry.settings.get( + "pyramid.includes", "" + ) + def add_user(request): """Return User object or None. User.authenticated may be True or False.""" user = None @@ -164,7 +170,7 @@ def includeme(config): # Register functions to app config as request methods. # To prevent multiple DB lookups, cache result with `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_active_cart, "active_cart", reify=True) config.add_request_method(add_session_cart, "session_cart", reify=True)