Repository: YuriyLisovskiy/pynotifier (language: python)

Issue:
FEATURE: Email backend



Discussion / hints:

Fix patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
deleted file mode 100644
index adafdde..0000000
--- a/.bumpversion.cfg
+++ /dev/null
@@ -1,9 +0,0 @@
-[bumpversion]
-current_version = 0.3.2
-commit = True
-tag = False
-parse = (?P<major>\d+).(?P<minor>\d+).(?P<patch>\d+)
-serialize = {major}.{minor}.{patch}
-message = Update version: {current_version} -> {new_version}
-
-[bumpversion:file:VERSION]
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..49c031f
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[run]
+omit =
+   venv/*
+   */__init__.py
+   tests/*
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dc92d80
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,71 @@
+name: "CI"
+on:
+  push:
+    branches:
+      - '*'
+      - '*/*'
+      - '**'
+      - '!master'
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+jobs:
+  test:
+    name: "Tests ${{ matrix.python-version }} on ${{ matrix.platform }}"
+    runs-on: ${{ matrix.platform }}
+    strategy:
+      matrix:
+        platform:
+          - ubuntu-20.04
+          - ubuntu-22.04
+          - macos-11
+          - macos-12
+          - windows-2019
+          - windows-2022
+        python-version:
+          - 3.6
+          - 3.7
+          - 3.8
+          - 3.9
+        exclude:
+          - platform: ubuntu-22.04
+            python-version: 3.6
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "${{ matrix.python-version }}"
+      - name: "Install dependencies"
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+      - name: "Run tests with Python ${{ matrix.python-version }} on ${{ matrix.platform }}"
+        run: |
+          python -m unittest discover tests/
+  coverage:
+    name: "Coverage Report"
+    if: github.ref == 'refs/heads/dev'
+    runs-on: ubuntu-latest
+    needs: test
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "3.9"
+      - name: "Install dependencies"
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install -r requirements.dev.txt
+      - name: "Run tests with coverage report"
+        run: |
+          coverage run -m unittest discover tests/
+          coverage xml
+      - name: "Upload coverage to Codecov"
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          files: ./coverage.xml
+          flags: pynotifier_lib
+          name: pynotifier_lib
+          fail_ci_if_error: true
+          verbose: true
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..939bf2c
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,27 @@
+name: "Deploy"
+on:
+  push:
+    branches:
+      - master
+jobs:
+  deploy:
+    name: "Deploy to PyPi"
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: "Add new version tag"
+        run: |
+          git tag $(cat VERSION)
+          git push --tags
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "3.6"
+      - name: "Deploy using twine"
+        env:
+          TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+          TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+        run: |
+          python3 -m pip install --upgrade setuptools wheel
+          python3 setup.py sdist bdist_wheel
+          python3 -m pip install --upgrade twine
+          twine upload --non-interactive --verbose dist/*
diff --git a/.gitignore b/.gitignore
index 3f73c73..2a294ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,6 +105,7 @@ venv.bak/
 
 # misc
 .idea/
+main.py
 
 # test reports
-reports/
\ No newline at end of file
+reports/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 4c37360..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
----
-repos:
-  - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v3.4.0
-    hooks:
-      - id: trailing-whitespace
-      - id: mixed-line-ending
-      - id: fix-byte-order-marker
-      - id: check-merge-conflict
-  - repo: https://gitlab.com/pycqa/flake8
-    rev: 3.9.0
-    hooks:
-      - id: flake8
-        additional_dependencies: [flake8-black,flake8-isort,flake8-docstrings,flake8-mypy,flake8-bandit,flake8-bugbear]
-  - repo: https://github.com/ambv/black
-    rev: 20.8b1
-    hooks:
-      - id: black
-  - repo: https://github.com/pre-commit/mirrors-isort
-    rev: v5.8.0
-    hooks:
-      - id: isort
diff --git a/LICENSE b/LICENSE
index 5e9bc70..fd816ef 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2019-2021 Yuriy Lisovskiy
+Copyright (c) 2019-2023 Yuriy Lisovskiy
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
index 817d53d..412d117 100644
--- a/Makefile
+++ b/Makefile
@@ -1,31 +1,6 @@
-VENV = .venv/bin
-
 .DEFAULT_GOAL := help
 
-.PHONY: clean help
-clean: ## Remove Python file artifacts and virtualenv
-	@echo "+ $@"
-	@rm -rf .venv
-
-${VENV}/python:
-	@echo "+ $@"
-	python -m venv .venv
-
-venv: ${VENV}/python ## Creates the virtualenv and installs tox
-	@echo "+ $@"
-	$(VENV)/pip install tox
-
-test: venv ## Run tests
-	@echo "+ $@"
-	PLATFORM=$$(python -c 'import sys; print(sys.platform)') $(VENV)/tox -e py
-
-lint: venv ## Lint source
-	@echo "+ $@"
-	$(VENV)/tox -e lint
-
-ci: venv ## Lint and Test
-	@echo "+ $@"
-	PLATFORM=$$(python -c 'import sys; print(sys.platform)') $(VENV)/tox -e lint,py
+.PHONY: bump-version-major bump-version-minor bump-version-patch help
 
 bump-version-major:
 	bump2version major
diff --git a/README.md b/README.md
index 5ce3436..96b3e67 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,53 @@
-## Desktop Notifications
+## Python Notifications
 
-[![pypi version](https://img.shields.io/pypi/v/py-notifier.svg)](https://pypi.python.org/pypi/py-notifier)
-[![# of downloads](https://img.shields.io/pypi/dm/py-notifier.svg)](https://pypi.python.org/pypi/py-notifier)
-[![Supported Python versions](https://img.shields.io/pypi/pyversions/py-notifier.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/py-notifier)
+[![Github Actions - CI](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/ci.yml/badge.svg)](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/ci.yml)
+[![Github Actions - Deploy](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/deploy.yml/badge.svg)](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/deploy.yml)
+[![PyPi Version](https://img.shields.io/pypi/v/py-notifier.svg)](https://pypi.python.org/pypi/py-notifier)
+[![# of Downloads](https://img.shields.io/pypi/dm/py-notifier.svg)](https://pypi.python.org/pypi/py-notifier)
+[![Supported Python Versions](https://img.shields.io/pypi/pyversions/py-notifier.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/py-notifier)
 [![License](https://img.shields.io/github/license/YuriyLisovskiy/pynotifier.svg)](LICENSE)
-[![Github Actions](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/tests.yml/badge.svg)](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/tests.yml)
 
-Simple Python3 module for displaying desktop notifications on Windows, Linux and MacOS.
+Python3 module for sending notifications.
 
-### Installation
-```bash
-$ pip install py-notifier
-```
+The list of available backends:
+* Platform (`pynotifier.backends.platform.Backend`):
+  * macOS
+  * Linux
+  * Windows
+* Email (`pynotifier.backends.smtp.Backend`)
 
-### Requirements
+### Platform notifications requirements
 #### Windows:
-[`WinToaster`](https://https://github.com/MaliciousFiles/WinToaster) - Python module
+[`WinToaster`](https://github.com/MaliciousFiles/WinToaster) - Python module
 #### Linux:
 `libnotify-bin` CLI tool (manual installation is required). For Ubuntu run:
 ```bash
 sudo apt-get install libnotify-bin
 ```
-#### MacOS:
-[`pync`](https://github.com/SeTeM/pync) - Python module
+
+### Installation
+```bash
+pip install py-notifier
+```
 
 ### Example
 ```python
-from pynotifier import Notification
-
-Notification(
-	title='Notification Title',
-	description='Notification Description',
-	icon_path='/absolute/path/to/image/icon.png', # On Windows .ico is required, on Linux - .png
-	duration=5,                                   # Duration in seconds
-	urgency='normal'
-).send()
-```
+from pynotifier import NotificationClient, Notification
+from pynotifier.backends import platform, smtp
 
-### Development
+c = NotificationClient()
 
-[![Tests](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/tests.yml/badge.svg)](https://github.com/YuriyLisovskiy/pynotifier/actions/workflows/tests.yml)
+c.register_backend(platform.Backend())
+c.register_backend(smtp.Backend(server=smtp.DSMTP_SSL('smtp.gmail.com'),
+                                email='sender@email.com',
+                                password='super_password'))
 
-```bash
-$ make
-ci               Lint and Test
-clean            Remove Python file artifacts and virtualenv
-lint             Lint source
-test             Run tests
-venv             Creates the virtualenv and installs tox
+notification = Notification(title='Hello',
+                            message='World',
+                            emails=['receiver_1@email.com', 'receiver_2@email.com'])
+c.notify_all(notification)
 ```
 
-### Author
-* [Yuriy Lisovskiy](https://github.com/YuriyLisovskiy)
-
 ### License
 The project is licensed under the terms of the [MIT License](https://opensource.org/licenses/mit),
 see the [LICENSE](LICENSE) file for more information.
diff --git a/VERSION b/VERSION
index 9fc80f9..60a2d3e 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-0.3.2
\ No newline at end of file
+0.4.0
\ No newline at end of file
diff --git a/deploy.sh b/deploy.sh
deleted file mode 100755
index 1d4cde2..0000000
--- a/deploy.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env bash
-
-python3 -m pip install --upgrade setuptools wheel
-python3 setup.py sdist bdist_wheel
-python3 -m pip install --upgrade twine
-twine upload dist/*
diff --git a/pynotifier/__init__.py b/pynotifier/__init__.py
index f6ea181..18b5d7a 100644
--- a/pynotifier/__init__.py
+++ b/pynotifier/__init__.py
@@ -1,4 +1,15 @@
-"""Display desktop notifications on Windows, Linux and MacOS."""
-from .pynotifier import Notification
+"""
+Send notifications using backends.
+Supported backends can be found in 'pynotifier.backends' module.
 
-__all__ = ["Notification"]
+This file is a part of py-notifier project.
+
+Copyright (c) 2018, 2021-2022 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+from .client import NotificationClient
+from .notification import Notification
+
+__all__ = ["NotificationClient", "Notification"]
diff --git a/pynotifier/backends/__init__.py b/pynotifier/backends/__init__.py
new file mode 100644
index 0000000..6caa8df
--- /dev/null
+++ b/pynotifier/backends/__init__.py
@@ -0,0 +1,12 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022-2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+from pynotifier.backends.backend import NotificationBackend
+from pynotifier.backends.smtp import Backend as SMTPBackend
+
+__all__ = ['NotificationBackend', 'SMTPBackend']
diff --git a/pynotifier/backends/backend.py b/pynotifier/backends/backend.py
new file mode 100644
index 0000000..2ca7f17
--- /dev/null
+++ b/pynotifier/backends/backend.py
@@ -0,0 +1,36 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022-2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+from pynotifier.notification import Notification
+
+
+class NotificationBackend:
+	"""
+	Base class for notification backends.
+
+	Child classes can provide some pre- or post-processing
+	of the notification it sends.
+	"""
+
+	IDENTIFIER = None
+
+	def notify(self, notification: Notification):
+		"""Send the notification using config from Notification object."""
+		raise NotImplemented
+
+	@property
+	def identifier(self):
+		"""
+		Backend identifier.
+
+		Usually it has package notation, i.e. pynotifier.backends.my_backend.
+		"""
+		if self.IDENTIFIER is None:
+			raise NotImplemented
+
+		return self.IDENTIFIER
diff --git a/pynotifier/backends/platform/__init__.py b/pynotifier/backends/platform/__init__.py
new file mode 100644
index 0000000..fa0644b
--- /dev/null
+++ b/pynotifier/backends/platform/__init__.py
@@ -0,0 +1,14 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+import platform
+from importlib import import_module
+
+module = import_module('pynotifier.backends.platform.{}'.format(platform.system().lower()))
+
+Backend = module.Backend
diff --git a/pynotifier/backends/platform/darwin.py b/pynotifier/backends/platform/darwin.py
new file mode 100644
index 0000000..30dbddb
--- /dev/null
+++ b/pynotifier/backends/platform/darwin.py
@@ -0,0 +1,66 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022-2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+import subprocess
+from os import walk
+
+from pynotifier.notification import Notification
+from pynotifier.utils import assert_system
+
+from ..backend import NotificationBackend
+
+assert_system('darwin')
+
+
+def _load_sounds(p: str):
+	try:
+		return set(next(walk(p), (None, None, []))[2])
+	except FileNotFoundError as exc:
+		print(exc)
+		return set()
+
+
+_sounds_locations = [
+	'/System/Library/Sounds',
+	'~/Library/Sounds'
+]
+
+sounds = set()
+for sounds_location in _sounds_locations:
+	sounds = sounds.union(_load_sounds(sounds_location))
+
+sounds = tuple(sounds)
+
+
+class DarwinBackend(NotificationBackend):
+	"""
+	Desktop notification backend for macOS.
+
+	Current implementation uses AppleScript to send notifications.
+	"""
+
+	IDENTIFIER = "pynotifier.backends.platform.darwin"
+
+	def notify(self, notification: Notification):
+		"""
+		Send macOS native notification using AppleScript.
+
+		'title' - a title of notification.
+		'message' - more info about the notification.
+		'sound' - notification sound
+		"""
+		script = f'display notification "{notification.message}" with title "{notification.title}"'
+		sound = notification.config.get('sound', None)
+		if sound is not None:
+			script += f' sound name "{str(sound)}"'
+
+		command = ['osascript', '-e', script]
+		subprocess.Popen(command, shell=False)
+
+
+Backend = DarwinBackend
diff --git a/pynotifier/backends/platform/linux.py b/pynotifier/backends/platform/linux.py
new file mode 100644
index 0000000..e8971d9
--- /dev/null
+++ b/pynotifier/backends/platform/linux.py
@@ -0,0 +1,72 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022-2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+import shutil
+import subprocess
+
+from pynotifier.notification import Notification
+from pynotifier.utils import assert_system
+
+from ..backend import NotificationBackend
+
+assert_system('linux')
+
+
+class LinuxBackend(NotificationBackend):
+	"""
+	Desktop notification backend for linux.
+
+	Current implementation uses libnotify-bin tool to send notifications.
+	"""
+
+	IDENTIFIER = "pynotifier.backends.platform.linux"
+
+	def notify(self, notification: Notification):
+		"""
+		Send notification on linux with notify-send.
+
+		'title' - a title of notification
+		'message' - more info about the notification
+		'duration' - the duration, in seconds, for the notification to appear on screen
+					(Ubuntu's Notify OSD and GNOME Shell both ignore this parameter.)
+		'urgency' - the urgency level (low, normal, critical)
+		'icon_path' - an icon filename or stock icon to display
+		'app_name' - the app name for the notification
+					(Works on Arch Linux)
+		"""
+		notify_send = shutil.which("notify-send")
+		if notify_send is None:
+			raise SystemError(
+				"""Please install libnotify-bin.
+For Ubuntu run the following command in terminal:
+	apt-get install libnotify-bin"""
+			)
+
+		command = [
+			notify_send,
+			"{}".format(notification.title),
+			"{}".format(notification.message),
+			"-t",
+			"{}".format(int(notification.config.get('duration', '5')) * 1000),
+		]
+		urgency = notification.config.get('urgency', None)
+		if urgency is not None:
+			command += ["-u", urgency]
+
+		icon_path = notification.config.get('icon_path', None)
+		if icon_path is not None:
+			command += ["-i", icon_path]
+
+		app_name = notification.config.get('app_name', None)
+		if app_name is not None:
+			command += ["-a", str(app_name)]
+
+		subprocess.Popen(command, shell=False)
+
+
+Backend = LinuxBackend
diff --git a/pynotifier/backends/platform/windows.py b/pynotifier/backends/platform/windows.py
new file mode 100644
index 0000000..a8f97c7
--- /dev/null
+++ b/pynotifier/backends/platform/windows.py
@@ -0,0 +1,51 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022-2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+from pynotifier.notification import Notification
+from pynotifier.utils import assert_system
+
+from ..backend import NotificationBackend
+
+assert_system('windows')
+
+try:
+	import win_toaster
+except ImportError:
+	raise ImportError("notifications are not supported, can't import win_toaster")
+
+
+class WindowsBackend(NotificationBackend):
+	"""
+	Desktop notification backend for windows.
+
+	Current implementation uses 3rd party module - "WinToaster" to send notifications.
+	"""
+
+	IDENTIFIER = "pynotifier.backends.platform.windows"
+
+	def notify(self, notification: Notification):
+		"""
+		Send notification on windows with WinToaster.
+
+		'title' - a title of notification
+		'message' - more info about the notification
+		'duration' - the duration, in seconds, for the notification to appear on screen
+		'icon_path' - a filename of the icon to display
+		'sound' - a filename of custom sound in .wav format
+		"""
+		win_toaster.create_toast(
+			threaded=True,
+			title=notification.title,
+			msg=notification.message,
+			duration=int(notification.config.get('duration', '5')),
+			icon_path=notification.config.get('icon_path', None),
+			sound_path=notification.config.get('sound', None)
+		).display()
+
+
+Backend = WindowsBackend
diff --git a/pynotifier/backends/smtp.py b/pynotifier/backends/smtp.py
new file mode 100644
index 0000000..95755f5
--- /dev/null
+++ b/pynotifier/backends/smtp.py
@@ -0,0 +1,83 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2023 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+import smtplib, ssl
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+from pynotifier.notification import Notification
+from .backend import NotificationBackend
+
+
+class DSMTP_SSL(smtplib.SMTP_SSL):
+	"""DSMTP_SSL - SMTP protocol with default SSL context and port.
+
+	"""
+	SSL_PORT = 465
+
+	def __init__(self, host, *args, **kwargs):
+		super().__init__(host, self.SSL_PORT, context=ssl.create_default_context(), *args, **kwargs)
+
+
+# TODO: add attachments to email from notification.config!
+class Backend(NotificationBackend):
+	"""SMTP notification backend.
+	mime_type: [str], default: "plain"
+
+	Additional parameters for Notification.Config object:
+	 - emails: [str]
+
+	Note: info about how to send email from Google Mail with App Password:
+	https://levelup.gitconnected.com/an-alternative-way-to-send-emails-in-python-5630a7efbe84
+
+	"""
+	IDENTIFIER = 'pynotifier.backends.smtp'
+
+	def __init__(self,
+	             server: smtplib.SMTP,
+	             email: str,
+	             password: str,
+	             mime_type: str = 'plain',
+	             *args,
+	             **kwargs):
+		"""Initialize the SMTPBackend.
+		If the SMTP server supports authentication, the backend will log in automatically.
+
+		"""
+		super().__init__(*args, **kwargs)
+		self._server = server
+		self._email = email
+		self._password = password
+		self._mime_type = mime_type
+
+		try:
+			self._server.login(self._email, self._password)
+		except smtplib.SMTPNotSupportedError:
+			pass
+
+	def notify(self, notification: Notification):
+		"""Send the notification using config from Notification object.
+
+		"""
+		to_emails = notification.config.get('emails', '')
+		for to_email in to_emails:
+			self._send_notification(to_email, notification)
+
+	def _send_notification(self, to_email: str, notification: Notification):
+		message = self._get_message(notification, to_email)
+		self._server.sendmail(self._email, to_email, message.as_string())
+
+	def _get_message(self, notification: Notification, to_email: str):
+		message = MIMEMultipart()
+		message['Subject'] = notification.title
+		message['From'] = self._email
+		message['To'] = to_email
+
+		message.attach(MIMEText(notification.message, self._mime_type))
+
+		return message
diff --git a/pynotifier/client.py b/pynotifier/client.py
new file mode 100644
index 0000000..a2a076e
--- /dev/null
+++ b/pynotifier/client.py
@@ -0,0 +1,27 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+from .backends import NotificationBackend
+from .notification import Notification
+
+
+class NotificationClient:
+	"""Holds backends to notify and sends the notification using them."""
+
+	def __init__(self):
+		"""Initialize an empty backends list."""
+		self._backends = []
+
+	def register_backend(self, backend: NotificationBackend):
+		"""Append the provided backend."""
+		self._backends.append(backend)
+
+	def notify_all(self, notification: Notification):
+		"""Send the notification to all backends."""
+		for backend in self._backends:
+			backend.notify(notification)
diff --git a/pynotifier/notification.py b/pynotifier/notification.py
new file mode 100644
index 0000000..22d0ac3
--- /dev/null
+++ b/pynotifier/notification.py
@@ -0,0 +1,40 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2018, 2021-2022 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+
+class Notification:
+    """Holds notification properties."""
+
+    def __init__(self, *args, **kwargs):
+        """
+        Construct with notification properties.
+
+        Common arguments:
+        'title' - a title of notification.
+        'message' - more info about the notification.
+
+        Other args are backend-specific.
+        """
+        self._config = kwargs
+        if 'title' not in self._config or self._config.get('title', None) is None:
+            raise RuntimeError('notification title is required')
+
+    @property
+    def config(self):
+        """Notification properties."""
+        return self._config
+
+    @property
+    def title(self):
+        """Notification title."""
+        return self._config.get('title')
+
+    @property
+    def message(self):
+        """Notification description."""
+        return self._config.get('message', '')
diff --git a/pynotifier/pynotifier.py b/pynotifier/pynotifier.py
deleted file mode 100644
index b7ae6ae..0000000
--- a/pynotifier/pynotifier.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""
-py-notifier: Display desktop notifications on Windows, Linux and MacOS.
-
-Copyright (c) 2018, 2021 Yuriy Lisovskiy
-
-Distributed under the MIT licence,
-see the accompanying file LICENSE.
-"""
-
-import platform
-import shutil
-import subprocess
-
-
-class Notification:
-    """Display desktop notifications on Windows, Linux and MacOS."""
-
-    def __init__(
-        self,
-        title,
-        description="",
-        duration=5,
-        urgency="low",
-        icon_path=None,
-        app_name=None,
-    ):
-        """
-        Construct with notification properties.
-
-        'title' - a title of notification.
-        'description' - more info about the notification.
-        'duration' - notification timeout in seconds.
-        'urgency' - notification urgency level (ignored under Windows);
-                                possible values: 'low', 'normal', 'critical'.
-        'icon_path' - path to notification icon file.
-        'app_name' - name of app sending notification (Linux only).
-        """
-        if title is None:
-            raise ValueError("title with None value is not allowed")
-
-        if title == "":
-            raise ValueError("title must not be empty")
-
-        system = platform.system().lower()
-
-        platforms = ["windows", "linux", "darwin"]
-        if system not in platforms:
-            raise SystemError(
-                "notifications are not supported for {} system".format(system)
-            )
-
-        if system == "linux" and urgency not in ["low", "normal", "critical", None]:
-            raise ValueError("invalid urgency was given: {}".format(urgency))
-
-        self.system = system
-        self.__title = title
-        self.__description = description
-        self.__duration = duration
-        self.__urgency = urgency
-        self.__icon_path = icon_path
-        self.__app_name = app_name
-
-    def send(self):
-        """Send the notification."""
-        # https://stackoverflow.com/questions/3951840/how-to-invoke-a-function-on-an-object-dynamically-by-name
-        getattr(self, f"send_{self.system}")()
-
-    def send_linux(self):
-        """Notify on linux with notify-send."""
-        if shutil.which("notify-send") is None:
-            raise SystemError(
-                "Please install libnotify-bin\n\tsudo apt-get install libnotify-bin"
-            )
-
-        command = [
-            "notify-send",
-            "{}".format(self.__title),
-            "{}".format(self.__description),
-            "-t",
-            "{}".format(self.__duration * 1000),
-        ]
-        if self.__urgency is not None:
-            command += ["-u", self.__urgency]
-
-        if self.__icon_path is not None:
-            command += ["-i", self.__icon_path]
-
-        if self.__app_name is not None:
-            command += ["-a", self.__app_name]
-
-        subprocess.Popen(command, shell=False)
-
-    def send_windows(self):
-        """Notify on windows with win10toast."""
-        try:
-            import wintoaster
-
-            wintoaster.create_toast(
-                threaded=True,
-                title=self.__title,
-                msg=self.__description,
-                duration=self.__duration,
-                icon_path=self.__icon_path,
-            ).display()
-        except ImportError:
-            raise ImportError(
-                "notifications are not supported, can't import win10toast"
-            )
-
-    def send_darwin(self):
-        """Notify on macos with pync."""
-        try:
-            import pync
-
-            pync.notify(
-                message=self.__description, title=self.__title, appIcon=self.__icon_path
-            )
-        except ImportError:
-            raise ImportError("notifications are not supported, can't import pync")
diff --git a/pynotifier/utils.py b/pynotifier/utils.py
new file mode 100644
index 0000000..e8ec5f5
--- /dev/null
+++ b/pynotifier/utils.py
@@ -0,0 +1,20 @@
+"""
+This file is a part of py-notifier project.
+
+Copyright (c) 2022 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
+
+import platform
+
+
+def assert_system(target: str):
+	"""
+	Check if current OS matches target.
+
+	Raises SystemError if OS does not match.
+	"""
+	current_system = platform.system().lower()
+	if current_system != target:
+		raise SystemError(f'{target} notification backend is not supported on {current_system}')
diff --git a/requirements-dev.txt b/requirements.dev.txt
similarity index 58%
rename from requirements-dev.txt
rename to requirements.dev.txt
index 7c2e98d..1235609 100644
--- a/requirements-dev.txt
+++ b/requirements.dev.txt
@@ -1,1 +1,2 @@
 bump2version==1.0.1
+coverage==5.3
diff --git a/requirements.txt b/requirements.txt
index ec5bba2..e32e3a1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,1 @@
 WinToaster==0.1; platform_system=='Windows'
-pync==2.0.3; platform_system=='Darwin'
diff --git a/setup.py b/setup.py
index ba38aee..a56ebe8 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,12 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
-"""Display desktop notifications on Windows, Linux and MacOS."""
+"""
+Send notifications using backends.
+
+Copyright (c) 2018, 2021-2022 Yuriy Lisovskiy
+
+Distributed under the MIT licence, see the accompanying file LICENSE.
+"""
 
 import io
 import os
@@ -8,17 +14,11 @@
 from setuptools import find_packages, setup
 
 NAME = "py-notifier"
-DESCRIPTION = "Cross-platform desktop push-notifications."
+DESCRIPTION = "Python push-notifications."
 URL = "https://github.com/YuriyLisovskiy/pynotifier"
 EMAIL = "yuralisovskiy98@gmail.com"
 AUTHOR = "Yuriy Lisovskiy"
 REQUIRES_PYTHON = ">=3.6.0"
-VERSION = (0, 3, 0)
-
-REQUIRED = [
-    "WinToaster==0.1; platform_system=='Windows'",
-    "pync==2.0.3; platform_system=='Darwin'",
-]
 
 here = os.path.abspath(os.path.dirname(__file__))
 
@@ -50,13 +50,22 @@
     license="MIT",
     classifiers=[
         # Full list: https://pypi.org/classifiers
+        "Intended Audience :: Information Technology",
+        "Intended Audience :: Developers",
+        "Operating System :: OS Independent",
+        "Environment :: MacOS X",
+        "Topic :: Software Development",
+        "Topic :: Software Development :: Libraries",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+        "Topic :: Software Development :: Libraries :: Application Frameworks",
+        "Typing :: Typed",
         "License :: OSI Approved :: MIT License",
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Environment :: MacOS X",
-        "Operating System :: MacOS",
-        "Operating System :: POSIX :: Linux",
-        "Operating System :: Microsoft :: Windows :: Windows 10",
+        "Programming Language :: Python :: 3 :: Only",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
     ],
 )
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 1ed8965..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,47 +0,0 @@
-[tox]
-envlist = {py36,py37,py38,py39}-{linux,macos,windows}, lint
-skipsdist = True
-
-[gh-actions]
-python =
-    3.6: py36
-    3.7: py37
-    3.8: py38
-    3.9: py39
-
-[gh-actions:env]
-PLATFORM =
-    ubuntu-latest: linux
-    macos-latest: macos
-    windows-latest: windows
-
-[testenv]
-deps =
-    pytest
-    pytest-cov
-    pytest-mock
-commands_pre =
-    pip install -e .
-whitelist_externals = echo
-commands =
-    pytest {posargs}
-
-[testenv:lint]
-deps = pre-commit
-skip_install = true
-whitelist_externals = echo
-commands =
-    pre-commit run {posargs:--all}
-
-[flake8]
-max-line-length = 88
-# https://github.com/PyCQA/bandit/issues/333
-ignore = S404,S603
-
-[pytest]
-addopts = --junitxml=reports/junit.xml --cov pynotifier --cov-report=term-missing --cov-report=html --cov-report=xml
-
-[coverage:html]
-directory = reports/coverage_html
-[coverage:xml]
-output = reports/cobertura.xml


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index 800cda4..0000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Continuous Integration
-on:
-  # Triggers the workflow on push or pull request events
-  push:
-  pull_request:
-  # Allows you to run this workflow manually from the Actions tab
-  workflow_dispatch:
-jobs:
-  lint:
-    name: "Lint"
-    runs-on: "ubuntu-latest"
-    steps:
-      - uses: "actions/checkout@v2"
-      - uses: "actions/setup-python@v2"
-        with:
-          python-version: "3.6"
-      - name: "Pre-commit linting"
-        run: |
-          python -VV
-          python -m site
-          python -m pip install --upgrade pip tox
-          tox -e lint
-
-  test:
-    name: "Tests ${{ matrix.python-version }} on ${{ matrix.platform }}"
-    runs-on: ${{ matrix.platform }}
-    strategy:
-      matrix:
-        platform: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.6, 3.7, 3.8, 3.9]
-    steps:
-      - uses: "actions/checkout@v2"
-      - uses: "actions/setup-python@v2"
-        with:
-          python-version: "${{ matrix.python-version }}"
-      - name: "Run tests with Python ${{ matrix.python-version }} on ${{ matrix.platform }}"
-        env:
-          PLATFORM: ${{ matrix.platform }}
-        run: |
-          python -VV
-          python -m site
-          python -m pip install --upgrade pip tox tox-gh-actions
-          tox
-      - name: Upload test results
-        uses: actions/upload-artifact@v2
-        with:
-          name: test-results-${{ matrix.python-version }}-${{ matrix.platform }}
-          path: |
-            reports/junit.xml
-            reports/cobertura.xml
-        if: ${{ always() }}
diff --git a/test_notification.py b/test_notification.py
deleted file mode 100644
index 13eac55..0000000
--- a/test_notification.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""py-notifier tests."""
-import pytest
-
-from pynotifier import Notification
-
-osx = pytest.mark.skipif("sys.platform != 'darwin'")
-
-
-@osx
-def test_osx(mocker):
-    """Tests pync is imported and called on OSX."""
-    pync = mocker.patch("pync.notify")
-    Notification(
-        title="OSX Title",
-        description="OSX Description",
-    ).send()
-    pync.assert_called_with(message="OSX Description", title="OSX Title", appIcon=None)
-
-
-@osx
-def test_linux_fails_on_osx(mocker):
-    """Tests linux fails on OSX because notify-send is not on the PATH."""
-    system = mocker.patch("platform.system")
-    system.return_value = "Linux"
-
-    with pytest.raises(SystemError):
-        Notification(
-            title="Notification Title",
-            description="Notification Description",
-        ).send()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_notification.py b/tests/test_notification.py
new file mode 100644
index 0000000..1dabdd4
--- /dev/null
+++ b/tests/test_notification.py
@@ -0,0 +1,24 @@
+import unittest
+
+from pynotifier import Notification
+
+
+class TestNotification(unittest.TestCase):
+
+	def setUp(self) -> None:
+		self.notification = Notification(
+			title='Hello',
+			message='World'
+		)
+
+	def test_title(self):
+		self.assertEqual(self.notification.title, 'Hello')
+
+	def test_message(self):
+		self.assertEqual(self.notification.message, 'World')
+
+	def test_config(self):
+		self.assertEqual(self.notification.config, {
+			'title': 'Hello',
+			'message': 'World'
+		})