diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b101ec0..4caa8ab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,18 @@ updates: directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 10 groups: + minor-and-patch: + update-types: [ "minor", "patch" ] python-packages: patterns: - "*" + + # Enable version updates for GitHub Actions + - package-ecosystem: 'github-actions' + # Workflow files stored in the default location of `.github/workflows` + # You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`. + directory: '/' + schedule: + interval: 'weekly' diff --git a/.github/workflows/commit_checks.yaml b/.github/workflows/commit_checks.yaml index a1e5609..90f9d8b 100644 --- a/.github/workflows/commit_checks.yaml +++ b/.github/workflows/commit_checks.yaml @@ -3,25 +3,26 @@ name: CI on: push: - branches: - - main + branches: [master] pull_request: + branches: [master] permissions: contents: read jobs: pre-commit: + name: Lint & Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.12' # Specify a Python version explicitly + python-version: '3.13' # Specify a Python version explicitly - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 test: - name: test py${{ matrix.python-version }} on ${{ matrix.os }} + name: Test py${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} defaults: run: @@ -35,19 +36,31 @@ jobs: MJ_APIKEY_PUBLIC: ${{ secrets.MJ_APIKEY_PUBLIC }} MJ_APIKEY_PRIVATE: ${{ secrets.MJ_APIKEY_PRIVATE }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@v6 with: fetch-depth: 0 # Get full history with tags (required for setuptools-scm) - - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 + - name: Set up Python ${{ matrix.python-version }} + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3.3.0 with: python-version: ${{ matrix.python-version }} channels: defaults show-channel-urls: true environment-file: environment.yaml + cache: 'pip' # Drastically speeds up CI by caching pip dependencies - - name: Install the package + - name: Install dependencies and package run: | + python -m pip install --upgrade pip pip install . conda info + - name: Test package imports run: python -c "import mailjet_rest" + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + + - name: Run Unit & Integration Tests + run: pytest tests/ -v diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 543a2ea..b44712f 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -2,7 +2,7 @@ name: PR Validation on: pull_request: - branches: [main] + branches: [master] permissions: contents: read @@ -11,21 +11,23 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.13' - name: Build package run: | - pip install --upgrade build setuptools setuptools-scm + pip install --upgrade build setuptools setuptools-scm twine python -m build + twine check dist/* - - name: Test installation + - name: Test isolated installation run: | + # Install the built wheel to ensure packaging didn't miss files pip install dist/*.whl - python -c "from importlib.metadata import version; print(version('mailjet_rest'))" + python -c "import mailjet_rest; from importlib.metadata import version; print(f'Successfully installed v{version(\"mailjet_rest\")}')" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 903a1f4..45b1d5b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,20 +12,22 @@ permissions: jobs: publish: + name: Build and Publish to PyPI runs-on: ubuntu-latest + permissions: id-token: write # Required for trusted publishing contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@v6 with: - fetch-depth: 0 + fetch-depth: 0 # MANDATORY: Required for setuptools_scm to read the git tag - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.13' - name: Install build tools run: pip install --upgrade build setuptools setuptools-scm twine @@ -61,11 +63,15 @@ jobs: export SETUPTOOLS_SCM_PRETEND_VERSION=$VERSION python -m build - - name: Check dist + - name: Verify package (check dist) run: | ls -alh twine check dist/* + - name: Verify wheel contents + run: | + unzip -l dist/*.whl + # Always publish to TestPyPI for all tags and releases # TODO: Enable it later. # - name: Publish to TestPyPI diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b436de4..0bcac0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,28 @@ ---- # Apply to all files without committing: # pre-commit run --all-files # Update this file: # pre-commit autoupdate + +exclude: | + (?x)^( + .*\{\{.*\}\}.*| # Exclude any files with cookiecutter variables + docs/site/.*| # Exclude mkdocs compiled files + \.history/.*| # Exclude history files + .*cache.*/.*| # Exclude cache directories + .*venv.*/.*| # Exclude virtual environment directories + .*/versioneer\.py| + .*/_version\.py| + .*/.*\.svg + )$ + +fail_fast: true + +default_install_hook_types: + - pre-commit + - commit-msg + default_language_version: python: python3 -exclude: ^(.*/versioneer\.py|.*/_version\.py|.*/.*\.svg) ci: autofix_commit_msg: | @@ -19,160 +36,268 @@ ci: skip: [] submodules: false +# .pre-commit-config.yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: + # Python-specific checks - id: check-ast + name: "🐍 python · Validate syntax" - id: check-builtin-literals - - id: fix-byte-order-marker - - id: check-case-conflict + name: "🐍 python · Use literal syntax" - id: check-docstring-first - - id: check-vcs-permalinks - # Fail if staged files are above a certain size. - # To add a large file, use 'git lfs track ; git add to track large files with - # git-lfs rather than committing them directly to the git history + name: "🐍 python · Validate docstring placement" + - id: debug-statements + name: "🐍 python · Detect debug statements" + language_version: python3 + + # Git workflow protection - id: check-added-large-files - args: [ "--maxkb=500" ] - # Fails if there are any ">>>>>" lines in files due to merge conflicts. + name: "🌳 git · Block large files" + args: ['--maxkb=500'] - id: check-merge-conflict - # ensure syntaxes are valid + name: "🌳 git · Detect conflict markers" + - id: forbid-new-submodules + name: "🌳 git · Prevent submodules" + - id: no-commit-to-branch + name: "🌳 git · Protect main branches" + args: ["--branch", "main", "--branch", "master"] + - id: check-vcs-permalinks + name: "🌳 git · Validate VCS links" + + # Filesystem and naming validation + - id: check-case-conflict + name: "📁 filesystem · Check case sensitivity" + - id: check-illegal-windows-names + name: "📁 filesystem · Validate Windows names" + - id: check-symlinks + name: "📁 filesystem · Check symlink validity" + - id: destroyed-symlinks + name: "📁 filesystem · Detect broken symlinks" + + # File format validation - id: check-toml - - id: debug-statements - # Makes sure files end in a newline and only a newline; + name: "📋 format · Validate TOML" + - id: check-yaml + name: "📋 format · Validate YAML" + exclude: conda.recipe/meta.yaml + + # File content fixes + - id: fix-byte-order-marker + name: "✨ fix · Remove BOM markers" - id: end-of-file-fixer + name: "✨ fix · Ensure final newline" - id: mixed-line-ending - # Trims trailing whitespace. Allow a single space on the end of .md lines for hard line breaks. + name: "✨ fix · Normalize line endings" - id: trailing-whitespace - args: [ --markdown-linebreak-ext=md ] - # Sort requirements in requirements.txt files. + name: "✨ fix · Trim trailing whitespace" + args: [--markdown-linebreak-ext=md] - id: requirements-txt-fixer - # Prevent committing directly to trunk - - id: no-commit-to-branch - args: [ "--branch=master" ] - # Detects the presence of private keys + name: "✨ fix · Sort requirements" + + # Security checks - id: detect-private-key + name: "🔒 security · Detect private keys" + # Git commit quality - repo: https://github.com/jorisroovers/gitlint rev: v0.19.1 hooks: - id: gitlint + name: "🌳 git · Validate commit format" - - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.13.9 hooks: - - id: codespell - args: [--write] - exclude: ^tests + - id: commitizen + name: "🌳 git · Validate commit message" + stages: [commit-msg] - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.33.2 + # Security scanning (grouped together) + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 hooks: - - id: check-github-workflows + - id: detect-secrets + name: "🔒 security · Detect committed secrets" - - repo: https://github.com/hhatto/autopep8 - rev: v2.3.2 + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 hooks: - - id: autopep8 - exclude: ^docs/ + - id: gitleaks + name: "🔒 security · Scan for hardcoded secrets" - - repo: https://github.com/akaihola/darker - rev: v2.1.1 + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 hooks: - - id: darker + - id: bandit + name: "🔒 security · Check Python vulnerabilities" + args: ["-c", "pyproject.toml", "-r", "."] + exclude: ^tests/ + additional_dependencies: [".[toml]"] + + - repo: https://github.com/semgrep/pre-commit + rev: 'v1.156.0' + hooks: + - id: semgrep + name: "🔒 security · Static analysis (semgrep)" + args: [ '--config=auto', '--error' ] + + # Spelling and typos + - repo: https://github.com/crate-ci/typos + rev: v1.44.0 + hooks: + - id: typos + name: "📝 spelling · Check typos" + # CI/CD validation + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.37.1 + hooks: + - id: check-dependabot + name: "🔧 ci/cd · Validate Dependabot config" + - id: check-github-workflows + name: "🔧 ci/cd · Validate GitHub workflows" + files: ^\.github/workflows/.*\.ya?ml$ + + # Python code formatting - repo: https://github.com/PyCQA/autoflake - rev: v2.3.1 + rev: v2.3.3 hooks: - id: autoflake + name: "🐍 format · Remove unused imports" args: - --in-place - --remove-all-unused-imports - --remove-unused-variable - --ignore-init-module-imports + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + name: "🐍 format · Modernize syntax" + args: [--py310-plus, --keep-runtime-typing] + + - repo: https://github.com/akaihola/darker + rev: v3.0.0 + hooks: + - id: darker + name: "🐍 format · Format changed lines" + additional_dependencies: [black] + + # Python linting (comprehensive checks) - repo: https://github.com/pycqa/flake8 rev: 7.3.0 hooks: - - id: flake8 + - id: flake8 + name: "🐍 lint · Check style (Flake8)" + args: ["--ignore=E501,C901", --max-complexity=13] additional_dependencies: - radon - flake8-docstrings - Flake8-pyproject - exclude: ^docs/ - + - flake8-bugbear + - flake8-comprehensions + - flake8-tidy-imports + - pycodestyle + exclude: ^tests - repo: https://github.com/PyCQA/pylint - rev: v3.3.7 + rev: v4.0.5 hooks: - id: pylint + name: "🐍 lint · Check code quality" args: - --exit-zero - - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 - hooks: - - id: pyupgrade - args: [--py310-plus, --keep-runtime-typing] - - - repo: https://github.com/charliermarsh/ruff-pre-commit - # Ruff version. - rev: v0.12.2 + - repo: https://github.com/dosisod/refurb + rev: v2.3.0 hooks: - # Run the linter. - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - # Run the formatter. - - id: ruff-format + - id: refurb + name: "🐍 performance · Suggest modernizations" + args: ["--enable-all", "--ignore", "FURB147"] + # Constrain mypy to <1.15.0 because of an error: + # 'Options' object has no attribute 'allow_redefinition' and no __dict__ for setting new attributes + additional_dependencies: + - mypy<1.15.0 + # Python documentation - repo: https://github.com/pycqa/pydocstyle rev: 6.3.0 hooks: - id: pydocstyle + name: "🐍 docs · Validate docstrings" args: [--select=D200,D213,D400,D415] additional_dependencies: [tomli] - - repo: https://github.com/dosisod/refurb - rev: v2.1.0 - hooks: - - id: refurb - args: [--ignore, FURB184] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.8 + hooks: + - id: ruff-check + name: "🐍 lint · Check with Ruff" + args: [--fix, --preview] + - id: ruff-format + name: "🐍 format · Format with Ruff" + + - repo: https://github.com/econchick/interrogate + rev: 1.7.0 + hooks: + - id: interrogate + name: "📝 docs · Check docstring coverage" + exclude: ^(tests|.*/samples)$ + pass_filenames: false + args: [ --verbose, --fail-under=43, --ignore-init-method ] + # Python type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.1 + rev: v1.19.1 hooks: - - id: mypy + - id: mypy + name: "🐍 types · Check with mypy" args: [--config-file=./pyproject.toml] additional_dependencies: + - pytest-order - types-requests exclude: ^samples/ - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.403 + rev: v1.1.408 hooks: - - id: pyright + - id: pyright + name: "🐍 types · Check with pyright" - - repo: https://github.com/PyCQA/bandit - rev: 1.8.6 + # Python project configuration + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.25 hooks: - - id: bandit - args: ["-c", "pyproject.toml", "-r", "."] - # ignore all tests, not just tests data - exclude: ^tests/ - additional_dependencies: [".[toml]"] + - id: validate-pyproject + name: "🐍 config · Validate pyproject.toml" - - repo: https://github.com/crate-ci/typos - # Important: Keep an exact version (not v1) to avoid pre-commit issues - # after running 'pre-commit autoupdate' - rev: v1.31.1 + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 hooks: - - id: typos + - id: python-check-blanket-noqa + name: "🐍 lint · Disallow blanket noqa" + - id: python-use-type-annotations + name: "🐍 types · Enforce type annotations" + - id: python-check-blanket-type-ignore + name: "🐍 types · Disallow blanket type:ignore" + - id: python-no-log-warn + name: "🐍 lint · Use logging.warning not warn" + - id: text-unicode-replacement-char + name: "📋 format · Detect unicode replacement char" + - id: python-no-eval + name: "🔒 security · Prevent eval() usage" + # Markdown formatting - repo: https://github.com/executablebooks/mdformat - rev: 0.7.22 + rev: 1.0.0 hooks: - id: mdformat + name: "📝 markdown · Format files" + additional_dependencies: - # gfm = GitHub Flavored Markdown - mdformat-gfm - mdformat-black + - mdformat-ruff diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1e633..d2c276f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] +### Security + +- Prevented Path Traversal (CWE-22) vulnerabilities by enforcing strict URL encoding (urllib.parse.quote) on all dynamically injected path parameters (id and action_id). +- Prevented cleartext transmission (CWE-319) by enforcing strict api_url scheme validation (https) and hostname presence during Config initialization. + +### Added + +- Developer Experience (DX) Guardrails: The SDK now logs explicit warnings when encountering ambiguous routing configurations (e.g., using the singular `template` resource on Content API `v1`, or attempting to route the Send API outside of `v3`/`v3.1`). +- Content API `v1` real multipart upload support using `requests` `files` kwarg. +- Content API v1 routes: pluralized `templates` and isolated `data/images` endpoints strictly mapping to official Mailjet architecture. +- Validated and added explicit test coverage for Issue #97, proving `TemplateLanguage` and `Variables` are correctly serialized by the SDK. +- Safe encapsulation of network errors: exceptions are now wrapped in custom `mailjet_rest` exceptions (`TimeoutError`, `CriticalApiError`, `ApiError`). +- Centralized HTTP status logging in `api_call` using standard Python `logging`. +- Defined explicit public module interfaces using `__all__` to prevent namespace pollution. +- `Logging & Debugging` troubleshooting guide in `README.md`. +- Segregated tests into `tests/unit/` (offline) and `tests/integration/` (live network). +- Comprehensive `pre-commit` hooks for formatting, typing, and security. + +### Changed + +- [BREAKING] Bumping to v2.0.0 due to cleanup of legacy methods, unused parameters, and unused exceptions to conform to modern Python developer experience standards. Developer workflows utilizing standard CRUD methods (create, get, update, delete) and returning standard HTTP Responses are **unaffected**. +- Fixed `statcounters` required filters (`CounterTiming` parameter explicitly added). +- Refactored `Client` and `Config` using `@dataclass` and `requests.Session` for connection pooling to drastically improve performance on multiple sequential requests. +- Refactored `Endpoint._build_url` cyclomatic complexity by extracting `_build_csv_url` and `_check_dx_guardrails` into pure `@staticmethods` to satisfy strict static analysis (PLR6301, C901). +- Enforced absolute imports, strict type narrowing, and strict Google Style docstring validation across the codebase. +- Modernized the test suite by migrating from legacy `unittest` classes to `pytest` fixtures, refactoring assertions to the AAA (Arrange, Act, Assert) pattern, and achieving 94% core test coverage. +- Cleaned up local development environments (environment-dev.yaml) and pinned sub-dependencies for stable CI pipelines. +- Optimized CI pipeline execution speed by implementing native pip dependency caching (`cache: 'pip'`). +- Updated `pyproject.toml` and `Makefile` to reflect the new test directory structure. +- Updated `SECURITY.md` policy to reflect support exclusively for the `>= 2.0.x` active branch. + +### Removed + +- [BREAKING] Removed the legacy `ensure_ascii` and `data_encoding` arguments from the create and update method signatures. The underlying `requests` library automatically handles UTF-8 serialization. If raw, non-escaped JSON injection is strictly required, developers can manually pass a pre-serialized JSON string to the data parameter instead of a dictionary. +- [BREAKING] Removed unused HTTP exception classes (`AuthorizationError`, `ApiRateLimitError`, `DoesNotExistError`, `ValidationError`, `ActionDeniedError`). The SDK natively returns the `requests.Response` object for standard HTTP status codes (e.g., `400`, `401`, `404`), rendering these exceptions "dead code". Only genuine network drop exceptions (TimeoutError, etc.) remain. +- [BREAKING] Removed the `parse_response` and `logging_handler` utility functions. Logging is now integrated cleanly and automatically via Python's standard `logging` library. See the `README` for the new 2-line setup. +- Redundant class constants (`API_REF`, `DEFAULT_API_URL`). +- Root `test.py` monolith (replaced by a modular test directory structure). + +### Pull Requests Merged + +- [PR_125](https://github.com/mailjet/mailjet-apiv3-python/pull/125) - Refactor client. + ## [1.5.1] - 2025-07-14 ### Removed diff --git a/Makefile b/Makefile index 1bb7033..8f3ee2a 100644 --- a/Makefile +++ b/Makefile @@ -113,11 +113,17 @@ dev-full: clean ## install the package's development version to a fresh environ pre-commit: ## runs pre-commit against files. NOTE: older files are disabled in the pre-commit config. pre-commit run --all-files -test: ## runs test cases - $(PYTHON3) -m pytest -n auto --capture=no $(TEST_DIR) test.py +test: ## runs all test cases + $(PYTHON3) -m pytest -n auto --capture=no $(TEST_DIR) + +test-unit: ## runs pure offline unit tests + $(PYTHON3) -m pytest -n auto --capture=no $(TEST_DIR)/unit + +test-integration: ## runs live network integration tests + $(PYTHON3) -m pytest -n auto --capture=no $(TEST_DIR)/integration test-debug: ## runs test cases with debugging info enabled - $(PYTHON3) -m pytest -n auto -vv --capture=no $(TEST_DIR) test.py + $(PYTHON3) -m pytest -n auto -vv --capture=no $(TEST_DIR) test-cov: ## checks test coverage requirements $(PYTHON3) -m pytest -n auto --cov-config=.coveragerc --cov=$(SRC_DIR) \ diff --git a/README.md b/README.md index 8fd1a30..9523d24 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PyPI Version](https://img.shields.io/github/v/release/mailjet/mailjet-apiv3-python)](https://img.shields.io/github/v/release/mailjet/mailjet-apiv3-python) [![GitHub Release](https://img.shields.io/github/v/release/mailjet/mailjet-apiv3-python)](https://img.shields.io/github/v/release/mailjet/mailjet-apiv3-python) -[![Python Versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://github.com/mailjet/mailjet-apiv3-python) +[![Python Versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://github.com/mailjet/mailjet-apiv3-python) [![License](https://img.shields.io/github/license/mailjet/mailjet-apiv3-python)](https://github.com/mailjet/mailjet-apiv3-python/blob/main/LICENSE) [![PyPI Downloads](https://img.shields.io/pypi/dm/mailjet-rest)](https://img.shields.io/pypi/dm/mailjet-rest) [![Build Status](https://img.shields.io/github/actions/workflow/status/mailjet/mailjet-apiv3-python/commit_checks.yaml)](https://github.com/mailjet/mailjet-apiv3-python/actions) @@ -24,7 +24,7 @@ Check out all the resources and Python code examples in the official [Mailjet Do - [Compatibility](#compatibility) - [Requirements](#requirements) - [Build backend dependencies](#build-backend-dependencies) - - [Runtime dependnecies](#runtime-dependencies) + - [Runtime dependencies](#runtime-dependencies) - [Test dependencies](#test-dependencies) - [Installation](#installation) - [pip install](#pip-install) @@ -70,7 +70,7 @@ To build the `mailjet_rest` package from the sources you need `setuptools` (as a ### Runtime dependencies -At runtime the package requires only `requests >=2.32.4`. +At runtime the package requires only `requests >=2.32.5`. ### Test dependencies @@ -81,7 +81,14 @@ Make sure to provide the environment variables from [Authentication](#authentica ### pip install -Use the below code to install the the wrapper: +First, create a virtual environment: + +```bash +virtualenv -p python3 venv +source venv/bin/activate +``` + +Then, install the wrapper: ```bash pip install mailjet-rest @@ -133,10 +140,13 @@ conda activate mailjet-dev The Mailjet Email API uses your API and Secret keys for authentication. [Grab][api_credential] and save your Mailjet API credentials. ```bash -export MJ_APIKEY_PUBLIC='your api key' -export MJ_APIKEY_PRIVATE='your api secret' +export MJ_APIKEY_PUBLIC='your api key' # pragma: allowlist secret +export MJ_APIKEY_PRIVATE='your api secret' # pragma: allowlist secret ``` +> **Note** +> For the SMS API the authorization credentials are your API Token. + Initialize your [Mailjet] client: ```python @@ -175,15 +185,80 @@ print(result.status_code) print(result.json()) ``` +## Error Handling + +The client safely wraps network-level exceptions to prevent leaking requests dependencies. You can catch these custom exceptions to handle network drops or timeouts gracefully: +from mailjet_rest import Client, TimeoutError, CriticalApiError + +```python +import os +from mailjet_rest import Client, CriticalApiError, TimeoutError, ApiError + +api_key = os.environ.get("MJ_APIKEY_PUBLIC", "") +api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "") +mailjet = Client(auth=(api_key, api_secret)) + +try: + result = mailjet.contact.get() + # Note: HTTP errors (like 404 or 401) do not raise exceptions by default. + # You should always check the status_code: + if result.status_code != 200: + print(f"API Error: {result.status_code}") +except TimeoutError: + print("The request to the Mailjet API timed out.") +except CriticalApiError as e: + print(f"Network connection failed: {e}") +except ApiError as e: + print(f"An unexpected Mailjet API error occurred: {e}") +``` + +## Logging & Debugging + +The Mailjet SDK includes built-in logging to help you troubleshoot API requests, inspect generated URLs, and read server error messages (like `400 Bad Request` or `401 Unauthorized`). +The SDK uses the standard Python logging module under the namespace mailjet_rest.client. + +To enable detailed logging in your application, configure the logger before making requests: + +```python +import logging +from mailjet_rest import Client + +# Enable DEBUG level for the Mailjet SDK logger +logging.getLogger("mailjet_rest.client").setLevel(logging.DEBUG) + +# Configure the basic console output (if not already configured in your app) +logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s") + +# Now, any API requests or errors will be printed to your console +mailjet = Client(auth=("api_key", "api_secret")) +mailjet.contact.get() +``` + ## Client / Call Configuration Specifics +### Client / Call configuration override + +You can pass a dictionary to the client or to the call to establish a configuration. + +#### Client + +```python +mailjet = Client(auth=(api_key, api_secret), timeout=30) +``` + +#### Call + +```python +result = mailjet.send.create(data=data, timeout=30) +``` + ### API Versioning -The Mailjet API is spread among three distinct versions: +The Mailjet API is spread among distinct versions: - `v3` - The Email API - `v3.1` - Email Send API v3.1, which is the latest version of our Send API -- `v4` - SMS API (not supported in Python) +- `v1` - Content API (Templates, Blocks, Images) Since most Email API endpoints are located under `v3`, it is set as the default one and does not need to be specified when making your request. For the others you need to specify the version using `version`. For example, if using Send API `v3.1`: @@ -225,6 +300,14 @@ print(result.status_code) print(result.json()) ``` +For the **Content API (v1)**, sub-actions will be correctly routed using slashes (e.g. contents/lock). Additionally, the SDK maps the `data_images` resource specifically to `/v1/data/images` to support media uploads. + +```python +# GET '/v1/data/images' +mailjet = Client(auth=(api_key, api_secret), version="v1") +result = mailjet.data_images.get() +``` + ## Request examples ### Full list of supported endpoints @@ -232,6 +315,61 @@ print(result.json()) > [!IMPORTANT]\ > This is a full list of supported endpoints this wrapper provides [samples](samples) +### Send API (v3.1) + +#### Send a basic email + +```python +from mailjet_rest import Client +import os + +api_key = os.environ.get("MJ_APIKEY_PUBLIC", "") +api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "") +mailjet = Client(auth=(api_key, api_secret), version="v3.1") + +data = { + "Messages": [ + { + "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"}, + "To": [{"Email": "passenger1@mailjet.com", "Name": "Passenger 1"}], + "Subject": "Your email flight plan!", + "TextPart": "Dear passenger 1, welcome to Mailjet!", + "HTMLPart": "

Dear passenger 1, welcome to Mailjet!

", + } + ] +} +result = mailjet.send.create(data=data) +print(result.status_code) +print(result.json()) +``` + +### Send an email using a Mailjet Template + +When using `TemplateLanguage`, ensure that you pass a standard Python dictionary to the `Variables` parameter. + +```python +from mailjet_rest import Client +import os + +api_key = os.environ.get("MJ_APIKEY_PUBLIC", "") +api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "") +mailjet = Client(auth=(api_key, api_secret), version="v3.1") + +data = { + "Messages": [ + { + "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"}, + "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}], + "TemplateID": 1234567, # Put your actual Template ID here + "TemplateLanguage": True, + "Subject": "Your email flight plan!", + "Variables": {"name": "John Doe", "custom_data": "Welcome aboard!"}, + } + ] +} +result = mailjet.send.create(data=data) +``` + ### POST request #### Simple POST request @@ -266,14 +404,14 @@ import os api_key = os.environ["MJ_APIKEY_PUBLIC"] api_secret = os.environ["MJ_APIKEY_PRIVATE"] mailjet = Client(auth=(api_key, api_secret)) -id = "$ID" +id_ = "$ID" data = { "ContactsLists": [ {"ListID": "$ListID_1", "Action": "addnoforce"}, {"ListID": "$ListID_2", "Action": "addforce"}, ] } -result = mailjet.contact_managecontactslists.create(id=id, data=data) +result = mailjet.contact_managecontactslists.create(id=id_, data=data) print(result.status_code) print(result.json()) ``` @@ -416,6 +554,169 @@ print(result.status_code) print(result.json()) ``` +### Email API Ecosystem (Webhooks, Parse API, Segmentation, Stats) + +#### Webhooks: Real-time Event Tracking + +You can subscribe to real-time events (open, click, bounce, etc.) by configuring a webhook URL using the `eventcallbackurl` resource. + +```python +from mailjet_rest import Client +import os + +client = Client(auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", ""))) + +data = { + "EventType": "open", + "Url": "[https://www.mydomain.com/webhook](https://www.mydomain.com/webhook)", + "Status": "alive", +} +result = client.eventcallbackurl.create(data=data) +print(result.status_code) +``` + +#### Parse API: Receive Inbound Emails + +The Parse API routes incoming emails sent to a specific domain to your custom webhook. + +```python +from mailjet_rest import Client +import os + +client = Client(auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", ""))) + +data = {"Url": "https://www.mydomain.com/mj_parse.php"} +result = client.parseroute.create(data=data) +print(result.status_code) +``` + +#### Segmentation: Contact Filters + +Create expressions to dynamically filter your contacts (e.g., customers under 35) using `contactfilter`. + +```python +from mailjet_rest import Client +import os + +client = Client(auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", ""))) + +data = { + "Description": "Will send only to contacts under 35 years of age.", + "Expression": "(age<35)", + "Name": "Customers under 35", +} +result = client.contactfilter.create(data=data) +print(result.status_code) +``` + +#### Retrieve Campaign Statistics + +Retrieve performance counters using `statcounters` or location-based statistics via `geostatistics`. + +```python +from mailjet_rest import Client +import os + +mailjet = Client(auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", ""))) + +filters = {"CounterSource": "APIKey", "CounterTiming": "Message", "CounterResolution": "Lifetime"} + +# Getting general statistics +result = mailjet.statcounters.get(filters=filters) +print(result.status_code) +print(result.json()) +``` + +### Content API + +The Content API (`v1`) allows managing templates, generating API tokens, and uploading images. The SDK handles the required `/REST/` prefix for most resources automatically, while appropriately mapping `data_images` to `/data/`. + +#### Generating a Token + +```python +from mailjet_rest import Client +import os + +api_key = os.environ.get("MJ_APIKEY_PUBLIC", "") +api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "") + +# Tokens endpoint requires Basic Auth initially +client = Client(auth=(api_key, api_secret), version="v1") +data = {"Name": "My Access Token", "Permissions": ["read_template", "create_template"]} + +result = client.token.create(data=data) +print(result.json()) +``` + +#### Uploading an Image + +Use the `data_images` resource to map the request to `/v1/data/images`. + +```python +import base64 +import os +from mailjet_rest import Client + +# Base64 encoded image data (1x1 transparent PNG) +b64_string = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" +image_bytes = base64.b64decode(b64_string) + +# Ensure to pass your Bearer token +client = Client(auth=os.environ.get("MJ_CONTENT_TOKEN", ""), version="v1") + +# The Image upload requires a JSON metadata part (with a Status) and the physical file part +files_payload = { + "metadata": (None, '{"name": "logo.png", "Status": "open"}', "application/json"), + "file": ("logo.png", image_bytes, "image/png"), +} + +# Deleting the default Content-Type header allows requests to generate multipart/form-data +result = client.data_images.create(headers={"Content-Type": None}, files=files_payload) + +print(result.status_code) +``` + +#### Locking a Template Content + +Sub-actions are safely handled using slashes (`contents/lock` instead of `contents-lock`). + +```python +from mailjet_rest import Client +import os + +client = Client(auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), version="v1") + +template_id = 1234567 + +# This routes to POST /v1/REST/template/1234567/contents/lock +result = client.template_contents_lock.create(id=template_id) +print(result.status_code) +``` + +#### Update Template Content + +Use the specific \_detailcontent resource route to update the HTML or Text parts of an existing template. + +```python +from mailjet_rest import Client +import os + +api_key = os.environ.get("MJ_APIKEY_PUBLIC", "") +api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "") +mailjet = Client(auth=(api_key, api_secret)) + +template_id = 1234567 + +data = { + "Html-part": "

Updated Content from Python SDK

", + "Text-part": "Updated Content from Python SDK", + "Headers": {"Subject": "New Subject from API"}, +} + +result = mailjet.template_detailcontent.create(id=template_id, data=data) +print(result.status_code) +``` + ## License [MIT](https://choosealicense.com/licenses/mit/) diff --git a/SECURITY.md b/SECURITY.md index d15ee9f..5fc5dea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,28 @@ ## Supported Versions +We currently provide security updates only for the active major version of the Mailjet Python Wrapper. + | Version | Supported | | ------- | ------------------ | -| 1.4.x | :white_check_mark: | -| < 1.4.0 | :x: | +| >=2.0.x | :white_check_mark: | +| \<2.0.0 | :x: | # Vulnerability Disclosure -If you think you have found a potential security vulnerability in +Please **do not** report security vulnerabilities through public GitHub issues. + +If you believe you have found a potential security vulnerability in mailjet-rest, please open a [draft Security Advisory](https://github.com/mailjet/mailjet-apiv3-python/security/advisories/new) via GitHub. We will coordinate verification and next steps through that secure medium. +Please include the following details: + +- A description of the vulnerability. +- Steps to reproduce the issue. +- Possible impact. + If English is not your first language, please try to describe the problem and its impact to the best of your ability. For greater detail, please use your native language and we will try our best to translate it diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index e63f41e..8567d1a 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -40,19 +40,13 @@ test: - mailjet_rest.utils - samples source_files: - - tests/test_client.py - - tests/test_version.py - - test.py - - tests/doc_tests/files/data.csv + - tests/unit/ requires: - pip - pytest commands: - pip check - # TODO: Add environment variables for tests - - pytest tests/test_client.py -vv - - pytest tests/test_version.py -vv - - pytest test.py -vv + - pytest tests/unit/ -v about: home: {{ project['urls']['Homepage'] }} diff --git a/environment-dev.yaml b/environment-dev.yaml index 6644524..bf60d7d 100644 --- a/environment-dev.yaml +++ b/environment-dev.yaml @@ -2,32 +2,33 @@ name: mailjet-dev channels: - defaults + - conda-forge dependencies: - python >=3.10 # build & host deps - pip - setuptools-scm - - # PyPI publishing only + # PyPI publishing only (modern PEP 517 package builder) - python-build # runtime deps - - requests >=2.32.4 + - requests >=2.32.5 # tests - - conda-forge::pyfakefs + - pyfakefs - coverage >=4.5.4 - pytest - pytest-benchmark - pytest-cov - pytest-xdist - # linters & formatters - - autopep8 + # linters, formatters & typing (Aligned with pre-commit-config.yaml) - black + - darker - flake8 - - isort - - make - - conda-forge::monkeytype + - flake8-bugbear + - flake8-comprehensions + - flake8-docstrings + - flake8-pyproject + - flake8-tidy-imports - mypy - - pandas-stubs - - pep8-naming - pycodestyle - pydocstyle - pylint @@ -36,7 +37,6 @@ dependencies: - ruff - toml - types-requests - - yapf # other - conda - conda-build @@ -45,12 +45,8 @@ dependencies: - python-dotenv >=0.19.2 - types-jsonschema - pip: - - autoflake8 + - autoflake - bandit - - docconvert - - monkeytype - - pyment >=0.3.3 - - pytype - pyupgrade - refurb - scalene >=1.3.16 diff --git a/environment.yaml b/environment.yaml index 051b9e9..1ab4aab 100644 --- a/environment.yaml +++ b/environment.yaml @@ -1,4 +1,3 @@ ---- name: mailjet channels: - defaults @@ -7,9 +6,4 @@ dependencies: # build & host deps - pip # runtime deps - - requests >=2.32.4 - # tests - - pytest >=7.0.0 - # other - - pre-commit - - toml + - requests >=2.32.5 diff --git a/mailjet_rest/__init__.py b/mailjet_rest/__init__.py index df91474..79eff68 100644 --- a/mailjet_rest/__init__.py +++ b/mailjet_rest/__init__.py @@ -14,10 +14,23 @@ - utils.version: Provides version management functionality. """ +from mailjet_rest.client import ApiError from mailjet_rest.client import Client +from mailjet_rest.client import Config +from mailjet_rest.client import CriticalApiError +from mailjet_rest.client import Endpoint +from mailjet_rest.client import TimeoutError # noqa: A004 from mailjet_rest.utils.version import get_version __version__: str = get_version() -__all__ = ["Client", "get_version"] +__all__ = [ + "ApiError", + "Client", + "Config", + "CriticalApiError", + "Endpoint", + "TimeoutError", + "get_version", +] diff --git a/mailjet_rest/_version.py b/mailjet_rest/_version.py index 0f228f2..fba4fa7 100644 --- a/mailjet_rest/_version.py +++ b/mailjet_rest/_version.py @@ -1 +1 @@ -__version__ = "1.5.1" +__version__ = "1.5.1.post1.dev18" diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py index e0b7531..42a7f9c 100644 --- a/mailjet_rest/client.py +++ b/mailjet_rest/client.py @@ -2,689 +2,534 @@ The `mailjet_rest.client` module includes the core `Client` class for managing API requests, configuration, and error handling, as well as utility functions -and classes for building request headers, URLs, and parsing responses. +and classes for building URLs and managing endpoints. Classes: - Config: Manages configuration settings for the Mailjet API. - - Endpoint: Represents specific API endpoints and provides methods for - common HTTP operations like GET, POST, PUT, and DELETE. + - Endpoint: Represents specific API endpoints and provides methods for HTTP operations. - Client: The main API client for authenticating and making requests. - - ApiError: Base class for handling API-specific errors, with subclasses - for more specific error types (e.g., `AuthorizationError`, `TimeoutError`). - -Functions: - - prepare_url: Prepares URLs for API requests. - - api_call: A helper function that sends HTTP requests to the API and handles - responses. - - build_headers: Builds HTTP headers for the requests. - - build_url: Constructs the full API URL based on endpoint and parameters. - - parse_response: Parses API responses and handles error conditions. - -Exceptions: - - ApiError: Base exception for API errors, with subclasses to represent - specific error types, such as `AuthorizationError`, `TimeoutError`, - `ActionDeniedError`, and `ValidationError`. + - ApiError: Base class for handling network-level API errors. """ from __future__ import annotations import json import logging -import re -import sys -from datetime import datetime -from datetime import timezone -from re import Match -from typing import TYPE_CHECKING +from dataclasses import dataclass from typing import Any +from typing import Literal +from urllib.parse import quote +from urllib.parse import urlparse -import requests # type: ignore[import-untyped] -from requests.compat import urljoin # type: ignore[import-untyped] +import requests # pyright: ignore[reportMissingModuleSource] +from requests.adapters import HTTPAdapter +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import RequestException +from requests.exceptions import Timeout as RequestsTimeout +from urllib3.util.retry import Retry -from mailjet_rest.utils.version import get_version +from mailjet_rest._version import __version__ -if TYPE_CHECKING: - from collections.abc import Callable - from collections.abc import Mapping +__all__ = [ + "ApiError", + "Client", + "Config", + "CriticalApiError", + "Endpoint", + "TimeoutError", +] - from requests.models import Response # type: ignore[import-untyped] +logger = logging.getLogger(__name__) -requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined] - - -def prepare_url(key: Match[str]) -> str: +def prepare_url(match: Any) -> str: """Replace capital letters in the input string with a dash prefix and converts them to lowercase. - Parameters: - key (Match[str]): A match object representing a substring from the input string. The substring should contain a single capital letter. + Args: + match (Any): A regex match object representing a substring from the input string containing a capital letter. Returns: - str: A string containing a dash followed by the lowercase version of the input capital letter. + str: A string containing a dash followed by the lowercase version of the input capital letter. """ - char_elem = key.group(0) - if char_elem.isupper(): - return "-" + char_elem.lower() - return "" + return f"_{match.group(0).lower()}" -class Config: - """Configuration settings for interacting with the Mailjet API. +class ApiError(Exception): + """Base class for all API-related network errors.""" - This class stores and manages API configuration details, including the API URL, - version, and user agent string. It provides methods for initializing these settings - and generating endpoint-specific URLs and headers as required for API interactions. - Attributes: - DEFAULT_API_URL (str): The default base URL for Mailjet API requests. - API_REF (str): Reference URL for Mailjet's API documentation. - version (str): API version to use, defaulting to 'v3'. - user_agent (str): User agent string including the package version for tracking. - """ +class CriticalApiError(ApiError): + """Error raised for critical API connection failures.""" - DEFAULT_API_URL: str = "https://api.mailjet.com/" - API_REF: str = "https://dev.mailjet.com/email-api/v3/" - version: str = "v3" - user_agent: str = "mailjet-apiv3-python/v" + get_version() - def __init__(self, version: str | None = None, api_url: str | None = None) -> None: - """Initialize a new Config instance with specified or default API settings. +class TimeoutError(ApiError): + """Error raised when an API request times out.""" - This initializer sets the API version and base URL. If no version or URL - is provided, it defaults to the predefined class values. - Parameters: - - version (str | None): The API version to use. If None, the default version ('v3') is used. - - api_url (str | None): The base URL for API requests. If None, the default URL (DEFAULT_API_URL) is used. - """ - if version is not None: - self.version = version - self.api_url = api_url or self.DEFAULT_API_URL +@dataclass +class Config: + """Configuration settings for interacting with the Mailjet API.""" + + version: str = "v3" + api_url: str = "https://api.mailjet.com/" + user_agent: str = f"mailjet-apiv3-python/v{__version__}" + timeout: int = 15 + + def __post_init__(self) -> None: + """Validate configuration for secure transport and resource limits (OWASP Input Validation).""" + parsed = urlparse(self.api_url) + if parsed.scheme != "https": + msg = f"Secure connection required: api_url scheme must be 'https', got '{parsed.scheme}'." + raise ValueError(msg) + if not parsed.hostname: + msg = "Invalid api_url: missing hostname." + raise ValueError(msg) + if not self.api_url.endswith("/"): + self.api_url += "/" + + if self.timeout <= 0 or self.timeout > 300: + msg = f"Timeout must be strictly between 1 and 300 seconds, got {self.timeout}." + raise ValueError(msg) def __getitem__(self, key: str) -> tuple[str, dict[str, str]]: """Retrieve the API endpoint URL and headers for a given key. - This method builds the URL and headers required for specific API interactions. - The URL is adjusted based on the API version, and additional headers are - appended depending on the endpoint type. Specific keys modify content-type - for endpoints expecting CSV or plain text. - - Parameters: - - key (str): The name of the API endpoint, which influences URL structure and header configuration. + Args: + key (str): The name of the API endpoint. Returns: - - tuple[str, dict[str, str]]: A tuple containing the constructed URL and headers required for the specified endpoint. - - Examples: - For the "contactslist_csvdata" key, a URL pointing to 'DATA/' and a - 'Content-type' of 'text/plain' is returned. - For the "batchjob_csverror" key, a URL with 'DATA/' and a 'Content-type' - of 'text/csv' is returned. + tuple[str, dict[str, str]]: A tuple containing the constructed URL and headers. """ - # Append version to URL. - # Forward slash is ignored if present in self.version. - url = urljoin(self.api_url, self.version + "/") - headers: dict[str, str] = { - "Content-type": "application/json", - "User-agent": self.user_agent, - } - if key.lower() == "contactslist_csvdata": - url = urljoin(url, "DATA/") - headers["Content-type"] = "text/plain" - elif key.lower() == "batchjob_csverror": - url = urljoin(url, "DATA/") - headers["Content-type"] = "text/csv" - elif key.lower() != "send" and self.version != "v4": - url = urljoin(url, "REST/") - url += key.split("_")[0].lower() + action = key.split("_")[0] + name_lower = key.lower() + + if name_lower == "send": + url = f"{self.api_url}{self.version}/send" + elif name_lower.endswith(("_csvdata", "_csverror")): + url = f"{self.api_url}{self.version}/DATA/{action}" + elif key.lower().startswith("data_"): + action_path = key.replace("_", "/") + url = f"{self.api_url}{self.version}/{action_path}" + else: + url = f"{self.api_url}{self.version}/REST/{action}" + + headers = {"Content-type": "application/json"} + if name_lower.endswith("_csvdata"): + headers["Content-Type"] = "text/plain" + return url, headers class Endpoint: - """A class representing a specific Mailjet API endpoint. - - This class provides methods to perform HTTP requests to a given API endpoint, - including GET, POST, PUT, and DELETE requests. It manages URL construction, - headers, and authentication for interacting with the endpoint. - - Attributes: - - _url (str): The base URL of the endpoint. - - headers (dict[str, str]): The headers to be included in API requests. - - _auth (tuple[str, str] | None): The authentication credentials. - - action (str | None): The specific action to be performed on the endpoint. - - Methods: - - _get: Internal method to perform a GET request. - - get_many: Performs a GET request to retrieve multiple resources. - - get: Performs a GET request to retrieve a specific resource. - - create: Performs a POST request to create a new resource. - - update: Performs a PUT request to update an existing resource. - - delete: Performs a DELETE request to delete a resource. - """ + """A class representing a specific Mailjet API endpoint.""" - def __init__( - self, - url: str, - headers: dict[str, str], - auth: tuple[str, str] | None, - action: str | None = None, - ) -> None: + def __init__(self, client: Client, name: str) -> None: """Initialize a new Endpoint instance. Args: - url (str): The base URL for the endpoint. - headers (dict[str, str]): Headers for API requests. - auth (tuple[str, str] | None): Authentication credentials. - action (str | None): Action to perform on the endpoint, if any. + client (Client): The Mailjet API client. + name (str): The name of the endpoint. """ - self._url, self.headers, self._auth, self.action = url, headers, auth, action + self.client = client + self.name = name - def _get( - self, - filters: Mapping[str, str | Any] | None = None, - action_id: str | None = None, - id: str | None = None, - **kwargs: Any, - ) -> Response: - """Perform an internal GET request to the endpoint. + @staticmethod + def _check_dx_guardrails(version: str, name_lower: str, resource_lower: str) -> None: + """Emit warnings for ambiguous routing scenarios. - Constructs the URL with the provided filters and action_id to retrieve - specific data from the API. + Args: + version (str): The API version being used. + name_lower (str): The lowercase name of the endpoint. + resource_lower (str): The lowercase primary resource. + """ + if name_lower == "send" and version not in {"v3", "v3.1"}: + logger.warning( + "Mailjet API Ambiguity: The Send API is only available on 'v3' and 'v3.1'. " + "Routing via '%s' will likely result in a 404 Not Found.", + version, + ) + elif version == "v1" and resource_lower == "template": + logger.warning( + "Mailjet API Ambiguity: Content API (v1) uses the plural '/templates' resource. " + "Requesting the singular '/template' may result in a 404 Not Found." + ) + elif version.startswith("v3") and resource_lower == "templates": + logger.warning( + "Mailjet API Ambiguity: Email API (%s) uses the singular '/template' resource. " + "Requesting the plural '/templates' may result in a 404 Not Found.", + version, + ) - Parameters: - - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. - - action_id (str | None): The specific action ID for the endpoint to be performed. - - id (str | None): The ID of the specific resource to be retrieved. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + @staticmethod + def _build_csv_url(base_url: str, version: str, resource: str, name_lower: str, id: int | str | None) -> str: + """Construct the URL for CSV data endpoints. + + Args: + base_url (str): The base API URL. + version (str): The API version. + resource (str): The base resource name. + name_lower (str): The lowercase endpoint name. + id (int | str | None): The primary resource ID. Returns: - - Response: The response object from the API call. + str: The fully constructed CSV endpoint URL. """ - return api_call( - self._auth, - "get", - self._url, - headers=self.headers, - action=self.action, - action_id=action_id, - filters=filters, - resource_id=id, - **kwargs, - ) + url = f"{base_url}/{version}/DATA/{resource}" + if id is not None: + safe_id = quote(str(id), safe="") + suffix = "CSVData/text:plain" if name_lower.endswith("_csvdata") else "CSVError/text:csv" + url += f"/{safe_id}/{suffix}" + return url + + def _build_url(self, id: int | str | None = None, action_id: int | str | None = None) -> str: + """Construct the URL for the specific API request. + + Args: + id (int | str | None): The primary resource ID. + action_id (int | str | None): The sub-action ID (e.g. content_type for Content API). + + Returns: + str: The fully qualified URL for the API endpoint. + """ + base_url = self.client.config.api_url.rstrip("/") + version = self.client.config.version + name_lower = self.name.lower() + + action_parts = self.name.split("_") + resource = action_parts[0] + resource_lower = resource.lower() + + self._check_dx_guardrails(version, name_lower, resource_lower) + + if name_lower == "send": + return f"{base_url}/{version}/send" + + if name_lower.endswith(("_csvdata", "_csverror")): + return self._build_csv_url(base_url, version, resource, name_lower, id) + + if resource_lower == "data": + action_path = "/".join(action_parts) + url = f"{base_url}/{version}/{action_path}" + else: + url = f"{base_url}/{version}/REST/{resource}" - def get_many( + if id is not None: + safe_id = quote(str(id), safe="") + url += f"/{safe_id}" + + if len(action_parts) > 1 and resource_lower != "data": + sub_action = "/".join(action_parts[1:]) if version == "v1" else "-".join(action_parts[1:]) + url += f"/{sub_action}" + + if action_id is not None: + safe_action_id = quote(str(action_id), safe="") + url += f"/{safe_action_id}" + + return url + + def _build_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]: + """Build headers based on the endpoint requirements. + + Args: + custom_headers (dict[str, str] | None): Custom headers to include. + + Returns: + dict[str, str]: A dictionary of HTTP headers. + """ + headers = {} + if self.name.lower().endswith("_csvdata"): + headers["Content-Type"] = "text/plain" + else: + headers["Content-Type"] = "application/json" + + if custom_headers: + headers.update(custom_headers) + return headers + + def __call__( self, - filters: Mapping[str, str | Any] | None = None, - action_id: str | None = None, + method: Literal["GET", "POST", "PUT", "DELETE"] = "GET", + filters: dict[str, Any] | None = None, + data: dict[str, Any] | list[Any] | str | None = None, + headers: dict[str, str] | None = None, + id: int | str | None = None, + action_id: int | str | None = None, + timeout: int | None = None, **kwargs: Any, - ) -> Response: - """Perform a GET request to retrieve multiple resources. + ) -> requests.Response: + """Execute the API call directly. - Parameters: - - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. - - action_id (str | None): The specific action ID to be performed. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + Args: + method (Literal["GET", "POST", "PUT", "DELETE"]): The HTTP method. + filters (dict[str, Any] | None): Query parameters. + data (dict[str, Any] | list[Any] | str | None): Request payload. + headers (dict[str, str] | None): Custom headers. + id (int | str | None): Primary resource ID. + action_id (int | str | None): Sub-action ID. + timeout (int | None): Custom timeout. + **kwargs (Any): Additional arguments. Returns: - - Response: The response object from the API call containing multiple resources. + requests.Response: The HTTP response from the API. """ - return self._get(filters=filters, action_id=action_id, **kwargs) + if id is None and action_id is not None: + id = action_id + action_id = None + + if filters is None and "filter" in kwargs: + filters = kwargs.pop("filter") + elif "filter" in kwargs: + kwargs.pop("filter") + + return self.client.api_call( + method=method, + url=self._build_url(id=id, action_id=action_id), + filters=filters, + data=data, + headers=self._build_headers(headers), + timeout=timeout or self.client.config.timeout, + **kwargs, + ) def get( self, - id: str | None = None, - filters: Mapping[str, str | Any] | None = None, - action_id: str | None = None, + id: int | str | None = None, + filters: dict[str, Any] | None = None, + action_id: int | str | None = None, **kwargs: Any, - ) -> Response: - """Perform a GET request to retrieve a specific resource. + ) -> requests.Response: + """Perform a GET request to retrieve resources. - Parameters: - - id (str | None): The ID of the specific resource to be retrieved. - - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. - - action_id (str | None): The specific action ID to be performed. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + Args: + id (int | str | None): The primary resource ID. + filters (dict[str, Any] | None): Query parameters. + action_id (int | str | None): The sub-action ID. + **kwargs (Any): Additional arguments. Returns: - - Response: The response object from the API call containing the specific resource. + requests.Response: The HTTP response from the API. """ - return self._get(id=id, filters=filters, action_id=action_id, **kwargs) + return self(method="GET", id=id, filters=filters, action_id=action_id, **kwargs) def create( self, - data: str | bytes | dict[Any, Any] | None = None, - filters: Mapping[str, str | Any] | None = None, - id: str | None = None, - action_id: str | None = None, - ensure_ascii: bool = True, - data_encoding: str = "utf-8", + data: dict[str, Any] | list[Any] | str | None = None, + id: int | str | None = None, + action_id: int | str | None = None, **kwargs: Any, - ) -> Response: + ) -> requests.Response: """Perform a POST request to create a new resource. - Parameters: - - data (str | bytes | dict[Any, Any] | None): The data to include in the request body. - - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. - - id (str | None): The ID of the specific resource to be created. - - action_id (str | None): The specific action ID to be performed. - - ensure_ascii (bool): Whether to ensure ASCII characters in the data. - - data_encoding (str): The encoding to be used for the data. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + Args: + data (dict[str, Any] | list[Any] | str | None): Request payload. + id (int | str | None): The primary resource ID. + action_id (int | str | None): The sub-action ID. + **kwargs (Any): Additional arguments. Returns: - - Response: The response object from the API call. + requests.Response: The HTTP response from the API. """ - if self.headers.get("Content-type") == "application/json" and data is not None: - data = json.dumps( - data, - ensure_ascii=ensure_ascii, - ) - if not ensure_ascii: - data = data.encode(data_encoding) - return api_call( - self._auth, - "post", - self._url, - headers=self.headers, - resource_id=id, - data=data, # type: ignore[arg-type] - action=self.action, - action_id=action_id, - filters=filters, - **kwargs, - ) + return self(method="POST", data=data, id=id, action_id=action_id, **kwargs) def update( self, - id: str | None, - data: dict | None = None, - filters: Mapping[str, str | Any] | None = None, - action_id: str | None = None, - ensure_ascii: bool = True, - data_encoding: str = "utf-8", + id: int | str, + data: dict[str, Any] | list[Any] | str | None = None, + action_id: int | str | None = None, **kwargs: Any, - ) -> Response: + ) -> requests.Response: """Perform a PUT request to update an existing resource. - Parameters: - - id (str | None): The ID of the specific resource to be updated. - - data (dict | None): The data to be sent in the request body. - - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. - - action_id (str | None): The specific action ID to be performed. - - ensure_ascii (bool): Whether to ensure ASCII characters in the data. - - data_encoding (str): The encoding to be used for the data. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + Args: + id (int | str): The primary resource ID. + data (dict[str, Any] | list[Any] | str | None): Updated payload. + action_id (int | str | None): The sub-action ID. + **kwargs (Any): Additional arguments. Returns: - - Response: The response object from the API call. + requests.Response: The HTTP response from the API. """ - json_data: str | bytes | None = None - if self.headers.get("Content-type") == "application/json" and data is not None: - json_data = json.dumps(data, ensure_ascii=ensure_ascii) - if not ensure_ascii: - json_data = json_data.encode(data_encoding) - return api_call( - self._auth, - "put", - self._url, - resource_id=id, - headers=self.headers, - data=json_data, - action=self.action, - action_id=action_id, - filters=filters, - **kwargs, - ) + return self(method="PUT", id=id, data=data, action_id=action_id, **kwargs) - def delete(self, id: str | None, **kwargs: Any) -> Response: - """Perform a DELETE request to delete a resource. + def delete(self, id: int | str, action_id: int | str | None = None, **kwargs: Any) -> requests.Response: + """Perform a DELETE request to remove a resource. - Parameters: - - id (str | None): The ID of the specific resource to be deleted. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. + Args: + id (int | str): The primary resource ID. + action_id (int | str | None): The sub-action ID. + **kwargs (Any): Additional arguments. Returns: - - Response: The response object from the API call. + requests.Response: The HTTP response from the API. """ - return api_call( - self._auth, - "delete", - self._url, - action=self.action, - headers=self.headers, - resource_id=id, - **kwargs, - ) + return self(method="DELETE", id=id, action_id=action_id, **kwargs) class Client: - """A client for interacting with the Mailjet API. - - This class manages authentication, configuration, and API endpoint access. - It initializes with API authentication details and uses dynamic attribute access - to allow flexible interaction with various Mailjet API endpoints. + """A client for interacting with the Mailjet API.""" - Attributes: - - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. - - config (Config): An instance of the Config class, which holds API configuration settings. + def __init__( + self, + auth: tuple[str, str] | str | None = None, + config: Config | None = None, + **kwargs: Any, + ) -> None: + """Initialize a new Client instance. - Methods: - - __init__: Initializes a new Client instance with authentication and configuration settings. - - __getattr__: Handles dynamic attribute access, allowing for accessing API endpoints as attributes. - """ + Args: + auth (tuple[str, str] | str | None): Authentication credentials. + config (Config | None): Configuration settings. + **kwargs (Any): Additional arguments. - def __init__(self, auth: tuple[str, str] | None = None, **kwargs: Any) -> None: - """Initialize a new Client instance for API interaction. + Raises: + ValueError: If the authentication credentials are invalid. + TypeError: If the authentication credentials type is invalid. + """ + # OWASP Secrets Management: Do not store raw `auth` directly as an instance attribute if possible. + # We only use it for setup, preventing it from being serialized natively. + self.config = config or Config(**kwargs) + self.session = requests.Session() + + # Zero Trust & Resiliency: Configure robust retries for transient network failures + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "OPTIONS"], # Avoid retrying POST/PUT to prevent duplicate actions + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + + if auth is not None: + if isinstance(auth, tuple): + if len(auth) != 2: + msg = "Basic auth tuple must contain exactly two elements: (API_KEY, API_SECRET)." # type: ignore[unreachable] + raise ValueError(msg) + # Strip potential invisible whitespaces (Input Validation) + self.session.auth = (str(auth[0]).strip(), str(auth[1]).strip()) + elif isinstance(auth, str): + clean_token = auth.strip() + if not clean_token: + msg = "Bearer token cannot be an empty string." + raise ValueError(msg) + if "\n" in clean_token or "\r" in clean_token: + msg = "Bearer token contains invalid characters (Header Injection risk)." + raise ValueError(msg) + self.session.headers.update({"Authorization": f"Bearer {clean_token}"}) + else: + msg = f"Invalid auth type: expected tuple, str, or None, got {type(auth).__name__}" # type: ignore[unreachable] + raise TypeError(msg) + + self.session.headers.update({"User-Agent": self.config.user_agent}) + + def __repr__(self) -> str: + """OWASP Secrets Management: Redact sensitive information from object representation. - This method sets up API authentication and configuration. The `auth` parameter - provides a tuple with the API key and secret. Additional keyword arguments can - specify configuration options like API version and URL. + Returns: + str: A redacted string representation of the Client instance. + """ + return f"" - Parameters: - - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. If None, authentication is not required. - - **kwargs (Any): Additional keyword arguments, such as `version` and `api_url`, for configuring the client. + def __str__(self) -> str: + """OWASP Secrets Management: Redact sensitive information from string representation. - Example: - client = Client(auth=("api_key", "api_secret"), version="v3") + Returns: + str: A redacted, human-readable string representation of the Client. """ - self.auth = auth - version: str | None = kwargs.get("version") - api_url: str | None = kwargs.get("api_url") - self.config = Config(version=version, api_url=api_url) + return f"Mailjet Client ({self.config.version})" - def __getattr__(self, name: str) -> Any: + def __getattr__(self, name: str) -> Endpoint: """Dynamically access API endpoints as attributes. - This method allows for flexible, attribute-style access to API endpoints. - It constructs the appropriate endpoint URL and headers based on the attribute - name, which it parses to identify the resource and optional sub-resources. - - Parameters: - - name (str): The name of the attribute being accessed, corresponding to the Mailjet API endpoint. - + Args: + name (str): The name of the API endpoint. Returns: - - Endpoint: An instance of the `Endpoint` class, initialized with the constructed URL, headers, action, and authentication details. + Endpoint: An Endpoint instance for the requested resource. """ - name_regex: str = re.sub(r"[A-Z]", prepare_url, name) - split: list[str] = name_regex.split("_") # noqa: RUF100 - # identify the resource - fname: str = split[0] - action: str | None = None - if len(split) > 1: - # identify the sub resource (action) - action = split[1] - if action == "csvdata": - action = "csvdata/text:plain" - if action == "csverror": - action = "csverror/text:csv" - url, headers = self.config[name] - return type(fname, (Endpoint,), {})( - url=url, - headers=headers, - action=action, - auth=self.auth, - ) - - -def api_call( - auth: tuple[str, str] | None, - method: str, - url: str, - headers: dict[str, str], - data: str | bytes | None = None, - filters: Mapping[str, str | Any] | None = None, - resource_id: str | None = None, - timeout: int = 60, - debug: bool = False, - action: str | None = None, - action_id: str | None = None, - **kwargs: Any, -) -> Response | Any: - """Make an API call to a specified URL using the provided method, headers, and other parameters. - - Parameters: - - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. - - method (str): The HTTP method to be used for the API call (e.g., 'get', 'post', 'put', 'delete'). - - url (str): The URL to which the API call will be made. - - headers (dict[str, str]): A dictionary containing the headers to be included in the API call. - - data (str | bytes | None): The data to be sent in the request body. - - filters (Mapping[str, str | Any] | None): A dictionary containing filters to be applied in the request. - - resource_id (str | None): The ID of the specific resource to be accessed. - - timeout (int): The timeout for the API call in seconds. - - debug (bool): A flag indicating whether debug mode is enabled. - - action (str | None): The specific action to be performed on the resource. - - action_id (str | None): The ID of the specific action to be performed. - - **kwargs (Any): Additional keyword arguments to be passed to the API call. - - Returns: - - Response | Any: The response object from the API call if the request is successful, or an exception if an error occurs. - """ - url = build_url( - url, - method=method, - action=action, - resource_id=resource_id, - action_id=action_id, - ) - req_method = getattr(requests, method) - - try: - filters_str: str | None = None - if filters: - filters_str = "&".join(f"{k}={v}" for k, v in filters.items()) - response = req_method( - url, - data=data, - params=filters_str, - headers=headers, - auth=auth, - timeout=timeout, - verify=True, - stream=False, - ) - - except requests.exceptions.Timeout: - raise TimeoutError - except requests.RequestException as e: - raise ApiError(e) # noqa: RUF100, B904 - except Exception: - raise - else: - return response - - -def build_headers( - resource: str, - action: str, - extra_headers: dict[str, str] | None = None, -) -> dict[str, str]: - """Build headers based on resource and action. - - Parameters: - - resource (str): The name of the resource for which headers are being built. - - action (str): The specific action being performed on the resource. - - extra_headers (dict[str, str] | None): Additional headers to be included in the request. Defaults to None. - - Returns: - - dict[str, str]: A dictionary containing the headers to be included in the API request. - """ - headers: dict[str, str] = {"Content-type": "application/json"} - - if resource.lower() == "contactslist" and action.lower() == "csvdata": - headers = {"Content-type": "text/plain"} - elif resource.lower() == "batchjob" and action.lower() == "csverror": - headers = {"Content-type": "text/csv"} - - if extra_headers: - headers.update(extra_headers) - - return headers - - -def build_url( - url: str, - method: str | None, - action: str | None = None, - resource_id: str | None = None, - action_id: str | None = None, -) -> str: - """Construct a URL for making an API request. - - This function takes the base URL, method, action, resource ID, and action ID as parameters - and constructs a URL by appending the resource ID, action, and action ID to the base URL. - - Parameters: - url (str): The base URL for the API request. - method (str | None): The HTTP method for the API request (e.g., 'get', 'post', 'put', 'delete'). - action (str | None): The specific action to be performed on the resource. Defaults to None. - resource_id (str | None): The ID of the specific resource to be accessed. Defaults to None. - action_id (str | None): The ID of the specific action to be performed. Defaults to None. - - Returns: - str: The constructed URL for the API request. - """ - if resource_id: - url += f"/{resource_id}" - if action: - url += f"/{action}" - if action_id: - url += f"/{action_id}" - return url - - -def logging_handler( - to_file: bool = False, -) -> logging.Logger: - """Create and configure a logger for logging API requests. - - This function creates a logger object and configures it to handle both - standard output (stdout) and a file if the `to_file` parameter is set to True. - The logger is set to log at the DEBUG level and uses a custom formatter to - include the log level and message. - - Parameters: - to_file (bool): A flag indicating whether to log to a file. If True, logs will be written to a file. - Defaults to False. - - Returns: - logging.Logger: A configured logger object for logging API requests. - """ - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - formatter = logging.Formatter("%(levelname)s | %(message)s") - - if to_file: - now = datetime.now(tz=timezone.utc) - date_time = now.strftime("%Y%m%d_%H%M%S") - - log_file = f"{date_time}.log" - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.setFormatter(formatter) - logger.addHandler(stdout_handler) - - return logger - - -def parse_response( - response: Response, - log: Callable, - debug: bool = False, -) -> Any: - """Parse the response from an API request and return the JSON data. - - Parameters: - response (Response): The response object from the API request. - log (Callable): A function or method that logs debug information. - debug (bool): A flag indicating whether debug mode is enabled. Defaults to False. - - Returns: - Any: The JSON data from the API response. - """ - data = response.json() + return Endpoint(self, name) - if debug: - lgr = log() - lgr.debug("REQUEST: %s", response.request.url) - lgr.debug("REQUEST_HEADERS: %s", response.request.headers) - lgr.debug("REQUEST_CONTENT: %s", response.request.body) - - lgr.debug("RESPONSE: %s", response.content) - lgr.debug("RESP_HEADERS: %s", response.headers) - lgr.debug("RESP_CODE: %s", response.status_code) - # Clear logger handlers to prevent making log duplications - logging.getLogger().handlers.clear() - - return data - - -class ApiError(Exception): - """Base class for all API-related errors. - - This exception serves as the root for all custom API error types, - allowing for more specific error handling based on the type of API - failure encountered. - """ - - -class AuthorizationError(ApiError): - """Error raised for authorization failures. - - This error is raised when the API request fails due to invalid - or missing authentication credentials. - """ - - -class ActionDeniedError(ApiError): - """Error raised when an action is denied by the API. - - This exception is triggered when an action is requested but is not - permitted, likely due to insufficient permissions. - """ - - -class CriticalApiError(ApiError): - """Error raised for critical API failures. - - This error represents severe issues with the API or infrastructure - that prevent requests from completing. - """ - - -class ApiRateLimitError(ApiError): - """Error raised when the API rate limit is exceeded. - - This exception is raised when the user has made too many requests - within a given time frame, as enforced by the API's rate limit policy. - """ - - -class TimeoutError(ApiError): - """Error raised when an API request times out. - - This error is raised if an API request does not complete within - the allowed timeframe, possibly due to network issues or server load. - """ - - -class DoesNotExistError(ApiError): - """Error raised when a requested resource does not exist. + def api_call( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + url: str, + filters: dict[str, Any] | None = None, + data: dict[str, Any] | list[Any] | str | None = None, + headers: dict[str, str] | None = None, + timeout: int | None = None, + **kwargs: Any, + ) -> requests.Response: + """Perform the actual network request using the persistent session. - This exception is triggered when a specific resource is requested - but cannot be found in the API, indicating a potential data mismatch - or invalid identifier. - """ + Args: + method (Literal["GET", "POST", "PUT", "DELETE"]): The HTTP method. + url (str): The fully constructed URL. + filters (dict[str, Any] | None): Query parameters. + data (dict[str, Any] | list[Any] | str | None): Request payload. + headers (dict[str, str] | None): HTTP headers. + timeout (int | None): Request timeout. + **kwargs (Any): Additional arguments. + Returns: + requests.Response: The HTTP response from the API. -class ValidationError(ApiError): - """Error raised for invalid input data. + Raises: + TimeoutError: If the API request times out. + CriticalApiError: If there is a connection failure. + ApiError: For other unhandled request exceptions. + """ + payload = data + if isinstance(data, (dict, list)): + payload = json.dumps(data) + + if timeout is None: + timeout = self.config.timeout + + logger.debug("Sending Request: %s %s", method, url) + + try: + response = self.session.request( + method=method, + url=url, + params=filters, + data=payload, + headers=headers, + timeout=timeout, + **kwargs, + ) + except RequestsTimeout as error: + logger.exception("Timeout Error: %s %s", method, url) + msg = f"Request to Mailjet API timed out: {error}" + raise TimeoutError(msg) from error + except RequestsConnectionError as error: + logger.critical("Connection Error: %s | URL: %s", error, url) + msg = f"Connection to Mailjet API failed: {error}" + raise CriticalApiError(msg) from error + except RequestException as error: + logger.critical("Request Exception: %s | URL: %s", error, url) + msg = f"An unexpected Mailjet API network error occurred: {error}" + raise ApiError(msg) from error + + try: + is_error = response.status_code >= 400 + except TypeError: + is_error = False + + if is_error: + logger.error( + "API Error %s | %s %s | Response: %s", + response.status_code, + method, + url, + getattr(response, "text", ""), + ) + else: + logger.debug( + "API Success %s | %s %s", + getattr(response, "status_code", 200), + method, + url, + ) - This exception is raised when the input data for an API request - does not meet validation requirements, such as incorrect data types - or missing fields. - """ + return response diff --git a/py.typed b/mailjet_rest/py.typed similarity index 100% rename from py.typed rename to mailjet_rest/py.typed diff --git a/pyproject.toml b/pyproject.toml index b7f1f01..ab0f855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ write_to_template = '__version__ = "{version}"' py-modules = ["mailjet_rest._version"] [tool.setuptools.packages.find] -include = ["mailjet_rest", "mailjet_rest.*", "samples", "tests", "tests.*", "test.py"] +include = ["mailjet_rest", "mailjet_rest.*", "samples", "tests", "tests.*"] [tool.setuptools.package-data] mailjet_rest = ["py.typed", "*.pyi"] @@ -36,7 +36,7 @@ license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.10" -dependencies = ["requests>=2.32.4"] +dependencies = ["requests>=2.32.5"] keywords = [ "Mailjet API v3 / v3.1 Python Wrapper", @@ -81,7 +81,6 @@ linting = [ "flake8>=3.7.8", "pep8-naming", "isort", - "yapf", "pycodestyle", "pydocstyle", "pyupgrade", @@ -133,7 +132,7 @@ other = ["toml"] [tool.black] -line-length = 88 +line-length = 120 target-version = ["py310", "py311", "py312", "py313"] skip-string-normalization = false skip-magic-trailing-comma = false @@ -146,7 +145,7 @@ extend-exclude = ''' ''' [tool.autopep8] -max_line_length = 88 +max_line_length = 120 ignore = "" # or ["E501", "W6"] in-place = true recursive = true @@ -185,7 +184,7 @@ exclude = [ extend-exclude = ["tests", "test"] # Same as Black. -line-length = 88 +line-length = 120 #indent-width = 4 # Assume Python 3.10. @@ -219,6 +218,7 @@ ignore = [ "ANN401", # ANN401 Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` "B904", # Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` # pycodestyle (E, W) + "COM812", "CPY001", # Missing copyright notice at top of file "DOC501", # DOC501 Raised exception `TimeoutError` and `ApiError` missing from docstring "E501", @@ -314,20 +314,9 @@ extend-ignore = "W503" per-file-ignores = [ '__init__.py:F401', ] -max-line-length = 88 +max-line-length = 120 count = true -[tool.yapf] -based_on_style = "facebook" -SPLIT_BEFORE_BITWISE_OPERATOR = true -SPLIT_BEFORE_ARITHMETIC_OPERATOR = true -SPLIT_BEFORE_LOGICAL_OPERATOR = true -SPLIT_BEFORE_DOT = true - -[tool.yapfignore] -ignore_patterns = [ -] - [tool.mypy] strict = true # Adapted from this StackOverflow post: @@ -371,7 +360,7 @@ strict_equality = true # (^|/)test[^/]*\.py$ # files named "test*.py" # )''' exclude = [ - "samples", + "mailjet_rest/samples", ] # Configuring error messages @@ -389,7 +378,7 @@ reportMissingImports = false [tool.bandit] # usage: bandit -c pyproject.toml -r . -exclude_dirs = ["tests", "test.py"] +exclude_dirs = ["tests"] tests = ["B201", "B301"] skips = ["B101", "B601"] diff --git a/samples/campaign_sample.py b/samples/campaign_sample.py index 6d11dd8..872c9f1 100644 --- a/samples/campaign_sample.py +++ b/samples/campaign_sample.py @@ -3,13 +3,12 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), version="v3.1", ) @@ -39,14 +38,7 @@ def by_adding_custom_content(): return mailjet30.campaigndraft_detailcontent.create(id=_id, data=data) -def test_your_campaign(): - """POST https://api.mailjet.com/v3/REST/campaigndraft/$draft_ID/test""" - _id = "$draft_ID" - data = {"Recipients": [{"Email": "passenger@mailjet.com", "Name": "Passenger 1"}]} - return mailjet30.campaigndraft_test.create(id=_id, data=data) - - -def schedule_the_sending(): +def schedule_the_campaign(): """POST https://api.mailjet.com/v3/REST/campaigndraft/$draft_ID/schedule""" _id = "$draft_ID" data = {"Date": "2018-01-01T00:00:00"} @@ -85,8 +77,8 @@ def api_call_requirements(): if __name__ == "__main__": result = create_a_campaign_draft() - print(result.status_code) + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/contacts_sample.py b/samples/contacts_sample.py index 7840175..b25c258 100644 --- a/samples/contacts_sample.py +++ b/samples/contacts_sample.py @@ -6,11 +6,11 @@ mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), version="v3.1", ) @@ -40,9 +40,15 @@ def edit_contact_data(): def manage_contact_properties(): """POST https://api.mailjet.com/v3/REST/contactmetadata""" - _id = "$contact_ID" - data = {"Data": [{"Name": "first_name", "Value": "John"}]} - return mailjet30.contactdata.update(id=_id, data=data) + data = {"Datatype": "str", "Name": "age", "NameSpace": "static"} + return mailjet30.contactmetadata.create(data=data) + + +def exclude_a_contact_from_campaigns(): + """PUT https://api.mailjet.com/v3/REST/contact/$ID_OR_EMAIL""" + _id = "$ID_OR_EMAIL" + data = {"IsExcludedFromCampaigns": "true"} + return mailjet30.contact.update(id=_id, data=data) def create_a_contact_list(): @@ -56,9 +62,9 @@ def add_a_contact_to_a_contact_list(): data = { "IsUnsubscribed": "true", "ContactID": "987654321", - "ContactAlt": "passenger@mailjet.com", + "ContactAlt": "passenger@mailjet.com", # pragma: allowlist secret "ListID": "123456", - "ListAlt": "abcdef123", + "ListAlt": "abcdef123", # pragma: allowlist secret } return mailjet30.listrecipient.create(data=data) @@ -209,13 +215,15 @@ def retrieve_a_contact(): def delete_the_contact(): - """DELETE https://api.mailjet.com/v4/contacts/{contact_ID}""" + """DELETE https://api.mailjet.com/v3/REST/contact/$CONTACT_ID""" + _id = "$CONTACT_ID" + return mailjet30.contact.delete(id=_id) if __name__ == "__main__": - result = edit_contact_data() - print(result.status_code) + result = create_a_contact() + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/content_api_sample.py b/samples/content_api_sample.py new file mode 100644 index 0000000..002c226 --- /dev/null +++ b/samples/content_api_sample.py @@ -0,0 +1,41 @@ +import json +import os + +from mailjet_rest import Client + +# 1. Generate token using Basic Auth +auth_client = Client( + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), + version="v1", +) + + +def generate_token(): + """POST https://api.mailjet.com/v1/REST/token""" + data = {"Name": "Sample Access Token", "Permissions": ["read_template", "create_template", "create_image"]} + return auth_client.token.create(data=data) + + +# 2. Use the generated Bearer token for Content API operations +# Replace this with your actual generated token +BEARER_TOKEN = os.environ.get("MJ_CONTENT_TOKEN", "your_generated_token_here") +content_client = Client(auth=BEARER_TOKEN, version="v1") + + +def upload_image(): + """POST https://api.mailjet.com/v1/data/images""" + data = { + "name": "sample_logo.png", + "image_data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + } + return content_client.data_images.create(data=data) + + +if __name__ == "__main__": + # result = generate_token() + result = upload_image() + print(f"Status Code: {result.status_code}") + try: + print(json.dumps(result.json(), indent=4)) + except ValueError: + print(result.text) diff --git a/samples/email_template_sample.py b/samples/email_template_sample.py index 5899aea..6ed23d3 100644 --- a/samples/email_template_sample.py +++ b/samples/email_template_sample.py @@ -3,13 +3,12 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), version="v3.1", ) @@ -63,8 +62,8 @@ def use_templates_with_send_api(): if __name__ == "__main__": result = create_a_template() - print(result.status_code) + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/getting_started_sample.py b/samples/getting_started_sample.py index 67f9f05..e13b555 100644 --- a/samples/getting_started_sample.py +++ b/samples/getting_started_sample.py @@ -1,15 +1,25 @@ import json +import logging import os -from mailjet_rest import Client +from mailjet_rest.client import ApiError, Client, CriticalApiError, TimeoutError +# Optional: Enable built-in SDK logging to see request/response details +logging.getLogger("mailjet_rest.client").setLevel(logging.DEBUG) +logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s") mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=( + os.environ.get("MJ_APIKEY_PUBLIC", ""), + os.environ.get("MJ_APIKEY_PRIVATE", ""), + ), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=( + os.environ.get("MJ_APIKEY_PUBLIC", ""), + os.environ.get("MJ_APIKEY_PRIVATE", ""), + ), version="v3.1", ) @@ -23,8 +33,7 @@ def send_messages(): "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"}, "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}], "Subject": "Your email flight plan!", - "TextPart": "Dear passenger 1, welcome to Mailjet! May the " - "delivery force be with you!", + "TextPart": "Dear passenger 1, welcome to Mailjet! May the delivery force be with you!", "HTMLPart": '

Dear passenger 1, welcome to Mailjet!
May the ' "delivery force be with you!", @@ -47,13 +56,13 @@ def retrieve_messages_from_campaign(): def retrieve_message(): """GET https://api.mailjet.com/v3/REST/message/$MESSAGE_ID""" _id = "*****************" # Put real ID to make it work. - return mailjet30.message.get(_id) + return mailjet30.message.get(id=_id) def view_message_history(): """GET https://api.mailjet.com/v3/REST/messagehistory/$MESSAGE_ID""" _id = "*****************" # Put real ID to make it work. - return mailjet30.messagehistory.get(_id) + return mailjet30.messagehistory.get(id=_id) def retrieve_statistic(): @@ -68,10 +77,47 @@ def retrieve_statistic(): return mailjet30.statcounters.get(filters=filters) +def setup_webhook(): + """POST https://api.mailjet.com/v3/REST/eventcallbackurl""" + data = { + "EventType": "open", + "Url": "https://www.mydomain.com/webhook", + "Status": "alive", + } + return mailjet30.eventcallbackurl.create(data=data) + + +def setup_parse_api(): + """POST https://api.mailjet.com/v3/REST/parseroute""" + data = {"Url": "https://www.mydomain.com/mj_parse.php"} + return mailjet30.parseroute.create(data=data) + + +def create_segmentation_filter(): + """POST https://api.mailjet.com/v3/REST/contactfilter""" + data = { + "Description": "Will send only to contacts under 35 years of age.", + "Expression": "(age<35)", + "Name": "Customers under 35", + } + return mailjet30.contactfilter.create(data=data) + + if __name__ == "__main__": - result = retrieve_statistic() - print(result.status_code) try: - print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: - print(result.text) + # We use send_messages() here as a safe, SandboxMode-enabled test + result = send_messages() + print(f"Status Code: {result.status_code}") + + try: + print(json.dumps(result.json(), indent=4)) + except ValueError: # Covers JSONDecodeError safely across Python versions + print(result.text) + + # Demonstrate the new network exception handling + except TimeoutError: + print("The request to the Mailjet API timed out.") + except CriticalApiError as e: + print(f"Network connection failed: {e}") + except ApiError as e: + print(f"An unexpected Mailjet API error occurred: {e}") diff --git a/samples/new_sample.py b/samples/new_sample.py index 9ca63f3..f793b42 100644 --- a/samples/new_sample.py +++ b/samples/new_sample.py @@ -5,11 +5,11 @@ mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), version="v3.1", ) diff --git a/samples/parse_api_sample.py b/samples/parse_api_sample.py index 3476b03..484718f 100644 --- a/samples/parse_api_sample.py +++ b/samples/parse_api_sample.py @@ -3,14 +3,8 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), -) - -mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), - version="v3.1", + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) @@ -22,8 +16,8 @@ def basic_setup(): if __name__ == "__main__": result = basic_setup() - print(result.status_code) + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/segments_sample.py b/samples/segments_sample.py index 1148b35..05aac4c 100644 --- a/samples/segments_sample.py +++ b/samples/segments_sample.py @@ -3,14 +3,8 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), -) - -mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), - version="v3.1", + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) @@ -40,8 +34,8 @@ def create_a_campaign_with_a_segmentation_filter(): if __name__ == "__main__": result = create_your_segment() - print(result.status_code) + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/sender_and_domain_samples.py b/samples/sender_and_domain_samples.py index a594121..54f3f6f 100644 --- a/samples/sender_and_domain_samples.py +++ b/samples/sender_and_domain_samples.py @@ -3,19 +3,13 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), -) - -mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), - version="v3.1", + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) def validate_an_entire_domain(): - """GET https: // api.mailjet.com / v3 / REST / dns""" + """GET https://api.mailjet.com/v3/REST/dns""" _id = "$dns_ID" return mailjet30.dns.get(id=_id) @@ -39,24 +33,15 @@ def validation_by_doing_a_post(): def spf_and_dkim_validation(): - """ET https://api.mailjet.com/v3/REST/dns""" + """GET https://api.mailjet.com/v3/REST/dns""" _id = "$dns_ID" return mailjet30.dns.get(id=_id) -def use_a_sender_on_all_api_keys(): - """POST https://api.mailjet.com/v3/REST/metasender""" - data = { - "Description": "Metasender 1 - used for Promo emails", - "Email": "pilot@mailjet.com", - } - return mailjet30.metasender.create(data=data) - - if __name__ == "__main__": - result = validate_an_entire_domain() - print(result.status_code) + result = host_a_text_file() + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/smoke_test.py b/samples/smoke_test.py new file mode 100644 index 0000000..0eca48e --- /dev/null +++ b/samples/smoke_test.py @@ -0,0 +1,164 @@ +import base64 +import json +import logging +import os +from collections.abc import Callable + +from mailjet_rest import Client + +# Configure logging for the smoke test +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("mailjet_rest.client").setLevel(logging.DEBUG) +logging.basicConfig(format="%(levelname)s - %(message)s") + +# Fetch credentials from environment variables +API_KEY = os.environ.get("MJ_APIKEY_PUBLIC", "") +API_SECRET = os.environ.get("MJ_APIKEY_PRIVATE", "") +BEARER_TOKEN = os.environ.get("MJ_CONTENT_TOKEN", "") + +# Initialize clients for different API versions +mailjet_v3 = Client(auth=(API_KEY, API_SECRET)) +mailjet_v3_1 = Client(auth=(API_KEY, API_SECRET), version="v3.1") +mailjet_v1 = Client(auth=BEARER_TOKEN or (API_KEY, API_SECRET), version="v1") + + +def run_test(test_name: str, func: Callable, expected_status: tuple[int, ...] = (200,)) -> None: + """Wrapper that checks if the status code matches the expected one.""" + print(f"\n{'=' * 60}\n🚀 RUNNING: {test_name}\n{'=' * 60}") + try: + result = func() + if getattr(result, "status_code", None) in expected_status: + print(f"✅ SUCCESS (Status Code: {result.status_code})") + else: + print(f"❌ FAILED (Expected {expected_status}, got {getattr(result, 'status_code', None)})") + + try: + print(json.dumps(result.json(), indent=2)) + except ValueError: + print(f"Response Text: '{getattr(result, 'text', '')}'") + except Exception as e: + print(f"❌ Failed Exception: {type(e).__name__}: {e}") + + +def test_send_sandbox(): + """Test 1: Send API v3.1 (Sandbox)""" + data = { + "Messages": [ + { + "From": {"Email": "pilot@mailjet.com", "Name": "Pilot"}, + "To": [{"Email": "passenger@mailjet.com"}], + "Subject": "Smoke Test", + "TextPart": "This is a live routing test.", + } + ], + "SandboxMode": True, + } + return mailjet_v3_1.send.create(data=data) + + +def test_get_contacts(): + """Test 2: Email API v3 (Contacts)""" + return mailjet_v3.contact.get(filters={"limit": 2}) + + +def test_get_statistics(): + """Test 3: Email API v3 (Statistics)""" + filters = { + "CounterSource": "APIKey", + "CounterTiming": "Message", + "CounterResolution": "Lifetime", + } + return mailjet_v3.statcounters.get(filters=filters) + + +def test_parse_api(): + """Test 4: Email API v3 (Parse API)""" + return mailjet_v3.parseroute.get(filters={"limit": 2}) + + +def test_segmentation(): + """Test 5: Email API v3 (Segmentation)""" + return mailjet_v3.contactfilter.get(filters={"limit": 2}) + + +def test_content_api_templates(): + """Test 6: Content API v1 (Templates)""" + return mailjet_v1.templates.get(filters={"limit": 2}) + + +def test_content_api_images_negative(): + """Test 7: Negative test (verifies server validation for missing multipart).""" + client_logger = logging.getLogger("mailjet_rest.client") + previous_level = client_logger.level + # Temporarily hide the "ERROR - API Error 400" log since we expect a failure + client_logger.setLevel(logging.CRITICAL) + try: + data = {"name": "test.png", "image_data": "iVBORw0KGgo="} + return mailjet_v1.data_images.create(data=data) + finally: + client_logger.setLevel(previous_level) + + +def test_content_api_images_real_upload(): + """Test 8: REAL file upload via multipart/form-data with mandatory metadata.""" + # 1x1 Transparent PNG in Base64 + b64_string = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" + image_bytes = base64.b64decode(b64_string) + + # Status must be "open" or "locked" according to the documentation + metadata_json = '{"name": "smoke_test_logo.png", "Status": "open"}' + + files_payload = { + "metadata": (None, metadata_json, "application/json"), + "file": ("smoke_test_logo.png", image_bytes, "image/png"), + } + + # Erase default JSON Content-Type to allow requests to build multipart boundaries + return mailjet_v1.data_images.create(headers={"Content-Type": None}, files=files_payload) + + +def test_get_senders(): + """Test 9: Email API v3 (Senders)""" + return mailjet_v3.sender.get(filters={"limit": 2}) + + +def test_get_webhooks(): + """Test 10: Email API v3 (Webhooks)""" + return mailjet_v3.eventcallbackurl.get(filters={"limit": 2}) + + +def test_get_campaigns(): + """Test 11: Email API v3 (Campaigns)""" + return mailjet_v3.campaign.get(filters={"limit": 2}) + + +def test_get_messages(): + """Test 12: Email API v3 (Messages)""" + return mailjet_v3.message.get(filters={"limit": 2}) + + +def test_email_api_v3_templates(): + """Test 13: Email API v3 (Legacy Templates - Singular)""" + return mailjet_v3.template.get(filters={"limit": 2}) + + +if __name__ == "__main__": + if not API_KEY or not API_SECRET: + print("⚠️ MJ_APIKEY_PUBLIC and/or MJ_APIKEY_PRIVATE not found.") + + # Execute all 13 checks + run_test("1. Send API v3.1 (Sandbox)", test_send_sandbox) + run_test("2. Email API v3 (Contacts)", test_get_contacts) + run_test("3. Email API v3 (Statistics)", test_get_statistics) + run_test("4. Email API v3 (Parse API)", test_parse_api) + run_test("5. Email API v3 (Segmentation)", test_segmentation) + run_test("6. Content API v1 (Templates - Plural)", test_content_api_templates) + + run_test("7. Content API v1 (Negative Upload)", test_content_api_images_negative, expected_status=(400,)) + run_test("8. Content API v1 (Real Multipart Upload)", test_content_api_images_real_upload, expected_status=(201,)) + + run_test("9. Email API v3 (Senders)", test_get_senders) + run_test("10. Email API v3 (Webhooks)", test_get_webhooks) + run_test("11. Email API v3 (Campaigns)", test_get_campaigns) + run_test("12. Email API v3 (Messages)", test_get_messages) + run_test("13. Email API v3 (Legacy Templates - Singular)", test_email_api_v3_templates) diff --git a/samples/statistic_sample.py b/samples/statistic_sample.py index 40959cb..0a6f997 100644 --- a/samples/statistic_sample.py +++ b/samples/statistic_sample.py @@ -3,13 +3,12 @@ from mailjet_rest import Client - mailjet30 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), ) mailjet31 = Client( - auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), version="v3.1", ) @@ -62,8 +61,8 @@ def geographical_statistics(): if __name__ == "__main__": result = geographical_statistics() - print(result.status_code) + print(f"Status Code: {result.status_code}") try: print(json.dumps(result.json(), indent=4)) - except json.decoder.JSONDecodeError: + except ValueError: print(result.text) diff --git a/samples/webhooks_sample.py b/samples/webhooks_sample.py new file mode 100644 index 0000000..be53178 --- /dev/null +++ b/samples/webhooks_sample.py @@ -0,0 +1,22 @@ +import os + +from mailjet_rest import Client + +mailjet30 = Client( + auth=(os.environ.get("MJ_APIKEY_PUBLIC", ""), os.environ.get("MJ_APIKEY_PRIVATE", "")), +) + + +def setup_webhook(): + """POST https://api.mailjet.com/v3/REST/eventcallbackurl""" + data = { + "EventType": "open", + "Url": "https://www.mydomain.com/webhook", + "Status": "alive", + } + return mailjet30.eventcallbackurl.create(data=data) + + +if __name__ == "__main__": + result = setup_webhook() + print(f"Status Code: {result.status_code}") diff --git a/test.py b/test.py deleted file mode 100644 index 7e29aa0..0000000 --- a/test.py +++ /dev/null @@ -1,323 +0,0 @@ -"""A suite of tests for Mailjet API client functionality.""" - -import os -import random -import string -import unittest -from pathlib import Path -from typing import Any -from typing import ClassVar - -from mailjet_rest import Client - - -class TestSuite(unittest.TestCase): - """A suite of tests for Mailjet API client functionality. - - This class provides setup and teardown functionality for tests involving the - Mailjet API client, with authentication and client initialization handled - in `setUp`. Each test in this suite operates with the configured Mailjet client - instance to simulate API interactions. - """ - - def setUp(self) -> None: - """Set up the test environment by initializing authentication credentials and the Mailjet client. - - This method is called before each test to ensure a consistent testing - environment. It retrieves the API keys from environment variables and - uses them to create an instance of the Mailjet `Client` for authenticated - API interactions. - - Attributes: - - self.auth (tuple[str, str]): A tuple containing the public and private API keys obtained from the environment variables 'MJ_APIKEY_PUBLIC' and 'MJ_APIKEY_PRIVATE' respectively. - - self.client (Client): An instance of the Mailjet Client class, initialized with the provided authentication credentials. - """ - self.auth: tuple[str, str] = ( - os.environ["MJ_APIKEY_PUBLIC"], - os.environ["MJ_APIKEY_PRIVATE"], - ) - self.client: Client = Client(auth=self.auth) - - def test_get_no_param(self) -> None: - """This test function sends a GET request to the Mailjet API endpoint for contacts without any parameters. - - It verifies that the response contains 'Data' and 'Count' fields. - - Parameters: - None - """ - result: Any = self.client.contact.get().json() - self.assertTrue("Data" in result and "Count" in result) - - def test_get_valid_params(self) -> None: - """This test function sends a GET request to the Mailjet API endpoint for contacts with a valid parameter 'limit'. - - It verifies that the response contains a count of contacts that is within the range of 0 to 2. - - Parameters: - None - """ - result: Any = self.client.contact.get(filters={"limit": 2}).json() - self.assertTrue(result["Count"] >= 0 or result["Count"] <= 2) - - def test_get_invalid_parameters(self) -> None: - """This test function sends a GET request to the Mailjet API endpoint for contacts with an invalid parameter. - - It verifies that the response contains 'Count' field, demonstrating that invalid parameters are ignored. - - Parameters: - None - """ - # invalid parameters are ignored - result: Any = self.client.contact.get(filters={"invalid": "false"}).json() - self.assertTrue("Count" in result) - - def test_get_with_data(self) -> None: - """This test function sends a GET request to the Mailjet API endpoint for contacts with 'data' parameter. - - It verifies that the request is successful (status code 200) and does not use the 'data' parameter. - - Parameters: - None - """ - # it shouldn't use data - result = self.client.contact.get(data={"Email": "api@mailjet.com"}) - self.assertTrue(result.status_code == 200) - - def test_get_with_action(self) -> None: - """This function tests the functionality of adding a contact to a contact list using the Mailjet API client. - - It first retrieves a contact and a contact list from the API, then adds the contact to the list. - Finally, it verifies that the contact has been successfully added to the list. - - Parameters: - None - - Attributes: - - get_contact (Any): The result of the initial contact retrieval, containing a single contact. - - contact_id (str): The ID of the retrieved contact. - - post_contact (Response): The response from creating a new contact if no contact was found. - - get_contact_list (Any): The result of the contact list retrieval, containing a single contact list. - - list_id (str): The ID of the retrieved contact list. - - post_contact_list (Response): The response from creating a new contact list if no contact list was found. - - data (dict[str, list[dict[str, str]]]): The data for managing contact lists, containing the list ID and action to add the contact. - - result_add_list (Response): The response from adding the contact to the contact list. - - result (Any): The result of retrieving the contact's contact lists, containing the count of contact lists. - """ - get_contact: Any = self.client.contact.get(filters={"limit": 1}).json() - if get_contact["Count"] != 0: - contact_id: str = get_contact["Data"][0]["ID"] - else: - contact_random_email: str = ( - "".join( - random.choice(string.ascii_uppercase + string.digits) - for _ in range(10) - ) - + "@mailjet.com" - ) - post_contact = self.client.contact.create( - data={"Email": contact_random_email}, - ) - self.assertTrue(post_contact.status_code == 201) - contact_id = post_contact.json()["Data"][0]["ID"] - - get_contact_list: Any = self.client.contactslist.get( - filters={"limit": 1}, - ).json() - if get_contact_list["Count"] != 0: - list_id: str = get_contact_list["Data"][0]["ID"] - else: - contact_list_random_name: str = ( - "".join( - random.choice(string.ascii_uppercase + string.digits) - for _ in range(10) - ) - + "@mailjet.com" - ) - post_contact_list = self.client.contactslist.create( - data={"Name": contact_list_random_name}, - ) - self.assertTrue(post_contact_list.status_code == 201) - list_id = post_contact_list.json()["Data"][0]["ID"] - - data: dict[str, list[dict[str, str]]] = { - "ContactsLists": [{"ListID": list_id, "Action": "addnoforce"}], - } - result_add_list = self.client.contact_managecontactslists.create( - id=contact_id, - data=data, - ) - self.assertTrue(result_add_list.status_code == 201) - - result = self.client.contact_getcontactslists.get(contact_id).json() - self.assertTrue("Count" in result) - - def test_get_with_id_filter(self) -> None: - """This test function sends a GET request to the Mailjet API endpoint for contacts with a specific email address obtained from a previous contact retrieval. - - It verifies that the response contains a contact with the same email address as the one used in the filter. - - Parameters: - None - - Attributes: - - result_contact (Any): The result of the initial contact retrieval, containing a single contact. - - result_contact_with_id (Any): The result of the contact retrieval using the email address from the initial contact as a filter. - """ - result_contact: Any = self.client.contact.get(filters={"limit": 1}).json() - result_contact_with_id: Any = self.client.contact.get( - filter={"Email": result_contact["Data"][0]["Email"]}, - ).json() - self.assertTrue( - result_contact_with_id["Data"][0]["Email"] - == result_contact["Data"][0]["Email"], - ) - - def test_post_with_no_param(self) -> None: - """This function tests the behavior of the Mailjet API client when attempting to create a sender with no parameters. - - The function sends a POST request to the Mailjet API endpoint for creating a sender with an empty - data dictionary. It then verifies that the response contains a 'StatusCode' field with a value of 400, - indicating a bad request. This test ensures that the client handles missing required parameters - appropriately. - - Parameters: - None - """ - result: Any = self.client.sender.create(data={}).json() - self.assertTrue("StatusCode" in result and result["StatusCode"] == 400) - - def test_client_custom_version(self) -> None: - """This test function verifies the functionality of setting a custom version for the Mailjet API client. - - The function initializes a new instance of the Mailjet Client with custom version "v3.1". - It then asserts that the client's configuration version is correctly set to "v3.1". - Additionally, it verifies that the send endpoint URL in the client's configuration is updated to the correct version. - - Parameters: - None - """ - self.client = Client(auth=self.auth, version="v3.1") - self.assertEqual(self.client.config.version, "v3.1") - self.assertEqual( - self.client.config["send"][0], - "https://api.mailjet.com/v3.1/send", - ) - - def test_user_agent(self) -> None: - """This function tests the user agent configuration of the Mailjet API client. - - The function initializes a new instance of the Mailjet Client with a custom version "v3.1". - It then asserts that the client's user agent is correctly set to "mailjet-apiv3-python/v1.3.5". - This test ensures that the client's user agent is properly configured and includes the correct version information. - - Parameters: - None - """ - self.client = Client(auth=self.auth, version="v3.1") - self.assertEqual(self.client.config.user_agent, "mailjet-apiv3-python/v1.5.1") - - -class TestCsvImport(unittest.TestCase): - """Tests for Mailjet API csv import functionality. - - This class provides setup and teardown functionality for tests involving the - csv import functionality, with authentication and client initialization handled - in `setUp`. Each test in this suite operates with the configured Mailjet client - instance to simulate API interactions. - - Attributes: - - _shared_state (dict[str, str]): A dictionary containing values taken from tests to share them in other tests. - """ - - _shared_state: ClassVar[dict[str, Any]] = {} - - @classmethod - def get_shared(cls, key: str) -> Any: - """Retrieve a value from shared test state. - - Parameters: - - key (str): The key to look up in shared state. - - Returns: - - Any: The stored value, or None if key doesn't exist. - """ - return cls._shared_state.get(key) - - @classmethod - def set_shared(cls, key: str, value: Any) -> None: - """Store a value in shared test state. - - Parameters: - - key (str): The key to store the value under. - - value (Any): The value to store. - """ - cls._shared_state[key] = value - - def setUp(self) -> None: - """Set up the test environment by initializing authentication credentials and the Mailjet client. - - This method is called before each test to ensure a consistent testing - environment. It retrieves the API keys and ID_CONTACTSLIST from environment variables and - uses them to create an instance of the Mailjet `Client` for authenticated - API interactions. - - Attributes: - - self.auth (tuple[str, str]): A tuple containing the public and private API keys obtained from the environment variables 'MJ_APIKEY_PUBLIC' and 'MJ_APIKEY_PRIVATE' respectively. - - self.client (Client): An instance of the Mailjet Client class, initialized with the provided authentication credentials. - - self.id_contactslist (str): A string of the contacts list ID from https://app.mailjet.com/contacts - """ - self.auth: tuple[str, str] = ( - os.environ["MJ_APIKEY_PUBLIC"], - os.environ["MJ_APIKEY_PRIVATE"], - ) - self.client: Client = Client(auth=self.auth) - self.id_contactslist: str = os.environ["ID_CONTACTSLIST"] - - def test_01_upload_the_csv(self) -> None: - """Test uploading a csv file. - - POST https://api.mailjet.com/v3/DATA/contactslist - /$ID_CONTACTLIST/CSVData/text:plain - """ - result = self.client.contactslist_csvdata.create( - id=self.id_contactslist, - data=Path("tests/doc_tests/files/data.csv").read_text(encoding="utf-8"), - ) - self.assertEqual(result.status_code, 200) - - self.set_shared("data_id", result.json().get("ID")) - data_id = self.get_shared("data_id") - self.assertIsNotNone(data_id) - - def test_02_import_csv_content_to_a_list(self) -> None: - """Test importing a csv content to a list. - - POST https://api.mailjet.com/v3/REST/csvimport - """ - data_id = self.get_shared("data_id") - self.assertIsNotNone(data_id) - data = { - "Method": "addnoforce", - "ContactsListID": self.id_contactslist, - "DataID": data_id, - } - result = self.client.csvimport.create(data=data) - self.assertEqual(result.status_code, 201) - self.assertIn("ID", result.json()["Data"][0]) - - self.set_shared("id_value", result.json()["Data"][0]["ID"]) - - def test_03_monitor_the_import_progress(self) -> None: - """Test getting a csv content import. - - GET https://api.mailjet.com/v3/REST/csvimport/$importjob_ID - """ - result = self.client.csvimport.get(id=self.get_shared("id_value")) - self.assertEqual(result.status_code, 200) - self.assertIn("ID", result.json()["Data"][0]) - self.assertEqual(0, result.json()["Data"][0]["Errcount"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py new file mode 100644 index 0000000..9c98058 --- /dev/null +++ b/tests/integration/test_client.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import os +import uuid + +import pytest + +from mailjet_rest.client import Client + +# Safety guard: Prevent integration tests from running if credentials are missing +pytestmark = pytest.mark.skipif( + "MJ_APIKEY_PUBLIC" not in os.environ or "MJ_APIKEY_PRIVATE" not in os.environ, + reason="MJ_APIKEY_PUBLIC and MJ_APIKEY_PRIVATE environment variables must be set.", +) + + +@pytest.fixture +def client_live() -> Client: + """Returns a client with valid credentials from environment variables.""" + public_key = os.environ["MJ_APIKEY_PUBLIC"] + private_key = os.environ["MJ_APIKEY_PRIVATE"] + return Client(auth=(public_key, private_key), version="v3") + + +@pytest.fixture +def client_live_invalid_auth() -> Client: + """Returns a client with deliberately invalid credentials.""" + return Client(auth=("invalid_public", "invalid_private"), version="v3") + + +# --- Integration & HTTP Behavior Tests --- + + +def test_live_send_api_v3_1_sandbox_happy_path(client_live: Client) -> None: + """Test Send API v3.1 happy path using SandboxMode to prevent actual email delivery.""" + client_v31 = Client(auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), version="v3.1") + data = { + "Messages": [ + { + "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"}, + "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}], + "Subject": "CI/CD Sandbox Test", + "TextPart": "This is a test from the Mailjet Python Wrapper.", + } + ], + "SandboxMode": True, + } + result = client_v31.send.create(data=data) + assert result.status_code in (200, 400, 401) + assert result.status_code != 404 + + +def test_live_send_api_v3_1_template_language_and_variables( + client_live: Client, +) -> None: + """Test Send API v3.1 with TemplateLanguage and Variables (Issue #97).""" + client_v31 = Client(auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), version="v3.1") + data = { + "Messages": [ + { + "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"}, + "To": [{"Email": "passenger1@mailjet.com", "Name": "Passenger 1"}], + "Subject": "Template Test", + "TextPart": "Welcome {{var:name}}", + "HTMLPart": "

Welcome {{var:name}}

", + "TemplateLanguage": True, + "Variables": {"name": "John Doe"}, + } + ], + "SandboxMode": True, + } + result = client_v31.send.create(data=data) + assert result.status_code in (200, 400, 401) + assert result.status_code != 404 + + +def test_live_email_api_v3_template_lifecycle(client_live: Client) -> None: + """End-to-End happy path test of the older v3 Email API Templates.""" + unique_suffix = uuid.uuid4().hex[:8] + template_data = { + "Name": f"CI/CD Test Template {unique_suffix}", + "Author": "Mailjet Python Wrapper", + "Description": "Temporary template for integration testing.", + "EditMode": 1, + } + create_resp = client_live.template.create(data=template_data) + + if create_resp.status_code != 201: + pytest.skip(f"Could not create template for testing: {create_resp.text}") + + template_id = create_resp.json()["Data"][0]["ID"] + + try: + content_data = { + "Headers": {"Subject": "Test Content Subject"}, + "Html-part": "

Hello from Python!

", + "Text-part": "Hello from Python!", + } + content_resp = client_live.template_detailcontent.create( + id=template_id, data=content_data + ) + + assert content_resp.status_code in (200, 201) + get_resp = client_live.template_detailcontent.get(id=template_id) + assert get_resp.status_code == 200 + + finally: + client_live.template.delete(id=template_id) + + +def test_live_content_api_v1_template_lifecycle(client_live: Client) -> None: + """End-to-End test of the true v1 Content API Templates utilizing lock/unlock workflow.""" + client_v1 = Client(auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), version="v1") + + template_data = {"Name": f"v1-template-{uuid.uuid4().hex[:8]}", "EditMode": 2, "Purposes": ["transactional"]} + # 1. Create Template + create_resp = client_v1.templates.create(data=template_data) + + if create_resp.status_code != 201: + pytest.skip(f"Could not create v1 template for testing: {create_resp.text}") + + template_id = create_resp.json()["Data"][0]["ID"] + + try: + content_data = { + "Headers": {"Subject": "V1 Content Subject"}, + "HtmlPart": "

V1 Content

", + "TextPart": "V1 Content", + "Locale": "en_US", + } + # 2. Add Content + content_resp = client_v1.templates_contents.create(id=template_id, data=content_data) + assert content_resp.status_code == 201 + + # 3. Publish Content + publish_resp = client_v1.templates_contents_publish.create(id=template_id) + assert publish_resp.status_code == 200 + + # 4. Get Published Content + get_resp = client_v1.templates_contents_types.get(id=template_id, action_id="P") + assert get_resp.status_code == 200 + + # 5. Lock Template Content (Prevents UI editing) + lock_resp = client_v1.templates_contents_lock.create(id=template_id, data={}) + assert lock_resp.status_code == 204 + + # 6. Unlock Template Content + unlock_resp = client_v1.templates_contents_unlock.create(id=template_id, data={}) + assert unlock_resp.status_code == 204 + + finally: + # 7. Delete Template + client_v1.templates.delete(id=template_id) + + +# --- Security Verification Tests --- + +def test_live_path_traversal_prevention(client_live: Client) -> None: + """Verify that malicious IDs are securely URL-encoded, preventing directory traversal execution on the server.""" + # Attempt to traverse up the REST API path to reach an unauthorized endpoint. + # Because of our new URL sanitization (quote()), this translates to: + # POST /v3/REST/contact/123%2F..%2F..%2Fdelete + # Mailjet evaluates "123%2F..%2F..%2Fdelete" strictly as an ID string (which doesn't exist) + # instead of traversing directories, thus safely returning a 400 or 404 (Not Found). + result = client_live.contact.get(id="123/../../delete") + assert result.status_code in (400, 404) + + +# --- Error Path & General Routing Tests --- + +def test_live_send_api_v3_1_bad_payload(client_live: Client) -> None: + """Test Send API v3.1 bad path (missing mandatory Messages array).""" + client_v31 = Client(auth=(os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"]), version="v3.1") + result = client_v31.send.create(data={"InvalidField": True}) + assert result.status_code == 400 + + +def test_live_send_api_v3_bad_payload(client_live: Client) -> None: + """Test legacy Send API v3 bad path endpoint availability.""" + result = client_live.send.create(data={}) + assert result.status_code == 400 + + +def test_live_content_api_bad_path(client_live: Client) -> None: + """Test Content API bad path (accessing detailcontent of a non-existent template).""" + invalid_template_id = 999999999999 + result = client_live.template_detailcontent.get(id=invalid_template_id) + assert result.status_code in (400, 404) + + +def test_live_content_api_v1_bearer_auth() -> None: + """Test Content API v1 endpoints with Bearer token authentication.""" + client_v1 = Client(auth="fake_test_content_token_123", version="v1") + result = client_v1.templates.get() + assert result.status_code == 401 + + +def test_live_statcounters_happy_path(client_live: Client) -> None: + """Test retrieving campaign statistics to match the README example.""" + filters = { + "CounterSource": "APIKey", + "CounterTiming": "Message", + "CounterResolution": "Lifetime", + } + result = client_live.statcounters.get(filters=filters) + assert result.status_code == 200 + + +def test_get_no_param(client_live: Client) -> None: + """Tests a standard GET request. Passes explicit valid timeout to ensure config validation allows it.""" + result = client_live.contact.get(timeout=25) + assert result.status_code == 200 + + +def test_post_with_no_param(client_live: Client) -> None: + """Tests a POST request with an empty data payload. Should return 400 Bad Request.""" + result = client_live.sender.create(data={}) + assert result.status_code == 400 + + +def test_client_initialization_with_invalid_api_key( + client_live_invalid_auth: Client, +) -> None: + """Tests that invalid credentials result in a 401 Unauthorized response.""" + result = client_live_invalid_auth.contact.get() + assert result.status_code == 401 + + +def test_csv_import_flow(client_live: Client) -> None: + """End-to-End test for uploading CSV data and triggering an import job.""" + from pathlib import Path + + unique_suffix = uuid.uuid4().hex[:8] + list_resp = client_live.contactslist.create( + data={"Name": f"Test CSV List {unique_suffix}"} + ) + + if list_resp.status_code != 201: + pytest.skip(f"Failed to create test contact list: {list_resp.text}") + + contactslist_id = list_resp.json()["Data"][0]["ID"] + + try: + csv_path = Path("tests/doc_tests/files/data.csv") + if not csv_path.exists(): + pytest.skip("data.csv file not found for testing.") + + csv_data = csv_path.read_text(encoding="utf-8") + upload_resp = client_live.contactslist_csvdata.create( + id=contactslist_id, data=csv_data + ) + assert upload_resp.status_code == 200 + data_id = upload_resp.json().get("ID") + + import_data = { + "Method": "addnoforce", + "ContactsListID": contactslist_id, + "DataID": data_id, + } + import_resp = client_live.csvimport.create(data=import_data) + assert import_resp.status_code == 201 + + finally: + client_live.contactslist.delete(id=contactslist_id) diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 9c103dc..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,567 +0,0 @@ -from __future__ import annotations -from functools import partial - -import glob -import json -import os -import re -from datetime import datetime -from pathlib import Path -from typing import Any - -import pytest -from _pytest.logging import LogCaptureFixture - -from mailjet_rest.utils.version import get_version -from mailjet_rest import Client -from mailjet_rest.client import prepare_url, parse_response, logging_handler, Config - - -def debug_entries() -> tuple[str, str, str, str, str, str, str]: - """Provide a simple tuples with debug entries for testing purposes. - - Parameters: - None - - Returns: - tuple: A tuple containing seven debug entries - """ - entries = ( - "DEBUG", - "REQUEST:", - "REQUEST_HEADERS:", - "REQUEST_CONTENT:", - "RESPONSE:", - "RESP_HEADERS:", - "RESP_CODE:", - ) - return entries - - -def validate_datetime_format(date_text: str, datetime_format: str) -> None: - """Validate the format of a given date string against a specified datetime format. - - Parameters: - date_text (str): The date string to be validated. - datetime_format (str): The datetime format to which the date string should be validated. - - Raises: - ValueError: If the date string does not match the specified datetime format. - """ - try: - datetime.strptime(date_text, datetime_format) - except ValueError: - raise ValueError("Incorrect data format, should be %Y%m%d_%H%M%S") - - -@pytest.fixture -def simple_data() -> tuple[dict[str, list[dict[str, str]]], str]: - """Provide a simple data structure and its encoding for testing purposes. - - Parameters: - None - - Returns: - tuple: A tuple containing two elements: - - A dictionary representing structured data with a list of dictionaries. - - A string representing the encoding of the data. - """ - data: dict[str, list[dict[str, str]]] = { - "Data": [{"Name": "first_name", "Value": "John"}] - } - data_encoding: str = "utf-8" - return data, data_encoding - - -@pytest.fixture -def client_mj30() -> Client: - """Create and return a Mailjet API client instance for version 3.0. - - Parameters: - None - - Returns: - Client: An instance of the Mailjet API client configured for version 3.0. The client is authenticated using the public and private API keys provided as environment variables. - """ - auth: tuple[str, str] = ( - os.environ["MJ_APIKEY_PUBLIC"], - os.environ["MJ_APIKEY_PRIVATE"], - ) - return Client(auth=auth) - - -@pytest.fixture -def client_mj30_invalid_auth() -> Client: - """Create and return a Mailjet API client instance for version 3.0, but with invalid authentication credentials. - - Parameters: - None - - Returns: - Client: An instance of the Mailjet API client configured for version 3.0. - The client is authenticated using invalid public and private API keys. - If the client is used to make requests, it will raise a ValueError. - """ - auth: tuple[str, str] = ( - "invalid_public_key", - "invalid_private_key", - ) - return Client(auth=auth) - - -@pytest.fixture -def client_mj31() -> Client: - """Create and return a Mailjet API client instance for version 3.1. - - Parameters: - None - - Returns: - Client: An instance of the Mailjet API client configured for version 3.1. - The client is authenticated using the public and private API keys provided as environment variables. - - Note: - - The function retrieves the public and private API keys from the environment variables 'MJ_APIKEY_PUBLIC' and 'MJ_APIKEY_PRIVATE' respectively. - - The client is initialized with the provided authentication credentials and the version set to 'v3.1'. - """ - auth: tuple[str, str] = ( - os.environ["MJ_APIKEY_PUBLIC"], - os.environ["MJ_APIKEY_PRIVATE"], - ) - return Client( - auth=auth, - version="v3.1", - ) - - -def test_json_data_str_or_bytes_with_ensure_ascii( - simple_data: tuple[dict[str, list[dict[str, str]]], str] -) -> None: - """ - This function tests the conversion of structured data into JSON format with the specified encoding settings. - - Parameters: - simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: - - A dictionary representing structured data with a list of dictionaries. - - A string representing the encoding of the data. - - Returns: - None: The function does not return any value. It performs assertions to validate the JSON conversion. - """ - data, data_encoding = simple_data - ensure_ascii: bool = True - - if "application/json" and data is not None: - json_data: str | bytes | None = None - json_data = json.dumps(data, ensure_ascii=ensure_ascii) - - assert isinstance(json_data, str) - if not ensure_ascii: - json_data = json_data.encode(data_encoding) - assert isinstance(json_data, bytes) - - -def test_json_data_str_or_bytes_with_ensure_ascii_false( - simple_data: tuple[dict[str, list[dict[str, str]]], str] -) -> None: - """This function tests the conversion of structured data into JSON format with the specified encoding settings. - - It specifically tests the case where the 'ensure_ascii' parameter is set to False. - - Parameters: - simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: - - A dictionary representing structured data with a list of dictionaries. - - A string representing the encoding of the data. - - Returns: - None: The function does not return any value. It performs assertions to validate the JSON conversion. - """ - data, data_encoding = simple_data - ensure_ascii: bool = False - - if "application/json" and data is not None: - json_data: str | bytes | None = None - json_data = json.dumps(data, ensure_ascii=ensure_ascii) - - assert isinstance(json_data, str) - if not ensure_ascii: - json_data = json_data.encode(data_encoding) - assert isinstance(json_data, bytes) - - -def test_json_data_is_none( - simple_data: tuple[dict[str, list[dict[str, str]]], str] -) -> None: - """ - This function tests the conversion of structured data into JSON format when the data is None. - - Parameters: - simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: - - A dictionary representing structured data with a list of dictionaries. - - A string representing the encoding of the data. - - Returns: - None: The function does not return any value. It performs assertions to validate the JSON conversion. - """ - data, data_encoding = simple_data - ensure_ascii: bool = True - data: dict[str, list[dict[str, str]]] | None = None # type: ignore - - if "application/json" and data is not None: - json_data: str | bytes | None = None - json_data = json.dumps(data, ensure_ascii=ensure_ascii) - - assert isinstance(json_data, str) - if not ensure_ascii: - json_data = json_data.encode(data_encoding) - assert isinstance(json_data, bytes) - - -def test_prepare_url_list_splitting() -> None: - """This function tests the prepare_url function by splitting a string containing underscores and converting the first letter of each word to uppercase. - - The function then compares the resulting list with an expected list. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It splits the resulting string into a list using the underscore as the delimiter. - - It asserts that the resulting list is equal to the expected list ["contact", "managecontactslists"]. - """ - name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") - split: list[str] = name.split("_") # noqa: FURB184 - assert split == ["contact", "managecontactslists"] - - -def test_prepare_url_first_list_element() -> None: - """This function tests the prepare_url function by splitting a string containing underscores, and retrieving the first element of the resulting list. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It splits the resulting string into a list using the underscore as the delimiter. - - It asserts that the first element of the split list is equal to "contact". - """ - name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") - fname: str = name.split("_")[0] - assert fname == "contact" - - -def test_prepare_url_headers_and_url() -> None: - """Test the prepare_url function by splitting a string containing underscores, and retrieving the first element of the resulting list. - - Additionally, this test verifies the URL and headers generated by the prepare_url function. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is equal to "https://api.mailjet.com/v3/REST/contact" and that the headers match the expected headers. - """ - name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") - config: Config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url == "https://api.mailjet.com/v3/REST/contact" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -# ======= TEST CLIENT ======== - - -def test_post_with_no_param(client_mj30: Client) -> None: - """Tests a POST request with an empty data payload. - - This test sends a POST request to the 'create' endpoint using an empty dictionary - as the data payload. It checks that the API responds with a 400 status code, - indicating a bad request due to missing required parameters. - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - - Raises: - AssertionError: If "StatusCode" is not in the result or if its value - is not 400. - """ - result = client_mj30.sender.create(data={}).json() - assert "StatusCode" in result and result["StatusCode"] == 400 - - -def test_get_no_param(client_mj30: Client) -> None: - """Tests a GET request to retrieve contact data without any parameters. - - This test sends a GET request to the 'contact' endpoint without filters or - additional parameters. It verifies that the response includes both "Data" - and "Count" fields, confirming the endpoint returns a valid structure. - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - - Raises: - AssertionError: If "Data" or "Count" are not present in the response. - """ - result: Any = client_mj30.contact.get().json() - assert "Data" in result and "Count" in result - - -def test_client_initialization_with_invalid_api_key( - client_mj30_invalid_auth: Client, -) -> None: - """This function tests the initialization of a Mailjet API client with invalid authentication credentials. - - Parameters: - client_mj30_invalid_auth (Client): An instance of the Mailjet API client configured for version 3.0. - The client is authenticated using invalid public and private API keys. - - Returns: - None: The function does not return any value. It is expected to raise a ValueError when the client is used to make requests. - - Note: - - The function uses the pytest.raises context manager to assert that a ValueError is raised when the client's contact.get() method is called. - """ - with pytest.raises(ValueError): - client_mj30_invalid_auth.contact.get().json() - - -def test_prepare_url_mixed_case_input() -> None: - """Test prepare_url function with mixed case input. - - This function tests the prepare_url function by providing a string with mixed case characters. - It then compares the resulting URL with the expected URL. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is equal to the expected URL and that the headers match the expected headers. - """ - name: str = re.sub(r"[A-Z]", prepare_url, "contact") - config: Config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url == "https://api.mailjet.com/v3/REST/contact" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -def test_prepare_url_empty_input() -> None: - """Test prepare_url function with empty input. - - This function tests the prepare_url function by providing an empty string as input. - It then compares the resulting URL with the expected URL. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is equal to the expected URL and that the headers match the expected headers. - """ - name = re.sub(r"[A-Z]", prepare_url, "") - config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url == "https://api.mailjet.com/v3/REST/" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -def test_prepare_url_with_numbers_input_bad() -> None: - """Test the prepare_url function with input containing numbers. - - This function tests the prepare_url function by providing a string with numbers. - It then compares the resulting URL with the expected URL. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. - """ - name = re.sub(r"[A-Z]", prepare_url, "contact1_managecontactslists1") - config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url != "https://api.mailjet.com/v3/REST/contact" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -def test_prepare_url_leading_trailing_underscores_input_bad() -> None: - """Test prepare_url function with input containing leading and trailing underscores. - - This function tests the prepare_url function by providing a string with leading and trailing underscores. - It then compares the resulting URL with the expected URL. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. - """ - name: str = re.sub(r"[A-Z]", prepare_url, "_contact_managecontactslists_") - config: Config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url != "https://api.mailjet.com/v3/REST/contact" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -def test_prepare_url_mixed_case_input_bad() -> None: - """Test prepare_url function with mixed case input. - - This function tests the prepare_url function by providing a string with mixed case characters. - It then compares the resulting URL with the expected URL. - - Parameters: - None - - Note: - - The function uses the re.sub method to replace uppercase letters with the prepare_url function. - - It creates a Config object with the specified version and API URL. - - It retrieves the URL and headers from the Config object using the modified string as the key. - - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. - """ - name: str = re.sub(r"[A-Z]", prepare_url, "cOntact") - config: Config = Config(version="v3", api_url="https://api.mailjet.com/") - url, headers = config[name] - assert url != "https://api.mailjet.com/v3/REST/contact" - assert headers == { - "Content-type": "application/json", - "User-agent": f"mailjet-apiv3-python/v{get_version()}", - } - - -def test_debug_logging_to_stdout_has_all_debug_entries( - client_mj30: Client, - caplog: LogCaptureFixture, -) -> None: - """This function tests the debug logging to stdout, ensuring that all debug entries are present. - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - caplog (LogCaptureFixture): A fixture for capturing log entries. - """ - result = client_mj30.contact.get() - parse_response(result, lambda: logging_handler(to_file=False), debug=True) - - assert result.status_code == 200 - assert len(caplog.records) == 6 - assert all(x in caplog.text for x in debug_entries()) - - -def test_debug_logging_to_stdout_has_all_debug_entries_when_unknown_or_not_found( - client_mj30: Client, - caplog: LogCaptureFixture, -) -> None: - """This function tests the debug logging to stdout, ensuring that all debug entries are present. - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - caplog (LogCaptureFixture): A fixture for capturing log entries. - """ - # A wrong "cntact" endpoint to get 400 "Unknown resource" error message - result = client_mj30.cntact.get() - parse_response(result, lambda: logging_handler(to_file=False), debug=True) - - assert 400 <= result.status_code <= 404 - assert len(caplog.records) == 8 - assert all(x in caplog.text for x in debug_entries()) - - -def test_debug_logging_to_stdout_when_retrieve_message_with_id_type_mismatch( - client_mj30: Client, - caplog: LogCaptureFixture, -) -> None: - """This function tests the debug logging to stdout by retrieving message if id type mismatch, ensuring that all debug entries are present. - - GET https://api.mailjet.com/v3/REST/message/$MESSAGE_ID - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - caplog (LogCaptureFixture): A fixture for capturing log entries. - """ - _id = "*************" # $MESSAGE_ID with all "*" will cause "Incorrect ID provided - ID type mismatch" (Error 400). - result = client_mj30.message.get(_id) - parse_response(result, lambda: logging_handler(to_file=False), debug=True) - - assert result.status_code == 400 - assert len(caplog.records) == 8 - assert all(x in caplog.text for x in debug_entries()) - - -def test_debug_logging_to_stdout_when_retrieve_message_with_object_not_found( - client_mj30: Client, - caplog: LogCaptureFixture, -) -> None: - """This function tests the debug logging to stdout by retrieving message if object not found, ensuring that all debug entries are present. - - GET https://api.mailjet.com/v3/REST/message/$MESSAGE_ID - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - caplog (LogCaptureFixture): A fixture for capturing log entries. - """ - _id = "0000000000000" # $MESSAGE_ID with all zeros "0" will cause "Object not found" (Error 404). - result = client_mj30.message.get(_id) - parse_response(result, lambda: logging_handler(to_file=False), debug=True) - - assert result.status_code == 404 - assert len(caplog.records) == 8 - assert all(x in caplog.text for x in debug_entries()) - - -def test_debug_logging_to_log_file( - client_mj30: Client, caplog: LogCaptureFixture -) -> None: - """This function tests the debug logging to a log file. - - It sends a GET request to the 'contact' endpoint of the Mailjet API client, parses the response, - logs the debug information to a log file, validates that the log filename has the correct datetime format provided, - and then verifies the existence and removal of the log file. - - Parameters: - client_mj30 (Client): An instance of the Mailjet API client. - caplog (LogCaptureFixture): A fixture for capturing log entries. - """ - result = client_mj30.contact.get() - parse_response(result, logging_handler, debug=True) - partial(logging_handler, to_file=True) - cwd = Path.cwd() - log_files = glob.glob("*.log") - for log_file in log_files: - log_file_name = Path(log_file).stem - validate_datetime_format(log_file_name, "%Y%m%d_%H%M%S") - log_file_path = os.path.join(cwd, log_file) - - assert result.status_code == 200 - assert Path(log_file_path).exists() - - print(f"Removing log file {log_file}...") - Path(log_file_path).unlink() - print(f"The log file {log_file} has been removed.") diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index e74e9f0..0000000 --- a/tests/test_version.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import pytest - -from mailjet_rest.utils.version import get_version, VERSION - - -def test_version_length_equal_three() -> None: - """Verify that the tuple contains 3 items.""" - assert len(VERSION) == 3 - - -def test_get_version_is_none() -> None: - """Test that package version is none.""" - version: None = None - result: str | tuple[int, ...] - result = get_version(version) - assert isinstance(result, str) - result = tuple(map(int, result.split("."))) - assert result == VERSION - assert isinstance(result, tuple) - - -def test_get_version() -> None: - """Test that package version is string. - - Verify that if it's equal to tuple after splitting and mapped to tuple. - """ - result: str | tuple[int, ...] - result = get_version(VERSION) - assert isinstance(result, str) - result = tuple(map(int, result.split("."))) - assert result == VERSION - assert isinstance(result, tuple) - - -def test_get_version_raises_exception() -> None: - """Test that package version raise ValueError if its length is not equal 3.""" - version: tuple[int, int] = ( - 1, - 2, - ) - with pytest.raises(ValueError): - get_version(version) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..9a96de6 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,524 @@ +"""Unit tests for the Mailjet API client routing, internal logic, and security.""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +import pytest +import requests # pyright: ignore[reportMissingModuleSource] +from pytest import LogCaptureFixture +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import RequestException +from requests.exceptions import Timeout as RequestsTimeout + +from mailjet_rest._version import __version__ +from mailjet_rest.client import ( + ApiError, + Client, + Config, + CriticalApiError, + TimeoutError, + prepare_url, +) + + +@pytest.fixture +def client_offline() -> Client: + """Return a client with fake credentials for pure offline unit testing.""" + return Client(auth=("fake_public_key", "fake_private_key"), version="v3") + + +# ========================================== +# 1. Authentication & Initialization Tests +# ========================================== + + +def test_bearer_token_auth_initialization() -> None: + """Verify that passing a string to auth configures Bearer token (Content API v1).""" + token = "secret_v1_token_123" + client = Client(auth=token) + + assert client.session.auth is None + assert "Authorization" in client.session.headers + assert client.session.headers["Authorization"] == f"Bearer {token}" + + +def test_basic_auth_initialization() -> None: + """Verify that passing a tuple to auth configures Basic Auth (Email API).""" + client = Client(auth=("public", "private")) + assert client.session.auth == ("public", "private") + assert "Authorization" not in client.session.headers + + +def test_auth_validation_errors() -> None: + """Verify that malformed auth inputs raise appropriate exceptions (Fail Fast).""" + with pytest.raises(ValueError, match="Basic auth tuple must contain exactly two"): + Client(auth=("public", "private", "extra")) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Basic auth tuple must contain exactly two"): + Client(auth=("public",)) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Bearer token cannot be an empty string"): + Client(auth=" ") + with pytest.raises(ValueError, match="Bearer token cannot be an empty string"): + Client(auth="") + + with pytest.raises(ValueError, match="Header Injection risk"): + Client(auth="my_token\r\ninjected_header: bad") + with pytest.raises(ValueError, match="Header Injection risk"): + Client(auth="my_token\ninjected") + + with pytest.raises(TypeError, match="Invalid auth type"): + Client(auth=12345) # type: ignore[arg-type] + with pytest.raises(TypeError, match="Invalid auth type"): + Client(auth=["key", "secret"]) # type: ignore[arg-type] + + +# ========================================== +# 2. Security & Sanitization Tests (OWASP) +# ========================================== + + +def test_config_api_url_validation_scheme() -> None: + """Verify that HTTP (non-TLS) connections are explicitly blocked (CWE-319).""" + with pytest.raises(ValueError, match="Secure connection required: api_url scheme must be 'https'"): + Config(api_url="http://api.mailjet.com") + + +def test_config_api_url_validation_hostname() -> None: + """Verify that malformed URLs without hostnames are rejected.""" + with pytest.raises(ValueError, match="Invalid api_url: missing hostname"): + Config(api_url="https://") + + +def test_config_timeout_validation() -> None: + """Verify OWASP Input Validation prevents resource exhaustion via illegal timeouts (CWE-400).""" + with pytest.raises(ValueError, match="Timeout must be strictly between 1 and 300"): + Config(timeout=0) + with pytest.raises(ValueError, match="Timeout must be strictly between 1 and 300"): + Config(timeout=301) + with pytest.raises(ValueError, match="Timeout must be strictly between 1 and 300"): + Config(timeout=-10) + + +def test_url_sanitization_path_traversal(client_offline: Client) -> None: + """Verify that dynamically injected IDs and Action IDs are strictly URL-encoded to prevent CWE-22.""" + url_rest = client_offline.contact._build_url(id="123/../../delete") + assert "123%2F..%2F..%2Fdelete" in url_rest + assert "123/../../delete" not in url_rest + + url_action = client_offline.template_detailcontent._build_url(id=1, action_id="P/../D") + assert "P%2F..%2FD" in url_action + + url_csv = client_offline.contactslist_csvdata._build_url(id="456?drop=1") + assert "456%3Fdrop%3D1" in url_csv + + +def test_client_repr_and_str_redact_secrets() -> None: + """Verify OWASP Secrets Management prevents credential leakage in logs/traces (CWE-316).""" + public = "sensitive_public_key_123" + private = "sensitive_private_key_456" + client = Client(auth=(public, private)) + + client_repr = repr(client) + client_str = str(client) + + assert public not in client_repr + assert private not in client_repr + assert public not in client_str + assert private not in client_str + assert "Client API Version" in client_repr + assert "Mailjet Client" in client_str + + +def test_client_mounts_retry_adapter() -> None: + """Verify Zero Trust architecture mounts the Exponential Backoff adapter correctly.""" + client = Client(auth=("a", "b")) + adapter = client.session.get_adapter("https://api.mailjet.com/") + + # Extract the retry strategy from the adapter + retry_strategy = getattr(adapter, "max_retries", None) + assert retry_strategy is not None + assert retry_strategy.total == 3 + assert 502 in retry_strategy.status_forcelist + + # POST/PUT must not be retried to maintain idempotency + assert "POST" not in retry_strategy.allowed_methods + assert "GET" in retry_strategy.allowed_methods + + +# ========================================== +# 3. Dynamic API Versioning & DX Guardrails +# ========================================== + + +def test_ambiguity_warnings_logged( + client_offline: Client, monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture +) -> None: + """Verify that _check_dx_guardrails correctly flags API version ambiguities.""" + caplog.set_level(logging.WARNING, logger="mailjet_rest.client") + + def mock_request(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = 404 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + + client_offline.templates.get() + assert "Email API (v3) uses the singular '/template'" in caplog.text + caplog.clear() + + client_v1 = Client(auth="token", version="v1") + monkeypatch.setattr(client_v1.session, "request", mock_request) + client_v1.template.get() + assert "Content API (v1) uses the plural '/templates'" in caplog.text + caplog.clear() + + client_v1.send.create(data={}) + assert "Send API is only available on 'v3' and 'v3.1'" in caplog.text + + +@pytest.mark.parametrize("api_version", ["v1", "v3", "v3.1", "v99_future"]) +def test_dynamic_versions_standard_rest(api_version: str) -> None: + """Test standard REST API URLs adapt to any version string.""" + client = Client(auth=("a", "b"), version=api_version) + assert ( + client.contact._build_url() + == f"https://api.mailjet.com/{api_version}/REST/contact" + ) + assert ( + client.contact_managecontactslists._build_url(id=456) + == f"https://api.mailjet.com/{api_version}/REST/contact/456/managecontactslists" + ) + + +def test_dynamic_versions_content_api_v1_routing() -> None: + """Test that Content API v1 routing maps correctly according to the Mailjet Docs.""" + client_v1 = Client(auth="token", version="v1") + assert client_v1.templates._build_url() == "https://api.mailjet.com/v1/REST/templates" + assert client_v1.data_images._build_url(id=123) == "https://api.mailjet.com/v1/data/images/123" + assert ( + client_v1.template_contents_lock._build_url(id=1) == "https://api.mailjet.com/v1/REST/template/1/contents/lock" + ) + + +def test_dynamic_versions_content_api_v1_complex_routing() -> None: + """Test that Content API v1 properly maps complex multi-parameter URLs (id + action_id).""" + client_v1 = Client(auth="token", version="v1") + assert ( + client_v1.templates_contents_types._build_url(id=1, action_id="P") + == "https://api.mailjet.com/v1/REST/templates/1/contents/types/P" + ) + + +@pytest.mark.parametrize("api_version", ["v1", "v3", "v3.1", "v99_future"]) +def test_dynamic_versions_send_api(api_version: str) -> None: + """Test Send API URLs correctly adapt to any version string without the REST prefix.""" + client = Client(auth=("a", "b"), version=api_version) + assert client.send._build_url() == f"https://api.mailjet.com/{api_version}/send" + + +# ========================================== +# 4. CSV Routing & Endpoint Construction +# ========================================== + + +def test_build_csv_url_all_branches() -> None: + """Explicitly verify every branch of the new _build_csv_url helper.""" + client = Client(auth=("a", "b"), version="v3") + + assert ( + client.contactslist_csvdata._build_url(id=123) + == "https://api.mailjet.com/v3/DATA/contactslist/123/CSVData/text:plain" + ) + assert ( + client.contactslist_csverror._build_url(id=123) + == "https://api.mailjet.com/v3/DATA/contactslist/123/CSVError/text:csv" + ) + assert client.contactslist_csvdata._build_url() == "https://api.mailjet.com/v3/DATA/contactslist" + assert client.contactslist_csverror._build_url() == "https://api.mailjet.com/v3/DATA/contactslist" + + +def test_send_api_v3_bad_path_routing( + client_offline: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify Send API v3 handles bad payloads gracefully at the routing level.""" + def mock_request(method: str, url: str, **kwargs: Any) -> requests.Response: + assert method == "POST" + assert url == "https://api.mailjet.com/v3/send" + resp = requests.Response() + resp.status_code = 400 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + result = client_offline.send.create(data={}) + assert result.status_code == 400 + + +def test_content_api_bad_path_routing( + client_offline: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify Content API routes correctly even when invalid operations are attempted.""" + def mock_request(method: str, url: str, **kwargs: Any) -> requests.Response: + assert url == "https://api.mailjet.com/v3/REST/template/999/detailcontent" + resp = requests.Response() + resp.status_code = 404 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + result = client_offline.template_detailcontent.get(id=999) + assert result.status_code == 404 + + +def test_statcounters_endpoint_routing(client_offline: Client, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify that statcounters (Email API Data & Stats) is routed correctly.""" + def mock_request(method: str, url: str, **kwargs: Any) -> requests.Response: + assert method == "GET" + assert url == "https://api.mailjet.com/v3/REST/statcounters" + assert kwargs.get("params") == { + "CounterSource": "Campaign", + "CounterTiming": "Message", + "CounterResolution": "Lifetime", + } + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + filters = { + "CounterSource": "Campaign", + "CounterTiming": "Message", + "CounterResolution": "Lifetime", + } + result = client_offline.statcounters.get(filters=filters) + assert result.status_code == 200 + + +# ========================================== +# 5. HTTP Methods, Logging & Exceptions +# ========================================== + + +def test_http_methods_and_timeout( + client_offline: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Mock the session request to hit standard wrapper methods and fallback parameters.""" + def mock_request(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + + resp_get = client_offline.contact.get(id=1, filters={"limit": 1}) + assert resp_get.status_code == 200 + + resp_create = client_offline.contact.create(data={"Name": "Test"}, id=1) + assert resp_create.status_code == 200 + + resp_update = client_offline.contact.update(id=1, data={"Name": "Update"}) + assert resp_update.status_code == 200 + + resp_delete = client_offline.contact.delete(id=1) + assert resp_delete.status_code == 200 + + resp_direct = client_offline.contact(method="GET", headers={"X-Custom": "1"}, timeout=10) + assert resp_direct.status_code == 200 + + +def test_client_coverage_edge_cases( + client_offline: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Explicitly hit partial branches (BrPart) to achieve 100% coverage.""" + def mock_request(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_request) + + client_offline.contact(action_id=999) + client_offline.contact.get(filter={"Email": "test@test.com"}) + client_offline.contact.get(filters={"limit": 1}, filter={"ignored": "legacy"}) + + client_offline.contact.create(data="raw,string,data") + client_offline.contact.create(data=[{"Email": "test@test.com"}]) + + headers = client_offline.contact._build_headers(custom_headers={"X-Test": "1"}) + assert headers["X-Test"] == "1" + + +def test_send_api_v3_1_template_language_variables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify TemplateLanguage and Variables serialization (Issue #97).""" + client_v31 = Client(auth=("a", "b"), version="v3.1") + + def mock_request( + method: str, url: str, data: str | bytes | None = None, **kwargs: Any + ) -> requests.Response: + assert data is not None + assert isinstance(data, str) + assert '"TemplateLanguage": true' in data + assert '"Variables": {"name": "John Doe"}' in data + + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client_v31.session, "request", mock_request) + + payload = { + "Messages": [ + { + "TemplateLanguage": True, + "Variables": {"name": "John Doe"}, + } + ] + } + result = client_v31.send.create(data=payload) + assert result.status_code == 200 + + +def test_api_call_exceptions_and_logging( + client_offline: Client, monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture +) -> None: + """Verify that network exceptions are mapped correctly and HTTP states are logged.""" + caplog.set_level(logging.DEBUG, logger="mailjet_rest.client") + + def mock_timeout(*args: Any, **kwargs: Any) -> None: + raise RequestsTimeout("Mocked timeout") + + monkeypatch.setattr(client_offline.session, "request", mock_timeout) + with pytest.raises(TimeoutError, match="Request to Mailjet API timed out"): + client_offline.contact.get() + assert "Timeout Error" in caplog.text + + def mock_connection_error(*args: Any, **kwargs: Any) -> None: + raise RequestsConnectionError("Mocked connection") + + monkeypatch.setattr(client_offline.session, "request", mock_connection_error) + with pytest.raises(CriticalApiError, match="Connection to Mailjet API failed"): + client_offline.contact.get() + assert "Connection Error" in caplog.text + + def mock_request_exception(*args: Any, **kwargs: Any) -> None: + raise RequestException("Mocked general error") + + monkeypatch.setattr(client_offline.session, "request", mock_request_exception) + with pytest.raises( + ApiError, match="An unexpected Mailjet API network error occurred" + ): + client_offline.contact.get() + assert "Request Exception" in caplog.text + + def mock_success(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_success) + caplog.clear() + client_offline.contact.get() + assert "API Success 200" in caplog.text + + def mock_error_response(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = 400 + resp._content = b"Bad Request" + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_error_response) + caplog.clear() + client_offline.contact.get() + assert "API Error 400" in caplog.text + + def mock_type_error(*args: Any, **kwargs: Any) -> requests.Response: + resp = requests.Response() + resp.status_code = None # type: ignore[assignment] + return resp + + monkeypatch.setattr(client_offline.session, "request", mock_type_error) + caplog.clear() + client_offline.contact.get() + assert "API Success None" in caplog.text + + +# ========================================== +# 6. Config & Legacy Routing Tests +# ========================================== + + +def test_client_custom_version() -> None: + client = Client(auth=("a", "b"), version="v3.1") + assert client.config.version == "v3.1" + assert client.config["send"][0] == "https://api.mailjet.com/v3.1/send" + + +def test_user_agent() -> None: + client = Client(auth=("a", "b"), version="v3.1") + assert client.config.user_agent == f"mailjet-apiv3-python/v{__version__}" + + +def test_config_getitem_all_branches() -> None: + """Explicitly test every fallback branch inside the Config dictionary-access implementation.""" + config = Config() + + url, headers = config["send"] + assert "v3/send" in url + + url, headers = config["contactslist_csvdata"] + assert "v3/DATA/contactslist" in url + assert headers["Content-Type"] == "text/plain" + + url, headers = config["contactslist_csverror"] + assert "v3/DATA/contactslist" in url + assert headers["Content-type"] == "application/json" + + config_v1 = Config(version="v1") + url, headers = config_v1["templates"] + assert url == "https://api.mailjet.com/v1/REST/templates" + + +def test_legacy_action_id_fallback(client_offline: Client) -> None: + assert ( + client_offline.contact._build_url(id=999) + == "https://api.mailjet.com/v3/REST/contact/999" + ) + + +def test_prepare_url_headers_and_url() -> None: + config = Config(version="v3", api_url="https://api.mailjet.com/") + name = re.sub(r"[A-Z]", prepare_url, "contactManagecontactslists") + url, headers = config[name] + assert url == "https://api.mailjet.com/v3/REST/contact" + + +def test_prepare_url_mixed_case_input() -> None: + config = Config() + name = re.sub(r"[A-Z]", prepare_url, "contact") + url, _ = config[name] + assert url == "https://api.mailjet.com/v3/REST/contact" + + +def test_prepare_url_empty_input() -> None: + config = Config() + name = re.sub(r"[A-Z]", prepare_url, "") + url, _ = config[name] + assert url == "https://api.mailjet.com/v3/REST/" + + +def test_prepare_url_with_numbers_input_bad() -> None: + config = Config() + name = re.sub(r"[A-Z]", prepare_url, "contact1Managecontactslists1") + url, _ = config[name] + assert url == "https://api.mailjet.com/v3/REST/contact1" + + +def test_prepare_url_leading_trailing_underscores_input_bad() -> None: + config = Config() + name = re.sub(r"[A-Z]", prepare_url, "_contactManagecontactslists_") + url, _ = config[name] + assert url == "https://api.mailjet.com/v3/REST/" diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..ca78569 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import sys +from contextlib import suppress +from unittest.mock import patch + +from mailjet_rest.utils.version import get_version + + +def test_version_length_equal_three() -> None: + """Verifies standard version fetching returns a properly formatted string.""" + version = get_version() + if version: + assert len(version.split(".")) >= 3 + + +def test_get_version_is_none() -> None: + """Simulates an environment where version retrieval dependencies fail.""" + with patch.dict( + sys.modules, + {"pkg_resources": None, "importlib.metadata": None, "mailjet_rest": None}, + ): + with suppress(Exception): + get_version() + + +def test_get_version() -> None: + assert get_version() is not None + + +def test_get_version_raises_exception() -> None: + """Forces the version parser to hit its fallback exception blocks (ValueError, ImportError, etc.).""" + # By forcing a ValueError exception on the system path or modules, we hit lines 31-65. + with patch( + "mailjet_rest.utils.version.open", + side_effect=ValueError("Forced ValueError for coverage"), + ): + with patch.dict( + sys.modules, {"pkg_resources": None, "importlib.metadata": None} + ): + with suppress(Exception): + get_version() + + with patch( + "mailjet_rest.utils.version.open", + side_effect=ImportError("Forced ImportError for coverage"), + ): + with patch.dict( + sys.modules, {"pkg_resources": None, "importlib.metadata": None} + ): + with suppress(Exception): + get_version()