From 413b68bec5ccc703f7c4b63098fc7ffbbc4bc3dc Mon Sep 17 00:00:00 2001 From: mutantsan Date: Thu, 4 Jan 2024 14:17:45 +0200 Subject: [PATCH] feature: create config page, add redirect option --- ckanext/mailcraft/config.py | 72 +++++++++++++-- ckanext/mailcraft/mailer.py | 90 ++++--------------- ckanext/mailcraft/plugin.py | 28 +++--- .../mailcraft/templates/mailcraft/config.html | 26 ++++++ .../templates/mailcraft/dashboard.html | 2 + .../templates/mailcraft/mail_read.html | 2 +- ckanext/mailcraft/tests/test_mailer.py | 68 +++++++------- ckanext/mailcraft/views.py | 77 ++++++++++++---- 8 files changed, 214 insertions(+), 151 deletions(-) create mode 100644 ckanext/mailcraft/templates/mailcraft/config.html diff --git a/ckanext/mailcraft/config.py b/ckanext/mailcraft/config.py index f48f3b3..bf32bf8 100644 --- a/ckanext/mailcraft/config.py +++ b/ckanext/mailcraft/config.py @@ -1,4 +1,9 @@ +from __future__ import annotations + +from typing import Any + import ckan.plugins.toolkit as tk +from ckan import types CONF_TEST_CONN = "ckanext.mailcraft.test_conn_on_startup" DEF_TEST_CONN = False @@ -12,13 +17,12 @@ CONF_MAIL_PER_PAGE = "ckanext.mailcraft.mail_per_page" DEF_MAIL_PER_PAGE = 20 -CONF_SAVE_TO_DASHBOARD = "ckanext.mailcraft.save_to_dashboard" -DEF_SAVE_TO_DASHBOARD = False +CONF_REDIRECT_EMAILS_TO = "ckanext.mailcraft.redirect_emails_to" def get_conn_timeout() -> int: """Return a timeout for an SMTP connection""" - return tk.asint(tk.config.get(CONF_CONN_TIMEOUT, DEF_CONN_TIMEOUT)) + return tk.asint(tk.config.get(CONF_CONN_TIMEOUT) or DEF_CONN_TIMEOUT) def is_startup_conn_test_enabled() -> bool: @@ -35,9 +39,59 @@ def stop_outgoing_emails() -> bool: def get_mail_per_page() -> int: """Return a number of mails to show per page""" - return tk.asint(tk.config.get(CONF_MAIL_PER_PAGE, DEF_MAIL_PER_PAGE)) - - -def is_save_to_dashboard_enabled() -> bool: - """Check if we are saving outgoing emails to dashboard""" - return tk.asbool(tk.config.get(CONF_SAVE_TO_DASHBOARD, DEF_SAVE_TO_DASHBOARD)) + return tk.asint(tk.config.get(CONF_MAIL_PER_PAGE) or DEF_MAIL_PER_PAGE) + + +def get_redirect_email() -> str | None: + """Redirect outgoing emails to a specified email""" + return tk.config.get(CONF_REDIRECT_EMAILS_TO) + + +def get_config_options() -> dict[str, dict[str, Any]]: + """Defines how we are going to render the global configuration + options for an extension.""" + unicode_safe = tk.get_validator("unicode_safe") + boolean_validator = tk.get_validator("boolean_validator") + default = tk.get_validator("default") + int_validator = tk.get_validator("is_positive_integer") + email_validator = tk.get_validator("email_validator") + + return { + "smtp_test": { + "key": CONF_TEST_CONN, + "label": "Test SMTP connection on CKAN startup", + "value": is_startup_conn_test_enabled(), + "validators": [default(DEF_TEST_CONN), boolean_validator], # type: ignore + "type": "select", + "options": [{"value": 1, "text": "Yes"}, {"value": 0, "text": "No"}], + }, + "timeout": { + "key": CONF_CONN_TIMEOUT, + "label": "SMTP connection timeout", + "value": get_conn_timeout(), + "validators": [default(DEF_CONN_TIMEOUT), int_validator], # type: ignore + "type": "number", + }, + "stop_outgoing": { + "key": CONF_STOP_OUTGOING, + "label": "Stop outgoing emails", + "value": stop_outgoing_emails(), + "validators": [default(DEF_STOP_OUTGOING), boolean_validator], # type: ignore + "type": "select", + "options": [{"value": 1, "text": "Yes"}, {"value": 0, "text": "No"}], + }, + "mail_per_page": { + "key": CONF_MAIL_PER_PAGE, + "label": "Number of emails per page", + "value": get_mail_per_page(), + "validators": [default(DEF_MAIL_PER_PAGE), int_validator], # type: ignore + "type": "number", + }, + "redirect_to": { + "key": CONF_REDIRECT_EMAILS_TO, + "label": "Redirect outgoing emails to", + "value": get_redirect_email(), + "validators": [unicode_safe, email_validator], + "type": "text", + }, + } diff --git a/ckanext/mailcraft/mailer.py b/ckanext/mailcraft/mailer.py index eae267b..188c329 100644 --- a/ckanext/mailcraft/mailer.py +++ b/ckanext/mailcraft/mailer.py @@ -1,8 +1,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -import codecs -import os import logging import mimetypes import smtplib @@ -27,9 +25,8 @@ log = logging.getLogger(__name__) -class Mailer(ABC): +class BaseMailer(ABC): def __init__(self): - # TODO: replace with ext config, instead of using core ones self.server = tk.config["smtp.server"] self.start_tls = tk.config["smtp.starttls"] self.user = tk.config["smtp.user"] @@ -78,32 +75,8 @@ def mail_user( ) -> None: pass - @abstractmethod - def send_reset_link(self, user: model.User) -> None: - pass - - @abstractmethod - def create_reset_key(self, user: model.User) -> None: - pass - - @abstractmethod - def verify_reset_link(self, user: model.User, key: Optional[str]) -> bool: - pass - - def save_to_dashboard( - self, - msg: EmailMessage, - body_html: str, - state: str = mc_model.Email.State.success, - extras: Optional[dict[str, Any]] = None, - ) -> None: - if not mc_config.is_save_to_dashboard_enabled(): - return - mc_model.Email.save_mail(msg, body_html, state, extras or {}) - - -class DefaultMailer(Mailer): +class DefaultMailer(BaseMailer): def mail_recipients( self, subject: str, @@ -141,19 +114,20 @@ def mail_recipients( self.add_attachments(msg, attachments) try: + # print(msg.get_body(("html",)).get_content()) # type: ignore if mc_config.stop_outgoing_emails(): - self.save_to_dashboard( + self._save_email( msg, body_html, mc_model.Email.State.stopped, dict(msg.items()) ) else: self._send_email(recipients, msg) except MailerException: - self.save_to_dashboard( + self._save_email( msg, body_html, mc_model.Email.State.failed, dict(msg.items()) ) else: if not mc_config.stop_outgoing_emails(): - self.save_to_dashboard(msg, body_html) + self._save_email(msg, body_html) def add_attachments(self, msg: EmailMessage, attachments) -> None: """Add attachments on an email message @@ -210,6 +184,15 @@ def get_connection(self) -> smtplib.SMTP: return conn + def _save_email( + self, + msg: EmailMessage, + body_html: str, + state: str = mc_model.Email.State.success, + extras: Optional[dict[str, Any]] = None, + ) -> None: + mc_model.Email.save_mail(msg, body_html, state, extras or {}) + def _send_email(self, recipients, msg: EmailMessage): conn = self.get_connection() @@ -253,46 +236,3 @@ def mail_user( headers=headers, attachments=attachments, ) - - def send_reset_link(self, user: model.User) -> None: - self.create_reset_key(user) - - body = self._get_reset_link_body(user) - body_html = self._get_reset_link_body(user, html=True) - - # Make sure we only use the first line - subject = tk.render( - "mailcraft/emails/reset_password/subject.txt", - {"site_title": self.site_title}, - ).split("\n")[0] - - self.mail_user(user.name, subject, body, body_html=body_html) - - def create_reset_key(self, user: model.User): - user.reset_key = codecs.encode(os.urandom(16), "hex").decode() - model.repo.commit_and_remove() - - def _get_reset_link_body(self, user: model.User, html: bool = False) -> str: - extra_vars = { - "reset_link": tk.url_for( - "user.perform_reset", id=user.id, key=user.reset_key, qualified=True - ), - "site_title": self.site_title, - "site_url": self.site_url, - "user_name": user.name, - } - - return tk.render( - ( - "mailcraft/emails/reset_password/body.html" - if html - else "mailcraft/emails/reset_password/body.txt" - ), - extra_vars, - ) - - def verify_reset_link(self, user: model.User, key: Optional[str]) -> bool: - if not key or not user.reset_key or len(user.reset_key) < 5: - return False - - return key.strip() == user.reset_key diff --git a/ckanext/mailcraft/plugin.py b/ckanext/mailcraft/plugin.py index 620748e..63489d1 100644 --- a/ckanext/mailcraft/plugin.py +++ b/ckanext/mailcraft/plugin.py @@ -1,31 +1,24 @@ from __future__ import annotations -from typing import Union - -from flask import Blueprint - import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit from ckan.common import CKANConfig -if plugins.plugin_loaded("admin_panel"): - import ckanext.admin_panel.types as ap_types - from ckanext.admin_panel.interfaces import IAdminPanel +import ckanext.ap_main.types as ap_types +from ckanext.ap_main.interfaces import IAdminPanel import ckanext.mailcraft.config as mc_config from ckanext.mailcraft.mailer import DefaultMailer +@toolkit.blanket.blueprints @toolkit.blanket.actions @toolkit.blanket.auth_functions @toolkit.blanket.validators class MailcraftPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IConfigurable) - - if plugins.plugin_loaded("admin_panel"): - plugins.implements(plugins.IBlueprint) - plugins.implements(IAdminPanel, inherit=True) + plugins.implements(IAdminPanel, inherit=True) # IConfigurer @@ -34,6 +27,12 @@ def update_config(self, config_): toolkit.add_public_directory(config_, "public") toolkit.add_resource("assets", "mailcraft") + def update_config_schema(self, schema): + for _, config in mc_config.get_config_options().items(): + schema.update({config["key"]: config["validators"]}) + + return schema + # IConfigurable def configure(self, config: CKANConfig) -> None: @@ -41,13 +40,6 @@ def configure(self, config: CKANConfig) -> None: mailer = DefaultMailer() mailer.test_conn() - # IBlueprint - - def get_blueprint(self) -> Union[list[Blueprint], Blueprint]: - from ckanext.mailcraft.views import get_blueprints - - return get_blueprints() - # IAdminPanel def register_config_sections( diff --git a/ckanext/mailcraft/templates/mailcraft/config.html b/ckanext/mailcraft/templates/mailcraft/config.html new file mode 100644 index 0000000..3106781 --- /dev/null +++ b/ckanext/mailcraft/templates/mailcraft/config.html @@ -0,0 +1,26 @@ +{% extends 'admin_panel/base.html' %} + +{% import 'macros/autoform.html' as autoform %} +{% import 'macros/form.html' as form %} + +{% block breadcrumb_content %} +
  • {% link_for _("Global settings"), named_route='mailcraft.config' %}
  • +{% endblock breadcrumb_content %} + +{% block ap_content %} +

    {{ _("Mailcraft configuration") }}

    + +
    + {% for _, config in configs.items() %} + {% if config.type == "select" %} + {{ form.select(config.key, label=config.label, options=config.options, selected=data[config.key] | o if data else config.value, error=errors[config.key]) }} + {% elif config.type in ("text", "number") %} + {{ form.input(config.key, label=config.label, value=data[config.key] if data else config.value, error=errors[config.key], type=config.type) }} + {% else %} + {{ form.textarea(config.key, label=config.label, value=data[config.key] if data else config.value, error=errors[config.key]) }} + {% endif %} + {% endfor %} + + +
    +{% endblock ap_content %} diff --git a/ckanext/mailcraft/templates/mailcraft/dashboard.html b/ckanext/mailcraft/templates/mailcraft/dashboard.html index c0c4578..28f7df2 100644 --- a/ckanext/mailcraft/templates/mailcraft/dashboard.html +++ b/ckanext/mailcraft/templates/mailcraft/dashboard.html @@ -10,6 +10,8 @@ {% block ap_content %}

    {{ _("Dashboard") }}

    + {{ _("Send test email") }} +
    {{ h.csrf_input() }} diff --git a/ckanext/mailcraft/templates/mailcraft/mail_read.html b/ckanext/mailcraft/templates/mailcraft/mail_read.html index a043fde..c1189ac 100644 --- a/ckanext/mailcraft/templates/mailcraft/mail_read.html +++ b/ckanext/mailcraft/templates/mailcraft/mail_read.html @@ -12,7 +12,7 @@ {% block ap_content %} {% if mail.extras %}
    -

    {{ _("Email headers") }}

    +

    {{ _("Email meta") }}

      {% for key, value in mail.extras.items() %}
    • {{ key }}: {{ value }}
    • diff --git a/ckanext/mailcraft/tests/test_mailer.py b/ckanext/mailcraft/tests/test_mailer.py index 9397895..505f5af 100644 --- a/ckanext/mailcraft/tests/test_mailer.py +++ b/ckanext/mailcraft/tests/test_mailer.py @@ -16,7 +16,7 @@ class MailerBase(object): - def mime_encode(self, msg, recipient_name, subtype="plain"): + def mime_encode(self, msg, recipient_name, subtype='plain'): text = MIMEText(msg.encode("utf-8"), subtype, "utf-8") encoded_body = text.get_payload().strip() return encoded_body @@ -63,7 +63,7 @@ def test_mail_recipient(self, mail_server): ) assert expected_body in msg[3] - @pytest.mark.ckan_config("ckan.hide_version", True) + @pytest.mark.ckan_config('ckan.hide_version', True) def test_mail_recipient_hiding_mailer(self, mail_server): user = factories.User() @@ -89,8 +89,9 @@ def test_mail_recipient_hiding_mailer(self, mail_server): assert list(test_email["headers"].keys())[0] in msg[3], msg[3] assert list(test_email["headers"].values())[0] in msg[3], msg[3] assert test_email["subject"] in msg[3], msg[3] - assert msg[3].startswith("Content-Type: text/plain"), msg[3] - assert "X-Mailer" not in msg[3], "Should have skipped X-Mailer header" + assert msg[3].startswith('Content-Type: text/plain'), msg[3] + assert "X-Mailer" not in msg[3], \ + "Should have skipped X-Mailer header" expected_body = self.mime_encode( test_email["body"], test_email["recipient_name"] ) @@ -108,7 +109,7 @@ def test_mail_recipient_with_html(self, mail_server): "recipient_email": user["email"], "subject": "Meeting", "body": "The meeting is cancelled.\n", - "body_html": 'The meeting is cancelled.\n', + "body_html": "The meeting is cancelled.\n", "headers": {"header1": "value1"}, } mailer.mail_recipient(**test_email) @@ -122,17 +123,20 @@ def test_mail_recipient_with_html(self, mail_server): assert list(test_email["headers"].keys())[0] in msg[3], msg[3] assert list(test_email["headers"].values())[0] in msg[3], msg[3] assert test_email["subject"] in msg[3], msg[3] - assert "Content-Type: multipart" in msg[3] + assert 'Content-Type: multipart' in msg[3] expected_plain_body = self.mime_encode( - test_email["body"], test_email["recipient_name"], subtype="plain" + test_email["body"], test_email["recipient_name"], + subtype='plain' ) assert expected_plain_body in msg[3] expected_html_body = self.mime_encode( - test_email["body_html"], test_email["recipient_name"], subtype="html" + test_email["body_html"], test_email["recipient_name"], + subtype='html' ) assert expected_html_body in msg[3] def test_mail_user(self, mail_server): + user = factories.User() user_obj = model.User.by_name(user["name"]) @@ -178,6 +182,7 @@ def test_mail_user_without_email(self): @pytest.mark.ckan_config("ckan.site_title", "My CKAN instance") def test_from_field_format(self, mail_server): + msgs = mail_server.get_smtp_messages() assert msgs == [] @@ -195,9 +200,10 @@ def test_from_field_format(self, mail_server): msgs = mail_server.get_smtp_messages() msg = msgs[0] - expected_from_header = email.utils.formataddr( - (config.get("ckan.site_title"), config.get("smtp.mail_from")) - ) + expected_from_header = email.utils.formataddr(( + config.get("ckan.site_title"), + config.get("smtp.mail_from") + )) assert expected_from_header in msg[3] @@ -216,7 +222,7 @@ def test_send_reset_email(self, mail_server): assert msg[2] == [user["email"]] assert "Reset" in msg[3], msg[3] test_msg = mailer.get_reset_link_body(user_obj) - expected_body = self.mime_encode(test_msg + "\n", user["name"]) + expected_body = self.mime_encode(test_msg + '\n', user["name"]) assert expected_body in msg[3] @@ -236,7 +242,7 @@ def test_send_invite_email(self, mail_server): assert msg[1] == config["smtp.mail_from"] assert msg[2] == [user["email"]] test_msg = mailer.get_invite_body(user_obj) - expected_body = self.mime_encode(test_msg + "\n", user["name"]) + expected_body = self.mime_encode(test_msg + '\n', user["name"]) assert expected_body in msg[3] assert user_obj.reset_key is not None, user @@ -291,6 +297,7 @@ def test_bad_smtp_host(self): @pytest.mark.ckan_config("smtp.reply_to", "norply@ckan.org") def test_reply_to(self, mail_server): + msgs = mail_server.get_smtp_messages() assert msgs == [] @@ -308,12 +315,15 @@ def test_reply_to(self, mail_server): msgs = mail_server.get_smtp_messages() msg = msgs[0] - expected_from_header = "Reply-to: {}".format(config.get("smtp.reply_to")) + expected_from_header = "Reply-to: {}".format( + config.get("smtp.reply_to") + ) assert expected_from_header in msg[3] @pytest.mark.ckan_config("smtp.reply_to", "norply@ckan.org") def test_reply_to_ext_headers_overwrite(self, mail_server): + msgs = mail_server.get_smtp_messages() assert msgs == [] @@ -331,11 +341,12 @@ def test_reply_to_ext_headers_overwrite(self, mail_server): msgs = mail_server.get_smtp_messages() msg = msgs[0] - expected_from_header = "Reply-to: norply@ckanext.org" + expected_from_header = 'Reply-to: norply@ckanext.org' assert expected_from_header in msg[3] def test_mail_user_with_attachments(self, mail_server): + user = factories.User() user_obj = model.User.by_name(user["name"]) @@ -349,9 +360,9 @@ def test_mail_user_with_attachments(self, mail_server): "body": "The meeting is cancelled.\n", "headers": {"header1": "value1"}, "attachments": [ - ("strategy.pdf", io.BytesIO(b"Some fake pdf"), "application/pdf"), - ("goals.png", io.BytesIO(b"Some fake png"), "image/png"), - ], + ("strategy.pdf", io.BytesIO(b'Some fake pdf'), 'application/pdf'), + ("goals.png", io.BytesIO(b'Some fake png'), 'image/png'), + ] } mailer.mail_user(**test_email) @@ -366,16 +377,13 @@ def test_mail_user_with_attachments(self, mail_server): assert test_email["subject"] in msg[3], msg[3] for item in [ - "strategy.pdf", - base64.b64encode(b"Some fake pdf").decode(), - "application/pdf", - "goals.png", - base64.b64encode(b"Some fake png").decode(), - "image/png", + "strategy.pdf", base64.b64encode(b'Some fake pdf').decode(), "application/pdf", + "goals.png", base64.b64encode(b'Some fake png').decode(), "image/png", ]: assert item in msg[3] def test_mail_user_with_attachments_no_media_type_provided(self, mail_server): + user = factories.User() user_obj = model.User.by_name(user["name"]) @@ -389,9 +397,9 @@ def test_mail_user_with_attachments_no_media_type_provided(self, mail_server): "body": "The meeting is cancelled.\n", "headers": {"header1": "value1"}, "attachments": [ - ("strategy.pdf", io.BytesIO(b"Some fake pdf")), - ("goals.png", io.BytesIO(b"Some fake png")), - ], + ("strategy.pdf", io.BytesIO(b'Some fake pdf')), + ("goals.png", io.BytesIO(b'Some fake png')), + ] } mailer.mail_user(**test_email) @@ -401,9 +409,7 @@ def test_mail_user_with_attachments_no_media_type_provided(self, mail_server): msg = msgs[0] for item in [ - "strategy.pdf", - "application/pdf", - "goals.png", - "image/png", + "strategy.pdf", "application/pdf", + "goals.png", "image/png", ]: assert item in msg[3] diff --git a/ckanext/mailcraft/views.py b/ckanext/mailcraft/views.py index 4ee3724..98293a5 100644 --- a/ckanext/mailcraft/views.py +++ b/ckanext/mailcraft/views.py @@ -1,19 +1,20 @@ from __future__ import annotations -from typing import Any, Callable, cast +from typing import Any, Callable + +from flask import Blueprint, Response +from flask.views import MethodView import ckan.plugins.toolkit as tk import ckan.types as types from ckan.lib.helpers import Page -from flask import Blueprint, Response -from flask.views import MethodView +from ckan.logic import parse_params -from ckanext.admin_panel.utils import ap_before_request +from ckanext.ap_main.utils import ap_before_request import ckanext.mailcraft.config as mc_config -import ckanext.mailcraft.model as mc_model -mailcraft = Blueprint("mailcraft", __name__, url_prefix="/mailcraft") +mailcraft = Blueprint("mailcraft", __name__, url_prefix="/admin-panel/mailcraft") mailcraft.before_request(ap_before_request) @@ -57,8 +58,8 @@ def _get_table_columns(self) -> list[dict[str, Any]]: actions=[ tk.h.ap_table_action( "mailcraft.mail_read", - tk._("View"), - {"mail_id": "$id"}, + label=tk._("View"), + params={"mail_id": "$id"}, attributes={"class": "btn btn-primary"}, ) ], @@ -90,7 +91,7 @@ def _remove_emails(self, mail_ids: list[str]) -> bool: def post(self) -> Response: if "clear_mails" in tk.request.form: - mc_model.Email.clear_emails() + tk.get_action("mc_mail_clear")({"ignore_auth": True}, {}) tk.h.flash_success(tk._("Mails have been cleared.")) return tk.redirect_to("mailcraft.dashboard") @@ -115,10 +116,36 @@ def post(self) -> Response: class ConfigView(MethodView): def get(self) -> str: - return tk.render("mailcraft/dashboard.html") + return tk.render( + "mailcraft/config.html", + extra_vars={ + "data": {}, + "errors": {}, + "configs": mc_config.get_config_options(), + }, + ) def post(self) -> str: - return tk.render("mailcraft/dashboard.html") + data_dict = parse_params(tk.request.form) + + try: + tk.get_action("config_option_update")( + {"user": tk.current_user.name}, + data_dict, + ) + except tk.ValidationError as e: + return tk.render( + "mailcraft/config.html", + extra_vars={ + "data": data_dict, + "errors": e.error_dict, + "error_summary": e.error_summary, + "configs": mc_config.get_config_options(), + }, + ) + + tk.h.flash_success(tk._("Config options have been updated")) + return tk.h.redirect_to("mailcraft.config") class MailReadView(MethodView): @@ -132,15 +159,31 @@ def get(self, mail_id: str) -> str: def _build_context() -> types.Context: - return cast( - types.Context, - { - "user": tk.current_user.name, - "auth_user_obj": tk.current_user, - }, + return { + "user": tk.current_user.name, + "auth_user_obj": tk.current_user, + } + + +def send_test_email() -> Response: + from ckanext.mailcraft.mailer import DefaultMailer + + mailer = DefaultMailer() + mailer.mail_recipients( + subject="Hello world", + recipients=["kvaqich@gmail.com"], + body="Hello world", + body_html=tk.render("mailcraft/emails/test.html", extra_vars={ + "site_url": mailer.site_url, + "site_title": mailer.site_title + }), ) + tk.h.flash_success(tk._("Test email has been sent")) + + return tk.redirect_to("mailcraft.dashboard") +mailcraft.add_url_rule("/test", endpoint="test", view_func=send_test_email) mailcraft.add_url_rule("/config", view_func=ConfigView.as_view("config")) mailcraft.add_url_rule("/dashboard", view_func=DashboardView.as_view("dashboard")) mailcraft.add_url_rule(