From cafb8ff1feae926802c78fdbcaefe716e7c177be Mon Sep 17 00:00:00 2001 From: Stepan Henek Date: Tue, 14 Nov 2023 09:32:27 +0100 Subject: [PATCH] feature: basic stuff implemented - topic and attachments --- .gitignore | 6 +++ README.md | 3 ++ django_ntfy/__init__.py | 75 ++++++++++++++++++++++++++++++++ pyproject.toml | 52 ++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 23 ++++++++++ tests/settings.py | 5 +++ tests/test_ntfy_backend.py | 89 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 253 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 django_ntfy/__init__.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/settings.py create mode 100644 tests/test_ntfy_backend.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f144be --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.cache +.coverage +/.pytest_cache +*.pyc +.mypy_cache/ +poetry.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..671aece --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# django-ntfy + +Django's email backend which is used to send messages to ntfy.sh (or self hostated instace) instead of actually sending an email. diff --git a/django_ntfy/__init__.py b/django_ntfy/__init__.py new file mode 100644 index 0000000..c8dbe62 --- /dev/null +++ b/django_ntfy/__init__.py @@ -0,0 +1,75 @@ +import typing + +import requests +from django import dispatch +from django.conf import settings +from django.core.mail import EmailMessage +from django.core.mail.backends.base import BaseEmailBackend + +topic_signal = dispatch.Signal() +# TODO ntfy_icon_signal = dispatch.Signal() +# TODO ntfy_actions_signal = dispatch.Signal() +# TODO ntfy_severity_signal = dispatch.Signal() +# TODO ntfy_tags_signal = dispatch.Signal() +# TODO ntfy_priority_signal = dispatch.Signal() +# TODO ntfy_click_signal = dispatch.Signal() +# TODO ntfy_icon_signal = dispatch.Signal() + + +def get_from_signal(signal: dispatch.Signal, message: EmailMessage, default): + responses = signal.send(message) + return responses[0][1] if responses else default + + +class NtfyBackend(BaseEmailBackend): + def send_ntfy_message( + self, + title: str, + message: str, + topic: str, + ) -> requests.Response: + # TODO ntfy authentication + url = settings.NTFY_BASE_URL + + resp = requests.post( + url, + json={ + "topic": topic, + "title": title, + "message": message, + }, + ) + return resp + + def send_ntfy_file( + self, + title: str, + data, + filename: str, + topic: str, + ): + # TODO ntfy authentication + url = f"{settings.NTFY_BASE_URL}/{topic}" + + requests.put( + url, + data=data, + headers={"Filename": filename}, + ) + + def send_messages(self, email_messages: typing.List[EmailMessage]): + count = 0 + for message in email_messages: + topic = get_from_signal(topic_signal, message, settings.NTFY_DEFAULT_TOPIC) + + # Send message + resp = self.send_ntfy_message(message.subject, message.body, topic) + + # Send attachments + if getattr(settings, 'NTFY_SEND_ATTACHMENTS', False): + for filename, content, mimetype in message.attachments: + self.send_ntfy_file(message.subject, content, filename, topic) + + count += 1 if resp.status_code / 100 == 2 else 0 + + return count diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a0c343 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 100 +skip-string-normalization = true +target-version = ["py38"] + +[tool.ruff] +line-length = 100 +select = [ + # Pyflakes + "F", + # pycodestyle + "E", + # isort + "I", +] +src = ["django_ntfy", "tests"] + +[tool.ruff.isort] +known-first-party = ["django_ntfy"] + +[tool.pytest.ini_options] +testpaths = "tests/" +DJANGO_SETTINGS_MODULE = "tests.settings" + +[tool.poetry] +name = "django-ntfy" +version = "0.1.0" +description = "Django's email backend which is used to send messages to ntfy.sh" +authors = ["Stepan Henek "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.8" + +Django = ">=4.2.0" +requests = "^2.31" + +[tool.poetry.dev-dependencies] +black = "23.11.0" +build = "~1.0.3" +mypy = "^1.7" +pre-commit = "~3.5.0" +pytest = "~7.4.3" +pytest-cov = "~4.1.0" +pytest-django = "~4.7.0" +responses = ">=0.24.0" +ruff = "~0.1.5" +types-requests = "*" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..37f5b90 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +import pytest + + +@pytest.fixture +def use_ntfy_email_backend(settings): + # we need to explicitly override it here + settings.EMAIL_BACKEND = 'django_ntfy.NtfyBackend' + + +@pytest.fixture +def topic_signal(): + topic = "altered-topic" + + def handler(*args, **kwargs): + return topic + + from django_ntfy import topic_signal + + topic_signal.connect(handler, dispatch_uid="test_topic") + + yield topic + + topic_signal.disconnect(dispatch_uid="test_topic") diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..577394d --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,5 @@ +# NTFY +NTFY_BASE_URL = "https://example.com/" +NTFY_DEFAULT_TOPIC = "django-ntfy" + +NTFY_SEND_ATTACHMENTS = False diff --git a/tests/test_ntfy_backend.py b/tests/test_ntfy_backend.py new file mode 100644 index 0000000..e5bf95b --- /dev/null +++ b/tests/test_ntfy_backend.py @@ -0,0 +1,89 @@ +import responses +from django.core import mail +from responses import matchers + + +def test_basic(settings, use_ntfy_email_backend): + with responses.RequestsMock() as rsps: + rsps.post( + settings.NTFY_BASE_URL, + status=200, + match=[ + matchers.json_params_matcher( + { + "message": "Body", + "title": "Sub", + "topic": "django-ntfy", # Default topic from settings + } + ), + ], + ) + assert mail.send_mail("Sub", "Body", "from@example.com", ["to@example.com"]) == 1 + + +def test_custom_topic(settings, use_ntfy_email_backend, topic_signal): + with responses.RequestsMock() as rsps: + rsps.post( + settings.NTFY_BASE_URL, + status=200, + match=[ + matchers.json_params_matcher( + { + "message": "Body", + "title": "Sub", + "topic": "altered-topic", # Default topic from settings + } + ), + ], + ) + assert mail.send_mail("Sub", "Body", "from@example.com", ["to@example.com"]) == 1 + + +def test_attachments(settings, use_ntfy_email_backend): + settings.NTFY_SEND_ATTACHMENTS = True + + with mail.get_connection() as connection, responses.RequestsMock() as rsps: + rsps.post( + settings.NTFY_BASE_URL, + status=200, + match=[ + matchers.json_params_matcher( + { + "message": "Body1", + "title": "Subject1", + "topic": "django-ntfy", # Default topic from settings + } + ), + ], + ) + + rsps.put( + f"{settings.NTFY_BASE_URL}/django-ntfy", + status=200, + match=[ + matchers.header_matcher( + {"Filename": "File1.txt"}, + ) + ], + ) + + rsps.put( + f"{settings.NTFY_BASE_URL}/django-ntfy", + status=200, + match=[ + matchers.header_matcher( + {"Filename": "File2.txt"}, + ) + ], + ) + + message = mail.EmailMessage( + subject="Subject1", + body="Body1", + from_email="from@example.com", + to=["to@example.com"], + connection=connection, + ) + message.attach("File1.txt", "Content1", "text/plain") + message.attach("File2.txt", "Content1", "text/plain") + message.send()