From 9e6e3da7b0ed7c75184561c5c7ec69c3d1c59f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Kaspary?= Date: Wed, 17 May 2023 14:03:04 -0300 Subject: [PATCH] first commit --- .gitignore | 350 +++++++++++++++++++++++++++++++++++++++++ Makefile | 19 +++ README.md | 16 ++ setup.cfg | 17 ++ setup.py | 0 src/__init__.py | 0 src/clients.py | 71 +++++++++ src/enums.py | 10 ++ src/models.py | 16 ++ src/services.py | 38 +++++ tests/__init__.py | 0 tests/test_clients.py | 105 +++++++++++++ tests/test_services.py | 41 +++++ 13 files changed, 683 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100755 setup.cfg create mode 100644 setup.py create mode 100644 src/__init__.py create mode 100644 src/clients.py create mode 100644 src/enums.py create mode 100644 src/models.py create mode 100644 src/services.py create mode 100644 tests/__init__.py create mode 100644 tests/test_clients.py create mode 100644 tests/test_services.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e93212d --- /dev/null +++ b/.gitignore @@ -0,0 +1,350 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode,linux,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,visualstudiocode,linux,windows + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode,linux,windows \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3cc247a --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +create-venv: + python -m venv .venv + +setup: + pip install -r requirements.txt + +clean: + rm -rf ./**/__pycache__ __pycache__ .benchmarks htmlcov .pytest_cache + rm -f .coverage + +code-convention: + flake8 + pycodestyle + +test: + py.test -v + +test-cov: + py.test -v --cov-report=term --cov-report=html --cov=. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..869c591 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +[commit-shield]: https://img.shields.io/github/last-commit/Kaspary/email-hub-sdk?style=for-the-badge&logo=GitHub +[commit-url]: https://github.com/Kaspary/email-hub-sdk/commits/main +[linkedin-shield]: https://img.shields.io/badge/-João%20Pedro%20Kaspary-6633cc?style=for-the-badge&logo=Linkedin&colorB=2366c2 +[linkedin-url]: https://linkedin.com/in/joao-pedro-kaspary +[github-shield]: https://img.shields.io/github/followers/Kaspary?label=João%20Pedro%20Kaspary&style=for-the-badge&logo=GitHub +[github-url]: https://github.com/Kaspary + +[![commit-shield]][commit-url] +[![linkedin-shield]][linkedin-url] +[![GitHub followers][github-shield]][github-url] + + +# Email SDK Hub + +This is a project for implement an interface to send email with different emails server. + diff --git a/setup.cfg b/setup.cfg new file mode 100755 index 0000000..d3f8ada --- /dev/null +++ b/setup.cfg @@ -0,0 +1,17 @@ +[coverage:run] +omit = **/tests/* + .venv/* + +[coverage:report] +fail_under = 90 + +[coverage:html] +directory = reports/coverage + +[flake8] +max-line-length=120 +exclude= *test/*, */**/site-packages/ + +[pycodestyle] +max-line-length=120 +exclude= *test/*, */**/site-packages/ \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients.py b/src/clients.py new file mode 100644 index 0000000..5b9a012 --- /dev/null +++ b/src/clients.py @@ -0,0 +1,71 @@ +from abc import ABC, abstractmethod +import smtplib +from typing import Sequence + +from src.enums import PortSMTP, ServerSMTP + + +class BaseEmailClient(ABC): + _client: any = None + + def __init__(self, server: str, port: int, account: str, password: str): + self._server = server + self._port = port + self._account = account + self._password = password + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._client: + self._client.close() + return False + + def smtp(self): + return self + + @abstractmethod + def send_mail(self, to_addrs: str | Sequence[str], msg: str, from_addr: str = None): + pass + + +class GmailClient(BaseEmailClient): + _client: smtplib.SMTP_SSL + + def __init__(self, account: str, password: str): + super().__init__( + ServerSMTP.GMAIL.value, PortSMTP.GMAIL.value, account, password + ) + + def smtp(self): + self._client = smtplib.SMTP_SSL(self._server, self._port) + self._client.login(self._account, self._password) + return self + + def send_mail( + self, to_addrs: str | Sequence[str], message: str, from_addr: str = None + ): + from_addr = from_addr or self._account + self._client.sendmail(from_addr, to_addrs, message) + + +class OutlookClient(BaseEmailClient): + _client: smtplib.SMTP + + def __init__(self, account: str, password: str): + super().__init__( + ServerSMTP.OUTLOOK.value, PortSMTP.OUTLOOK.value, account, password + ) + + def smtp(self): + self._client = smtplib.SMTP(self._server, self._port) + self._client.starttls() + self._client.login(self._account, self._password) + return self + + def send_mail( + self, to_addrs: str | Sequence[str], message: str, from_addr: str = None + ): + from_addr = from_addr or self._account + self._client.sendmail(from_addr, to_addrs, message) diff --git a/src/enums.py b/src/enums.py new file mode 100644 index 0000000..731bf40 --- /dev/null +++ b/src/enums.py @@ -0,0 +1,10 @@ +from enum import Enum + +class ServerSMTP(Enum): + GMAIL = 'smtp.gmail.com' + OUTLOOK = 'smtp-mail.outlook.com' + + +class PortSMTP(Enum): + GMAIL = 465 + OUTLOOK = 587 \ No newline at end of file diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..945dfc0 --- /dev/null +++ b/src/models.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field +from typing import List + +@dataclass +class EmailMime: + sender: str + subject: str + body: str + to: List[str] = field(default_factory=list) + cc: List[str] = field(default_factory=list) + bcc: List[str] = field(default_factory=list) + attatchment: bytes = None + + @property + def recipients(self) -> List[str]: + return self.to + self.cc + self.bcc diff --git a/src/services.py b/src/services.py new file mode 100644 index 0000000..41be629 --- /dev/null +++ b/src/services.py @@ -0,0 +1,38 @@ +import logging +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import COMMASPACE, formatdate + +from src.clients import BaseEmailClient +from src.models import EmailMime + + +class EmailService: + def __init__(self, client: BaseEmailClient): + self.__client = client + + def _mount_email(self, email: EmailMime) -> MIMEMultipart: + mime = MIMEMultipart() + mime["From"] = f"No Reply <{email.sender}>" + mime["To"] = COMMASPACE.join(email.to) + mime["Cc"] = COMMASPACE.join(email.cc) + mime["Bcc"] = COMMASPACE.join(email.bcc) + mime["Date"] = formatdate(localtime=True) + mime["Subject"] = email.subject + mime.attach(MIMEText(email.body)) + + if email.attatchment: + part = MIMEBase("application", "octet-stream") + part.set_payload(email.attatchment) + encoders.encode_base64(part) + mime.attach(part) + + return mime + + def send_email(self, email: EmailMime) -> None: + logging.debug(f"Send e-mail to {email.recipients}") + mime = self._mount_email(email) + with self.__client.smtp() as smtp: + smtp.send_mail(email.recipients, mime.as_string()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..e634ce4 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,105 @@ +from unittest import mock +from src.clients import BaseEmailClient, GmailClient, OutlookClient +from src.enums import PortSMTP, ServerSMTP + +SERVER_MOCK = "smtp.server.com" +PORT_MOCK = "8888" +ACCOUNT_MOCK = "account@email.com" +PASSWORD_MOCK = "password1234" +TO_MOCK = ['to_example@email.com'] +MESSAGE_MOCK = 'EMAIL MESSAGE' + +class TestBaseEmailClient: + def setup_method(self): + BaseEmailClient.__abstractmethods__ = set() + self.client = BaseEmailClient( + server=SERVER_MOCK, + port=PORT_MOCK, + account=ACCOUNT_MOCK, + password=PASSWORD_MOCK, + ) + + def test_initialization(self): + assert self.client._server == SERVER_MOCK + assert self.client._port == PORT_MOCK + assert self.client._account == ACCOUNT_MOCK + assert self.client._password == PASSWORD_MOCK + assert self.client._client == None + + @mock.patch("src.clients.BaseEmailClient._client") + @mock.patch("src.clients.BaseEmailClient.__enter__") + def test_the_with_statement(self, enter_mock, client_mock): + enter_mock.return_value = self.client + client_mock.close.return_value = None + + with self.client.smtp() as smtp: + assert isinstance(smtp, self.client.__class__) + + assert client_mock.close.called_once + assert enter_mock.called_once + + +class TestGmailClient: + def setup_method(self): + self.client = GmailClient(account=ACCOUNT_MOCK, password=PASSWORD_MOCK) + + def test_initialization(self): + assert self.client._server == ServerSMTP.GMAIL.value + assert self.client._port == PortSMTP.GMAIL.value + assert self.client._account == ACCOUNT_MOCK + assert self.client._password == PASSWORD_MOCK + assert self.client._client == None + + @mock.patch("src.clients.smtplib.SMTP_SSL") + def test_smtp(self, smtp_mock): + smtp_mock.login.return_value = None + + result = self.client.smtp() + assert isinstance(result, self.client.__class__) + assert smtp_mock.called_once_with(ServerSMTP.GMAIL.value, PortSMTP.GMAIL.value) + assert smtp_mock.login.called_once_with(ACCOUNT_MOCK, PASSWORD_MOCK) + + @mock.patch("src.clients.GmailClient._client") + def test_send_mail(self, client_mock): + client_mock.sendmail.return_value = None + result = self.client.send_mail( + to_addrs=TO_MOCK, + message=MESSAGE_MOCK, + ) + + assert result == None + assert client_mock.sendmail.called_once_with(ACCOUNT_MOCK, TO_MOCK, MESSAGE_MOCK) + + +class TestOutlookClient: + def setup_method(self): + self.client = OutlookClient(account=ACCOUNT_MOCK, password=PASSWORD_MOCK) + + def test_initialization(self): + assert self.client._server == ServerSMTP.OUTLOOK.value + assert self.client._port == PortSMTP.OUTLOOK.value + assert self.client._account == ACCOUNT_MOCK + assert self.client._password == PASSWORD_MOCK + assert self.client._client == None + + @mock.patch("src.clients.smtplib.SMTP") + def test_smtp(self, smtp_mock): + smtp_mock.login.return_value = None + smtp_mock.starttls.return_value = None + + result = self.client.smtp() + assert isinstance(result, self.client.__class__) + assert smtp_mock.called_once_with(ServerSMTP.GMAIL.value, PortSMTP.GMAIL.value) + assert smtp_mock.login.called_once_with(ACCOUNT_MOCK, PASSWORD_MOCK) + assert smtp_mock.starttls.called_once + + @mock.patch("src.clients.OutlookClient._client") + def test_send_mail(self, client_mock): + client_mock.sendmail.return_value = None + result = self.client.send_mail( + to_addrs=TO_MOCK, + message=MESSAGE_MOCK, + ) + + assert result == None + assert client_mock.sendmail.called_once_with(ACCOUNT_MOCK, TO_MOCK, MESSAGE_MOCK) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..718a8d7 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,41 @@ +from unittest import mock +from src.models import EmailMime +from src.services import EmailService + + +class TestEmailService: + + def setup_method(self): + pass + + + @mock.patch("src.services.EmailService._mount_email") + def test_send_email(self, mount_email): + + mime_obj = mock.Mock() + mime_obj.as_string.return_value = 'EMAIL MESSAGE' + mount_email.return_value = mime_obj + + client_obj = mock.MagicMock() + client_obj.__enter__.return_value = client_obj + client_obj.__exit__.return_value = None + client_obj.smtp.return_value = client_obj + client_obj.send_mail.return_value = None + + service = EmailService(client_obj) + + email_mime = EmailMime( + sender='sender_example@email.com', + subject='TESTE', + body='BODY', + to=['to_example@email.com'], + cc=['cc_example@email.com'], + bcc=['bcc_example@email.com'], + ) + result = service.send_email(email_mime) + + assert client_obj.__enter__.called_once + assert client_obj.__exit__.called_once + assert client_obj.smtp.called_once + assert client_obj.send_mail.called_once_with(email_mime.recipients, mime_obj.as_string.return_value) + assert result == None