From ffb5ab30bbe3d5196d0c87140852d9207828a69f Mon Sep 17 00:00:00 2001 From: Ludvik Jerabek Date: Fri, 17 Jan 2025 12:24:11 -0500 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .github/workflows/python-publish.yml | 39 ++++ .gitignore | 155 +++++++++++++ LICENSE | 21 ++ README.md | 208 ++++++++++++++++++ example/example.py | 50 +++++ pyproject.toml | 29 +++ src/ser_mail_api/v1/__init__.py | 14 ++ src/ser_mail_api/v1/client.py | 67 ++++++ src/ser_mail_api/v1/data/attachment.py | 140 ++++++++++++ src/ser_mail_api/v1/data/content.py | 43 ++++ src/ser_mail_api/v1/data/mailuser.py | 37 ++++ src/ser_mail_api/v1/data/message.py | 100 +++++++++ src/ser_mail_api/v1/data/result.py | 15 ++ src/ser_mail_api/v1/endpoints/send.py | 13 ++ src/ser_mail_api/v1/resources/__init__.py | 9 + src/ser_mail_api/v1/resources/dictionary.py | 50 +++++ .../v1/resources/dictionary_resource.py | 48 ++++ .../v1/resources/error_handler.py | 74 +++++++ .../v1/resources/filter_options.py | 105 +++++++++ src/ser_mail_api/v1/resources/parameter.py | 10 + src/ser_mail_api/v1/resources/resource.py | 72 ++++++ src/ser_mail_api/v1/resources/resources.py | 21 ++ .../v1/resources/response_wrapper.py | 49 +++++ 24 files changed, 1371 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example/example.py create mode 100644 pyproject.toml create mode 100644 src/ser_mail_api/v1/__init__.py create mode 100644 src/ser_mail_api/v1/client.py create mode 100644 src/ser_mail_api/v1/data/attachment.py create mode 100644 src/ser_mail_api/v1/data/content.py create mode 100644 src/ser_mail_api/v1/data/mailuser.py create mode 100644 src/ser_mail_api/v1/data/message.py create mode 100644 src/ser_mail_api/v1/data/result.py create mode 100644 src/ser_mail_api/v1/endpoints/send.py create mode 100644 src/ser_mail_api/v1/resources/__init__.py create mode 100644 src/ser_mail_api/v1/resources/dictionary.py create mode 100644 src/ser_mail_api/v1/resources/dictionary_resource.py create mode 100644 src/ser_mail_api/v1/resources/error_handler.py create mode 100644 src/ser_mail_api/v1/resources/filter_options.py create mode 100644 src/ser_mail_api/v1/resources/parameter.py create mode 100644 src/ser_mail_api/v1/resources/resource.py create mode 100644 src/ser_mail_api/v1/resources/resources.py create mode 100644 src/ser_mail_api/v1/resources/response_wrapper.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..4ba9ac4 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [ published ] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e913e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,155 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Password files +*.api_key + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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 maintainted 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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3fadb1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ludvik Jerabek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80557f0 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# Proofpoint Secure Email Relay Mail API Package + +Library implements all the functions of the SER Email Relay User Management API via Python. + +### Requirements: + +* Python 3.9+ +* requests +* requests-oauth2client +* pysocks + +### Installing the Package + +You can install the tool using the following command directly from Github. + +``` +pip install git+https://github.com/pfptcommunity/ser-mail-api-python.git +``` + +or can install the tool using pip. + +``` +# When testing on Ubuntu 24.04 the following will not work: +pip install ser-mail-api +``` + +If you see an error similar to the following: + +``` +error: externally-managed-environment + +× This environment is externally managed +╰─> To install Python packages system-wide, try apt install + python3-xyz, where xyz is the package you are trying to + install. + + If you wish to install a non-Debian-packaged Python package, + create a virtual environment using python3 -m venv path/to/venv. + Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make + sure you have python3-full installed. + + If you wish to install a non-Debian packaged Python application, + it may be easiest to use pipx install xyz, which will manage a + virtual environment for you. Make sure you have pipx installed. + + See /usr/share/doc/python3.12/README.venv for more information. + +note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages. +hint: See PEP 668 for the detailed specification. +``` + +You should use install pipx or you can configure your own virtual environment and use the command referenced above. + +``` +pipx install ser-mail-api +``` + +### Creating an API client object + +```python +from ser_mail_api.v1 import * + +if __name__ == "__main__": + client = Client("","") +``` + +### Sending an Email Message + +```python +import json + +from ser_mail_api.v1 import * + +if __name__ == "__main__": + # Load API key + with open("../ser.api_key", "r") as api_key_file: + api_key_data = json.load(api_key_file) + + client = Client(api_key_data.get("client_id"), api_key_data.get("client_secret")) + + # Create a new Message object + message = Message("This is a test email", MailUser("sender@proofpoint.com", "Joe Sender")) + # Add content body + message.add_content(Content("This is a test message", ContentType.TEXT)) + message.add_content(Content("This is a test message", ContentType.HTML)) + # Add Recipients + message.add_recipient(MailUser("recipient1@proofpoint.com", "Recipient 1")) + message.add_recipient(MailUser("recipient2@proofpoint.com", "Recipient 2")) + # Add CC + message.add_cc(MailUser("cc1@proofpoint.com", "Carbon Copy 1")) + message.add_cc(MailUser("cc2@proofpoint.com", "Carbon Copy 2")) + # Add BCC + message.add_bcc(MailUser("bcc1@proofpoint.com", "Blind Carbon Copy 1")) + message.add_bcc(MailUser("bcc2@proofpoint.com", "Blind Carbon Copy 2")) + + # Add Base64 Encoded Attachment + message.add_attachment(Attachment("VGhpcyBpcyBhIHRlc3Qh", Disposition.ATTACHMENT, "test.txt", "text/plain")) + + # Add File Attachment from Disk, if Disposition is not passed, the default is Disposition.ATTACHMENT + message.add_attachment(FileAttachment(r"C:\temp\file.csv", Disposition.ATTACHMENT)) + + # In the following example, we will create a byte stream from a string. This byte stream is converted + # to base64 encoding within the StreamAttachment object + text = "This is a sample text stream." + + # Convert the string into bytes + byte_stream = text.encode("utf-8") + + # Add Byte Stream as Attachment, if Disposition is not passed, the default is Disposition.ATTACHMENT + message.add_attachment(StreamAttachment(byte_stream,"byte_stream.txt", "text/plain", Disposition.ATTACHMENT)) + + result = client.send(message) + + print("HTTP Status", result.get_status()) + print("HTTP Reason", result.get_reason()) + + print("Reason:", result.reason) + print("Message ID:", result.message_id) + print("Request ID:", result.request_id) +``` + +The following JSON data is a dump of the message object based on the code above. + +```json +{ + "attachments": [ + { + "content": "VGhpcyBpcyBhIHRlc3Qh", + "disposition": "attachment", + "filename": "test.txt", + "id": "d10205cf-a0a3-4b9e-9a57-253fd8e1c7df", + "type": "text/plain" + }, + { + "content": "77u/IlVzZXIiLCJTZW50Q291bnQiLCJSZWNlaXZlZENvdW50Ig0KIm5vcmVwbHlAcHJvb2Zwb2ludC5jb20sIGxqZXJhYmVrQHBmcHQuaW8iLCIwIiwiMCINCg==", + "disposition": "attachment", + "filename": "file.csv", + "id": "f66487f5-57c2-40e0-9402-5723a85c0df0", + "type": "application/vnd.ms-excel" + }, + { + "content": "VGhpcyBpcyBhIHNhbXBsZSB0ZXh0IHN0cmVhbS4=", + "disposition": "attachment", + "filename": "byte_stream.txt", + "id": "bc67d5fa-345a-4436-9979-5efa68223520", + "type": "text/plain" + } + ], + "content": [ + { + "body": "This is a test message", + "type": "text/plain" + }, + { + "body": "This is a test message", + "type": "text/html" + } + ], + "from": { + "email": "sender@proofpoint.com", + "name": "Joe Sender" + }, + "headers": { + "from": { + "email": "sender@proofpoint.com", + "name": "Joe Sender" + } + }, + "subject": "This is a test email", + "tos": [ + { + "email": "recipient1@proofpoint.com", + "name": "Recipient 1" + }, + { + "email": "recipient2@proofpoint.com", + "name": "Recipient 2" + } + ], + "cc": [ + { + "email": "cc1@proofpoint.com", + "name": "Carbon Copy 1" + }, + { + "email": "cc2@proofpoint.com", + "name": "Carbon Copy 2" + } + ], + "bcc": [ + { + "email": "bcc1@proofpoint.com", + "name": "Blind Carbon Copy 1" + }, + { + "email": "bcc2@proofpoint.com", + "name": "Blind Carbon Copy 2" + } + ], + "replyTos": [] +} +``` + +### Limitations + +There are no known limitations. + +For more information please see: https://api-docs.ser.proofpoint.com/docs/email-submission diff --git a/example/example.py b/example/example.py new file mode 100644 index 0000000..bb0f2a6 --- /dev/null +++ b/example/example.py @@ -0,0 +1,50 @@ +import json + +from ser_mail_api.v1 import * + +if __name__ == "__main__": + # Load API key + with open("../ser.api_key", "r") as api_key_file: + api_key_data = json.load(api_key_file) + + client = Client(api_key_data.get("client_id"), api_key_data.get("client_secret")) + + # Create a new Message object + message = Message("This is a test email", MailUser("sender@proofpoint.com", "Joe Sender")) + # Add content body + message.add_content(Content("This is a test message", ContentType.TEXT)) + message.add_content(Content("This is a test message", ContentType.HTML)) + # Add Recipients + message.add_recipient(MailUser("recipient1@proofpoint.com", "Recipient 1")) + message.add_recipient(MailUser("recipient2@proofpoint.com", "Recipient 2")) + # Add CC + message.add_cc(MailUser("cc1@proofpoint.com", "Carbon Copy 1")) + message.add_cc(MailUser("cc2@proofpoint.com", "Carbon Copy 2")) + # Add BCC + message.add_bcc(MailUser("bcc1@proofpoint.com", "Blind Carbon Copy 1")) + message.add_bcc(MailUser("bcc2@proofpoint.com", "Blind Carbon Copy 2")) + + # Add Base64 Encoded Attachment + message.add_attachment(Attachment("VGhpcyBpcyBhIHRlc3Qh", Disposition.ATTACHMENT, "test.txt", "text/plain")) + + # Add File Attachment from Disk, if Disposition is not passed, the default is Disposition.ATTACHMENT + message.add_attachment(FileAttachment(r"C:\temp\file.csv", Disposition.ATTACHMENT)) + + # In the following example, we will create a byte stream from a string. This byte stream is converted + # to base64 encoding within the StreamAttachment object + text = "This is a sample text stream." + + # Convert the string into bytes + byte_stream = text.encode("utf-8") + + # Add Byte Stream as Attachment, if Disposition is not passed, the default is Disposition.ATTACHMENT + message.add_attachment(StreamAttachment(byte_stream,"byte_stream.txt", "text/plain", Disposition.ATTACHMENT)) + + result = client.send(message) + + print("HTTP Status", result.get_status()) + print("HTTP Reason", result.get_reason()) + + print("Reason:", result.reason) + print("Message ID:", result.message_id) + print("Request ID:", result.request_id) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0817ec3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ['setuptools', 'setuptools-scm'] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +version_scheme = "post-release" +local_scheme = "node-and-date" + +[tools.setuptools.packages.find] +where = ["src"] + +[project] +name = "ser-mail-api" +dynamic = ["version"] +readme = "README.md" +description = "Secure Email Relay Mail API" +license = { text = "MIT" } +requires-python = ">3.9" +dependencies = [ + 'requests', + 'requests-oauth2client', + 'pysocks' +] + +[project.urls] +repository = "https://github.com/pfptcommunity/ser-mail-api-python" +homepage = "https://api-docs.ser.proofpoint.com/docs/email-submission" +#documentation = "https://github.com/pfptcommunity/ser-mail-api-python" +#changelog = "https://github.com/pfptcommunity/ser-mail-api-python" \ No newline at end of file diff --git a/src/ser_mail_api/v1/__init__.py b/src/ser_mail_api/v1/__init__.py new file mode 100644 index 0000000..64ab514 --- /dev/null +++ b/src/ser_mail_api/v1/__init__.py @@ -0,0 +1,14 @@ +""" +This code was tested against Python 3.9 + +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from .client import Client +from .data.attachment import Attachment, FileAttachment, StreamAttachment, Disposition +from .data.content import Content, ContentType +from .data.mailuser import MailUser +from .data.message import Message + +__all__ = ['Client', 'Attachment','FileAttachment', 'StreamAttachment', 'Disposition', 'Content', 'ContentType', 'MailUser', 'Message'] diff --git a/src/ser_mail_api/v1/client.py b/src/ser_mail_api/v1/client.py new file mode 100644 index 0000000..e7fd2ae --- /dev/null +++ b/src/ser_mail_api/v1/client.py @@ -0,0 +1,67 @@ +""" +This code was tested against Python 3.9 + +Author: Ludvik Jerabek +Package: ser_mail_api +License: MIT +""" +from requests.adapters import HTTPAdapter +from requests_oauth2client import OAuth2Client, OAuth2ClientCredentialsAuth + +from ser_mail_api.v1.endpoints.send import Send +from ser_mail_api.v1.resources.error_handler import ErrorHandler +from ser_mail_api.v1.resources.resource import Resource + + +class TimeoutHTTPAdapter(HTTPAdapter): + timeout = None + + def __init__(self, *args, **kwargs): + if "timeout" in kwargs: + self.timeout = kwargs["timeout"] + del kwargs["timeout"] + super().__init__(*args, **kwargs) + + def send(self, request, **kwargs): + timeout = kwargs.get("timeout") + if timeout is None and hasattr(self, 'timeout'): + kwargs["timeout"] = self.timeout + return super().send(request, **kwargs) + + +class Client(Resource): + __api_token: str + __error_handler: ErrorHandler + __send: Send + + def __init__(self, client_id: str, client_secret: str): + super().__init__(None, "https://mail.ser.proofpoint.com/v1") + + # self.__error_handler = ErrorHandler() + # self._session.hooks = {"response": self.__error_handler.handler} + + # Deal with OAuth2 + oauth2_client = OAuth2Client("https://mail.ser.proofpoint.com/v1/token", auth=(client_id, client_secret)) + oauth2_client.client_credentials({"grant_type": "client_credentials"}) + self._session.auth = OAuth2ClientCredentialsAuth(oauth2_client, scope="client_credentials") + self.__send = Send(self, 'send') + + @property + def send(self) -> Send: + return self.__send + + @property + def timeout(self): + return self._session.adapters.get('https://').timeout + + @timeout.setter + def timeout(self, timeout): + self._session.adapters.get('https://').timeout = timeout + + @property + def error_handler(self) -> ErrorHandler: + return self.__error_handler + + @error_handler.setter + def error_handler(self, error_handler: ErrorHandler): + self.__error_handler = error_handler diff --git a/src/ser_mail_api/v1/data/attachment.py b/src/ser_mail_api/v1/data/attachment.py new file mode 100644 index 0000000..12a3caa --- /dev/null +++ b/src/ser_mail_api/v1/data/attachment.py @@ -0,0 +1,140 @@ +import base64 +import mimetypes +import os +import uuid +from enum import Enum +from typing import Dict, Optional + + +def _is_valid_base64(s: str) -> bool: + """Check if a string is valid Base64.""" + try: + return base64.b64encode(base64.b64decode(s)).decode('utf-8') == s + except Exception: + return False + + +class Disposition(Enum): + INLINE = "inline" + ATTACHMENT = "attachment" + + +class Attachment: + def __init__(self, content: str, disposition: Disposition, filename: str, mime_type: str): + # Validate input types + if not isinstance(content, str): + raise TypeError(f"Expected 'content' to be a string, got {type(content).__name__}") + if not isinstance(disposition, Disposition): + raise TypeError(f"Expected 'disposition' to be a Disposition, got {type(disposition).__name__}") + if not isinstance(filename, str): + raise TypeError(f"Expected 'filename' to be a string, got {type(filename).__name__}") + if not isinstance(mime_type, str): + raise TypeError(f"Expected 'mime_type' to be a string, got {type(mime_type).__name__}") + + # Validate specific constraints + if not _is_valid_base64(content): + raise ValueError("Invalid Base64 content") + if len(filename) > 1000: + raise ValueError("Filename must be at most 1000 characters long") + if not mime_type.strip(): + raise ValueError("Mime type must be a non-empty string") + + # Set attributes + self.__id = str(uuid.uuid4()) + self.__content = content + self.__disposition = disposition + self.__filename = filename + self.__mime_type = mime_type + + @property + def id(self) -> str: + return self.__id + + @property + def content(self) -> str: + return self.__content + + @property + def disposition(self) -> Disposition: + return self.__disposition + + @property + def filename(self) -> str: + return self.__filename + + @property + def mime_type(self) -> str: + return self.__mime_type + + def to_dict(self) -> Dict: + return { + "content": self.content, + "disposition": self.disposition.value, + "filename": self.filename, + "id": self.id, + "type": self.mime_type, + } + + def __repr__(self) -> str: + return ( + f"Attachment(id={self.__id!r}, filename={self.__filename!r}, " + f"disposition={self.__disposition.value!r}, mime_type={self.__mime_type!r})" + ) + + +class FileAttachment(Attachment): + def __init__(self, file_path: str, disposition: Disposition = Disposition.ATTACHMENT, mime_type: Optional[str] = None): + """ + Args: + file_path (str): Path to the file. + disposition (Disposition): The disposition of the attachment (inline or attachment). + mime_type (Optional[str]): The MIME type of the file. If None, it will be deduced from the file path. + """ + if not isinstance(file_path, str): + raise TypeError(f"Expected 'file_path' to be a string, got {type(file_path).__name__}") + if not os.path.isfile(file_path): + raise FileNotFoundError(f"The file at path '{file_path}' does not exist.") + + # Use provided mime_type or deduce it + if mime_type is None: + mime_type = self._deduce_mime_type(file_path) + + # Encode file content + content = self._encode_file_content(file_path) + filename = os.path.basename(file_path) + + super().__init__(content, disposition, filename, mime_type) + + @staticmethod + def _deduce_mime_type(file_path: str) -> str: + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + raise ValueError(f"Unable to deduce MIME type for file: {file_path}") + return mime_type + + @staticmethod + def _encode_file_content(file_path: str) -> str: + with open(file_path, "rb") as file: + return base64.b64encode(file.read()).decode("utf-8") + + +class StreamAttachment(Attachment): + def __init__(self, stream: bytes, filename: str, mime_type: str, disposition: Disposition = Disposition.ATTACHMENT): + """ + Args: + stream (bytes): Byte stream of the content. + filename (str): Filename of the attachment. + mime_type (str): MIME type of the content. + disposition (Disposition): The disposition of the attachment (inline or attachment). + """ + if not isinstance(stream, bytes): + raise TypeError(f"Expected 'stream' to be bytes, got {type(stream).__name__}") + if not isinstance(filename, str): + raise TypeError(f"Expected 'filename' to be a string, got {type(filename).__name__}") + if not isinstance(mime_type, str): + raise TypeError(f"Expected 'mime_type' to be a string, got {type(mime_type).__name__}") + + # Encode stream to Base64 + content = base64.b64encode(stream).decode("utf-8") + + super().__init__(content, disposition, filename, mime_type) diff --git a/src/ser_mail_api/v1/data/content.py b/src/ser_mail_api/v1/data/content.py new file mode 100644 index 0000000..1ffca72 --- /dev/null +++ b/src/ser_mail_api/v1/data/content.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from enum import Enum +from typing import Dict + + +class ContentType(Enum): + TEXT = "text/plain" + HTML = "text/html" + + +class Content: + def __init__(self, body: str, content_type: ContentType): + # Validate body and content_type types + if not isinstance(body, str): + raise TypeError(f"Expected 'body' to be a string, got {type(body).__name__}") + if not isinstance(content_type, ContentType): + raise TypeError(f"Expected 'content_type' to be a ContentType, got {type(content_type).__name__}") + + # Set attributes (immutable after initialization) + self.__body = body + self.__content_type = content_type + + @property + def body(self) -> str: + """Get the content body.""" + return self.__body + + @property + def type(self) -> ContentType: + """Get the content type.""" + return self.__content_type + + def to_dict(self) -> Dict: + """Convert the Content object to a dictionary.""" + return { + "body": self.__body, + "type": self.__content_type.value, + } + + def __repr__(self) -> str: + """Developer-friendly string representation.""" + return f"Content(body={self.__body!r}, type={self.__content_type!r})" diff --git a/src/ser_mail_api/v1/data/mailuser.py b/src/ser_mail_api/v1/data/mailuser.py new file mode 100644 index 0000000..bd8e897 --- /dev/null +++ b/src/ser_mail_api/v1/data/mailuser.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Dict, Optional + + +class MailUser: + def __init__(self, email: str, name: Optional[str] = None): + # Validate email and name types + if not isinstance(email, str): + raise TypeError(f"Expected 'email' to be a string, got {type(email).__name__}") + if name is not None and not isinstance(name, str): + raise TypeError(f"Expected 'name' to be a string or None, got {type(name).__name__}") + + # Set attributes (immutable after initialization) + self.__email = email + self.__name = name + + @property + def email(self) -> str: + """Get the email address.""" + return self.__email + + @property + def name(self) -> Optional[str]: + """Get the display name.""" + return self.__name + + def to_dict(self) -> Dict: + """Convert the MailUser to a dictionary.""" + return { + "email": self.__email, + "name": self.__name, + } + + def __repr__(self) -> str: + """Developer-friendly string representation.""" + return f"MailUser(email={self.__email!r}, name={self.__name!r})" diff --git a/src/ser_mail_api/v1/data/message.py b/src/ser_mail_api/v1/data/message.py new file mode 100644 index 0000000..c2e53c8 --- /dev/null +++ b/src/ser_mail_api/v1/data/message.py @@ -0,0 +1,100 @@ +import json +from typing import List, Dict + +from .attachment import Attachment +from .content import Content +from .mailuser import MailUser + + +class Message: + def __init__(self, subject: str, sender: MailUser): + if not isinstance(sender, MailUser): + raise TypeError(f"Expected sender to be a MailUser, got {type(sender).__name__}") + if not isinstance(subject, str): + raise TypeError(f"Expected subject to be a string, got {type(subject).__name__}") + + self.__subject = subject + self.__sender = sender + self.__header_sender = sender + self.__recipients: List[MailUser] = [] + self.__cc: List[MailUser] = [] + self.__bcc: List[MailUser] = [] + self.__reply_tos: List[MailUser] = [] + self.__attachments: List[Attachment] = [] + self.__content: List[Content] = [] + self.__headers: Dict[str, Dict] = {} + + @property + def sender(self) -> MailUser: + return self.__header_sender + + @sender.setter + def sender(self, sender: MailUser): + if not isinstance(sender, MailUser): + raise TypeError(f"Expected sender to be a MailUser, got {type(sender).__name__}") + self.__header_sender = sender + + @property + def header_sender(self) -> MailUser: + return self.__header_sender + + @header_sender.setter + def header_sender(self, sender: MailUser): + if not isinstance(sender, MailUser): + raise TypeError(f"Expected header_sender to be a MailUser, got {type(sender).__name__}") + self.__header_sender = sender + + def add_recipient(self, recipient: MailUser): + if not isinstance(recipient, MailUser): + raise TypeError(f"Expected recipient to be a MailUser, got {type(recipient).__name__}") + self.__recipients.append(recipient) + + def add_cc(self, cc_user: MailUser): + if not isinstance(cc_user, MailUser): + raise TypeError(f"Expected cc_user to be a MailUser, got {type(cc_user).__name__}") + self.__cc.append(cc_user) + + def add_bcc(self, bcc_user: MailUser): + if not isinstance(bcc_user, MailUser): + raise TypeError(f"Expected bcc_user to be a MailUser, got {type(bcc_user).__name__}") + self.__bcc.append(bcc_user) + + def add_reply_to(self, reply_to_user: MailUser): + if not isinstance(reply_to_user, MailUser): + raise TypeError(f"Expected reply_to_user to be a MailUser, got {type(reply_to_user).__name__}") + self.__reply_tos.append(reply_to_user) + + def add_attachment(self, attachment: Attachment): + if not isinstance(attachment, Attachment): + raise TypeError(f"Expected attachment to be an Attachment, got {type(attachment).__name__}") + self.__attachments.append(attachment) + + def add_content(self, content: Content): + if not isinstance(content, Content): + raise TypeError(f"Expected content to be a Content, got {type(content).__name__}") + self.__content.append(content) + + def set_header(self, key: str, value: Dict): + if not isinstance(key, str): + raise TypeError(f"Expected key to be a string, got {type(key).__name__}") + if not isinstance(value, dict): + raise TypeError(f"Expected value to be a dictionary, got {type(value).__name__}") + self.__headers[key] = value + + def to_dict(self) -> Dict: + """Convert the message to a dictionary suitable for JSON serialization.""" + return { + "attachments": [attachment.to_dict() for attachment in self.__attachments], + "content": [content.to_dict() for content in self.__content], + "from": self.__sender.to_dict(), + "headers": self.__headers or {"from": self.__header_sender.to_dict()}, + "subject": self.__subject, + "tos": [recipient.to_dict() for recipient in self.__recipients], + "cc": [cc_user.to_dict() for cc_user in self.__cc], + "bcc": [bcc_user.to_dict() for bcc_user in self.__bcc], + "replyTos": [reply_to_user.to_dict() for reply_to_user in self.__reply_tos], + } + + def __str__(self) -> str: + """Convert the message to a JSON string.""" + return json.dumps(self.to_dict(), indent=4) diff --git a/src/ser_mail_api/v1/data/result.py b/src/ser_mail_api/v1/data/result.py new file mode 100644 index 0000000..90684a6 --- /dev/null +++ b/src/ser_mail_api/v1/data/result.py @@ -0,0 +1,15 @@ +from ser_mail_api.v1.resources import Dictionary + + +class Result(Dictionary): + @property + def message_id(self) -> str: + return self.get('message_id') + + @property + def reason(self) -> str: + return self.get('reason') + + @property + def request_id(self) -> str: + return self.get('request_id') diff --git a/src/ser_mail_api/v1/endpoints/send.py b/src/ser_mail_api/v1/endpoints/send.py new file mode 100644 index 0000000..65b6fcb --- /dev/null +++ b/src/ser_mail_api/v1/endpoints/send.py @@ -0,0 +1,13 @@ +from ser_mail_api.v1.data.message import Message +from ser_mail_api.v1.data.result import Result +from ser_mail_api.v1.resources import Resource + + +class Send(Resource): + def __init__(self, parent, uri: str): + super().__init__(parent, uri) + + def __call__(self, message: Message) -> Result: + if not isinstance(message, Message): + raise TypeError(f"Expected 'message' to be an instance of Message, got {type(message).__name__}") + return Result(self._session.post(self._uri, json=message.to_dict())) diff --git a/src/ser_mail_api/v1/resources/__init__.py b/src/ser_mail_api/v1/resources/__init__.py new file mode 100644 index 0000000..6dc3e90 --- /dev/null +++ b/src/ser_mail_api/v1/resources/__init__.py @@ -0,0 +1,9 @@ +from .dictionary import Dictionary +from .dictionary_resource import DictionaryResource +from .filter_options import FilterOptions +from .filter_options import TFilterOptions +from .resource import Resource +from .resources import Resources + +__all__ = ['DictionaryResource', 'Resource', 'Resources', + 'Dictionary', 'FilterOptions', 'TFilterOptions'] diff --git a/src/ser_mail_api/v1/resources/dictionary.py b/src/ser_mail_api/v1/resources/dictionary.py new file mode 100644 index 0000000..d086ef6 --- /dev/null +++ b/src/ser_mail_api/v1/resources/dictionary.py @@ -0,0 +1,50 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from __future__ import annotations + +from typing import Dict, TypeVar, Optional, Callable + +from requests import Response + +from .response_wrapper import ResponseWrapper + + +class Dictionary(Dict, ResponseWrapper): + """ + A specialized dictionary that wraps an HTTP response, providing access to both + the JSON data (as a dictionary) and the original response object. + """ + + def __init__(self, response: Response, transform: Optional[Callable[[Response], Dict]] = None): + """ + Initializes the Dictionary with JSON data from an HTTP response. + + Args: + response (Response): The HTTP response object. + transform (Callable[[Response], Dict], optional): A function to transform the response into + a dictionary. Defaults to None. + + Raises: + ValueError: If the response body is not valid JSON. + """ + try: + ResponseWrapper.__init__(self, response) + # Apply the transform function if provided, otherwise use response.json() + if transform is not None: + if not callable(transform): + raise TypeError("`transform` must be a callable function.") + transformed_data = transform(response) + if not isinstance(transformed_data, dict): + raise ValueError("`transform` function must return a dictionary.") + super().__init__(transformed_data) + else: + super().__init__(response.json()) + except ValueError as e: + raise ValueError( + f"Malformed JSON response from request. HTTP [{response.status_code}/{response.reason}] - {response.text[:255]}") from e + + +TDictionary = TypeVar('TDictionary', bound=Dictionary) diff --git a/src/ser_mail_api/v1/resources/dictionary_resource.py b/src/ser_mail_api/v1/resources/dictionary_resource.py new file mode 100644 index 0000000..5b584ba --- /dev/null +++ b/src/ser_mail_api/v1/resources/dictionary_resource.py @@ -0,0 +1,48 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from __future__ import annotations + +from typing import Type, Generic, Union + +from .dictionary import Dictionary, TDictionary +from .resource import Resource + + +class DictionaryResource(Generic[TDictionary], Resource): + """ + Represents a resource that maps to a dictionary-like object. + + Attributes: + _dict_type (Type[TDictionary]): The type of dictionary to use for resource data. + """ + + def __init__(self, parent: Union[Resource, None], uri: str, dict_type: Type[TDictionary] = Dictionary): + """ + Initializes a new DictionaryResource. + + Args: + parent (Resource): The parent resource. + uri (str): The name/URI segment for this resource. + dict_type (Type[TDictionary]): The dictionary type to use for resource data. Defaults to `Dictionary`. + """ + super().__init__(parent, uri) + self._dict_type = dict_type + + def __call__(self) -> TDictionary: + """ + Fetches the resource data and returns it as an instance of the dictionary type. + + Returns: + TDictionary: An instance of the dictionary type containing the resource data. + + Raises: + requests.exceptions.RequestException: If the HTTP request fails. + ValueError: If the response data cannot be converted to the specified dictionary type. + """ + try: + return self._dict_type(self._session.get(self._uri)) + except Exception as e: + raise ValueError(f"Failed to fetch or parse resource data from {self._uri}: {e}") diff --git a/src/ser_mail_api/v1/resources/error_handler.py b/src/ser_mail_api/v1/resources/error_handler.py new file mode 100644 index 0000000..e95373a --- /dev/null +++ b/src/ser_mail_api/v1/resources/error_handler.py @@ -0,0 +1,74 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +import logging +from typing import Any + +from requests import Response + +logger = logging.getLogger(__name__) + +ERROR_MESSAGES = { + 400: "The request is missing a mandatory request parameter, a parameter contains data which is incorrectly formatted, or the API doesn't have enough information to determine the identity of the customer.", + 401: "There is no authorization information included in the request, the authorization information is incorrect, or the user is not authorized.", + 404: "The campaign ID or threat ID does not exist.", + 429: "The user has made too many requests over the past 24 hours and has been throttled.", + 500: "The service has encountered an unexpected situation and is unable to give a better response to the request.", +} + + +class ErrorHandler: + """ + A class to handle HTTP responses, providing custom error messages for specific status codes + and optionally raising exceptions for non-successful responses. + + Attributes: + raise_for_status (bool): Whether to raise an exception for non-successful HTTP responses. + + Methods: + handler(response: Response, *args, **kwargs) -> Response: + Processes the response, sets custom error messages, and optionally raises exceptions. + """ + + def __init__(self, raise_for_status: bool = False): + """ + Initializes the ErrorHandler. + + Args: + raise_for_status (bool): Whether to raise exceptions for non-successful HTTP responses. Defaults to False. + """ + self.__raise_for_status: bool = raise_for_status + + def handler(self, response: Response, *args: Any, **kwargs: Any) -> Response: + """ + Processes the HTTP response, sets custom error messages for specific status codes, + and optionally raises exceptions for non-successful responses. + + Args: + response (Response): The HTTP response object. + *args: Additional positional arguments (ignored). + **kwargs: Additional keyword arguments (ignored). + + Returns: + Response: The processed HTTP response object. + """ + if response.status_code in ERROR_MESSAGES: + response.reason = ERROR_MESSAGES[response.status_code] + logger.error(f"HTTP {response.status_code}: {response.reason}") + + if self.__raise_for_status: + response.raise_for_status() + + return response + + @property + def raise_for_status(self) -> bool: + """Gets whether exceptions are raised for non-successful responses.""" + return self.__raise_for_status + + @raise_for_status.setter + def raise_for_status(self, raise_for_status: bool): + """Sets whether exceptions are raised for non-successful responses.""" + self.__raise_for_status = raise_for_status diff --git a/src/ser_mail_api/v1/resources/filter_options.py b/src/ser_mail_api/v1/resources/filter_options.py new file mode 100644 index 0000000..0b12099 --- /dev/null +++ b/src/ser_mail_api/v1/resources/filter_options.py @@ -0,0 +1,105 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from datetime import datetime +from enum import Enum +from typing import TypeVar, Dict, Any + +from .parameter import Parameter + + +class FilterOptions: + _options: Dict[str, Any] + + def __init__(self): + self._options = {} + + def clear(self) -> None: + """ + Clears all options. + """ + self._options.clear() + + def add_option(self, key: str, value: Any) -> None: + """ + Adds a key-value pair to the options with validation. + + Args: + key (str): The option key. + value (Any): The option value. + + Raises: + TypeError: If the value type is not supported. + """ + if value is None: + return + + if not isinstance(value, (str, list, datetime, Enum, int, float, bool, Parameter)): + raise TypeError(f"Unsupported type for option value: {type(value).__name__}") + self._options[key] = value + + def _format_value(self, key: str, value: Any) -> str: + """ + Formats a single value based on its type for query parameters. + + Args: + key (str): The key for the parameter. + value: The value to format. + + Returns: + str: The formatted value as a string. + """ + if isinstance(value, Parameter): + return f"{key}={str(value)}" + elif isinstance(value, list) and value: + if all(isinstance(n, str) for n in value): + return f"{key}=[{','.join(value)}]" + elif all(isinstance(n, Enum) for n in value): + return f"{key}=[{','.join(n.value for n in value)}]" + elif isinstance(value, datetime): + return f"{key}={value.strftime('%Y-%m-%dT%H:%M:%S')}" + elif isinstance(value, Enum): + return f"{key}={value.value}" + else: + return f"{key}={value}" + + def __str__(self) -> str: + """ + Converts the options into a query string. + + Returns: + str: The formatted query string. + """ + return "&".join( + self._format_value(k, v) for k, v in self._options.items() if v is not None + ) + + @property + def params(self) -> Dict[str, Any]: + """ + Converts the options into a dictionary format suitable for HTTP requests. + + Returns: + Dict[str, Any]: The formatted parameters as a dictionary. + """ + result = {} + for k, v in self._options.items(): + if isinstance(v, Parameter): + result[k] = str(v) + elif isinstance(v, list) and v: + if all(isinstance(n, str) for n in v): + result[k] = f"[{','.join(v)}]" + elif all(isinstance(n, Enum) for n in v): + result[k] = f"[{','.join(n.value for n in v)}]" + elif isinstance(v, datetime): + result[k] = v.strftime('%Y-%m-%dT%H:%M:%S') + elif isinstance(v, Enum): + result[k] = v.value + else: + result[k] = v + return result + + +TFilterOptions = TypeVar("TFilterOptions", bound=FilterOptions) diff --git a/src/ser_mail_api/v1/resources/parameter.py b/src/ser_mail_api/v1/resources/parameter.py new file mode 100644 index 0000000..b4e85bb --- /dev/null +++ b/src/ser_mail_api/v1/resources/parameter.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + + +class Parameter(ABC): + @abstractmethod + def __str__(self) -> str: + """ + Format the parameter as a string suitable for URI or POST data. + """ + pass diff --git a/src/ser_mail_api/v1/resources/resource.py b/src/ser_mail_api/v1/resources/resource.py new file mode 100644 index 0000000..922fc10 --- /dev/null +++ b/src/ser_mail_api/v1/resources/resource.py @@ -0,0 +1,72 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from __future__ import annotations + +from posixpath import join +from typing import Union, TypeVar + +from requests import Session + + +class Resource: + __session = Session() # Shared session for all Resource instances + """ + Represents a resource in a hierarchical structure with a URI. + + Attributes: + __parent (Resource | None): The parent resource. + __name (str): The name of the resource. + """ + + def __init__(self, parent: Union[Resource, None], uri: str): + """ + Initializes a new Resource. + + Args: + parent (Resource | None): The parent resource. + uri (str): The name/URI segment for this resource. + """ + if not isinstance(uri, str): + raise TypeError(f"Expected 'uri' to be a string, got {type(uri).__name__}") + + if parent is not None and not isinstance(parent, Resource): + raise TypeError(f"Expected 'parent' to be a Resource or None, got {type(parent).__name__}") + + self.__parent = parent + self.__name = uri + + @property + def _name(self) -> str: + """Gets the name of the resource.""" + return self.__name + + @property + def _uri(self) -> str: + """ + Constructs the full URI for this resource by traversing its parent chain. + + Returns: + str: The full URI. + """ + visited = set() # Track visited nodes to prevent infinite loops + uri = self.__name + parent = self.__parent + + while parent is not None: + if id(parent) in visited: + raise ValueError("Cyclic parent reference detected.") + visited.add(id(parent)) + uri = join(parent._name, uri) + parent = parent.__parent + return uri + + @property + def _session(self) -> Session: + """Gets the HTTP session associated with this resource.""" + return self.__session + + +TResource = TypeVar('TResource', bound=Resource) diff --git a/src/ser_mail_api/v1/resources/resources.py b/src/ser_mail_api/v1/resources/resources.py new file mode 100644 index 0000000..a56820d --- /dev/null +++ b/src/ser_mail_api/v1/resources/resources.py @@ -0,0 +1,21 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from __future__ import annotations + +from typing import Type, Generic, Union + +from .resource import Resource, TResource + + +class Resources(Generic[TResource], Resource): + __res: Type[TResource] + + def __init__(self, parent: Union[Resource, None], uri: str, res: Type[TResource]): + super().__init__(parent, uri) + self.__res = res + + def __getitem__(self, domain: str) -> TResource: + return self.__res(self, domain.casefold().strip()) diff --git a/src/ser_mail_api/v1/resources/response_wrapper.py b/src/ser_mail_api/v1/resources/response_wrapper.py new file mode 100644 index 0000000..cec5224 --- /dev/null +++ b/src/ser_mail_api/v1/resources/response_wrapper.py @@ -0,0 +1,49 @@ +""" +Author: Ludvik Jerabek +Package: tap_api +License: MIT +""" +from requests import Response + + +class ResponseWrapper: + """ + Mixin for managing an HTTP response, providing utilities for status, + reason, and access to the raw response. + """ + + def __init__(self, response: Response): + """ + Initializes the ResponseWrapper with a response. + + Args: + response (Response): The HTTP response object to wrap. + """ + self._response: Response = response + + def get_status(self) -> int: + """ + Returns the HTTP status code from the response. + + Returns: + int: The HTTP status code. + """ + return self._response.status_code + + def get_reason(self) -> str: + """ + Returns the HTTP reason phrase from the response. + + Returns: + str: The HTTP reason phrase. + """ + return self._response.reason + + def get_response(self) -> Response: + """ + Returns the original HTTP response object. + + Returns: + Response: The HTTP response object. + """ + return self._response