├── .github ├── FUNDING.yml ├── dependabot.yml ├── release-notes.yml └── workflows │ ├── codeql-analysis.yml │ ├── integration-tests.yml │ ├── quality.yml │ ├── release.yml │ ├── unit-test-pre.yml │ └── unit-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── aiospamc ├── __init__.py ├── cli.py ├── client.py ├── connections.py ├── exceptions.py ├── frontend.py ├── header_values.py ├── incremental_parser.py ├── requests.py ├── responses.py └── user_warnings.py ├── docs ├── Makefile ├── aiospamc.rst ├── cli.rst ├── conf.py ├── index.rst ├── installation.rst ├── library.rst ├── make.bat ├── modules.rst ├── protocol.rst ├── release_notes.rst └── user_guide.rst ├── example └── gtube_example.py ├── poetry.lock ├── pyproject.toml ├── readthedocs.yml ├── releasenotes ├── notes │ ├── 299-python-3.10-support-b350c2d26eb780c6.yaml │ ├── 305-7183-bug-warning-96c4ec43071155fb.yaml │ ├── 308-deprecate-python-3.6-e48c3cd467a4c0f8.yaml │ ├── 310-add-request-response-repr-cacd8ddb1e753d4a.yaml │ ├── 317-parse-timeout-message-4e202ecd1f6dd343.yaml │ ├── 320-stable-classifier-81d3b6676e1a4a39.yaml │ ├── 321-logging-to-loguru-d7e61fc248d933c2.yaml │ ├── 324-add-cli-52f614cdb9a4e2dc.yaml │ ├── 355-python-3.11-support-236266a1403d11cc.yaml │ ├── 357-actionoption-valueerror-9bec502c4520e1a2.yaml │ ├── 359-sphinx-github-issues-96277d03b6837595.yaml │ ├── 378-deprecate-python-37-07383e4320121a3d.yaml │ ├── 385-improved-headers-interface-87db548fc91424ed.yaml │ ├── 394-ssl-option-wrong-type-a4320c34bfd4ddaa.yaml │ ├── 404-add-client-certificate-24076b39e96cd311.yaml │ ├── 405-cli-report-doesnt-set-user-11fb68eae45c4178.yaml │ ├── 422-python-3.12-7b714da4ca0d8564.yaml │ ├── 462-deprecate-python-3.8-09c5fe8ef35177d6.yaml │ ├── 482-python-3.13-24ba8b89ec577349.yaml │ ├── 493-doc-formatting-ad79ab1402cff3e0.yaml │ └── loguru-cve-2022-0329-4053e0f715047699.yaml └── template.yml ├── reno.yaml ├── renovate.json ├── tests ├── conftest.py ├── test_cli.py ├── test_client.py ├── test_connections.py ├── test_frontend.py ├── test_header_values.py ├── test_incremental_parser.py ├── test_init.py ├── test_integration_example_messages.py ├── test_integration_ssl.py ├── test_integration_ssl_client.py ├── test_integration_tcp.py ├── test_integration_unix.py ├── test_requests.py ├── test_responses.py └── test_warnings.py └── util └── create_certs.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mjcaley 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/release-notes.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - dependabot 7 | - pre-commit-ci 8 | categories: 9 | - title: Security 10 | labels: 11 | - security 12 | - title: New Features 13 | labels: 14 | - enhancement 15 | - title: Bug Fixes 16 | labels: 17 | - bug 18 | - title: Deprecated 19 | labels: 20 | - deprecation 21 | - title: Documentation 22 | labels: 23 | - documentation 24 | - title: Other Changes 25 | labels: 26 | - "*" 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ development, main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ development ] 20 | schedule: 21 | - cron: '37 23 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration tests 2 | on: 3 | push: 4 | branches: [ development, main ] 5 | pull_request: 6 | branches: [ development ] 7 | schedule: 8 | - cron: '0 0 * * 5' 9 | jobs: 10 | integration-tests: 11 | runs-on: ubuntu-latest 12 | name: "Run integration tests" 13 | strategy: 14 | matrix: 15 | spamassassin: ["3.4.6", "4.0.1"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Install Poetry 19 | run: pipx install poetry 20 | - name: Setup Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.x' 24 | cache: 'poetry' 25 | - name: Install dependencies 26 | run: poetry install 27 | - name: Download SpamAssasin 28 | run: | 29 | curl -s https://dlcdn.apache.org//spamassassin/source/Mail-SpamAssassin-${{ matrix.spamassassin }}.tar.bz2 -o spamassassin.tar.bz2 30 | tar -xvjf spamassassin.tar.bz2 31 | - name: Build & Install SpamAssassin 32 | working-directory: ./Mail-SpamAssassin-${{ matrix.spamassassin }} 33 | run: | 34 | sudo apt-get update 35 | sudo apt-get install -y perl libssl-dev libhtml-parser-perl libnet-dns-perl libnetaddr-ip-perl debhelper-compat libberkeleydb-perl netbase libdbi-perl libdbd-mysql-perl libbsd-resource-perl libio-string-perl libtext-diff-perl 36 | sudo apt-get install -y adduser libarchive-tar-perl libhttp-date-perl libmail-dkim-perl libnet-dns-perl libnetaddr-ip-perl libsocket6-perl libio-socket-ssl-perl 37 | perl Makefile.PL BUILD_SPAMC=no ENABLE_SSL=yes CONTACT_ADDRESS=aiospamc-ci@example.org 38 | make 39 | sudo make install 40 | - run: sudo sa-update 41 | - run: spamd --version 42 | - name: Run integration tests 43 | run: > 44 | poetry run pytest -m integration 45 | --cov 46 | --cov-report="xml:output/coverage.xml" 47 | --cov-fail-under=0 48 | --junit-xml="output/integration-tests.xml" 49 | - name: Publish test results 50 | if: success() || failure() 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: test-results-${{ matrix.spamassassin }} 54 | path: output/** 55 | - name: Upload to Codecov.io 56 | if: success() || failure() 57 | uses: codecov/codecov-action@v5 58 | with: 59 | token: ${{ secrets.CODECOV_TOKEN }} 60 | files: output/coverage.xml 61 | flags: integrationtests 62 | status: 63 | if: ${{ always() }} 64 | runs-on: ubuntu-latest 65 | name: "Integration Test Status" 66 | needs: integration-tests 67 | steps: 68 | - name: Check test matrix status 69 | if: ${{ needs.integration-tests.result != 'success' }} 70 | run: exit 1 71 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | on: 3 | push: 4 | branches: [ development, main ] 5 | pull_request: 6 | branches: [ development ] 7 | jobs: 8 | quality: 9 | name: "Code quality" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install Poetry 14 | run: pipx install poetry 15 | - name: Setup Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.x' 19 | cache: 'poetry' 20 | - name: Install dependencies 21 | run: poetry install 22 | - name: Run mypy 23 | run: > 24 | poetry run 25 | mypy aiospamc 26 | --install-types 27 | --junit-xml output/mypy-tests.xml 28 | --non-interactive 29 | - name: Run interrogate 30 | run: poetry run interrogate 31 | - name: Publish test results 32 | if: success() || failure() 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: quality-report 36 | path: output/** 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | jobs: 7 | release: 8 | name: "Upload release artifacts" 9 | runs-on: ubuntu-latest 10 | environment: release 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Save tag 17 | run: | 18 | tag="$(git describe --tags --abbrev=0)" 19 | echo 'TAG='$tag >> $GITHUB_ENV 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Install Poetry 23 | run: pipx install poetry 24 | - name: Setup Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.x' 28 | cache: 'poetry' 29 | - name: Install dependencies 30 | run: poetry install 31 | - name: Run unit tests 32 | run: poetry run pytest --junit-xml="output/unit-tests.xml" 33 | - name: Install SpamAssassin 34 | run: sudo apt-get -y install spamassassin 35 | - name: Run integration tests 36 | run: poetry run pytest -m integration --junit-xml="output/integration-tests.xml" 37 | - name: Publish test results 38 | if: success() || failure() 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: test-results 42 | path: output/** 43 | - name: Build package 44 | run: poetry build 45 | - name: Publish package 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: package 49 | path: dist/ 50 | - name: GitHub release 51 | run: gh release create "$TAG" --generate-notes dist/* 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Publish to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /.github/workflows/unit-test-pre.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests prerelease 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * 5' 6 | env: 7 | python-version: '3.14-dev' 8 | jobs: 9 | unit-tests: 10 | name: "Run unit tests for Python pre-release" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install Poetry 15 | run: pipx install poetry 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ env.python-version }} 20 | cache: 'poetry' 21 | - name: Install dependencies 22 | run: poetry install 23 | - name: Run unit tests 24 | run: > 25 | poetry run pytest 26 | --junit-xml="output/unit-tests.xml" 27 | --cov 28 | --cov-report="xml:output/coverage.xml" 29 | --cov-report="html:output/htmlcov" 30 | - name: Publish test results 31 | if: success() || failure() 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: test-results-${{ matrix.os }}-${{ env.python-version }} 35 | path: output/** 36 | status: 37 | if: ${{ always() }} 38 | runs-on: ubuntu-latest 39 | name: "Unit Test Status" 40 | needs: unit-tests 41 | steps: 42 | - name: Check test matrix status 43 | if: ${{ needs.unit-tests.result != 'success' }} 44 | run: exit 1 45 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | on: 3 | push: 4 | branches: [ development, main ] 5 | pull_request: 6 | branches: [ development ] 7 | schedule: 8 | - cron: '0 0 * * 5' 9 | jobs: 10 | unit-tests: 11 | name: "Run unit tests" 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install Poetry 20 | run: pipx install poetry 21 | - name: Setup Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | cache: 'poetry' 26 | - name: Install dependencies 27 | run: poetry install 28 | - name: Run unit tests 29 | run: > 30 | poetry run pytest 31 | --junit-xml="output/unit-tests.xml" 32 | --cov 33 | --cov-report="xml:output/coverage.xml" 34 | --cov-report="html:output/htmlcov" 35 | - name: Publish test results 36 | if: success() || failure() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: test-results-${{ matrix.os }}-${{ matrix.python }} 40 | path: output/** 41 | - name: Upload to Codecov.io 42 | if: success() || failure() 43 | uses: codecov/codecov-action@v5 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | files: output/coverage.xml 47 | flags: unittests 48 | status: 49 | if: ${{ always() }} 50 | runs-on: ubuntu-latest 51 | name: "Unit Test Status" 52 | needs: unit-tests 53 | steps: 54 | - name: Check test matrix status 55 | if: ${{ needs.unit-tests.result != 'success' }} 56 | run: exit 1 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,macos,windows,virtualenv,pycharm,linux 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv/ 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mypy cache 98 | .mypy_cache/ 99 | 100 | 101 | ### macOS ### 102 | *.DS_Store 103 | .AppleDouble 104 | .LSOverride 105 | 106 | # Icon must end with two \r 107 | Icon 108 | # Thumbnails 109 | ._* 110 | # Files that might appear in the root of a volume 111 | .DocumentRevisions-V100 112 | .fseventsd 113 | .Spotlight-V100 114 | .TemporaryItems 115 | .Trashes 116 | .VolumeIcon.icns 117 | .com.apple.timemachine.donotpresent 118 | # Directories potentially created on remote AFP share 119 | .AppleDB 120 | .AppleDesktop 121 | Network Trash Folder 122 | Temporary Items 123 | .apdisk 124 | 125 | 126 | ### Windows ### 127 | # Windows thumbnail cache files 128 | Thumbs.db 129 | ehthumbs.db 130 | ehthumbs_vista.db 131 | 132 | # Folder config file 133 | Desktop.ini 134 | 135 | # Recycle Bin used on file shares 136 | $RECYCLE.BIN/ 137 | 138 | # Windows Installer files 139 | *.cab 140 | *.msi 141 | *.msm 142 | *.msp 143 | 144 | # Windows shortcuts 145 | *.lnk 146 | 147 | 148 | ### VirtualEnv ### 149 | # Virtualenv 150 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 151 | [Bb]in 152 | [Ii]nclude 153 | [Ll]ib 154 | [Ll]ib64 155 | [Ll]ocal 156 | [Ss]cripts 157 | pyvenv.cfg 158 | .venv 159 | pip-selfcheck.json 160 | 161 | 162 | ### PyCharm ### 163 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 164 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 165 | 166 | # Entire .idea folder: 167 | .idea 168 | 169 | # User-specific stuff: 170 | .idea/workspace.xml 171 | .idea/tasks.xml 172 | 173 | # Sensitive or high-churn files: 174 | .idea/dataSources/ 175 | .idea/dataSources.ids 176 | .idea/dataSources.xml 177 | .idea/dataSources.local.xml 178 | .idea/sqlDataSources.xml 179 | .idea/dynamic.xml 180 | .idea/uiDesigner.xml 181 | 182 | # Gradle: 183 | .idea/gradle.xml 184 | .idea/libraries 185 | 186 | # Mongo Explorer plugin: 187 | .idea/mongoSettings.xml 188 | 189 | ## File-based project format: 190 | *.iws 191 | 192 | ## Plugin-specific files: 193 | 194 | # IntelliJ 195 | /out/ 196 | 197 | # mpeltonen/sbt-idea plugin 198 | .idea_modules/ 199 | 200 | # JIRA plugin 201 | atlassian-ide-plugin.xml 202 | 203 | # Crashlytics plugin (for Android Studio and IntelliJ) 204 | com_crashlytics_export_strings.xml 205 | crashlytics.properties 206 | crashlytics-build.properties 207 | fabric.properties 208 | 209 | ### PyCharm Patch ### 210 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 211 | 212 | # *.iml 213 | # modules.xml 214 | # .idea/misc.xml 215 | # *.ipr 216 | 217 | 218 | ### Linux ### 219 | *~ 220 | 221 | # temporary files which can be created if a process still has a handle open of a deleted file 222 | .fuse_hidden* 223 | 224 | # KDE directory preferences 225 | .directory 226 | 227 | # Linux trash folder which might appear on any partition or disk 228 | .Trash-* 229 | 230 | # .nfs files are created when an open file is removed but is still being accessed 231 | .nfs* 232 | 233 | # End of https://www.gitignore.io/api/python,macos,windows,virtualenv,pycharm,linux 234 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: debug-statements 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/psf/black 10 | rev: 25.1.0 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/PyCQA/isort 14 | rev: 6.0.1 15 | hooks: 16 | - id: isort 17 | - repo: https://github.com/python-poetry/poetry 18 | rev: 2.1.2 19 | hooks: 20 | - id: poetry-check 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2025 Michael Caley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | aiospamc 3 | ######## 4 | 5 | |pypi| |docs| |license| |unit| |integration| |python| 6 | 7 | .. |pypi| image:: https://img.shields.io/pypi/v/aiospamc 8 | :target: https://pypi.org/project/aiospamc/ 9 | 10 | .. |unit| image:: https://github.com/mjcaley/aiospamc/actions/workflows/unit-tests.yml/badge.svg 11 | :target: https://github.com/mjcaley/aiospamc/actions/workflows/unit-tests.yml 12 | 13 | .. |integration| image:: https://github.com/mjcaley/aiospamc/actions/workflows/integration-tests.yml/badge.svg 14 | :target: https://github.com/mjcaley/aiospamc/actions/workflows/integration-tests.yml 15 | 16 | .. |docs| image:: https://readthedocs.org/projects/aiospamc/badge/?version=latest 17 | :target: https://aiospamc.readthedocs.io/en/latest/ 18 | 19 | .. |license| image:: https://img.shields.io/github/license/mjcaley/aiospamc 20 | :target: ./LICENSE 21 | 22 | .. |python| image:: https://img.shields.io/pypi/pyversions/aiospamc 23 | :target: https://python.org 24 | 25 | **aiospamc** is a client for SpamAssassin that you can use as a library or command line tool. 26 | 27 | The implementation is based on asyncio; so you can use it in your applications for asynchronous calls. 28 | 29 | The command line interface provides user-friendly access to SpamAssassin server commands and provides both JSON 30 | and user-consumable outputs. 31 | 32 | ************* 33 | Documentation 34 | ************* 35 | 36 | Detailed documentation can be found at: https://aiospamc.readthedocs.io/ 37 | 38 | ************ 39 | Requirements 40 | ************ 41 | 42 | * Python 3.9 or higher 43 | * `certifi` for updated certificate authorities 44 | * `loguru` for structured logging 45 | * `typer` for the command line interface 46 | 47 | ******** 48 | Examples 49 | ******** 50 | 51 | Command-Line Tool 52 | ================= 53 | 54 | `aiospamc` is your interface to SpamAssassin through CLI. To submit a message 55 | for a score, use: 56 | 57 | .. code-block:: console 58 | 59 | # Take the output of gtube.msg and have SpamAssasin return a score 60 | $ cat ./gtube.msg | aiospamc check 61 | 1000.0/5.0 62 | 63 | # Ping the server 64 | $ aiospamc ping 65 | PONG 66 | 67 | Library 68 | ======= 69 | 70 | .. code-block:: python 71 | 72 | import asyncio 73 | import aiospamc 74 | 75 | 76 | GTUBE = """Subject: Test spam mail (GTUBE) 77 | Message-ID: 78 | Date: Wed, 23 Jul 2003 23:30:00 +0200 79 | From: Sender 80 | To: Recipient 81 | Precedence: junk 82 | MIME-Version: 1.0 83 | Content-Type: text/plain; charset=us-ascii 84 | Content-Transfer-Encoding: 7bit 85 | 86 | This is the GTUBE, the 87 | Generic 88 | Test for 89 | Unsolicited 90 | Bulk 91 | Email 92 | 93 | If your spam filter supports it, the GTUBE provides a test by which you 94 | can verify that the filter is installed correctly and is detecting incoming 95 | spam. You can send yourself a test mail containing the following string of 96 | characters (in upper case and with no white spaces and line breaks): 97 | 98 | XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 99 | 100 | You should send this test mail from an account outside of your network. 101 | """.encode("ascii") 102 | 103 | 104 | # Ping the SpamAssassin server 105 | async def is_alive(): 106 | pong = await aiospamc.ping() 107 | return True if pong.status_code == 0 else False 108 | 109 | asyncio.run(is_alive()) 110 | # True 111 | 112 | 113 | # Get the spam score of a message 114 | async def get_score(message): 115 | response = await aiospamc.check(message) 116 | return response.headers.spam.score, response.headers.spam.threshold 117 | 118 | asyncio.run(get_score(GTUBE)) 119 | # (1000.0, 5.0) 120 | 121 | 122 | # List the modified headers 123 | async def list_headers(message): 124 | response = await aiospamc.headers(message) 125 | for line in response.body.splitlines(): 126 | print(line.decode()) 127 | 128 | asyncio.run(list_headers(GTUBE)) 129 | # Received: from localhost by DESKTOP. 130 | # with SpamAssassin (version 4.0.0); 131 | # Wed, 30 Aug 2023 20:11:34 -0400 132 | # From: Sender 133 | # To: Recipient 134 | # Subject: Test spam mail (GTUBE) 135 | # Date: Wed, 23 Jul 2003 23:30:00 +0200 136 | # Message-Id: 137 | # X-Spam-Checker-Version: SpamAssassin 4.0.0 (2022-12-14) on DESKTOP. 138 | # X-Spam-Flag: YES 139 | # X-Spam-Level: ************************************************** 140 | # X-Spam-Status: Yes, score=1000.0 required=5.0 tests=GTUBE,NO_RECEIVED, 141 | # NO_RELAYS,T_SCC_BODY_TEXT_LINE autolearn=no autolearn_force=no 142 | # version=4.0.0 143 | # MIME-Version: 1.0 144 | # Content-Type: multipart/mixed; boundary="----------=_64EFDAB6.3640FAEF" 145 | -------------------------------------------------------------------------------- /aiospamc/__init__.py: -------------------------------------------------------------------------------- 1 | """aiospamc package. 2 | 3 | An asyncio-based library to communicate with SpamAssassin's SPAMD service.""" 4 | 5 | from importlib.metadata import version 6 | 7 | from loguru import logger 8 | 9 | from .connections import Timeout 10 | from .frontend import ( 11 | check, 12 | headers, 13 | ping, 14 | process, 15 | report, 16 | report_if_spam, 17 | symbols, 18 | tell, 19 | ) 20 | from .header_values import ActionOption, MessageClassOption 21 | 22 | __author__ = "Michael Caley" 23 | __copyright__ = "Copyright 2016-2025 Michael Caley" 24 | __license__ = "MIT" 25 | __version__ = version("aiospamc") 26 | __email__ = "mjcaley@darkarctic.com" 27 | 28 | logger.disable(__package__) 29 | -------------------------------------------------------------------------------- /aiospamc/client.py: -------------------------------------------------------------------------------- 1 | """Module implementing client objects that all requests go through.""" 2 | 3 | from loguru import logger 4 | 5 | from .connections import ConnectionManager 6 | from .exceptions import BadResponse 7 | from .incremental_parser import ParseError, ResponseParser 8 | from .requests import Request 9 | from .responses import Response 10 | from .user_warnings import raise_warnings 11 | 12 | 13 | class Client: 14 | """Client object to submit requests.""" 15 | 16 | def __init__(self, connection_manager: ConnectionManager): 17 | """Client constructor. 18 | 19 | :param connection_manager: Instance of a connection manager. 20 | """ 21 | 22 | self.connection_manager = connection_manager 23 | 24 | async def request(self, req: Request): 25 | """Sends a request and returns the parsed response. 26 | 27 | :param req: The request to send. 28 | 29 | :return: The parsed response. 30 | 31 | :raises BadResponse: If the response from SPAMD is ill-formed this exception will be raised. 32 | :raises AIOSpamcConnectionFailed: Raised if an error occurred when trying to connect. 33 | :raises UsageException: Error in command line usage. 34 | :raises DataErrorException: Error with data format. 35 | :raises NoInputException: Cannot open input. 36 | :raises NoUserException: Addressee unknown. 37 | :raises NoHostException: Hostname unknown. 38 | :raises UnavailableException: Service unavailable. 39 | :raises InternalSoftwareException: Internal software error. 40 | :raises OSErrorException: System error. 41 | :raises OSFileException: Operating system file missing. 42 | :raises CantCreateException: Cannot create output file. 43 | :raises IOErrorException: Input/output error. 44 | :raises TemporaryFailureException: Temporary failure, may reattempt. 45 | :raises ProtocolException: Error in the protocol. 46 | :raises NoPermissionException: Permission denied. 47 | :raises ConfigException: Error in configuration. 48 | :raises ServerTimeoutException: Server returned a response that it timed out. 49 | :raises ClientTimeoutException: Client timed out during connection. 50 | """ 51 | 52 | context_logger = logger.bind( 53 | connection=self.connection_manager, 54 | request=req, 55 | ) 56 | 57 | raise_warnings(req, self.connection_manager) 58 | 59 | context_logger.info("Sending {} request", req.verb) 60 | response = await self.connection_manager.request(bytes(req)) 61 | context_logger = context_logger.bind(response_bytes=response) 62 | try: 63 | parser = ResponseParser() 64 | parsed_response = parser.parse(response) 65 | except ParseError as error: 66 | context_logger.exception("Error parsing response") 67 | raise BadResponse(response) from error 68 | response_obj = Response(**parsed_response) 69 | response_obj.raise_for_status() 70 | context_logger.bind(response=response_obj).success( 71 | "Successfully received response" 72 | ) 73 | 74 | return response_obj 75 | -------------------------------------------------------------------------------- /aiospamc/connections.py: -------------------------------------------------------------------------------- 1 | """ConnectionManager classes for TCP and Unix sockets.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import ssl 7 | from enum import Enum, auto 8 | from getpass import getpass 9 | from pathlib import Path 10 | from typing import Any, Callable, Optional, Union 11 | 12 | import certifi 13 | import loguru 14 | from loguru import logger 15 | 16 | from .exceptions import AIOSpamcConnectionFailed, ClientTimeoutException 17 | 18 | 19 | class Timeout: 20 | """Container object for defining timeouts.""" 21 | 22 | def __init__( 23 | self, 24 | total: float = 600, 25 | connection: Optional[float] = None, 26 | response: Optional[float] = None, 27 | ) -> None: 28 | """Timeout constructor. 29 | 30 | :param total: The total length of time in seconds to set the timeout. 31 | :param connection: The length of time in seconds to allow for a connection to live before timing out. 32 | :param response: The length of time in seconds to allow for a response from the server before timing out. 33 | """ 34 | 35 | self.total = float(total) 36 | self.connection = connection 37 | self.response = response 38 | 39 | def __repr__(self): 40 | return ( 41 | f"{self.__class__.__qualname__}(" 42 | f"total={self.total}, " 43 | f"connection={self.connection}, " 44 | f"response={self.response}" 45 | ")" 46 | ) 47 | 48 | 49 | class ConnectionManagerBuilder: 50 | """Builder for connection managers.""" 51 | 52 | class ManagerType(Enum): 53 | """Define connection manager type during build.""" 54 | 55 | Undefined = auto() 56 | Tcp = auto() 57 | Unix = auto() 58 | 59 | def __init__(self): 60 | """ConnectionManagerBuilder constructor.""" 61 | 62 | self._manager_type = self.ManagerType.Undefined 63 | self._tcp_builder = TcpConnectionManagerBuilder() 64 | self._unix_builder = UnixConnectionManagerBuilder() 65 | self._ssl_builder = SSLContextBuilder() 66 | self._ssl = False 67 | self._timeout = None 68 | 69 | def build(self) -> Union[UnixConnectionManager, TcpConnectionManager]: 70 | """Builds the :class:`aiospamc.connections.ConnectionManager`. 71 | 72 | :return: An instance of :class:`aiospamc.connections.TcpConnectionManager` 73 | or :class:`aiospamc.connections.UnixConnectionManager` 74 | """ 75 | 76 | if self._manager_type is self.ManagerType.Undefined: 77 | raise ValueError( 78 | "Connection type is undefined, builder must be called with 'with_unix_socket' or 'with_tcp'" 79 | ) 80 | elif self._manager_type is self.ManagerType.Tcp: 81 | ssl_context = None if not self._ssl else self._ssl_builder.build() 82 | self._tcp_builder.set_ssl_context(ssl_context) 83 | return self._tcp_builder.set_timeout(self._timeout).build() 84 | else: 85 | return self._unix_builder.set_timeout(self._timeout).build() 86 | 87 | def with_unix_socket(self, path: Path) -> ConnectionManagerBuilder: 88 | """Configures the builder to use a Unix socket connection. 89 | 90 | :param path: Path to the Unix socket. 91 | 92 | :return: This builder instance. 93 | """ 94 | 95 | self._manager_type = self.ManagerType.Unix 96 | self._unix_builder.set_path(path) 97 | self._tcp_host = self._tcp_port = None 98 | 99 | return self 100 | 101 | def with_tcp(self, host: str, port: int = 783) -> ConnectionManagerBuilder: 102 | """Configures the builder to use a TCP connection. 103 | 104 | :param host: Hostname to use. 105 | :param port: Port to use. 106 | 107 | :return: This builder instance. 108 | """ 109 | 110 | self._manager_type = self.ManagerType.Tcp 111 | self._tcp_builder.set_host(host).set_port(port) 112 | self._unix_path = None 113 | 114 | return self 115 | 116 | def add_ssl_context(self, context: ssl.SSLContext) -> ConnectionManagerBuilder: 117 | """Adds an SSL context when a TCP connection is being used. 118 | 119 | :param context: :class:`ssl.SSLContext` instance. 120 | 121 | :return: This builder instance. 122 | """ 123 | 124 | self._ssl_builder.with_context(context) 125 | self._ssl = True 126 | 127 | return self 128 | 129 | def set_timeout(self, timeout: Timeout) -> ConnectionManagerBuilder: 130 | """Sets the timeout for the connection. 131 | 132 | :param timeout: Timeout object. 133 | 134 | :return: This builder instance. 135 | """ 136 | 137 | self._timeout = timeout 138 | 139 | return self 140 | 141 | 142 | class ConnectionManager: 143 | """Stores connection parameters and creates connections.""" 144 | 145 | def __init__( 146 | self, connection_string: str, timeout: Optional[Timeout] = None 147 | ) -> None: 148 | """ConnectionManager constructor. 149 | 150 | :param timeout: Timeout configuration 151 | """ 152 | 153 | self._connection_string = connection_string 154 | self.timeout = timeout or Timeout() 155 | self._logger = logger.bind( 156 | connection_string=self.connection_string, 157 | timeout=self.timeout, 158 | ) 159 | 160 | @property 161 | def logger(self) -> loguru.Logger: 162 | """Return the logger object.""" 163 | 164 | return self._logger 165 | 166 | async def request(self, data: bytes) -> bytes: 167 | """Send bytes data and receive a response. 168 | 169 | :raises: AIOSpamcConnectionFailed 170 | :raises: ClientTimeoutException 171 | 172 | :param data: Data to send. 173 | """ 174 | 175 | try: 176 | response = await asyncio.wait_for(self._send(data), self.timeout.total) 177 | except asyncio.TimeoutError: 178 | self.logger.exception("Total timeout reached") 179 | raise 180 | 181 | return response 182 | 183 | async def _send(self, data: bytes) -> bytes: 184 | """Opens a connection, sends data to the writer, waits for the reader, then returns the response. 185 | 186 | :param data: Data to send. 187 | 188 | :return: Byte data from the response. 189 | """ 190 | 191 | reader, writer = await self._connect() 192 | 193 | writer.write(data) 194 | if writer.can_write_eof(): 195 | writer.write_eof() 196 | await writer.drain() 197 | 198 | response = await self._receive(reader) 199 | 200 | writer.close() 201 | await writer.wait_closed() 202 | 203 | return response 204 | 205 | async def _receive(self, reader: asyncio.StreamReader) -> bytes: 206 | """Takes a reader and returns the response. 207 | 208 | :param reader: asyncio reader. 209 | 210 | :return: Byte data from the response. 211 | """ 212 | 213 | try: 214 | response = await asyncio.wait_for(reader.read(), self.timeout.response) 215 | except asyncio.TimeoutError as error: 216 | self.logger.exception("Timed out receiving data") 217 | raise ClientTimeoutException from error 218 | 219 | self.logger.success("Successfully received data") 220 | 221 | return response 222 | 223 | async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: 224 | """Opens a connection from the connection manager. 225 | 226 | :return: Tuple or asyncio reader and writer. 227 | """ 228 | 229 | try: 230 | reader, writer = await asyncio.wait_for( 231 | self.open(), self.timeout.connection 232 | ) 233 | except asyncio.TimeoutError as error: 234 | self.logger.exception("Timeout when connecting") 235 | raise ClientTimeoutException from error 236 | 237 | self.logger.success("Successfully connected") 238 | 239 | return reader, writer 240 | 241 | async def open(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: 242 | """Opens a connection, returning the reader and writer objects.""" 243 | 244 | raise NotImplementedError 245 | 246 | @property 247 | def connection_string(self) -> str: 248 | """String representation of the connection.""" 249 | 250 | return self._connection_string 251 | 252 | 253 | class TcpConnectionManagerBuilder: 254 | """Builder for :class:`aiospamc.connections.TcpConnectionManager`""" 255 | 256 | def __init__(self): 257 | """`TcpConnectionManagerBuilder` constructor.""" 258 | 259 | self._args = {} 260 | 261 | def build(self) -> TcpConnectionManager: 262 | """Builds the :class:`aiospamc.connections.TcpConnectionManager`. 263 | 264 | :return: An instance of :class:`aiospamc.connections.TcpConnectionManager`. 265 | """ 266 | 267 | return TcpConnectionManager(**self._args) 268 | 269 | def set_host(self, host: str) -> TcpConnectionManagerBuilder: 270 | """Sets the host to use. 271 | 272 | :param host: Hostname to use. 273 | 274 | :return: This builder instance. 275 | """ 276 | 277 | self._args["host"] = host 278 | return self 279 | 280 | def set_port(self, port: int) -> TcpConnectionManagerBuilder: 281 | """Sets the port to use. 282 | 283 | :param port: Port to use. 284 | 285 | :return: This builder instance. 286 | """ 287 | 288 | self._args["port"] = port 289 | return self 290 | 291 | def set_ssl_context(self, context: ssl.SSLContext) -> TcpConnectionManagerBuilder: 292 | """Set an SSL context. 293 | 294 | :param context: An instance of :class:`ssl.SSLContext`. 295 | 296 | :return: This builder instance. 297 | """ 298 | 299 | self._args["ssl_context"] = context 300 | return self 301 | 302 | def set_timeout(self, timeout: Timeout) -> TcpConnectionManagerBuilder: 303 | """Sets the timeout for the connection. 304 | 305 | :param timeout: Timeout object. 306 | 307 | :return: This builder instance. 308 | """ 309 | 310 | self._args["timeout"] = timeout 311 | return self 312 | 313 | 314 | class TcpConnectionManager(ConnectionManager): 315 | """Connection manager for TCP connections.""" 316 | 317 | def __init__( 318 | self, 319 | host: str, 320 | port: int, 321 | ssl_context: Optional[ssl.SSLContext] = None, 322 | timeout: Optional[Timeout] = None, 323 | ) -> None: 324 | """TcpConnectionManager constructor. 325 | 326 | :param host: Hostname or IP address. 327 | :param port: TCP port. 328 | :param ssl_context: SSL context. 329 | :param timeout: Timeout configuration. 330 | """ 331 | 332 | super().__init__(f"{host}:{port}", timeout) 333 | self.host = host 334 | self.port = port 335 | self.ssl_context = ssl_context 336 | 337 | async def open(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: 338 | """Opens a TCP connection. 339 | 340 | :raises: AIOSpamcConnectionFailed 341 | 342 | :return: Reader and writer for the connection. 343 | """ 344 | 345 | try: 346 | reader, writer = await asyncio.open_connection( 347 | self.host, self.port, ssl=self.ssl_context 348 | ) 349 | except (ConnectionRefusedError, OSError) as error: 350 | self.logger.exception("Exception occurred when connecting") 351 | raise AIOSpamcConnectionFailed from error 352 | 353 | return reader, writer 354 | 355 | 356 | class UnixConnectionManagerBuilder: 357 | """Builder for :class:`aiospamc.connections.UnixConnectionManager`.""" 358 | 359 | def __init__(self): 360 | """`UnixConnectionManagerBuilder` constructor.""" 361 | 362 | self._args = {} 363 | 364 | def build(self) -> UnixConnectionManager: 365 | """Builds a :class:`aiospamc.connections.UnixConnectionManager`. 366 | 367 | :return: An instance of :class:`aiospamc.connections.UnixConnectionManager`. 368 | """ 369 | 370 | return UnixConnectionManager(**self._args) 371 | 372 | def set_path(self, path: Path) -> UnixConnectionManagerBuilder: 373 | """Sets the unix socket path. 374 | 375 | :param path: Path to the Unix socket. 376 | 377 | :return: This builder instance. 378 | """ 379 | 380 | self._args["path"] = path 381 | return self 382 | 383 | def set_timeout(self, timeout: Timeout) -> UnixConnectionManagerBuilder: 384 | """Sets the timeout for the connection. 385 | 386 | :param timeout: Timeout object. 387 | 388 | :return: This builder instance. 389 | """ 390 | 391 | self._args["timeout"] = timeout 392 | return self 393 | 394 | 395 | class UnixConnectionManager(ConnectionManager): 396 | """Connection manager for Unix pipes.""" 397 | 398 | def __init__(self, path: Path, timeout: Optional[Timeout] = None): 399 | """UnixConnectionManager constructor. 400 | 401 | :param path: Unix socket path. 402 | :param timeout: Timeout configuration 403 | """ 404 | 405 | super().__init__(str(path), timeout) 406 | self.path = path 407 | 408 | async def open(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: 409 | """Opens a unix socket path connection. 410 | 411 | :raises: AIOSpamcConnectionFailed 412 | 413 | :return: Reader and writer for the connection. 414 | """ 415 | 416 | try: 417 | reader, writer = await asyncio.open_unix_connection(self.path) 418 | except (ConnectionRefusedError, OSError) as error: 419 | self.logger.exception("Exception occurred when connecting") 420 | raise AIOSpamcConnectionFailed from error 421 | 422 | return reader, writer 423 | 424 | 425 | class SSLContextBuilder: 426 | """SSL context builder.""" 427 | 428 | def __init__(self): 429 | """Builder contstructor. Sets up a default SSL context.""" 430 | 431 | self._context = ssl.create_default_context() 432 | 433 | def build(self) -> ssl.SSLContext: 434 | """Builds the SSL context. 435 | 436 | :return: An instance of :class:`ssl.SSLContext`. 437 | """ 438 | 439 | return self._context 440 | 441 | def with_context(self, context: ssl.SSLContext) -> SSLContextBuilder: 442 | """Use the SSL context. 443 | 444 | :param context: Provided SSL context. 445 | 446 | :return: The builder instance. 447 | """ 448 | 449 | self._context = context 450 | 451 | return self 452 | 453 | def add_ca_file(self, file: Path) -> SSLContextBuilder: 454 | """Add certificate authority from a file. 455 | 456 | :param file: File of concatenated certificates. 457 | 458 | :return: The builder instance. 459 | """ 460 | 461 | self._context.load_verify_locations(cafile=file) 462 | 463 | return self 464 | 465 | def add_ca_dir(self, dir: Path) -> SSLContextBuilder: 466 | """Add certificate authority from a directory. 467 | 468 | :param dir: Directory of certificates. 469 | 470 | :return: The builder instance. 471 | """ 472 | 473 | self._context.load_verify_locations(capath=dir) 474 | 475 | return self 476 | 477 | def add_ca(self, path: Path) -> SSLContextBuilder: 478 | """Add a certificate authority. 479 | 480 | :param path: Directory or file of certificates. 481 | 482 | :return: The builder instance. 483 | """ 484 | 485 | if path.is_dir(): 486 | return self.add_ca_dir(path) 487 | elif path.is_file(): 488 | return self.add_ca_file(path) 489 | else: 490 | raise FileNotFoundError(path) 491 | 492 | def add_default_ca(self) -> SSLContextBuilder: 493 | """Add default certificate authorities. 494 | 495 | :return: The builder instance. 496 | """ 497 | 498 | self._context.load_verify_locations(cafile=certifi.where()) 499 | 500 | return self 501 | 502 | def add_client( 503 | self, 504 | file: Path, 505 | key: Optional[Path] = None, 506 | password: Optional[Callable[[], Union[str, bytes, bytearray]]] = None, 507 | ) -> SSLContextBuilder: 508 | """Add client certificate. 509 | 510 | :param file: Path to the client certificate. 511 | :param key: Path to the key. 512 | :param password: Callable that returns the password, if any. 513 | """ 514 | 515 | self._context.load_cert_chain(file, key, password) 516 | 517 | return self 518 | 519 | def dont_verify(self) -> SSLContextBuilder: 520 | """Set the context to not verify certificates.""" 521 | 522 | self._context.check_hostname = False 523 | self._context.verify_mode = ssl.CERT_NONE 524 | 525 | return self 526 | -------------------------------------------------------------------------------- /aiospamc/exceptions.py: -------------------------------------------------------------------------------- 1 | """Collection of exceptions.""" 2 | 3 | 4 | class ClientException(Exception): 5 | """Base class for exceptions raised from the client.""" 6 | 7 | pass 8 | 9 | 10 | class BadRequest(ClientException): 11 | """Request is not in the expected format.""" 12 | 13 | pass 14 | 15 | 16 | class BadResponse(ClientException): 17 | """Response is not in the expected format.""" 18 | 19 | pass 20 | 21 | 22 | class AIOSpamcConnectionFailed(ClientException): 23 | """Connection failed.""" 24 | 25 | pass 26 | 27 | 28 | class TimeoutException(Exception): 29 | """General timeout exception.""" 30 | 31 | pass 32 | 33 | 34 | class ClientTimeoutException(ClientException, TimeoutException): 35 | """Timeout exception from the client.""" 36 | 37 | pass 38 | 39 | 40 | class ParseError(Exception): 41 | """Error occurred while parsing.""" 42 | 43 | def __init__(self, message=None): 44 | """Construct parsing exception with optional message. 45 | 46 | :param message: User friendly message. 47 | """ 48 | 49 | self.message = message 50 | 51 | 52 | class NotEnoughDataError(ParseError): 53 | """Expected more data than what the protocol content specified.""" 54 | 55 | pass 56 | 57 | 58 | class TooMuchDataError(ParseError): 59 | """Too much data was received than what the protocol content specified.""" 60 | 61 | pass 62 | -------------------------------------------------------------------------------- /aiospamc/header_values.py: -------------------------------------------------------------------------------- 1 | """Collection of request and response header value objects.""" 2 | 3 | import getpass 4 | from base64 import b64encode 5 | from collections import UserDict 6 | from dataclasses import dataclass 7 | from enum import Enum 8 | from typing import Any, Optional, Protocol 9 | 10 | 11 | class HeaderValue(Protocol): # pragma: no cover 12 | """Protocol for headers.""" 13 | 14 | def __bytes__(self) -> bytes: 15 | pass 16 | 17 | def to_json(self) -> Any: 18 | """Convert to a JSON object.""" 19 | 20 | pass 21 | 22 | 23 | @dataclass 24 | class BytesHeaderValue: 25 | """Header with bytes value. 26 | 27 | :param value: Value of the header. 28 | """ 29 | 30 | value: bytes 31 | 32 | def __bytes__(self) -> bytes: 33 | return self.value 34 | 35 | def to_json(self) -> Any: 36 | """Converts object to a JSON serializable object.""" 37 | 38 | return b64encode(self.value).decode() 39 | 40 | 41 | @dataclass 42 | class GenericHeaderValue: 43 | """Generic header value.""" 44 | 45 | value: str 46 | encoding: str = "utf8" 47 | 48 | def __bytes__(self) -> bytes: 49 | return self.value.encode(self.encoding) 50 | 51 | def to_json(self) -> Any: 52 | """Converts object to a JSON serializable object.""" 53 | 54 | return self.value 55 | 56 | 57 | @dataclass 58 | class CompressValue: 59 | """Compress header. Specifies what encryption scheme to use. So far only 60 | 'zlib' is supported. 61 | """ 62 | 63 | algorithm: str = "zlib" 64 | 65 | def __bytes__(self) -> bytes: 66 | return self.algorithm.encode("ascii") 67 | 68 | def to_json(self) -> Any: 69 | """Converts object to a JSON serializable object.""" 70 | 71 | return self.algorithm 72 | 73 | 74 | @dataclass 75 | class ContentLengthValue: 76 | """ContentLength header. Indicates the length of the body in bytes.""" 77 | 78 | length: int = 0 79 | 80 | def __int__(self) -> int: 81 | return self.length 82 | 83 | def __bytes__(self) -> bytes: 84 | return str(self.length).encode("ascii") 85 | 86 | def to_json(self) -> Any: 87 | """Converts object to a JSON serializable object.""" 88 | 89 | return self.length 90 | 91 | 92 | class MessageClassOption(str, Enum): 93 | """Option to be used for the MessageClass header.""" 94 | 95 | spam = "spam" 96 | ham = "ham" 97 | 98 | def __str__(self) -> str: 99 | return self.value 100 | 101 | 102 | @dataclass 103 | class MessageClassValue: 104 | """MessageClass header. Used to specify whether a message is 'spam' or 105 | 'ham.' 106 | """ 107 | 108 | value: MessageClassOption = MessageClassOption.ham 109 | 110 | def __bytes__(self) -> bytes: 111 | return self.value.name.encode("ascii") 112 | 113 | def to_json(self) -> Any: 114 | """Converts object to a JSON serializable object.""" 115 | 116 | return self.value.value 117 | 118 | 119 | @dataclass 120 | class ActionOption: 121 | """Option to be used in the DidRemove, DidSet, Set, and Remove headers. 122 | 123 | :param local: An action will be performed on the SPAMD service's local database. 124 | :param remote: An action will be performed on the SPAMD service's remote database. 125 | """ 126 | 127 | local: bool = False 128 | remote: bool = False 129 | 130 | 131 | @dataclass 132 | class SetOrRemoveValue: 133 | """Base class for headers that implement "local" and "remote" rules.""" 134 | 135 | action: ActionOption 136 | 137 | def __bytes__(self) -> bytes: 138 | if not self.action.local and not self.action.remote: 139 | # if nothing is set, then return a blank string so the request 140 | # doesn't get tainted 141 | return b"" 142 | 143 | values = [] 144 | if self.action.local: 145 | values.append(b"local") 146 | if self.action.remote: 147 | values.append(b"remote") 148 | 149 | return b", ".join(values) 150 | 151 | def to_json(self) -> Any: 152 | """Converts object to a JSON serializable object.""" 153 | 154 | return {"local": self.action.local, "remote": self.action.remote} 155 | 156 | 157 | @dataclass 158 | class SpamValue: 159 | """Spam header. Used by the SPAMD service to report on if the submitted 160 | message was spam and the score/threshold that it used.""" 161 | 162 | value: bool = False 163 | score: float = 0.0 164 | threshold: float = 0.0 165 | 166 | def __bytes__(self) -> bytes: 167 | return b"%b ; %.1f / %.1f" % ( 168 | str(self.value).encode("ascii"), 169 | self.score, 170 | self.threshold, 171 | ) 172 | 173 | def to_json(self) -> Any: 174 | """Converts object to a JSON serializable object.""" 175 | 176 | return {"value": self.value, "score": self.score, "threshold": self.threshold} 177 | 178 | 179 | @dataclass 180 | class UserValue: 181 | """User header. Used to specify which user the SPAMD service should use 182 | when loading configuration files.""" 183 | 184 | name: str = getpass.getuser() 185 | 186 | def __bytes__(self) -> bytes: 187 | return self.name.encode("ascii") 188 | 189 | def __str__(self) -> str: 190 | return self.name 191 | 192 | def to_json(self) -> Any: 193 | """Converts object to a JSON serializable object.""" 194 | 195 | return self.name 196 | 197 | 198 | class Headers(UserDict): 199 | """Class to store headers with shortcut properties.""" 200 | 201 | def get_header(self, name: str) -> Optional[str]: 202 | """Get a string header if it exists. 203 | 204 | :param name: Name of the header. 205 | :return: The header value. 206 | """ 207 | 208 | if header := self.data.get(name): 209 | return header.value 210 | return None 211 | 212 | def set_header(self, name: str, value: str): 213 | """Sets a string header. 214 | 215 | :param name: Name of the header. 216 | :param value: Value of the header. 217 | """ 218 | 219 | self.data[name] = GenericHeaderValue(value) 220 | 221 | def get_bytes_header(self, name: str) -> Optional[bytes]: 222 | """Get a bytes header if it exists. 223 | 224 | :param name: Name of the header. 225 | :return: The header value. 226 | """ 227 | 228 | if header := self.data.get(name): 229 | return header.value 230 | return None 231 | 232 | def set_bytes_header(self, name: str, value: bytes): 233 | """Sets a string header. 234 | 235 | :param name: Name of the header. 236 | :param value: Value of the header. 237 | """ 238 | 239 | self.data[name] = BytesHeaderValue(value) 240 | 241 | @property 242 | def compress(self) -> Optional[str]: 243 | """Gets the Compress header if it exists. 244 | 245 | :return: Compress header value. 246 | """ 247 | 248 | if header := self.data.get("Compress"): 249 | return header.algorithm 250 | return None 251 | 252 | @compress.setter 253 | def compress(self, value: str = "zlib"): 254 | """Sets the Compress header. 255 | 256 | :param value: Value of the header. 257 | """ 258 | 259 | self.data["Compress"] = CompressValue(value) 260 | 261 | @property 262 | def content_length(self) -> Optional[int]: 263 | """Gets the Content-length header if it exists. 264 | 265 | :return: Content-length header value. 266 | """ 267 | 268 | if header := self.data.get("Content-length"): 269 | return header.length 270 | return None 271 | 272 | @content_length.setter 273 | def content_length(self, value: int): 274 | """Sets the Content-length header. 275 | 276 | :param value: Value of the header. 277 | """ 278 | 279 | self.data["Content-length"] = ContentLengthValue(value) 280 | 281 | @property 282 | def message_class(self) -> Optional[MessageClassOption]: 283 | """Gets the Message-class header if it exists. 284 | 285 | :return: Message-class header value. 286 | """ 287 | 288 | if header := self.data.get("Message-class"): 289 | return header.value 290 | return None 291 | 292 | @message_class.setter 293 | def message_class(self, value: MessageClassOption): 294 | """Sets the Message-class header. 295 | 296 | :param value: Value of the header. 297 | """ 298 | 299 | self.data["Message-class"] = MessageClassValue(value) 300 | 301 | @property 302 | def set_(self) -> Optional[ActionOption]: 303 | """Gets the Set header if it exists. 304 | 305 | :return: Set header value. 306 | """ 307 | 308 | if header := self.data.get("Set"): 309 | return header.action 310 | return None 311 | 312 | @set_.setter 313 | def set_(self, value: ActionOption): 314 | """Sets the Set header. 315 | 316 | :param value: Value of the header. 317 | """ 318 | 319 | self.data["Set"] = SetOrRemoveValue(value) 320 | 321 | @property 322 | def remove(self) -> Optional[ActionOption]: 323 | """Gets the Remove header if it exists. 324 | 325 | :return: Remove header value. 326 | """ 327 | 328 | if header := self.data.get("Remove"): 329 | return header.action 330 | return None 331 | 332 | @remove.setter 333 | def remove(self, value: ActionOption): 334 | """Sets the Remove header. 335 | 336 | :param value: Value of the header. 337 | """ 338 | 339 | self.data["Remove"] = SetOrRemoveValue(value) 340 | 341 | @property 342 | def did_set(self) -> Optional[ActionOption]: 343 | """Gets the DidSet header if it exists. 344 | 345 | :return: DidSet header value. 346 | """ 347 | 348 | if header := self.data.get("DidSet"): 349 | return header.action 350 | return None 351 | 352 | @did_set.setter 353 | def did_set(self, value: ActionOption): 354 | """Sets the DidSet header. 355 | 356 | :param value: Value of the header. 357 | """ 358 | 359 | self.data["DidSet"] = SetOrRemoveValue(value) 360 | 361 | @property 362 | def did_remove(self) -> Optional[ActionOption]: 363 | """Gets the DidRemove header if it exists. 364 | 365 | :return: DidRemove header value. 366 | """ 367 | 368 | if header := self.data.get("DidRemove"): 369 | return header.action 370 | return None 371 | 372 | @did_remove.setter 373 | def did_remove(self, value: ActionOption): 374 | """Sets the DidRemove header. 375 | 376 | :param value: Value of the header. 377 | """ 378 | 379 | self.data["DidRemove"] = SetOrRemoveValue(value) 380 | 381 | @property 382 | def spam(self) -> Optional[SpamValue]: 383 | """Gets the Spam header if it exists. 384 | 385 | :return: Spam header value. 386 | """ 387 | 388 | return self.data.get("Spam") 389 | 390 | @spam.setter 391 | def spam(self, value: SpamValue): 392 | """Sets the Spam header. 393 | 394 | :param value: Value of the header. 395 | """ 396 | 397 | self.data["Spam"] = value 398 | 399 | @property 400 | def user(self) -> Optional[str]: 401 | """Gets the User header if it exists. 402 | 403 | :return: User header value. 404 | """ 405 | 406 | if header := self.data.get("User"): 407 | return header.name 408 | return None 409 | 410 | @user.setter 411 | def user(self, value: str): 412 | """Sets the User header. 413 | 414 | :param value: Value of the header. 415 | """ 416 | 417 | self.data["User"] = UserValue(value) 418 | -------------------------------------------------------------------------------- /aiospamc/requests.py: -------------------------------------------------------------------------------- 1 | """Contains all requests that can be made to the SPAMD service.""" 2 | 3 | import zlib 4 | from base64 import b64encode 5 | from typing import Any, SupportsBytes, Union 6 | 7 | from .header_values import ContentLengthValue, Headers 8 | 9 | 10 | class Request: 11 | """SPAMC request object.""" 12 | 13 | def __init__( 14 | self, 15 | verb: str, 16 | version: str = "1.5", 17 | headers: Union[dict[str, Any], Headers, None] = None, 18 | body: Union[bytes, SupportsBytes] = b"", 19 | **_, 20 | ) -> None: 21 | """Request constructor. 22 | 23 | :param verb: Method name of the request. 24 | :param version: Version of the protocol. 25 | :param headers: Collection of headers to be added. 26 | :param body: Byte string representation of the body. 27 | """ 28 | 29 | self.verb = verb 30 | self.version = version 31 | if isinstance(headers, dict): 32 | self.headers = Headers(headers) 33 | elif isinstance(headers, Headers): 34 | self.headers = headers 35 | else: 36 | self.headers = Headers() 37 | self.body = bytes(body) 38 | 39 | def __bytes__(self) -> bytes: 40 | if "Compress" in self.headers.keys(): 41 | body = zlib.compress(self.body) 42 | else: 43 | body = self.body 44 | 45 | if len(body) > 0: 46 | self.headers["Content-length"] = ContentLengthValue(length=len(body)) 47 | 48 | encoded_headers = b"".join( 49 | [ 50 | b"%b: %b\r\n" % (key.encode("ascii"), bytes(value)) 51 | for key, value in self.headers.items() 52 | ] 53 | ) 54 | 55 | request = ( 56 | b"%(verb)b " b"SPAMC/%(version)b" b"\r\n" b"%(headers)b\r\n" b"%(body)b" 57 | ) 58 | 59 | return request % { 60 | b"verb": self.verb.encode("ascii"), 61 | b"version": self.version.encode("ascii"), 62 | b"headers": encoded_headers, 63 | b"body": body, 64 | } 65 | 66 | def __repr__(self) -> str: 67 | return str(self) 68 | 69 | def __str__(self) -> str: 70 | return ( 71 | f"<{self.__class__.__module__}.{self.__class__.__qualname__} " 72 | f"[{self.verb}] " 73 | f"object at {hex(id(self))}>" 74 | ) 75 | 76 | @property 77 | def body(self) -> bytes: 78 | """Body property getter. 79 | 80 | :return: Value of body. 81 | """ 82 | 83 | return self._body 84 | 85 | @body.setter 86 | def body(self, value: Union[bytes, SupportsBytes]) -> None: 87 | """Body property setter. 88 | 89 | :param value: Value to set the body. 90 | """ 91 | 92 | self._body = bytes(value) 93 | 94 | def to_json(self): 95 | """Converts to JSON serializable object.""" 96 | 97 | return { 98 | "verb": self.verb, 99 | "version": self.version, 100 | "headers": {key: value.to_json() for key, value in self.headers.items()}, 101 | "body": b64encode(self.body).decode(), 102 | } 103 | -------------------------------------------------------------------------------- /aiospamc/responses.py: -------------------------------------------------------------------------------- 1 | """Contains classes used for responses.""" 2 | 3 | from __future__ import annotations 4 | 5 | import zlib 6 | from base64 import b64encode 7 | from enum import IntEnum 8 | from typing import Any, SupportsBytes, Union 9 | 10 | from .exceptions import TimeoutException 11 | from .header_values import ContentLengthValue, Headers 12 | 13 | 14 | class Status(IntEnum): 15 | """Enumeration for the status values defined by SPAMD.""" 16 | 17 | EX_OK = 0 18 | EX_USAGE = 64 19 | EX_DATAERR = 65 20 | EX_NOINPUT = 66 21 | EX_NOUSER = 67 22 | EX_NOHOST = 68 23 | EX_UNAVAILABLE = 69 24 | EX_SOFTWARE = 70 25 | EX_OSERR = 71 26 | EX_OSFILE = 72 27 | EX_CANTCREAT = 73 28 | EX_IOERR = 74 29 | EX_TEMPFAIL = 75 30 | EX_PROTOCOL = 76 31 | EX_NOPERM = 77 32 | EX_CONFIG = 78 33 | EX_TIMEOUT = 79 34 | 35 | 36 | class Response: 37 | """Class to encapsulate response.""" 38 | 39 | def __init__( 40 | self, 41 | version: str = "1.5", 42 | status_code: Union[Status, int] = 0, 43 | message: str = "", 44 | headers: Union[dict[str, Any], Headers, None] = None, 45 | body: bytes = b"", 46 | **_, 47 | ): 48 | """Response constructor. 49 | 50 | :param version: Version reported by the SPAMD service response. 51 | :param status_code: Success or error code. 52 | :param message: Message associated with status code. 53 | :param body: Byte string representation of the body. 54 | :param headers: Collection of headers to be added. 55 | """ 56 | 57 | self.version = version 58 | if isinstance(headers, dict): 59 | self.headers = Headers(headers) 60 | elif isinstance(headers, Headers): 61 | self.headers = headers 62 | else: 63 | self.headers = Headers() 64 | self._status_code: Union[Status, int] 65 | self.status_code = status_code 66 | self.message = message 67 | self.body = body 68 | 69 | def __bytes__(self) -> bytes: 70 | if "Compress" in self.headers: 71 | body = zlib.compress(self.body) 72 | else: 73 | body = self.body 74 | 75 | if len(body) > 0: 76 | self.headers["Content-length"] = ContentLengthValue(length=len(body)) 77 | 78 | status = self.status_code 79 | encoded_headers = b"".join( 80 | [ 81 | b"%b: %b\r\n" % (key.encode("ascii"), bytes(value)) 82 | for key, value in self.headers.items() 83 | ] 84 | ) 85 | message = self.message.encode("ascii") 86 | 87 | return ( 88 | b"SPAMD/%(version)b " 89 | b"%(status)d " 90 | b"%(message)b\r\n" 91 | b"%(headers)b\r\n" 92 | b"%(body)b" 93 | % { 94 | b"version": self.version.encode("ascii"), 95 | b"status": status, 96 | b"message": message, 97 | b"headers": encoded_headers, 98 | b"body": body, 99 | } 100 | ) 101 | 102 | def __repr__(self) -> str: 103 | return str(self) 104 | 105 | def __str__(self) -> str: 106 | return ( 107 | f"<{self.__class__.__module__}.{self.__class__.__qualname__} " 108 | f"[{self.message}] " 109 | f"object at {hex(id(self))}>" 110 | ) 111 | 112 | def __eq__(self, other: Any) -> bool: 113 | try: 114 | return ( 115 | self.version == other.version 116 | and self.headers == other.headers 117 | and self.status_code == other.status_code 118 | and self.message == other.message 119 | and self.body == other.body 120 | ) 121 | except AttributeError: 122 | return False 123 | 124 | @property 125 | def status_code(self) -> Union[Status, int]: 126 | """Status code property getter. 127 | 128 | :return: Value of status code. 129 | """ 130 | 131 | return self._status_code 132 | 133 | @status_code.setter 134 | def status_code(self, code: Union[Status, int]) -> None: 135 | """Status code property setter. 136 | 137 | :param code: Status code value to set. 138 | """ 139 | 140 | try: 141 | self._status_code = Status(code) 142 | except ValueError: 143 | self._status_code = code 144 | 145 | @property 146 | def body(self) -> bytes: 147 | """Body property getter. 148 | 149 | :return: Value of body. 150 | """ 151 | 152 | return self._body 153 | 154 | @body.setter 155 | def body(self, value: Union[bytes, SupportsBytes]) -> None: 156 | """Body property setter. 157 | 158 | :param value: Value to set the body. 159 | """ 160 | 161 | self._body = bytes(value) 162 | 163 | def raise_for_status(self) -> None: 164 | """Raises an exception if the status code isn't zero. 165 | 166 | :raises ResponseException: 167 | :raises UsageException: 168 | :raises DataErrorException: 169 | :raises NoInputException: 170 | :raises NoUserException: 171 | :raises NoHostException: 172 | :raises UnavailableException: 173 | :raises InternalSoftwareException: 174 | :raises OSErrorException: 175 | :raises OSFileException: 176 | :raises CantCreateException: 177 | :raises IOErrorException: 178 | :raises TemporaryFailureException: 179 | :raises ProtocolException: 180 | :raises NoPermissionException: 181 | :raises ConfigException: 182 | :raises ServerTimeoutException: 183 | """ 184 | 185 | if self.status_code == 0: 186 | return 187 | else: 188 | status_exception = { 189 | 64: UsageException, 190 | 65: DataErrorException, 191 | 66: NoInputException, 192 | 67: NoUserException, 193 | 68: NoHostException, 194 | 69: UnavailableException, 195 | 70: InternalSoftwareException, 196 | 71: OSErrorException, 197 | 72: OSFileException, 198 | 73: CantCreateException, 199 | 74: IOErrorException, 200 | 75: TemporaryFailureException, 201 | 76: ProtocolException, 202 | 77: NoPermissionException, 203 | 78: ConfigException, 204 | 79: ServerTimeoutException, 205 | } 206 | if self.status_code in status_exception: 207 | raise status_exception[self.status_code](self.message, self) 208 | else: 209 | raise ResponseException(self.status_code, self.message, self) 210 | 211 | def to_json(self) -> dict[str, Any]: 212 | """Converts to JSON serializable object.""" 213 | 214 | return { 215 | "version": self.version, 216 | "status_code": int(self.status_code), 217 | "message": self.message, 218 | "headers": {key: value.to_json() for key, value in self.headers.items()}, 219 | "body": b64encode(self.body).decode(), 220 | } 221 | 222 | 223 | class ResponseException(Exception): 224 | """Base class for exceptions raised from a response.""" 225 | 226 | def __init__(self, code: int, message: str, response: Response): 227 | """ResponseException constructor. 228 | 229 | :param code: Response code number. 230 | :param message: Message response. 231 | """ 232 | 233 | self.code = code 234 | self.response = response 235 | super().__init__(message) 236 | 237 | 238 | class UsageException(ResponseException): 239 | """Command line usage error.""" 240 | 241 | def __init__(self, message: str, response: Response): 242 | """UsageException constructor. 243 | 244 | :param message: Message response. 245 | """ 246 | 247 | super().__init__(64, message, response) 248 | 249 | 250 | class DataErrorException(ResponseException): 251 | """Data format error.""" 252 | 253 | def __init__(self, message: str, response: Response): 254 | """DataErrorException constructor. 255 | 256 | :param message: Message response. 257 | """ 258 | 259 | super().__init__(65, message, response) 260 | 261 | 262 | class NoInputException(ResponseException): 263 | """Cannot open input.""" 264 | 265 | def __init__(self, message: str, response: Response): 266 | """NoInputException constructor. 267 | 268 | :param message: Message response. 269 | """ 270 | 271 | super().__init__(66, message, response) 272 | 273 | 274 | class NoUserException(ResponseException): 275 | """Addressee unknown.""" 276 | 277 | def __init__(self, message: str, response: Response): 278 | """NoUserException constructor. 279 | 280 | :param message: Message response. 281 | """ 282 | 283 | super().__init__(67, message, response) 284 | 285 | 286 | class NoHostException(ResponseException): 287 | """Hostname unknown.""" 288 | 289 | def __init__(self, message: str, response: Response): 290 | """NoHostException constructor. 291 | 292 | :param message: Message response. 293 | """ 294 | 295 | super().__init__(68, message, response) 296 | 297 | 298 | class UnavailableException(ResponseException): 299 | """Service unavailable.""" 300 | 301 | def __init__(self, message: str, response: Response): 302 | """UnavailableException constructor. 303 | 304 | :param message: Message response. 305 | """ 306 | 307 | super().__init__(69, message, response) 308 | 309 | 310 | class InternalSoftwareException(ResponseException): 311 | """Internal software error.""" 312 | 313 | def __init__(self, message: str, response: Response): 314 | """InternalSoftwareException constructor. 315 | 316 | :param message: Message response. 317 | """ 318 | 319 | super().__init__(70, message, response) 320 | 321 | 322 | class OSErrorException(ResponseException): 323 | """System error (e.g. can't fork the process).""" 324 | 325 | def __init__(self, message: str, response: Response): 326 | """OSErrorException constructor. 327 | 328 | :param message: Message response. 329 | """ 330 | 331 | super().__init__(71, message, response) 332 | 333 | 334 | class OSFileException(ResponseException): 335 | """Critical operating system file missing.""" 336 | 337 | def __init__(self, message: str, response: Response): 338 | """OSFileException constructor. 339 | 340 | :param message: Message response. 341 | """ 342 | 343 | super().__init__(72, message, response) 344 | 345 | 346 | class CantCreateException(ResponseException): 347 | """Can't create (user) output file.""" 348 | 349 | def __init__(self, message: str, response: Response): 350 | """CantCreateException constructor. 351 | 352 | :param message: Message response. 353 | """ 354 | 355 | super().__init__(73, message, response) 356 | 357 | 358 | class IOErrorException(ResponseException): 359 | """Input/output error.""" 360 | 361 | def __init__(self, message: str, response: Response): 362 | """IOErrorException constructor. 363 | 364 | :param message: Message response. 365 | """ 366 | 367 | super().__init__(74, message, response) 368 | 369 | 370 | class TemporaryFailureException(ResponseException): 371 | """Temporary failure, user is invited to try again.""" 372 | 373 | def __init__(self, message: str, response: Response): 374 | """TemporaryFailureException constructor. 375 | 376 | :param message: Message response. 377 | """ 378 | 379 | super().__init__(75, message, response) 380 | 381 | 382 | class ProtocolException(ResponseException): 383 | """Remote error in protocol.""" 384 | 385 | def __init__(self, message: str, response: Response): 386 | """ProtocolException constructor. 387 | 388 | :param message: Message response. 389 | """ 390 | 391 | super().__init__(76, message, response) 392 | 393 | 394 | class NoPermissionException(ResponseException): 395 | """Permission denied.""" 396 | 397 | def __init__(self, message: str, response: Response): 398 | """NoPermissionException constructor. 399 | 400 | :param message: Message response. 401 | """ 402 | 403 | super().__init__(77, message, response) 404 | 405 | 406 | class ConfigException(ResponseException): 407 | """Configuration error.""" 408 | 409 | def __init__(self, message: str, response: Response): 410 | """ConfigException constructor. 411 | 412 | :param message: Message response. 413 | """ 414 | 415 | super().__init__(78, message, response) 416 | 417 | 418 | class ServerTimeoutException(ResponseException, TimeoutException): 419 | """Timeout exception from the server.""" 420 | 421 | def __init__(self, message: str, response: Response): 422 | """ServerTimeoutException constructor. 423 | 424 | :param message: Message response. 425 | """ 426 | 427 | super().__init__(79, message, response) 428 | -------------------------------------------------------------------------------- /aiospamc/user_warnings.py: -------------------------------------------------------------------------------- 1 | """Functions to raise warnings based on user inputs.""" 2 | 3 | from warnings import warn 4 | 5 | from .connections import ConnectionManager, TcpConnectionManager 6 | from .requests import Request 7 | 8 | 9 | def raise_warnings(request: Request, connection: ConnectionManager): 10 | """Calls all warning functions. 11 | 12 | :param request: Instance of a request. 13 | :param connection: Connection manager instance. 14 | """ 15 | 16 | warn_spamd_bug_7183(request, connection) 17 | 18 | 19 | def warn_spamd_bug_7183(request: Request, connection: ConnectionManager): 20 | """Warn on spamd bug if using compression with an SSL connection. 21 | 22 | :param request: Instance of a request. 23 | :param connection: Connection manager instance. 24 | 25 | Bug: https://bz.apache.org/SpamAssassin/show_bug.cgi?id=7183 26 | """ 27 | 28 | if ( 29 | "Compress" in request.headers 30 | and isinstance(connection, TcpConnectionManager) 31 | and connection.ssl_context is not None 32 | ): 33 | message = ( 34 | "spamd bug 1783: SpamAssassin hangs when using SSL and compression are used in combination. " 35 | "Disable compression as a workaround. " 36 | "More information available at: https://bz.apache.org/SpamAssassin/show_bug.cgi?id=7183" 37 | ) 38 | warn(message) 39 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = aiospamc 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/aiospamc.rst: -------------------------------------------------------------------------------- 1 | aiospamc package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | aiospamc.cli module 8 | --------------------------- 9 | 10 | .. automodule:: aiospamc.cli 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | aiospamc.client module 16 | --------------------------- 17 | 18 | .. automodule:: aiospamc.client 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | aiospamc.connections module 24 | --------------------------- 25 | 26 | .. automodule:: aiospamc.connections 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | aiospamc.exceptions module 32 | -------------------------- 33 | 34 | .. automodule:: aiospamc.exceptions 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | aiospamc.frontend module 40 | ------------------------ 41 | 42 | .. automodule:: aiospamc.frontend 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | aiospamc.header\_values module 48 | ------------------------------ 49 | 50 | .. automodule:: aiospamc.header_values 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | aiospamc.incremental\_parser module 56 | ----------------------------------- 57 | 58 | .. automodule:: aiospamc.incremental_parser 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | aiospamc.requests module 64 | ------------------------ 65 | 66 | .. automodule:: aiospamc.requests 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | aiospamc.responses module 72 | ------------------------- 73 | 74 | .. automodule:: aiospamc.responses 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | aiospamc.user\_warnings module 80 | ------------------------------ 81 | 82 | .. automodule:: aiospamc.user_warnings 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | 88 | Module contents 89 | --------------- 90 | 91 | .. automodule:: aiospamc 92 | :ignore-module-all: 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | ###################### 2 | Command Line Interface 3 | ###################### 4 | 5 | *********** 6 | Description 7 | *********** 8 | 9 | :program:`aiospamc` is the command line interface for the SpamAssassin client. 10 | 11 | It provides common actions to interact with the SpamAssassin server. 12 | 13 | ************** 14 | Global Options 15 | ************** 16 | 17 | .. option:: --version 18 | 19 | Print the version of :program:`aiospamc` to the console. 20 | 21 | .. option:: --debug 22 | 23 | Enable debug logging. 24 | 25 | ******** 26 | Commands 27 | ******** 28 | 29 | .. program:: aiospamc 30 | 31 | .. option:: check [MESSAGE] 32 | 33 | Sends message to SpamAssassin and prints the score if there is any. 34 | 35 | If no message is given then it will read from `stdin`. 36 | 37 | The exit code will be 0 if the message is ham and 1 if it's spam. 38 | 39 | .. option:: -h, --host HOSTNAME 40 | 41 | |host_description| 42 | 43 | .. option:: -p, --port PORT 44 | 45 | |port_description| 46 | 47 | .. option:: --socket-path PATH 48 | 49 | |socket_description| 50 | 51 | .. option:: --ssl 52 | 53 | |ssl_description| 54 | 55 | .. option:: --ca-cert PATH 56 | 57 | |ca_cert_description| 58 | 59 | .. option:: --client-cert PATH 60 | 61 | |client_cert_description| 62 | 63 | .. option:: --client-key PATH 64 | 65 | |client_key_description| 66 | 67 | .. option:: --key-password 68 | 69 | |key_password_description| 70 | 71 | .. option:: --user USERNAME 72 | 73 | |user_description| 74 | 75 | .. option:: --timeout SECONDS 76 | 77 | |timeout_description| 78 | 79 | .. option:: --out [json|text] 80 | 81 | |out_description| 82 | 83 | .. option:: forget [MESSAGE] 84 | 85 | Forgets the classification of a message. 86 | 87 | .. option:: -h, --host HOSTNAME 88 | 89 | |host_description| 90 | 91 | .. option:: -p, --port PORT 92 | 93 | |port_description| 94 | 95 | .. option:: --socket-path PATH 96 | 97 | |socket_description| 98 | 99 | .. option:: --ssl 100 | 101 | |ssl_description| 102 | 103 | .. option:: --ca-cert PATH 104 | 105 | |ca_cert_description| 106 | 107 | .. option:: --client-cert PATH 108 | 109 | |client_cert_description| 110 | 111 | .. option:: --client-key PATH 112 | 113 | |client_key_description| 114 | 115 | .. option:: --key-password 116 | 117 | |key_password_description| 118 | 119 | .. option:: --user USERNAME 120 | 121 | |user_description| 122 | 123 | .. option:: --timeout SECONDS 124 | 125 | |timeout_description| 126 | 127 | .. option:: --out [json|text] 128 | 129 | |out_description| 130 | 131 | .. option:: learn [MESSAGE] 132 | 133 | Ask SpamAssassin to learn the message as spam or ham. 134 | 135 | .. option:: -h, --host HOSTNAME 136 | 137 | |host_description| 138 | 139 | .. option:: -p, --port PORT 140 | 141 | |port_description| 142 | 143 | .. option:: --socket-path PATH 144 | 145 | |socket_description| 146 | 147 | .. option:: --ssl 148 | 149 | |ssl_description| 150 | 151 | .. option:: --ca-cert PATH 152 | 153 | |ca_cert_description| 154 | 155 | .. option:: --client-cert PATH 156 | 157 | |client_cert_description| 158 | 159 | .. option:: --client-key PATH 160 | 161 | |client_key_description| 162 | 163 | .. option:: --key-password 164 | 165 | |key_password_description| 166 | 167 | .. option:: --user USERNAME 168 | 169 | |user_description| 170 | 171 | .. option:: --timeout SECONDS 172 | 173 | |timeout_description| 174 | 175 | .. option:: --out [json|text] 176 | 177 | |out_description| 178 | 179 | .. option:: ping 180 | 181 | Pings SpamAssassin and prints the response. 182 | 183 | An exit code of 0 is successful, 1 is not successful. 184 | 185 | .. option:: -h, --host HOSTNAME 186 | 187 | |host_description| 188 | 189 | .. option:: -p, --port PORT 190 | 191 | |port_description| 192 | 193 | .. option:: --socket-path PATH 194 | 195 | |socket_description| 196 | 197 | .. option:: --ssl 198 | 199 | |ssl_description| 200 | 201 | .. option:: --ca-cert PATH 202 | 203 | |ca_cert_description| 204 | 205 | .. option:: --client-cert PATH 206 | 207 | |client_cert_description| 208 | 209 | .. option:: --client-key PATH 210 | 211 | |client_key_description| 212 | 213 | .. option:: --key-password 214 | 215 | |key_password_description| 216 | 217 | .. option:: --user USERNAME 218 | 219 | |user_description| 220 | 221 | .. option:: --timeout SECONDS 222 | 223 | |timeout_description| 224 | 225 | .. option:: --out [json|text] 226 | 227 | |out_description| 228 | 229 | .. option:: report [MESSAGE] 230 | 231 | Report a message to collaborative filtering databases as spam. 232 | 233 | If reporting fails will exit with a code of 1. 234 | 235 | .. option:: -h, --host HOSTNAME 236 | 237 | |host_description| 238 | 239 | .. option:: -p, --port PORT 240 | 241 | |port_description| 242 | 243 | .. option:: --socket-path PATH 244 | 245 | |socket_description| 246 | 247 | .. option:: --ssl 248 | 249 | |ssl_description| 250 | 251 | .. option:: --ca-cert PATH 252 | 253 | |ca_cert_description| 254 | 255 | .. option:: --client-cert PATH 256 | 257 | |client_cert_description| 258 | 259 | .. option:: --client-key PATH 260 | 261 | |client_key_description| 262 | 263 | .. option:: --key-password 264 | 265 | |key_password_description| 266 | 267 | .. option:: --user USERNAME 268 | 269 | |user_description| 270 | 271 | .. option:: --timeout SECONDS 272 | 273 | |timeout_description| 274 | 275 | .. option:: --out [json|text] 276 | 277 | |out_description| 278 | 279 | .. option:: revoke [MESSAGE] 280 | 281 | Revoke a message to collaborative filtering databases. 282 | 283 | If revoking fails will exit with a code of 1. 284 | 285 | .. option:: -h, --host HOSTNAME 286 | 287 | |host_description| 288 | 289 | .. option:: -p, --port PORT 290 | 291 | |port_description| 292 | 293 | .. option:: --socket-path PATH 294 | 295 | |socket_description| 296 | 297 | .. option:: --ssl 298 | 299 | |ssl_description| 300 | 301 | .. option:: --ca-cert PATH 302 | 303 | |ca_cert_description| 304 | 305 | .. option:: --client-cert PATH 306 | 307 | |client_cert_description| 308 | 309 | .. option:: --client-key PATH 310 | 311 | |client_key_description| 312 | 313 | .. option:: --key-password 314 | 315 | |key_password_description| 316 | 317 | .. option:: --user USERNAME 318 | 319 | |user_description| 320 | 321 | .. option:: --timeout SECONDS 322 | 323 | |timeout_description| 324 | 325 | .. option:: --out [json|text] 326 | 327 | |out_description| 328 | 329 | .. |host_description| replace:: Hostname or IP address of the server. 330 | 331 | .. |port_description| replace:: Port number of the server. 332 | 333 | .. |socket_description| replace:: Path to UNIX domain socket. 334 | 335 | .. |ssl_description| replace:: Enables or disables SSL when using a TCP connection. Will use the 336 | system's root certificates by default. 337 | 338 | .. |ca_cert_description| replace:: Path to certificate authority file or path. Overrides the 339 | default path. 340 | 341 | .. |client_cert_description| replace:: Filename for the client certificate. 342 | 343 | .. |client_key_description| replace:: Filename for the client certificate key. Specify this if 344 | this isn't included in the client certificate. 345 | 346 | .. |key_password_description| replace:: Password for the client certificate key if encrypted. 347 | 348 | .. |user_description| replace:: User to send the request as. 349 | 350 | .. |timeout_description| replace:: Set the connection timeout. Default is 10 seconds. 351 | 352 | .. |out_description| replace:: Choose the output format to the console. `text` will print human friendly 353 | output. `json` will display JSON formatted output with keys for `request`, 354 | `response`, and `exit_code`. Default is `text`. 355 | 356 | ********************* 357 | Environment Variables 358 | ********************* 359 | 360 | .. envvar:: AIOSPAMC_CERT_FILE 361 | 362 | Path to the file containing trusted certificates. These will be used in place of 363 | the default root certificates when using the :option:`--ssl` option. 364 | 365 | ********** 366 | Exit Codes 367 | ********** 368 | 369 | * `3` - Error occurred when parsing response. 370 | * `4` - Network timeout. 371 | * `5` - Connection error. Check the host, port, or socket path. 372 | * `6` - Unexpected error. 373 | * `7` - Could not open the message. 374 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiospamc documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Jan 9 14:17:20 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | import aiospamc 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | needs_sphinx = "2.1" 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.viewcode", 38 | "sphinx.ext.intersphinx", 39 | "sphinx_toolbox", 40 | "reno.sphinxext", 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = aiospamc.__name__ 57 | copyright = aiospamc.__copyright__ 58 | author = aiospamc.__author__ 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = aiospamc.__version__ 66 | # The full version, including alpha/beta/rc tags. 67 | release = aiospamc.__version__ 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = "en" 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = "lovelace" 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "sphinx_rtd_theme" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | html_theme_options = {"sidebarwidth": 320} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ["_static"] 105 | 106 | 107 | # -- Options for HTMLHelp output ------------------------------------------ 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = "aiospamcdoc" 111 | 112 | 113 | # -- Options for LaTeX output --------------------------------------------- 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | # The font size ('10pt', '11pt' or '12pt'). 120 | # 121 | # 'pointsize': '10pt', 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, "aiospamc.tex", "aiospamc Documentation", "Michael Caley", "manual"), 135 | ] 136 | 137 | 138 | # -- Options for manual page output --------------------------------------- 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [(master_doc, "aiospamc", "aiospamc Documentation", [author], 1)] 143 | 144 | 145 | # -- Options for Texinfo output ------------------------------------------- 146 | 147 | # Grouping the document tree into Texinfo files. List of tuples 148 | # (source start file, target name, title, author, 149 | # dir menu entry, description, category) 150 | texinfo_documents = [ 151 | ( 152 | master_doc, 153 | "aiospamc", 154 | "aiospamc Documentation", 155 | author, 156 | "aiospamc", 157 | "One line description of project.", 158 | "Miscellaneous", 159 | ), 160 | ] 161 | 162 | # -- Options for sphinx-autodoc ------------------------------------------- 163 | 164 | autodoc_default_options = {"special-members": "__init__", "member-order": "bysource"} 165 | set_type_checking_flag = True 166 | always_document_param_types = True 167 | 168 | # -- Options for InterSphinx documentation -------------------------------- 169 | 170 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 171 | 172 | # Sphinx toolbox - GitHub 173 | github_username = "mjcaley" 174 | github_repository = aiospamc.__name__ 175 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | aiospamc 3 | ######## 4 | 5 | aiospamc is an asyncio-based library to interact with SpamAssassin's SPAMD service. 6 | 7 | ******** 8 | Contents 9 | ******** 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | installation 15 | user_guide 16 | modules 17 | protocol 18 | release_notes 19 | 20 | ****************** 21 | Indices and tables 22 | ****************** 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Installation 3 | ############ 4 | 5 | ************ 6 | Requirements 7 | ************ 8 | 9 | * Python 3.9 or later 10 | * SpamAssassin running as a service 11 | 12 | ******* 13 | Install 14 | ******* 15 | 16 | With pip 17 | ======== 18 | 19 | .. code-block:: bash 20 | 21 | pip install aiospamc 22 | 23 | With git 24 | ======== 25 | 26 | .. code-block:: bash 27 | 28 | git clone https://github.com/mjcaley/aiospamc.git 29 | cd aiospamc 30 | poetry install 31 | 32 | .. note:: 33 | aiospamc's build system uses Poetry which you can get from here: https://python-poetry.org/ 34 | -------------------------------------------------------------------------------- /docs/library.rst: -------------------------------------------------------------------------------- 1 | ####### 2 | Library 3 | ####### 4 | 5 | :mod:`aiospamc` provides top-level functions for all request types. 6 | 7 | For example, to ask SpamAssassin to check and score a message you can use the 8 | :func:`aiospamc.check` function. Just give it a bytes-encoded copy of the 9 | message, specify the host and await on the request. In this case, the response 10 | will contain a header called `Spam` with a boolean if the message is considered 11 | spam as well as the score. 12 | 13 | .. code-block:: python 14 | 15 | import asyncio 16 | import aiospamc 17 | 18 | example_message = ( 19 | "From: John Doe " 20 | "To: Mary Smith " 21 | "Subject: Saying Hello" 22 | "Date: Fri, 21 Nov 1997 09:55:06 -0600" 23 | "Message-ID: <1234@local.machine.example>" 24 | "" 25 | "This is a message just to say hello." 26 | "So, 'Hello'.").encode("ascii") 27 | 28 | response = asyncio.run(aiospamc.check(message, host="localhost")) 29 | print( 30 | f"Is the message spam? {response.headers.spam.value}\n", 31 | f"The score and threshold is {response.headers.spam.score} ", 32 | f"/ {response.headers.spam.threshold}", 33 | sep="" 34 | ) 35 | 36 | ***************** 37 | Connect using SSL 38 | ***************** 39 | 40 | Each frontend function has a `verify` parameter which allows configuring an SSL 41 | connection. 42 | 43 | If `True` is supplied, then root certificates from the `certifi` project 44 | will be used to verify the connection. 45 | 46 | If a path is supplied as a string or :class:`pathlib.Path` object then the path 47 | is used to load certificates to verify the connection. 48 | 49 | If `False` then an SSL connection is established, but the server certificate 50 | is not verified. 51 | 52 | ********************************* 53 | Client Certificate Authentication 54 | ********************************* 55 | 56 | Client certificate authentication can be used with SSL. It's driven through the `cert` 57 | parameter on frontend functions. The parameter value takes three forms: 58 | 59 | * A path to a file expecting the certificate and key in the PEM format 60 | * A tuple of certificate and key files 61 | * A tuple of certificate file, key file, and password if the key is encrypted 62 | 63 | .. code:: python 64 | 65 | import aiospamc 66 | 67 | # Client certificate and key in one file 68 | response = await aiospamc.ping("localhost", cert=cert_file) 69 | 70 | # Client certificate and key file 71 | response = await aiospamc.ping("localhost", cert=(cert_file, key_file)) 72 | 73 | # Client certificate and key in one file 74 | response = await aiospamc.ping("localhost", cert=(cert_file, key_file, password)) 75 | 76 | **************** 77 | Setting timeouts 78 | **************** 79 | 80 | `aiospamc` is configured by default to use a timeout of 600 seconds (or 10 minutes) 81 | from the point when a connection is attempted until a response comes in. 82 | 83 | If you would like more fine-grained control of timeouts then an 84 | `aiospamc.connections.Timeout` object can be passed in. 85 | 86 | You can configure any of the three optional parameters: 87 | * total - maximum time in seconds to wait for a connection and response 88 | * connection - time in seconds to wait for a connection to be established 89 | * response - time in seconds to wait for a response after sending the request 90 | 91 | .. code:: python 92 | 93 | import aiospamc 94 | 95 | my_timeout = aiospamc.Timeout(total=60, connection=10, response=10) 96 | 97 | await def check(): 98 | response = await aiospamc.check(example_message, timeout=my_timeout) 99 | 100 | return response 101 | 102 | ******* 103 | Logging 104 | ******* 105 | 106 | Logging is provided using through the `loguru `_ package. 107 | 108 | The `aiospamc` package disables logging by default. It can be enabled by calling the 109 | function: 110 | 111 | .. code-block:: python 112 | 113 | from loguru import logger 114 | logger.enable("aiospamc") 115 | 116 | Modules log under their own logger names (for example, frontend functions will log under 117 | `aiospamc.frontend`). Extra data like request and response objects are attached to log 118 | records which can be used to trace through flow. 119 | 120 | ******************** 121 | Interpreting results 122 | ******************** 123 | 124 | Responses are encapsulated in the :class:`aiospamc.responses.Response` class. 125 | It includes the status code, headers and body. 126 | 127 | The :class:`aiospamc.headers.Headers` class provides properties for headers defined in the 128 | protocol. 129 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=aiospamc 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | aiospamc 8 | -------------------------------------------------------------------------------- /docs/protocol.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: none 2 | 3 | ################################################### 4 | SPAMC/SPAMD Protocol As Implemented by SpamAssassin 5 | ################################################### 6 | 7 | ********************** 8 | Requests and Responses 9 | ********************** 10 | 11 | The structure of a request is similar to an HTTP request. [1]_ The method/verb, 12 | protocol name and version are listed followed by headers separated by newline 13 | characters (carriage return and linefeed or ``\r\n``). Following the headers 14 | is a blank line with a newline (``\r\n``). If there is a message body it will 15 | be added after all headers. 16 | 17 | The current requests are :ref:`check_request`, :ref:`headers_request`, 18 | :ref:`ping_request`, :ref:`process_request`, :ref:`report_request`, 19 | :ref:`report_ifspam_request`, :ref:`skip_request`, :ref:`symbols_request`, and 20 | :ref:`tell_request`:: 21 | 22 | METHOD SPAMC/1.5\r\n 23 | HEADER_NAME1: HEADER_VALUE1\r\n 24 | HEADER_NAME2: HEADER_VALUE2\r\n 25 | ... 26 | \r\n 27 | REQUEST_BODY 28 | 29 | 30 | The structure of responses are also similar to HTTP responses. The protocol 31 | name, version, status code, and message are listed on the first line. Any 32 | headers are also listed and all are separated by newline characters. Following 33 | the headers is a newline. If there is a message body it’s included after all 34 | headers:: 35 | 36 | SPAMD/1.5 STATUS_CODE MESSAGE\r\n 37 | HEADER_NAME1: HEADER_VALUE1\r\n 38 | HEADER_NAME2: HEADER_VALUE2\r\n 39 | ... 40 | \r\n 41 | RESPONSE_BODY 42 | 43 | .. note:: 44 | The header name and value are separated by a `:` character. For built-in 45 | headers the name must not have any whitespace surrounding it. It will be 46 | parsed exactly as it's represented. 47 | 48 | The following are descriptions of the requests that can be sent and examples of 49 | the responses that you can expect to receive. 50 | 51 | .. _check_request: 52 | 53 | CHECK 54 | ===== 55 | 56 | Instruct SpamAssassin to process the included message. 57 | 58 | Request 59 | ------- 60 | 61 | Required Headers 62 | ^^^^^^^^^^^^^^^^ 63 | 64 | * :ref:`content-length_header` 65 | 66 | Optional Headers 67 | ^^^^^^^^^^^^^^^^ 68 | 69 | * :ref:`compress_header` 70 | * :ref:`user_header` 71 | 72 | Required body 73 | ^^^^^^^^^^^^^ 74 | 75 | An email based on the :rfc:`5322` standard. 76 | 77 | Response 78 | -------- 79 | 80 | Will include a Spam header with a “True” or “False” value, followed by the 81 | score and threshold. 82 | Example:: 83 | 84 | SPAMD/1.1 0 EX_OK 85 | Spam: True ; 1000.0 / 5.0 86 | 87 | .. _headers_request: 88 | 89 | HEADERS 90 | ======= 91 | 92 | Process the included message and return only the modified headers. 93 | 94 | Request 95 | ------- 96 | 97 | Required Headers 98 | ^^^^^^^^^^^^^^^^ 99 | 100 | * :ref:`content-length_header` 101 | 102 | Optional Headers 103 | ^^^^^^^^^^^^^^^^ 104 | 105 | * :ref:`compress_header` 106 | * :ref:`user_header` 107 | 108 | Required Body 109 | ^^^^^^^^^^^^^ 110 | 111 | An email based on the :rfc:`5322` standard. 112 | 113 | Response 114 | -------- 115 | 116 | Will return the modified headers of the message in the body. The 117 | :ref:`spam_header` header is also included. 118 | :: 119 | 120 | SPAMD/1.1 0 EX_OK 121 | Spam: True ; 1000.0 / 5.0 122 | Content-length: 654 123 | 124 | Received: from localhost by debian 125 | with SpamAssassin (version 3.4.0); 126 | Tue, 10 Jan 2017 11:09:26 -0500 127 | From: Sender 128 | To: Recipient 129 | Subject: Test spam mail (GTUBE) 130 | Date: Wed, 23 Jul 2003 23:30:00 +0200 131 | Message-Id: 132 | X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on debian 133 | X-Spam-Flag: YES 134 | X-Spam-Level: ************************************************** 135 | X-Spam-Status: Yes, score=1000.0 required=5.0 tests=GTUBE,NO_RECEIVED, 136 | NO_RELAYS autolearn=no autolearn_force=no version=3.4.0 137 | MIME-Version: 1.0Content-Type: multipart/mixed; boundary="----------=_58750736.8D9F70BC" 138 | 139 | 140 | .. _ping_request: 141 | 142 | PING 143 | ==== 144 | 145 | Send a request to test if the server is alive. 146 | 147 | Request 148 | -------- 149 | 150 | Required Headers 151 | ^^^^^^^^^^^^^^^^ 152 | 153 | None. 154 | 155 | Optional Headers 156 | ^^^^^^^^^^^^^^^^ 157 | 158 | None. 159 | 160 | Response 161 | -------- 162 | 163 | Example:: 164 | 165 | SPAMD/1.5 0 PONG 166 | 167 | .. _process_request: 168 | 169 | PROCESS 170 | ======= 171 | 172 | Instruct SpamAssassin to process the message and return the modified message. 173 | 174 | Request 175 | ------- 176 | 177 | Required Headers 178 | ^^^^^^^^^^^^^^^^ 179 | 180 | * :ref:`content-length_header` 181 | 182 | Optional Headers 183 | ^^^^^^^^^^^^^^^^ 184 | 185 | * :ref:`compress_header` 186 | * :ref:`user_header` 187 | 188 | Required Body 189 | ^^^^^^^^^^^^^ 190 | 191 | An email based on the :rfc:`5322` standard. 192 | 193 | Response 194 | -------- 195 | 196 | Will return a modified message in the body. The :ref:`spam_header` header is 197 | also included. 198 | Example:: 199 | 200 | SPAMD/1.1 0 EX_OK 201 | Spam: True ; 1000.0 / 5.0 202 | Content-length: 2948 203 | 204 | Received: from localhost by debian 205 | with SpamAssassin (version 3.4.0); 206 | Tue, 10 Jan 2017 10:57:02 -0500 207 | From: Sender 208 | To: Recipient 209 | Subject: Test spam mail (GTUBE) 210 | Date: Wed, 23 Jul 2003 23:30:00 +0200 211 | Message-Id: 212 | X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on debian 213 | X-Spam-Flag: YES 214 | X-Spam-Level: ************************************************** 215 | X-Spam-Status: Yes, score=1000.0 required=5.0 tests=GTUBE,NO_RECEIVED, 216 | NO_RELAYS autolearn=no autolearn_force=no version=3.4.0 217 | MIME-Version: 1.0 218 | Content-Type: multipart/mixed; boundary="----------=_5875044E.D4EFFFD7" 219 | 220 | This is a multi-part message in MIME format. 221 | 222 | ------------=_5875044E.D4EFFFD7 223 | Content-Type: text/plain; charset=iso-8859-1 224 | Content-Disposition: inline 225 | Content-Transfer-Encoding: 8bit 226 | 227 | Spam detection software, running on the system "debian", 228 | has identified this incoming email as possible spam. The original 229 | message has been attached to this so you can view it or label 230 | similar future email. If you have any questions, see 231 | @@CONTACT_ADDRESS@@ for details. 232 | 233 | Content preview: This is the GTUBE, the Generic Test for Unsolicited Bulk Email 234 | If your spam filter supports it, the GTUBE provides a test by which you can 235 | verify that the filter is installed correctly and is detecting incoming spam. 236 | You can send yourself a test mail containing the following string of characters 237 | (in upper case and with no white spaces and line breaks): [...] 238 | 239 | Content analysis details: (1000.0 points, 5.0 required) 240 | 241 | pts rule name description 242 | ---- ---------------------- -------------------------------------------------- 243 | 1000 GTUBE BODY: Generic Test for Unsolicited Bulk Email 244 | -0.0 NO_RELAYS Informational: message was not relayed via SMTP 245 | -0.0 NO_RECEIVED Informational: message has no Received headers 246 | 247 | 248 | 249 | ------------=_5875044E.D4EFFFD7 250 | Content-Type: message/rfc822; x-spam-type=original 251 | Content-Description: original message before SpamAssassin 252 | Content-Disposition: inline 253 | Content-Transfer-Encoding: 8bit 254 | 255 | Subject: Test spam mail (GTUBE) 256 | Message-ID: 257 | Date: Wed, 23 Jul 2003 23:30:00 +0200 258 | From: Sender 259 | To: Recipient 260 | Precedence: junk 261 | MIME-Version: 1.0 262 | Content-Type: text/plain; charset=us-ascii 263 | Content-Transfer-Encoding: 7bit 264 | 265 | This is the GTUBE, the 266 | Generic 267 | Test for 268 | Unsolicited 269 | Bulk 270 | Email 271 | 272 | If your spam filter supports it, the GTUBE provides a test by which you 273 | can verify that the filter is installed correctly and is detecting incoming 274 | spam. You can send yourself a test mail containing the following string of 275 | characters (in upper case and with no white spaces and line breaks): 276 | 277 | XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 278 | 279 | You should send this test mail from an account outside of your network. 280 | 281 | 282 | ------------=_5875044E.D4EFFFD7-- 283 | 284 | 285 | 286 | .. _report_request: 287 | 288 | REPORT 289 | ====== 290 | 291 | Send a request to process a message and return a report. 292 | 293 | Request 294 | ------- 295 | 296 | Required Headers 297 | ^^^^^^^^^^^^^^^^ 298 | 299 | * :ref:`content-length_header` 300 | 301 | Optional Headers 302 | ^^^^^^^^^^^^^^^^ 303 | 304 | * :ref:`compress_header` 305 | * :ref:`user_header` 306 | 307 | Required body 308 | ^^^^^^^^^^^^^ 309 | 310 | An email based on the :rfc:`5322` standard. 311 | 312 | Response 313 | -------- 314 | 315 | Response returns the :ref:`spam_header` header and the body containing a 316 | report of the message scanned. 317 | 318 | Example:: 319 | 320 | SPAMD/1.1 0 EX_OK 321 | Content-length: 1071 322 | Spam: True ; 1000.0 / 5.0 323 | 324 | Spam detection software, running on the system "debian", 325 | has identified this incoming email as possible spam. The original 326 | message has been attached to this so you can view it or label 327 | similar future email. If you have any questions, see 328 | @@CONTACT_ADDRESS@@ for details. 329 | 330 | Content preview: This is the GTUBE, the Generic Test for Unsolicited Bulk Email 331 | If your spam filter supports it, the GTUBE provides a test by which you can 332 | verify that the filter is installed correctly and is detecting incoming spam. 333 | You can send yourself a test mail containing the following string of characters 334 | (in upper case and with no white spaces and line breaks): [...] 335 | 336 | Content analysis details: (1000.0 points, 5.0 required) 337 | 338 | pts rule name description 339 | ---- ---------------------- -------------------------------------------------- 340 | 1000 GTUBE BODY: Generic Test for Unsolicited Bulk Email 341 | -0.0 NO_RELAYS Informational: message was not relayed via SMTP 342 | -0.0 NO_RECEIVED Informational: message has no Received headers 343 | 344 | .. _report_ifspam_request: 345 | 346 | REPORT_IFSPAM 347 | ============= 348 | 349 | Matches the :ref:`report_request` request, with the exception a report will not 350 | be generated if the message is not spam. 351 | 352 | .. _skip_request: 353 | 354 | SKIP 355 | ==== 356 | 357 | Sent when a connection is made in error. The SPAMD service will immediately 358 | close the connection. 359 | 360 | Request 361 | ------- 362 | 363 | Required Headers 364 | ^^^^^^^^^^^^^^^^ 365 | 366 | None. 367 | 368 | Optional Headers 369 | ^^^^^^^^^^^^^^^^ 370 | 371 | None. 372 | 373 | .. _symbols_request: 374 | 375 | SYMBOLS 376 | ======= 377 | 378 | Instruct SpamAssassin to process the message and return the rules that were 379 | matched. 380 | 381 | Request 382 | ------- 383 | 384 | Required Headers 385 | ^^^^^^^^^^^^^^^^ 386 | 387 | * :ref:`content-length_header` 388 | 389 | Optional Headers 390 | ^^^^^^^^^^^^^^^^ 391 | 392 | * :ref:`compress_header` 393 | * :ref:`user_header` 394 | 395 | Required body 396 | ^^^^^^^^^^^^^ 397 | 398 | An email based on the :rfc:`5322` standard. 399 | 400 | Response 401 | -------- 402 | 403 | Response includes the :ref:`spam_header` header. The body contains the 404 | SpamAssassin rules that were matched. 405 | Example:: 406 | 407 | SPAMD/1.1 0 EX_OK 408 | Content-length: 27 409 | Spam: True ; 1000.0 / 5.0 410 | 411 | GTUBE,NO_RECEIVED,NO_RELAYS 412 | 413 | .. _tell_request: 414 | 415 | TELL 416 | ==== 417 | 418 | Send a request to classify a message and add or remove it from a database. The 419 | message type is defined by the :ref:`message-class_header`. The 420 | :ref:`remove_header` and :ref:`set_header` headers are used to choose the 421 | location ("local" or "remote") to add or remove it. SpamAssassin will return 422 | an error if a request tries to apply a conflicting change (e.g. both setting 423 | and removing to the same location). 424 | 425 | .. note:: 426 | 427 | The SpamAssassin daemon must have the ``--allow-tell`` option enabled to 428 | support this feature. 429 | 430 | Request 431 | ------- 432 | 433 | Required Headers 434 | ^^^^^^^^^^^^^^^^ 435 | 436 | * :ref:`content-length_header` 437 | * :ref:`message-class_header` 438 | * :ref:`remove_header` and/or :ref:`set_header` 439 | * :ref:`user_header` 440 | 441 | Optional Headers 442 | ^^^^^^^^^^^^^^^^ 443 | 444 | * :ref:`compress_header` 445 | 446 | Required Body 447 | ^^^^^^^^^^^^^ 448 | 449 | An email based on the :rfc:`5322` standard. 450 | 451 | Response 452 | -------- 453 | 454 | If successful, the response will include the :ref:`didremove_header` and/or 455 | :ref:`didset_header` headers depending on the request. 456 | 457 | Response from a request that sent a :ref:`remove_header`:: 458 | 459 | SPAMD/1.1 0 EX_OK 460 | DidRemove: local 461 | Content-length: 2 462 | 463 | 464 | Response from a request that sent a :ref:`set_header`:: 465 | 466 | SPAMD/1.1 0 EX_OK 467 | DidSet: local 468 | Content-length: 2 469 | 470 | 471 | .. _headers: 472 | 473 | ******* 474 | Headers 475 | ******* 476 | 477 | Headers are structured very simply. They have a name and value which are 478 | separated by a colon (:). All headers are followed by a newline. The current 479 | headers include :ref:`compress_header`, :ref:`content-length_header`, 480 | :ref:`didremove_header`, :ref:`didset_header`, :ref:`message-class_header`, 481 | :ref:`remove_header`, :ref:`set_header`, :ref:`spam_header`, and 482 | :ref:`user_header`. 483 | 484 | For example:: 485 | 486 | Content-length: 42\r\n 487 | 488 | The following is a list of headers defined by SpamAssassin, although anything 489 | is allowable as a header. If an unrecognized header is included in the 490 | request or response it should be ignored. 491 | 492 | .. _compress_header: 493 | 494 | Compress 495 | ======== 496 | 497 | Specifies that the body is compressed and what compression algorithm is used. 498 | Contains a string of the compression algorithm. 499 | Currently only ``zlib`` is supported. 500 | 501 | .. _content-length_header: 502 | 503 | Content-length 504 | ============== 505 | 506 | The length of the body in bytes. Contains an integer representing the body 507 | length. 508 | 509 | .. _didremove_header: 510 | 511 | DidRemove 512 | ========= 513 | 514 | Included in a response to a :ref:`tell_request` request. Identifies which 515 | databases a message was removed from. 516 | Contains a string containing either ``local``, ``remote`` or both seprated by a 517 | comma. 518 | 519 | .. _didset_header: 520 | 521 | DidSet 522 | ====== 523 | 524 | Included in a response to a :ref:`tell_request` request. Identifies which 525 | databases a message was set in. 526 | Contains a string containing either ``local``, ``remote`` or both seprated by a 527 | comma. 528 | 529 | .. _message-class_header: 530 | 531 | Message-class 532 | ============= 533 | 534 | Classifies the message contained in the body. 535 | Contains a string containing either ``local``, ``remote`` or both seprated by a 536 | comma. 537 | 538 | .. _remove_header: 539 | 540 | Remove 541 | ====== 542 | 543 | Included in a :ref:`tell_request` request to remove the message from the 544 | specified database. 545 | Contains a string containing either ``local``, ``remote`` or both seprated by a 546 | comma. 547 | 548 | .. _set_header: 549 | 550 | Set 551 | === 552 | 553 | Included in a :ref:`tell_request` request to remove the message from the 554 | specified database. 555 | Contains a string containing either ``local``, ``remote`` or both seprated by a 556 | comma. 557 | 558 | .. _spam_header: 559 | 560 | Spam 561 | ==== 562 | 563 | Identify whether the message submitted was spam or not including the score and 564 | threshold. 565 | Contains a string containing a boolean if the message is spam (either ``True``, 566 | ``False``, ``Yes``, or ``No``), followed by a ``;``, a floating point number 567 | representing the score, followed by a ``/``, and finally a floating point 568 | number representing the threshold of which to consider it spam. 569 | 570 | For example:: 571 | 572 | Spam: True ; 1000.0 / 5.0 573 | 574 | .. _user_header: 575 | 576 | User 577 | ==== 578 | 579 | Specify which user the request will run under. SpamAssassin will use the 580 | configuration files for the user included in the header. 581 | Contains a string containing the name of the user. 582 | 583 | ************ 584 | Status Codes 585 | ************ 586 | 587 | A status code is an integer detailing whether the request was successful or if 588 | an error occurred. 589 | 590 | The following status codes are defined in the SpamAssassin source repository 591 | [2]_. 592 | 593 | EX_OK 594 | ===== 595 | 596 | Code: 0 597 | 598 | Definition: No problems were found. 599 | 600 | EX_USAGE 601 | ======== 602 | 603 | Code: 64 604 | 605 | Definition: Command line usage error. 606 | 607 | EX_DATAERR 608 | ========== 609 | 610 | Code: 65 611 | 612 | Definition: Data format error. 613 | 614 | EX_NOINPUT 615 | ========== 616 | 617 | Code: 66 618 | 619 | Definition: Cannot open input. 620 | 621 | EX_NOUSER 622 | ========= 623 | 624 | Code: 67 625 | 626 | Definition: Addressee unknown. 627 | 628 | EX_NOHOST 629 | ========= 630 | 631 | Code: 68 632 | 633 | Definition: Hostname unknown. 634 | 635 | EX_UNAVAILABLE 636 | ============== 637 | 638 | Code: 69 639 | 640 | Definition: Service unavailable. 641 | 642 | EX_SOFTWARE 643 | =========== 644 | 645 | Code: 70 646 | 647 | Definition: Internal software error. 648 | 649 | EX_OSERR 650 | ======== 651 | 652 | Code: 71 653 | 654 | Definition: System error (e.g. can't fork the process). 655 | 656 | EX_OSFILE 657 | ========= 658 | 659 | Code: 72 660 | 661 | Definition: Critical operating system file missing. 662 | 663 | EX_CANTCREAT 664 | ============ 665 | 666 | Code: 73 667 | 668 | Definition: Can't create (user) output file. 669 | 670 | EX_IOERR 671 | ======== 672 | 673 | Code: 74 674 | 675 | Definition: Input/output error. 676 | 677 | EX_TEMPFAIL 678 | =========== 679 | 680 | Code: 75 681 | 682 | Definition: Temporary failure, user is invited to retry. 683 | 684 | EX_PROTOCOL 685 | =========== 686 | 687 | Code: 76 688 | 689 | Definition: Remote error in protocol. 690 | 691 | EX_NOPERM 692 | ========= 693 | 694 | Code: 77 695 | 696 | Definition: Permission denied. 697 | 698 | EX_CONFIG 699 | ========= 700 | 701 | Code: 78 702 | 703 | Definition: Configuration error. 704 | 705 | EX_TIMEOUT 706 | ========== 707 | 708 | Code: 79 709 | 710 | Definition: Read timeout. 711 | 712 | **** 713 | Body 714 | **** 715 | 716 | SpamAssassin will generally want the body of a request to be in a supported RFC 717 | email format. The response body will differ depending on the type of request 718 | that was sent. 719 | 720 | ********** 721 | References 722 | ********** 723 | 724 | .. [1] https://svn.apache.org/viewvc/spamassassin/tags/spamassassin_release_4_0_1/spamd/PROTOCOL?view=co 725 | .. [2] https://svn.apache.org/viewvc/spamassassin/tags/spamassassin_release_4_0_1/spamd/spamd.raw?view=co 726 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | .. release-notes:: Release Notes 2 | -------------------------------------------------------------------------------- /docs/user_guide.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | User Guide 3 | ########## 4 | 5 | .. toctree:: 6 | :maxdepth: 3 7 | 8 | cli 9 | library 10 | -------------------------------------------------------------------------------- /example/gtube_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiospamc 4 | 5 | GTUBE = """Subject: Test spam mail (GTUBE) 6 | Message-ID: 7 | Date: Wed, 23 Jul 2003 23:30:00 +0200 8 | From: Sender 9 | To: Recipient 10 | Precedence: junk 11 | MIME-Version: 1.0 12 | Content-Type: text/plain; charset=us-ascii 13 | Content-Transfer-Encoding: 7bit 14 | 15 | This is the GTUBE, the 16 | Generic 17 | Test for 18 | Unsolicited 19 | Bulk 20 | Email 21 | 22 | If your spam filter supports it, the GTUBE provides a test by which you 23 | can verify that the filter is installed correctly and is detecting incoming 24 | spam. You can send yourself a test mail containing the following string of 25 | characters (in upper case and with no white spaces and line breaks): 26 | 27 | XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 28 | 29 | You should send this test mail from an account outside of your network. 30 | """.encode( 31 | "ascii" 32 | ) 33 | 34 | loop = asyncio.get_event_loop() 35 | responses = loop.run_until_complete( 36 | asyncio.gather( 37 | aiospamc.ping(host="localhost"), 38 | aiospamc.check(GTUBE, host="localhost"), 39 | aiospamc.headers(GTUBE, host="localhost"), 40 | ) 41 | ) 42 | print(responses) 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiospamc" 3 | version = "1.1.1" 4 | description = "An asyncio-based library to communicate with SpamAssassin's SPAMD service." 5 | authors = [ 6 | {name = "Michael Caley", email = "mjcaley@darkarctic.com"} 7 | ] 8 | license = "MIT" 9 | readme = "README.rst" 10 | 11 | repository = "https://github.com/mjcaley/aiospamc" 12 | homepage = "https://github.com/mjcaley/aiospamc" 13 | documentation = "https://aiospamc.readthedocs.io" 14 | 15 | classifiers = [ 16 | 'Development Status :: 5 - Production/Stable', 17 | 'Programming Language :: Python :: 3.9', 18 | 'Programming Language :: Python :: 3.10', 19 | 'Programming Language :: Python :: 3.11', 20 | 'Programming Language :: Python :: 3.12', 21 | 'Programming Language :: Python :: 3.13', 22 | 'Intended Audience :: Developers', 23 | 'Intended Audience :: System Administrators', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Topic :: Communications :: Email :: Filters' 26 | ] 27 | 28 | keywords = ["spam", "spamc", "spamassassin"] 29 | 30 | requires-python = ">= 3.9, < 4.0" 31 | 32 | dependencies = [ 33 | "certifi", 34 | "loguru >=0.7", 35 | "typer >=0.9,<0.16" 36 | ] 37 | 38 | [tool.poetry.group.test.dependencies] 39 | pytest = ">=7.1,<9.0" 40 | pytest-cov = ">=4,<7" 41 | pytest-asyncio = ">=0.21,<0.26" 42 | pytest-mock = "^3.10" 43 | coverage = {extras = ["toml"], version = "^7.2"} 44 | trustme = "^1.1" 45 | cryptography = "^43.0" 46 | 47 | [project.scripts] 48 | aiospamc = "aiospamc.cli:app" 49 | 50 | [tool.pytest.ini_options] 51 | minversion = "6.0" 52 | testpaths = [ 53 | "tests" 54 | ] 55 | markers = ["integration: spawn an instance of spamd and test against it"] 56 | addopts = "-m \"not integration\"" 57 | asyncio_mode = "auto" 58 | asyncio_default_fixture_loop_scope = "function" 59 | 60 | [tool.coverage.run] 61 | source = ["aiospamc"] 62 | branch = true 63 | 64 | [tool.coverage.report] 65 | exclude_lines = [ 66 | "pragma: no cover", 67 | "def __repr__", 68 | "def __str__" 69 | ] 70 | fail_under = 95.0 71 | 72 | 73 | [tool.poetry.group.docs.dependencies] 74 | sphinx = ">=6.2,<8.0" 75 | sphinx-rtd-theme = ">=1.3,<4.0" 76 | sphinx-toolbox = "^4.0" 77 | reno = "^4.0" 78 | 79 | 80 | [tool.poetry.group.quality.dependencies] 81 | mypy = "^1.2" 82 | black = ">=25,<26" 83 | interrogate = "^1.5" 84 | isort = "^6.0.0" 85 | pre-commit = ">=3.2,<5.0" 86 | 87 | [tool.black] 88 | target-version = ["py38"] 89 | exclude = """ 90 | utils | 91 | docs | 92 | example 93 | """ 94 | 95 | [tool.interrogate] 96 | ignore-magic = true 97 | fail-under = 95 98 | exclude = ["changes", "docs", "example", "test", "util"] 99 | 100 | [tool.isort] 101 | profile = "black" 102 | src_paths = ["aiospamc", "tests"] 103 | 104 | 105 | [build-system] 106 | requires = ["poetry-core"] 107 | build-backend = "poetry.core.masonry.api" 108 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | build: 5 | os: ubuntu-24.04 6 | tools: 7 | python: "3" 8 | jobs: 9 | post_checkout: 10 | - git fetch --unshallow || true 11 | post_create_environment: 12 | - pip install poetry 13 | post_install: 14 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install 15 | -------------------------------------------------------------------------------- /releasenotes/notes/299-python-3.10-support-b350c2d26eb780c6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - Added support and tests for Python 3.10. :github:issue:`299` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/305-7183-bug-warning-96c4ec43071155fb.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added warning for using SSL and compression hangs the connection. :github:issue:`305` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/308-deprecate-python-3.6-e48c3cd467a4c0f8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - Support for Python 3.6. :github:issue:`308` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/310-add-request-response-repr-cacd8ddb1e753d4a.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - Made repr methods of Request and Response objects more descriptive. :github:issue:`310` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/317-parse-timeout-message-4e202ecd1f6dd343.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - Now correctly parses response messages that had spaces. :github:issue:`317` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/320-stable-classifier-81d3b6676e1a4a39.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Marked package as stable :github:issue:`320` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/321-logging-to-loguru-d7e61fc248d933c2.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Using the loguru library instead 5 | of logging from the standard library. :github:issue:`321` 6 | -------------------------------------------------------------------------------- /releasenotes/notes/324-add-cli-52f614cdb9a4e2dc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add command line interface and documentation :github:issue:`324` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/355-python-3.11-support-236266a1403d11cc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add support for Python 3.11. :github:issue:`355` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/357-actionoption-valueerror-9bec502c4520e1a2.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Resolved `ValueError` exception when importing ActionOption. :github:issue:`357` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/359-sphinx-github-issues-96277d03b6837595.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Documentation now links to GitHub issues. :github:issue:`359` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/378-deprecate-python-37-07383e4320121a3d.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - | 4 | Deprecating support for Python 3.7 :github:issue:`378` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/385-improved-headers-interface-87db548fc91424ed.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added headers class with properties for well-known headers. :github:issue:`385` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/394-ssl-option-wrong-type-a4320c34bfd4ddaa.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Changed `--ssl` CLI option so it accepts a boolean instead of an optional boolean. :github:issue:`394` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/404-add-client-certificate-24076b39e96cd311.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Adding support for using client certificates. :github:issue:`404` 5 | fixes: 6 | - | 7 | CLI `--ssl` option type changed from optional boolean to boolean. :github:issue:`394` 8 | -------------------------------------------------------------------------------- /releasenotes/notes/405-cli-report-doesnt-set-user-11fb68eae45c4178.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fixed `report` CLI sub-command that did not set the `User` header. :github:issue:`385` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/422-python-3.12-7b714da4ca0d8564.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added support for Python 3.12 :github:issue:`422` 5 | -------------------------------------------------------------------------------- /releasenotes/notes/462-deprecate-python-3.8-09c5fe8ef35177d6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - Deprecate support for Python 3.8 :github:issue:`462` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/482-python-3.13-24ba8b89ec577349.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - Add support for Python 3.13 :github:issue:`482` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/493-doc-formatting-ad79ab1402cff3e0.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - Fixed documentation formatting and added mention of Headers class in the User Guide :github:issue:`493` 4 | -------------------------------------------------------------------------------- /releasenotes/notes/loguru-cve-2022-0329-4053e0f715047699.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | security: 3 | - | 4 | Updated loguru dependency to 0.6.0 resolve vulnerability `CVE-2022-0329 `_. :github:issue:`339` 5 | -------------------------------------------------------------------------------- /releasenotes/template.yml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - Description of deprecation. :github:issue:`###` 4 | fixes: 5 | - Description of bugfix. :github:issue:`###` 6 | features: 7 | - | 8 | Description of feature :github:issue:`###` 9 | -------------------------------------------------------------------------------- /reno.yaml: -------------------------------------------------------------------------------- 1 | default_branch: development 2 | encoding: utf8 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import datetime 4 | import ssl 5 | import sys 6 | import threading 7 | from asyncio import StreamReader, StreamWriter 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | from shutil import which 11 | from socket import gethostbyname 12 | from subprocess import PIPE, STDOUT, Popen, TimeoutExpired 13 | 14 | import pytest 15 | import trustme 16 | from cryptography.hazmat.primitives.serialization import ( 17 | BestAvailableEncryption, 18 | Encoding, 19 | PrivateFormat, 20 | load_pem_private_key, 21 | ) 22 | from pytest_mock import MockerFixture 23 | 24 | from aiospamc.header_values import ContentLengthValue 25 | from aiospamc.requests import Request 26 | 27 | 28 | def pytest_addoption(parser): 29 | parser.addoption("--spamd-process-timeout", action="store", default=10, type=int) 30 | 31 | 32 | @pytest.fixture 33 | def x_headers(): 34 | from aiospamc.header_values import GenericHeaderValue 35 | 36 | return {"A": GenericHeaderValue(value="a"), "B": GenericHeaderValue(value="b")} 37 | 38 | 39 | @pytest.fixture 40 | def spam(): 41 | """Example spam message using SpamAssassin's GTUBE message.""" 42 | 43 | return ( 44 | b"Subject: Test spam mail (GTUBE)\n" 45 | b"Message-ID: \n" 46 | b"Date: Wed, 23 Jul 2003 23:30:00 +0200\n" 47 | b"From: Sender \n" 48 | b"To: Recipient \n" 49 | b"Precedence: junk\n" 50 | b"MIME-Version: 1.0\n" 51 | b"Content-Type: text/plain; charset=us-ascii\n" 52 | b"Content-Transfer-Encoding: 7bit\n\n" 53 | b"This is the GTUBE, the\n" 54 | b"\tGeneric\n" 55 | b"\tTest for\n" 56 | b"\tUnsolicited\n" 57 | b"\tBulk\n" 58 | b"\tEmail\n\n" 59 | b"If your spam filter supports it, the GTUBE provides a test by which you\n" 60 | b"can verify that the filter is installed correctly and is detecting incoming\n" 61 | b"spam. You can send yourself a test mail containing the following string of\n" 62 | b"characters (in upper case and with no white spaces and line breaks):\n\n" 63 | b"XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\n\n" 64 | b"You should send this test mail from an account outside of your network.\n\n" 65 | ) 66 | 67 | 68 | @pytest.fixture 69 | def request_with_body(): 70 | body = b"Test body\n" 71 | return Request( 72 | verb="CHECK", 73 | version="1.5", 74 | headers={"Content-length": ContentLengthValue(len(body))}, 75 | body=body, 76 | ) 77 | 78 | 79 | @pytest.fixture 80 | def request_ping(): 81 | """PING request.""" 82 | return Request(verb="PING") 83 | 84 | 85 | @pytest.fixture 86 | def response_empty(): 87 | """Empty response.""" 88 | return b"" 89 | 90 | 91 | @pytest.fixture 92 | def response_ok(): 93 | """OK response in bytes.""" 94 | return b"SPAMD/1.5 0 EX_OK\r\n\r\n" 95 | 96 | 97 | @pytest.fixture 98 | def response_pong(): 99 | """PONG response in bytes.""" 100 | return b"SPAMD/1.5 0 PONG\r\n" 101 | 102 | 103 | @pytest.fixture 104 | def response_tell(): 105 | """Examplte TELL response.""" 106 | return b"SPAMD/1.1 0 EX_OK\r\n\r\n\r\n" 107 | 108 | 109 | @pytest.fixture 110 | def response_spam_header(): 111 | """Response with Spam header in bytes.""" 112 | return b"SPAMD/1.1 0 EX_OK\r\nSpam: True ; 1000.0 / 1.0\r\n\r\n" 113 | 114 | 115 | @pytest.fixture 116 | def response_not_spam(): 117 | """Response with Spam header, but it's ham.""" 118 | return b"SPAMD/1.1 0 EX_OK\r\nSpam: False ; 0.0 / 1.0\r\n\r\n" 119 | 120 | 121 | @pytest.fixture 122 | def response_with_body(): 123 | """Response with body and Content-length header in bytes.""" 124 | return b"SPAMD/1.5 0 EX_OK\r\nContent-length: 10\r\n\r\nTest body\n" 125 | 126 | 127 | @pytest.fixture 128 | def response_empty_body(): 129 | """Response with Content-length header, but empty body in bytes.""" 130 | return b"SPAMD/1.5 0 EX_OK\r\nContent-length: 0\r\n\r\n" 131 | 132 | 133 | @pytest.fixture 134 | def response_learned(): 135 | """Response with DidSet set to local.""" 136 | return b"SPAMD/1.1 0 EX_OK\r\nDidSet: local\r\n\r\n" 137 | 138 | 139 | @pytest.fixture 140 | def response_forgotten(): 141 | """Response with DidRemove set to local.""" 142 | return b"SPAMD/1.1 0 EX_OK\r\nDidRemove: local\r\n\r\n" 143 | 144 | 145 | @pytest.fixture 146 | def response_reported(): 147 | """Response with DidSet set to remote.""" 148 | return b"SPAMD/1.1 0 EX_OK\r\nDidSet: remote\r\n\r\n" 149 | 150 | 151 | @pytest.fixture 152 | def response_revoked(): 153 | """Response with DidRemove set to remote.""" 154 | return b"SPAMD/1.1 0 EX_OK\r\nDidRemove: remote\r\n\r\n" 155 | 156 | 157 | @pytest.fixture 158 | def response_timeout(): 159 | """Server timeout response.""" 160 | return b"SPAMD/1.0 79 Timeout: (30 second timeout while trying to CHECK)\r\n" 161 | 162 | 163 | @pytest.fixture 164 | def response_invalid(): 165 | """Invalid response in bytes.""" 166 | return b"Invalid response" 167 | 168 | 169 | # Response exceptions 170 | @pytest.fixture 171 | def ex_usage(): 172 | """Command line usage error.""" 173 | return b"SPAMD/1.5 64 EX_USAGE\r\n\r\n" 174 | 175 | 176 | @pytest.fixture 177 | def ex_data_err(): 178 | """Data format error.""" 179 | return b"SPAMD/1.5 65 EX_DATAERR\r\n\r\n" 180 | 181 | 182 | @pytest.fixture 183 | def ex_no_input(): 184 | """No input response in bytes.""" 185 | return b"SPAMD/1.5 66 EX_NOINPUT\r\n\r\n" 186 | 187 | 188 | @pytest.fixture 189 | def ex_no_user(): 190 | """No user response in bytes.""" 191 | return b"SPAMD/1.5 67 EX_NOUSER\r\n\r\n" 192 | 193 | 194 | @pytest.fixture 195 | def ex_no_host(): 196 | """No host response in bytes.""" 197 | return b"SPAMD/1.5 68 EX_NOHOST\r\n\r\n" 198 | 199 | 200 | @pytest.fixture 201 | def ex_unavailable(): 202 | """Unavailable response in bytes.""" 203 | return b"SPAMD/1.5 69 EX_UNAVAILABLE\r\n\r\n" 204 | 205 | 206 | @pytest.fixture 207 | def ex_software(): 208 | """Software exception response in bytes.""" 209 | return b"SPAMD/1.5 70 EX_SOFTWARE\r\n\r\n" 210 | 211 | 212 | @pytest.fixture 213 | def ex_os_err(): 214 | """Operating system error response in bytes.""" 215 | return b"SPAMD/1.5 71 EX_OSERR\r\n\r\n" 216 | 217 | 218 | @pytest.fixture 219 | def ex_os_file(): 220 | """Operating system file error in bytes.""" 221 | return b"SPAMD/1.5 72 EX_OSFILE\r\n\r\n" 222 | 223 | 224 | @pytest.fixture 225 | def ex_cant_create(): 226 | """Can't create response error in bytes.""" 227 | return b"SPAMD/1.5 73 EX_CANTCREAT\r\n\r\n" 228 | 229 | 230 | @pytest.fixture 231 | def ex_io_err(): 232 | """Input/output error response in bytes.""" 233 | return b"SPAMD/1.5 74 EX_IOERR\r\n\r\n" 234 | 235 | 236 | @pytest.fixture 237 | def ex_temp_fail(): 238 | """Temporary failure error response in bytes.""" 239 | return b"SPAMD/1.5 75 EX_TEMPFAIL\r\n\r\n" 240 | 241 | 242 | @pytest.fixture 243 | def ex_protocol(): 244 | """Protocol error response in bytes.""" 245 | return b"SPAMD/1.5 76 EX_PROTOCOL\r\n\r\n" 246 | 247 | 248 | @pytest.fixture 249 | def ex_no_perm(): 250 | """No permission error response in bytes.""" 251 | return b"SPAMD/1.5 77 EX_NOPERM\r\n\r\n" 252 | 253 | 254 | @pytest.fixture 255 | def ex_config(): 256 | """Configuration error response in bytes.""" 257 | return b"SPAMD/1.5 78 EX_CONFIG\r\n\r\n" 258 | 259 | 260 | @pytest.fixture 261 | def ex_timeout(): 262 | """Timeout error response in bytes.""" 263 | return b"SPAMD/1.5 79 EX_TIMEOUT\r\n\r\n" 264 | 265 | 266 | @pytest.fixture 267 | def ex_undefined(): 268 | """Undefined exception in bytes.""" 269 | return b"SPAMD/1.5 999 EX_UNDEFINED\r\n\r\n" 270 | 271 | 272 | @pytest.fixture(scope="session") 273 | def hostname(): 274 | return "localhost" 275 | 276 | 277 | @pytest.fixture(scope="session") 278 | def ip_address(hostname): 279 | return gethostbyname(hostname) 280 | 281 | 282 | @pytest.fixture(scope="session") 283 | def tcp_port(): 284 | return 1783 285 | 286 | 287 | @pytest.fixture(scope="session") 288 | def ssl_port(): 289 | return 11783 290 | 291 | 292 | @pytest.fixture(scope="session") 293 | def unix_socket(tmp_path_factory): 294 | return str(tmp_path_factory.mktemp("sockets") / "spamd.sock") 295 | 296 | 297 | @pytest.fixture(scope="session") 298 | def ca(): 299 | yield trustme.CA() 300 | 301 | 302 | @pytest.fixture(scope="session") 303 | def server_cert(ca, hostname, ip_address): 304 | yield ca.issue_cert(hostname, ip_address) 305 | 306 | 307 | @pytest.fixture(scope="session") 308 | def ca_cert_path(ca, tmp_path_factory: pytest.TempdirFactory): 309 | tmp_path = tmp_path_factory.mktemp("ca_certs") 310 | cert_file = tmp_path / "ca_cert.pem" 311 | ca.cert_pem.write_to_path(cert_file) 312 | 313 | yield cert_file 314 | 315 | 316 | @pytest.fixture(scope="session") 317 | def server_cert_and_key(server_cert, tmp_path_factory: pytest.TempdirFactory): 318 | tmp_path = tmp_path_factory.mktemp("server_certs") 319 | cert_file = tmp_path / "server.cert" 320 | key_file = tmp_path / "server.key" 321 | 322 | cert_file.write_bytes( 323 | b"".join([blob.bytes() for blob in server_cert.cert_chain_pems]) 324 | ) 325 | server_cert.private_key_pem.write_to_path(key_file) 326 | 327 | yield cert_file, key_file 328 | 329 | 330 | @pytest.fixture(scope="session") 331 | def client_private_key_password(): 332 | yield b"password" 333 | 334 | 335 | @pytest.fixture(scope="session") 336 | def client_cert_and_key( 337 | ca, 338 | hostname, 339 | ip_address, 340 | tmp_path_factory: pytest.TempdirFactory, 341 | client_private_key_password, 342 | ): 343 | tmp_path = tmp_path_factory.mktemp("client_certs") 344 | cert_file = tmp_path / "client.cert" 345 | key_file = tmp_path / "client.key" 346 | cert_key_file = tmp_path / "client_cert_key.pem" 347 | enc_key_file = tmp_path / "client_enc_key.pem" 348 | 349 | cert: trustme.LeafCert = ca.issue_cert(hostname, ip_address) 350 | 351 | cert.private_key_and_cert_chain_pem.write_to_path(cert_key_file) 352 | cert_file.write_bytes(b"".join([blob.bytes() for blob in cert.cert_chain_pems])) 353 | cert.private_key_pem.write_to_path(key_file) 354 | 355 | client_private_key = load_pem_private_key( 356 | cert.private_key_pem.bytes(), 357 | None, 358 | ) 359 | client_enc_key_bytes = client_private_key.private_bytes( 360 | Encoding.PEM, 361 | PrivateFormat.PKCS8, 362 | BestAvailableEncryption(client_private_key_password), 363 | ) 364 | enc_key_file.write_bytes(client_enc_key_bytes) 365 | 366 | yield cert_file, key_file, cert_key_file, enc_key_file 367 | 368 | 369 | @pytest.fixture(scope="session") 370 | def server_cert_path(server_cert_and_key): 371 | yield server_cert_and_key[0] 372 | 373 | 374 | @pytest.fixture(scope="session") 375 | def server_key_path(server_cert_and_key): 376 | yield server_cert_and_key[1] 377 | 378 | 379 | @pytest.fixture(scope="session") 380 | def client_cert_and_key_path(client_cert_and_key): 381 | yield client_cert_and_key[2] 382 | 383 | 384 | @pytest.fixture(scope="session") 385 | def client_cert_path(client_cert_and_key): 386 | yield client_cert_and_key[0] 387 | 388 | 389 | @pytest.fixture(scope="session") 390 | def client_key_path(client_cert_and_key): 391 | yield client_cert_and_key[1] 392 | 393 | 394 | @pytest.fixture(scope="session") 395 | def client_encrypted_key_path(client_cert_and_key): 396 | yield client_cert_and_key[3] 397 | 398 | 399 | @dataclass 400 | class ServerResponse: 401 | response: bytes = b"" 402 | 403 | 404 | class FakeServer: 405 | def __init__(self, loop: asyncio.AbstractEventLoop, resp: ServerResponse): 406 | self.loop = loop 407 | self.resp = resp 408 | self.is_ready = threading.Event() 409 | self.is_done = None 410 | 411 | async def server(self, reader: StreamReader, writer: StreamWriter): 412 | buffer_size = 1024 413 | data = b"" 414 | while chunk := await reader.read(buffer_size): 415 | data += chunk 416 | if len(chunk) < buffer_size: 417 | break 418 | writer.write(self.resp.response) 419 | if writer.can_write_eof(): 420 | writer.write_eof() 421 | await writer.drain() 422 | writer.close() 423 | await writer.wait_closed() 424 | self.is_done.set() 425 | 426 | async def create_server(self, *args, **kwargs): 427 | raise NotImplementedError 428 | 429 | async def start_server(self, *args, **kwargs): 430 | server = await self.create_server(*args, **kwargs) 431 | self.is_ready.set() 432 | await self.is_done.wait() 433 | server.close() 434 | await server.wait_closed() 435 | 436 | def run(self, *args, **kwargs): 437 | asyncio.set_event_loop(self.loop) 438 | self.is_done = asyncio.Event() 439 | self.loop.run_until_complete(self.start_server(*args, **kwargs)) 440 | 441 | 442 | class FakeTcpServer(FakeServer): 443 | async def create_server(self, port, ssl_context=None): 444 | return await asyncio.start_server( 445 | self.server, "localhost", port, ssl=ssl_context 446 | ) 447 | 448 | 449 | class FakeUnixServer(FakeServer): 450 | async def create_server(self, path): 451 | return await asyncio.start_unix_server(self.server, path) 452 | 453 | 454 | @pytest.fixture 455 | async def fake_tcp_server(unused_tcp_port, response_ok): 456 | resp = ServerResponse(response_ok) 457 | fake = FakeTcpServer(asyncio.new_event_loop(), resp) 458 | with concurrent.futures.ThreadPoolExecutor() as executor: 459 | executor.submit(fake.run, unused_tcp_port) 460 | fake.is_ready.wait() 461 | yield resp, "localhost", unused_tcp_port 462 | fake.loop.call_soon_threadsafe(fake.is_done.set) 463 | 464 | 465 | @pytest.fixture 466 | async def fake_tcp_ssl_server(unused_tcp_port, response_ok, server_cert): 467 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 468 | server_cert.configure_cert(context) 469 | resp = ServerResponse(response_ok) 470 | fake = FakeTcpServer(asyncio.new_event_loop(), resp) 471 | with concurrent.futures.ThreadPoolExecutor() as executor: 472 | executor.submit(fake.run, unused_tcp_port, context) 473 | fake.is_ready.wait() 474 | yield resp, "localhost", unused_tcp_port 475 | fake.loop.call_soon_threadsafe(fake.is_done.set) 476 | 477 | 478 | @pytest.fixture 479 | async def fake_tcp_ssl_client(unused_tcp_port, response_ok, ca, server_cert): 480 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 481 | context.verify_mode = ssl.CERT_REQUIRED 482 | server_cert.configure_cert(context) 483 | ca.configure_trust(context) 484 | resp = ServerResponse(response_ok) 485 | fake = FakeTcpServer(asyncio.new_event_loop(), resp) 486 | with concurrent.futures.ThreadPoolExecutor() as executor: 487 | executor.submit(fake.run, unused_tcp_port, context) 488 | fake.is_ready.wait() 489 | yield resp, "localhost", unused_tcp_port 490 | fake.loop.call_soon_threadsafe(fake.is_done.set) 491 | 492 | 493 | @pytest.fixture 494 | def mock_reader_writer(mocker: MockerFixture, response_ok): 495 | mock_reader = mocker.MagicMock() 496 | mock_reader.read = mocker.AsyncMock(return_value=response_ok) 497 | mock_writer = mocker.MagicMock() 498 | mock_writer.drain = mocker.AsyncMock() 499 | mock_writer.write = mocker.MagicMock() 500 | 501 | mocker.patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)) 502 | if sys.platform != "win32": 503 | mocker.patch( 504 | "asyncio.open_unix_connection", return_value=(mock_reader, mock_writer) 505 | ) 506 | 507 | yield mock_reader, mock_writer 508 | 509 | 510 | # Integration fixtures 511 | 512 | 513 | def spawn_spamd(options, timeout): 514 | spamd_exe = Path(which("spamd")) 515 | process = Popen( 516 | [spamd_exe, *options], 517 | stdout=PIPE, 518 | stderr=STDOUT, 519 | cwd=spamd_exe.parent, 520 | universal_newlines=True, 521 | ) 522 | 523 | timeout = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=timeout) 524 | 525 | running = False 526 | spamd_start = "info: spamd: server started on" 527 | while not running: 528 | if datetime.datetime.now(datetime.UTC) > timeout: 529 | raise TimeoutError 530 | 531 | for line in process.stdout: 532 | if spamd_start in line: 533 | running = True 534 | break 535 | 536 | if not running: 537 | raise ChildProcessError 538 | 539 | return process 540 | 541 | 542 | def shutdown_spamd(process): 543 | process.terminate() 544 | try: 545 | process.wait(timeout=5) 546 | except TimeoutExpired: 547 | process.kill() 548 | process.wait(timeout=5) 549 | 550 | 551 | @pytest.fixture(scope="session") 552 | def spamd_timeout(request): 553 | yield request.config.getoption("--spamd-process-timeout") 554 | 555 | 556 | @pytest.fixture(scope="session") 557 | def spamd_common_options(): 558 | yield ["--local", "--allow-tell"] 559 | 560 | 561 | @pytest.fixture(scope="session") 562 | def spamd_tcp(spamd_common_options, unused_tcp_port_factory, spamd_timeout): 563 | port = unused_tcp_port_factory() 564 | process = spawn_spamd( 565 | spamd_common_options + [f"--listen=localhost:{port}"], spamd_timeout 566 | ) 567 | yield "localhost", port 568 | shutdown_spamd(process) 569 | 570 | 571 | @pytest.fixture(scope="session") 572 | def spamd_ssl( 573 | spamd_common_options, 574 | unused_tcp_port_factory, 575 | server_cert_path, 576 | server_key_path, 577 | spamd_timeout, 578 | ): 579 | port = unused_tcp_port_factory() 580 | process = spawn_spamd( 581 | spamd_common_options 582 | + [ 583 | f"--listen=ssl:localhost:{port}", 584 | "--server-cert", 585 | f"{server_cert_path}", 586 | "--server-key", 587 | f"{server_key_path}", 588 | ], 589 | spamd_timeout, 590 | ) 591 | yield "localhost", port 592 | shutdown_spamd(process) 593 | 594 | 595 | @pytest.fixture(scope="session") 596 | def spamd_ssl_client( 597 | spamd_common_options, 598 | unused_tcp_port_factory, 599 | server_cert_path, 600 | server_key_path, 601 | ca_cert_path, 602 | spamd_timeout, 603 | ): 604 | port = unused_tcp_port_factory() 605 | process = spawn_spamd( 606 | spamd_common_options 607 | + [ 608 | f"--listen=ssl:localhost:{port}", 609 | "--server-cert", 610 | f"{server_cert_path}", 611 | "--server-key", 612 | f"{server_key_path}", 613 | "--ssl-ca-file", 614 | f"{ca_cert_path}", 615 | "--ssl-verify", 616 | ], 617 | spamd_timeout, 618 | ) 619 | yield "localhost", port 620 | shutdown_spamd(process) 621 | 622 | 623 | @pytest.fixture(scope="session") 624 | def spamd_unix(spamd_common_options, tmp_path_factory, spamd_timeout): 625 | unix_socket = tmp_path_factory.mktemp("spamd") / "spamd.sock" 626 | process = spawn_spamd( 627 | spamd_common_options + [f"--socketpath={unix_socket}"], spamd_timeout 628 | ) 629 | yield unix_socket 630 | shutdown_spamd(process) 631 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_mock import MockerFixture 3 | 4 | from aiospamc.client import Client 5 | from aiospamc.connections import ConnectionManagerBuilder 6 | from aiospamc.exceptions import BadResponse 7 | from aiospamc.requests import Request 8 | from aiospamc.responses import ( 9 | CantCreateException, 10 | ConfigException, 11 | DataErrorException, 12 | InternalSoftwareException, 13 | IOErrorException, 14 | NoHostException, 15 | NoInputException, 16 | NoPermissionException, 17 | NoUserException, 18 | OSErrorException, 19 | OSFileException, 20 | ProtocolException, 21 | Response, 22 | ResponseException, 23 | ServerTimeoutException, 24 | TemporaryFailureException, 25 | UnavailableException, 26 | UsageException, 27 | ) 28 | 29 | 30 | async def test_successful_response(fake_tcp_server): 31 | _, host, port = fake_tcp_server 32 | c = Client(ConnectionManagerBuilder().with_tcp(host, port).build()) 33 | response = await c.request(Request("PING")) 34 | 35 | assert isinstance(response, Response) 36 | 37 | 38 | async def test_successful_parse_error(fake_tcp_server, response_invalid): 39 | resp, host, port = fake_tcp_server 40 | resp.response = response_invalid 41 | c = Client(ConnectionManagerBuilder().with_tcp(host, port).build()) 42 | 43 | with pytest.raises(BadResponse): 44 | await c.request(Request("PING")) 45 | 46 | 47 | async def test_raise_for_status_called(fake_tcp_server, mocker: MockerFixture): 48 | raise_spy = mocker.spy(Response, "raise_for_status") 49 | _, host, port = fake_tcp_server 50 | c = Client(ConnectionManagerBuilder().with_tcp(host, port).build()) 51 | response = await c.request(Request("PING")) 52 | 53 | assert isinstance(response, Response) 54 | assert raise_spy.called 55 | -------------------------------------------------------------------------------- /tests/test_connections.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ssl 3 | import sys 4 | from pathlib import Path 5 | 6 | import certifi 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | from aiospamc.connections import ( 11 | ConnectionManager, 12 | ConnectionManagerBuilder, 13 | SSLContextBuilder, 14 | TcpConnectionManager, 15 | Timeout, 16 | UnixConnectionManager, 17 | ) 18 | from aiospamc.exceptions import AIOSpamcConnectionFailed, ClientTimeoutException 19 | 20 | 21 | @pytest.fixture 22 | def mock_open_connection_refused(mocker): 23 | mocker.patch("asyncio.open_connection", side_effect=ConnectionRefusedError()) 24 | 25 | yield 26 | 27 | 28 | @pytest.fixture 29 | def mock_open_connection_error(mocker): 30 | mocker.patch("asyncio.open_connection", side_effect=OSError()) 31 | 32 | yield 33 | 34 | 35 | @pytest.fixture 36 | def mock_open_unix_connection(mocker): 37 | reader, writer = mocker.AsyncMock(), mocker.AsyncMock() 38 | mocker.patch( 39 | "asyncio.open_unix_connection", mocker.AsyncMock(return_value=(reader, writer)) 40 | ) 41 | 42 | yield reader, writer 43 | 44 | 45 | @pytest.fixture 46 | def mock_open_unix_connection_refused(mocker): 47 | mocker.patch("asyncio.open_unix_connection", side_effect=ConnectionRefusedError()) 48 | 49 | yield 50 | 51 | 52 | @pytest.fixture 53 | def mock_open_unix_connection_error(mocker): 54 | mocker.patch("asyncio.open_unix_connection", side_effect=OSError()) 55 | 56 | yield 57 | 58 | 59 | def test_connection_manager_returns_logger(): 60 | c = ConnectionManager("connection") 61 | 62 | assert c.logger is not None 63 | 64 | 65 | async def test_connection_manager_request_sends_and_receives(mocker): 66 | test_input = b"request" 67 | expected = b"response" 68 | 69 | c = ConnectionManager("connection") 70 | reader = mocker.AsyncMock(spec=asyncio.StreamReader) 71 | reader.read.return_value = expected 72 | writer = mocker.AsyncMock(spec=asyncio.StreamWriter) 73 | c.open = mocker.AsyncMock(return_value=(reader, writer)) 74 | result = await c.request(test_input) 75 | 76 | assert expected == result 77 | writer.write.assert_called_with(test_input) 78 | writer.can_write_eof.assert_called() 79 | writer.write_eof.assert_called() 80 | writer.drain.assert_awaited() 81 | 82 | 83 | async def test_connection_manager_request_sends_without_eof(mocker): 84 | test_input = b"request" 85 | expected = b"response" 86 | 87 | c = ConnectionManager("connection") 88 | reader = mocker.AsyncMock(spec=asyncio.StreamReader) 89 | reader.read.return_value = expected 90 | writer = mocker.AsyncMock(spec=asyncio.StreamWriter) 91 | writer.can_write_eof.return_value = False 92 | c.open = mocker.AsyncMock(return_value=(reader, writer)) 93 | result = await c.request(test_input) 94 | 95 | assert expected == result 96 | writer.write.assert_called_with(test_input) 97 | writer.can_write_eof.assert_called() 98 | writer.write_eof.assert_not_called() 99 | writer.drain.assert_awaited() 100 | 101 | 102 | async def test_connection_manager_timeout_total(mocker): 103 | async def sleep(): 104 | await asyncio.sleep(5) 105 | 106 | return mocker.AsyncMock(spec=asyncio.StreamReader), mocker.AsyncMock( 107 | spec=asyncio.StreamWriter 108 | ) 109 | 110 | c = ConnectionManager("connection", timeout=Timeout(total=0)) 111 | c.open = mocker.AsyncMock(side_effect=sleep) 112 | 113 | with pytest.raises(asyncio.TimeoutError): 114 | await c.request(b"data") 115 | 116 | 117 | async def test_connection_manager_timeout_connect(mocker): 118 | async def sleep(): 119 | await asyncio.sleep(5) 120 | 121 | return mocker.AsyncMock(spec=asyncio.StreamReader), mocker.AsyncMock( 122 | spec=asyncio.StreamWriter 123 | ) 124 | 125 | c = ConnectionManager("connection", timeout=Timeout(connection=0)) 126 | c.open = mocker.AsyncMock(side_effect=sleep) 127 | 128 | with pytest.raises(ClientTimeoutException): 129 | await c.request(b"data") 130 | 131 | 132 | async def test_connection_manager_timeout_read(mocker): 133 | async def sleep(): 134 | await asyncio.sleep(5) 135 | return b"response" 136 | 137 | reader = mocker.AsyncMock(spec=asyncio.StreamReader) 138 | reader.read = mocker.AsyncMock(side_effect=sleep) 139 | 140 | c = ConnectionManager("connection", timeout=Timeout(response=0)) 141 | c.open = mocker.AsyncMock( 142 | return_value=(reader, mocker.AsyncMock(spec=asyncio.StreamWriter)) 143 | ) 144 | 145 | with pytest.raises(ClientTimeoutException): 146 | await c.request(b"data") 147 | 148 | 149 | async def test_connection_manager_open_raises_not_implemented(): 150 | c = ConnectionManager("connection") 151 | 152 | with pytest.raises(NotImplementedError): 153 | await c.open() 154 | 155 | 156 | def test_tcp_connection_manager_init(mocker, hostname, tcp_port): 157 | mock_ssl_context = mocker.Mock() 158 | t = TcpConnectionManager(hostname, tcp_port, mock_ssl_context) 159 | 160 | assert hostname == t.host 161 | assert tcp_port == t.port 162 | assert mock_ssl_context is t.ssl_context 163 | 164 | 165 | async def test_tcp_connection_manager_open(mock_reader_writer, hostname, tcp_port): 166 | t = TcpConnectionManager(hostname, tcp_port) 167 | reader, writer = await t.open() 168 | 169 | assert mock_reader_writer[0] is reader 170 | assert mock_reader_writer[1] is writer 171 | 172 | 173 | async def test_tcp_connection_manager_open_refused( 174 | mock_open_connection_refused, hostname, tcp_port 175 | ): 176 | t = TcpConnectionManager(hostname, tcp_port) 177 | 178 | with pytest.raises(AIOSpamcConnectionFailed): 179 | await t.open() 180 | 181 | 182 | def test_tcp_connection_manager_connection_string(hostname, tcp_port): 183 | t = TcpConnectionManager(hostname, tcp_port) 184 | 185 | assert f"{hostname}:{tcp_port}" == t.connection_string 186 | 187 | 188 | def test_unix_connection_manager_init(unix_socket): 189 | u = UnixConnectionManager(unix_socket) 190 | 191 | assert unix_socket == u.path 192 | 193 | 194 | @pytest.mark.skipif( 195 | sys.platform == "win32", reason="Unix sockets not supported on Windows" 196 | ) 197 | async def test_unix_connection_manager_open(mock_open_unix_connection, unix_socket): 198 | u = UnixConnectionManager(unix_socket) 199 | reader, writer = await u.open() 200 | 201 | assert mock_open_unix_connection[0] is reader 202 | assert mock_open_unix_connection[1] is writer 203 | 204 | 205 | @pytest.mark.skipif( 206 | sys.platform == "win32", reason="Unix sockets not supported on Windows" 207 | ) 208 | async def test_unix_connection_manager_open_refused( 209 | mock_open_unix_connection_refused, unix_socket 210 | ): 211 | u = UnixConnectionManager(unix_socket) 212 | 213 | with pytest.raises(AIOSpamcConnectionFailed): 214 | await u.open() 215 | 216 | 217 | def test_unix_connection_manager_connection_string(unix_socket): 218 | u = UnixConnectionManager(unix_socket) 219 | 220 | assert unix_socket == u.connection_string 221 | 222 | 223 | def test_connection_manager_builder_builds_unix(unix_socket): 224 | timeout = Timeout() 225 | b = ( 226 | ConnectionManagerBuilder() 227 | .with_unix_socket(unix_socket) 228 | .set_timeout(timeout) 229 | .build() 230 | ) 231 | 232 | assert isinstance(b, UnixConnectionManager) 233 | assert unix_socket == b.path 234 | assert timeout == b.timeout 235 | 236 | 237 | def test_connection_manager_builder_builds_tcp(hostname, tcp_port): 238 | timeout = Timeout() 239 | b = ( 240 | ConnectionManagerBuilder() 241 | .with_tcp(hostname, tcp_port) 242 | .set_timeout(timeout) 243 | .build() 244 | ) 245 | 246 | assert isinstance(b, TcpConnectionManager) 247 | assert hostname == b.host 248 | assert tcp_port == b.port 249 | assert timeout == b.timeout 250 | assert None is b.ssl_context 251 | 252 | 253 | def test_connection_manager_builder_builds_tcp_with_ssl(hostname, tcp_port, mocker): 254 | ssl_context = mocker.Mock() 255 | b = ( 256 | ConnectionManagerBuilder() 257 | .with_tcp(hostname, tcp_port) 258 | .add_ssl_context(ssl_context) 259 | .build() 260 | ) 261 | 262 | assert isinstance(b, TcpConnectionManager) 263 | assert ssl_context == b.ssl_context 264 | 265 | 266 | def test_ssl_context_builder_default(mocker: MockerFixture): 267 | default_spy = mocker.spy(ssl, "create_default_context") 268 | s = SSLContextBuilder().build() 269 | 270 | assert isinstance(s, ssl.SSLContext) 271 | assert True is default_spy.called 272 | 273 | 274 | def test_ssl_context_builder_existing_context(): 275 | context = ssl.create_default_context() 276 | s = SSLContextBuilder().with_context(context).build() 277 | 278 | assert context is s 279 | 280 | 281 | def test_ssl_context_builder_dont_verify(): 282 | s = SSLContextBuilder().dont_verify().build() 283 | 284 | assert False is s.check_hostname 285 | assert ssl.CERT_NONE is s.verify_mode 286 | 287 | 288 | def test_ssl_context_builder_add_certifi(mocker: MockerFixture): 289 | s = SSLContextBuilder() 290 | certs_spy = mocker.spy(s._context, "load_verify_locations") 291 | s.add_default_ca().build() 292 | 293 | assert {"cafile": certifi.where()} == certs_spy.call_args.kwargs 294 | 295 | 296 | def test_ssl_context_builder_add_cafile(mocker: MockerFixture, server_cert_path): 297 | s = SSLContextBuilder() 298 | certs_spy = mocker.spy(s._context, "load_verify_locations") 299 | s.add_ca_file(server_cert_path).build() 300 | 301 | assert {"cafile": server_cert_path} == certs_spy.call_args.kwargs 302 | 303 | 304 | def test_ssl_context_builder_add_cadir(mocker: MockerFixture, server_cert_path): 305 | s = SSLContextBuilder() 306 | certs_spy = mocker.spy(s._context, "load_verify_locations") 307 | s.add_ca_dir(server_cert_path.parent).build() 308 | 309 | assert {"capath": server_cert_path.parent} == certs_spy.call_args.kwargs 310 | 311 | 312 | def test_ssl_context_builder_add_ca_path_of_file( 313 | mocker: MockerFixture, server_cert_path 314 | ): 315 | s = SSLContextBuilder() 316 | certs_spy = mocker.spy(s._context, "load_verify_locations") 317 | s.add_ca(server_cert_path).build() 318 | 319 | assert {"cafile": server_cert_path} == certs_spy.call_args.kwargs 320 | 321 | 322 | def test_ssl_context_builder_add_ca_path_of_dir( 323 | mocker: MockerFixture, server_cert_path 324 | ): 325 | s = SSLContextBuilder() 326 | certs_spy = mocker.spy(s._context, "load_verify_locations") 327 | s.add_ca(server_cert_path.parent).build() 328 | 329 | assert {"capath": server_cert_path.parent} == certs_spy.call_args.kwargs 330 | 331 | 332 | def test_ssl_context_builder_add_ca_path_not_found(): 333 | with pytest.raises(FileNotFoundError): 334 | SSLContextBuilder().add_ca(Path("fake")).build() 335 | 336 | 337 | def test_ssl_context_builder_add_client_cert( 338 | mocker: MockerFixture, 339 | client_cert_path, 340 | client_key_path, 341 | client_private_key_password, 342 | ): 343 | builder = SSLContextBuilder() 344 | certs_spy = mocker.spy(builder._context, "load_cert_chain") 345 | password_call = lambda: client_private_key_password 346 | s = builder.add_client(client_cert_path, client_key_path, password_call).build() 347 | 348 | assert ( 349 | client_cert_path, 350 | client_key_path, 351 | password_call, 352 | ) == certs_spy.call_args.args 353 | -------------------------------------------------------------------------------- /tests/test_header_values.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from base64 import b64encode 4 | 5 | import pytest 6 | 7 | from aiospamc.header_values import ( 8 | ActionOption, 9 | BytesHeaderValue, 10 | CompressValue, 11 | ContentLengthValue, 12 | GenericHeaderValue, 13 | Headers, 14 | MessageClassOption, 15 | MessageClassValue, 16 | SetOrRemoveValue, 17 | SpamValue, 18 | UserValue, 19 | ) 20 | 21 | 22 | def test_bytes_value(): 23 | b = BytesHeaderValue(b"test") 24 | 25 | assert b"test" == b.value 26 | 27 | 28 | def test_bytes_bytes(): 29 | b = BytesHeaderValue(b"test") 30 | 31 | assert b"test" == bytes(b) 32 | 33 | 34 | def test_bytes_to_json(): 35 | b = BytesHeaderValue(b"test") 36 | expected = b64encode(b"test").decode() 37 | 38 | assert expected == b.to_json() 39 | 40 | 41 | def test_header_bytes(): 42 | h = GenericHeaderValue(value="value", encoding="utf8") 43 | 44 | assert bytes(h) == b"value" 45 | 46 | 47 | def test_compress_bytes(): 48 | c = CompressValue() 49 | 50 | assert bytes(c) == b"zlib" 51 | 52 | 53 | def test_content_length_bytes(): 54 | c = ContentLengthValue(length=42) 55 | 56 | assert bytes(c) == b"42" 57 | 58 | 59 | def test_content_length_int(): 60 | c = ContentLengthValue(length=42) 61 | 62 | assert 42 == int(c) 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "test_input,expected", 67 | [[MessageClassOption.ham, b"ham"], [MessageClassOption.spam, b"spam"]], 68 | ) 69 | def test_message_class_bytes(test_input, expected): 70 | m = MessageClassValue(value=test_input) 71 | 72 | assert expected == bytes(m) 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "test_input,expected", 77 | [ 78 | [ActionOption(local=False, remote=False), b""], 79 | [ActionOption(local=True, remote=False), b"local"], 80 | [ActionOption(local=False, remote=True), b"remote"], 81 | [ActionOption(local=True, remote=True), b"local, remote"], 82 | ], 83 | ) 84 | def test_set_or_remove_bytes(test_input, expected): 85 | s = SetOrRemoveValue(action=test_input) 86 | 87 | assert bytes(s) == expected 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "value,score,threshold,expected", 92 | [ 93 | [True, 1, 42, b"True ; 1.0 / 42.0"], 94 | [False, 1, 42, b"False ; 1.0 / 42.0"], 95 | [True, 1.0, 42.0, b"True ; 1.0 / 42.0"], 96 | ], 97 | ) 98 | def test_spam_bytes(value, score, threshold, expected): 99 | s = SpamValue(value=value, score=score, threshold=threshold) 100 | 101 | assert bytes(s) == expected 102 | 103 | 104 | def test_user_str(): 105 | u = UserValue(name="username") 106 | 107 | assert str(u) == "username" 108 | 109 | 110 | def test_user_bytes(): 111 | u = UserValue(name="username") 112 | 113 | assert bytes(u) == b"username" 114 | 115 | 116 | @pytest.mark.parametrize( 117 | "test_input", 118 | [ 119 | GenericHeaderValue("value"), 120 | CompressValue(), 121 | ContentLengthValue(), 122 | SetOrRemoveValue(ActionOption(local=True, remote=False)), 123 | MessageClassValue(value=MessageClassOption.ham), 124 | SpamValue(), 125 | UserValue(), 126 | ], 127 | ) 128 | def test_equal(test_input): 129 | assert test_input == test_input 130 | 131 | 132 | @pytest.mark.parametrize( 133 | "test_input", 134 | [ 135 | GenericHeaderValue("value"), 136 | CompressValue(), 137 | ContentLengthValue(), 138 | SetOrRemoveValue(ActionOption(local=True, remote=False)), 139 | MessageClassValue(value=MessageClassOption.ham), 140 | SpamValue(), 141 | UserValue(), 142 | ], 143 | ) 144 | def test_eq_attribute_exception_false(test_input): 145 | class Empty: 146 | pass 147 | 148 | e = Empty() 149 | 150 | assert test_input != e 151 | 152 | 153 | @pytest.mark.parametrize( 154 | "test_input,expected", 155 | [ 156 | (GenericHeaderValue("value"), "value"), 157 | (CompressValue(), "zlib"), 158 | (ContentLengthValue(42), 42), 159 | ( 160 | SetOrRemoveValue(ActionOption(local=True, remote=False)), 161 | {"local": True, "remote": False}, 162 | ), 163 | (MessageClassValue(value=MessageClassOption.ham), "ham"), 164 | ( 165 | SpamValue(value=True, score=1.0, threshold=10.0), 166 | {"value": True, "score": 1.0, "threshold": 10.0}, 167 | ), 168 | (UserValue("username"), "username"), 169 | ], 170 | ) 171 | def test_to_json(test_input, expected): 172 | result = test_input.to_json() 173 | 174 | assert expected == result 175 | 176 | 177 | def test_headers_get_header(): 178 | h = Headers({"Exists": GenericHeaderValue("test")}) 179 | 180 | assert None is h.get_header("Doesnt-exist") 181 | assert "test" == h.get_header("Exists") 182 | 183 | 184 | def test_headers_set_header(): 185 | h = Headers() 186 | h.set_header("Test", "test") 187 | 188 | assert "test" == h.get_header("Test") 189 | 190 | 191 | def test_headers_get_bytes_header(): 192 | test_input = BytesHeaderValue(b"test") 193 | h = Headers({"Exists": BytesHeaderValue(test_input)}) 194 | 195 | assert None is h.get_bytes_header("Doesnt-exist") 196 | assert test_input == h.get_bytes_header("Exists") 197 | 198 | 199 | def test_headers_set_bytes_header(): 200 | test_input = BytesHeaderValue(b"test") 201 | h = Headers() 202 | h.set_bytes_header("Test", test_input) 203 | 204 | assert test_input == h.get_bytes_header("Test") 205 | 206 | 207 | def test_headers_compress(): 208 | test_input = CompressValue() 209 | h = Headers() 210 | 211 | assert None is h.compress 212 | h.compress = test_input 213 | assert test_input == h.compress 214 | 215 | 216 | def test_headers_content_length(): 217 | test_input = ContentLengthValue() 218 | h = Headers() 219 | 220 | assert None is h.content_length 221 | h.content_length = test_input 222 | assert test_input == h.content_length 223 | 224 | 225 | def test_headers_message_class(): 226 | test_input = MessageClassOption.ham 227 | h = Headers() 228 | 229 | assert None is h.message_class 230 | h.message_class = test_input 231 | assert test_input == h.message_class 232 | 233 | 234 | def test_headers_set(): 235 | test_input = SetOrRemoveValue(action=ActionOption(False, False)) 236 | h = Headers() 237 | 238 | assert None is h.set_ 239 | h.set_ = test_input 240 | assert test_input == h.set_ 241 | 242 | 243 | def test_headers_remove(): 244 | test_input = SetOrRemoveValue(action=ActionOption(False, False)) 245 | h = Headers() 246 | 247 | assert None is h.remove 248 | h.remove = test_input 249 | assert test_input == h.remove 250 | 251 | 252 | def test_headers_did_set(): 253 | test_input = SetOrRemoveValue(action=ActionOption(False, False)) 254 | h = Headers() 255 | 256 | assert None is h.did_set 257 | h.did_set = test_input 258 | assert test_input == h.did_set 259 | 260 | 261 | def test_headers_did_remove(): 262 | test_input = SetOrRemoveValue(action=ActionOption(False, False)) 263 | h = Headers() 264 | 265 | assert None is h.did_remove 266 | h.did_remove = test_input 267 | assert test_input == h.did_remove 268 | 269 | 270 | def test_headers_spam(): 271 | test_input = SpamValue() 272 | h = Headers() 273 | 274 | assert None is h.spam 275 | h.spam = test_input 276 | assert test_input == h.spam 277 | 278 | 279 | def test_headers_user(): 280 | test_input = UserValue() 281 | h = Headers() 282 | 283 | assert None is h.user 284 | h.user = test_input 285 | assert test_input == h.user 286 | -------------------------------------------------------------------------------- /tests/test_incremental_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiospamc.exceptions import NotEnoughDataError, ParseError, TooMuchDataError 4 | from aiospamc.header_values import ( 5 | ActionOption, 6 | BytesHeaderValue, 7 | CompressValue, 8 | ContentLengthValue, 9 | GenericHeaderValue, 10 | MessageClassOption, 11 | MessageClassValue, 12 | SetOrRemoveValue, 13 | SpamValue, 14 | UserValue, 15 | ) 16 | from aiospamc.incremental_parser import ( 17 | Parser, 18 | RequestParser, 19 | ResponseParser, 20 | States, 21 | parse_body, 22 | parse_content_length_value, 23 | parse_header, 24 | parse_header_value, 25 | parse_message_class_value, 26 | parse_request_status, 27 | parse_response_status, 28 | parse_set_remove_value, 29 | parse_spam_value, 30 | ) 31 | 32 | 33 | @pytest.fixture 34 | def delimiter(): 35 | return b"\r\n" 36 | 37 | 38 | def test_default_result(delimiter, mocker): 39 | p = Parser( 40 | delimiter=delimiter, 41 | status_parser=mocker.stub(), 42 | header_parser=mocker.stub(), 43 | body_parser=mocker.stub(), 44 | ) 45 | 46 | assert p.result == {"headers": {}, "body": b""} 47 | 48 | 49 | def test_default_state(delimiter, mocker): 50 | p = Parser( 51 | delimiter=delimiter, 52 | status_parser=mocker.stub(), 53 | header_parser=mocker.stub(), 54 | body_parser=mocker.stub(), 55 | ) 56 | 57 | assert p.state == States.Status 58 | 59 | 60 | def test_status_transitions_to_header_state(delimiter, mocker): 61 | p = Parser( 62 | delimiter=delimiter, 63 | status_parser=mocker.Mock(return_value={"status": "status success"}), 64 | header_parser=mocker.stub(), 65 | body_parser=mocker.stub(), 66 | ) 67 | p.buffer = b"left\r\nright" 68 | p.status() 69 | 70 | assert p.result["status"] == "status success" 71 | assert p.state == States.Header 72 | 73 | 74 | def test_status_raises_not_enough_data(delimiter, mocker): 75 | p = Parser( 76 | delimiter=delimiter, 77 | status_parser=mocker.stub(), 78 | header_parser=mocker.stub(), 79 | body_parser=mocker.stub(), 80 | ) 81 | 82 | with pytest.raises(NotEnoughDataError): 83 | p.status() 84 | 85 | 86 | def test_header_writes_value(delimiter, mocker): 87 | p = Parser( 88 | delimiter=delimiter, 89 | status_parser=mocker.stub(), 90 | header_parser=mocker.Mock(return_value=("header key", "header value")), 91 | body_parser=mocker.stub(), 92 | start=States.Header, 93 | ) 94 | p.buffer = b"header key: header value\r\n\r\nright" 95 | p.header() 96 | 97 | assert p.result["headers"]["header key"] == "header value" 98 | assert p.state == States.Header 99 | 100 | 101 | def test_header_transitions_to_body_state(delimiter, mocker): 102 | p = Parser( 103 | delimiter=delimiter, 104 | status_parser=mocker.stub(), 105 | header_parser=mocker.stub(), 106 | body_parser=mocker.stub(), 107 | start=States.Header, 108 | ) 109 | p.buffer = b"\r\nright" 110 | p.header() 111 | 112 | assert p.state == States.Body 113 | 114 | 115 | def test_empty_header_transitions_to_body(delimiter, mocker): 116 | p = Parser( 117 | delimiter=delimiter, 118 | status_parser=mocker.stub(), 119 | header_parser=mocker.stub(), 120 | body_parser=mocker.stub(), 121 | start=States.Header, 122 | ) 123 | p.buffer = b"" 124 | p.header() 125 | 126 | assert p.state == States.Body 127 | 128 | 129 | def test_header_raises_on_bad_format(delimiter, mocker): 130 | p = Parser( 131 | delimiter=delimiter, 132 | status_parser=mocker.stub(), 133 | header_parser=mocker.stub(), 134 | body_parser=mocker.stub(), 135 | start=States.Header, 136 | ) 137 | p.buffer = b"\n" 138 | 139 | with pytest.raises(ParseError): 140 | p.header() 141 | 142 | 143 | def test_body_saves_value_and_transitions_to_done(delimiter, mocker): 144 | p = Parser( 145 | delimiter=delimiter, 146 | status_parser=mocker.stub(), 147 | header_parser=mocker.stub(), 148 | body_parser=mocker.Mock(return_value=b"body value"), 149 | start=States.Body, 150 | ) 151 | p.buffer = b"body value" 152 | p.result["headers"]["Content-length"] = ContentLengthValue(length=len(p.buffer)) 153 | p.body() 154 | 155 | assert p.result["body"] == b"body value" 156 | assert p.state == States.Done 157 | 158 | 159 | def test_body_transitions_to_done_on_empty_body(delimiter, mocker): 160 | p = Parser( 161 | delimiter=delimiter, 162 | status_parser=mocker.stub(), 163 | header_parser=mocker.stub(), 164 | body_parser=mocker.Mock(return_value=b"body value"), 165 | start=States.Body, 166 | ) 167 | p.body() 168 | 169 | assert p.state == States.Done 170 | 171 | 172 | def test_body_doesnt_transition_on_not_enough_data(delimiter, mocker): 173 | p = Parser( 174 | delimiter=delimiter, 175 | status_parser=mocker.stub(), 176 | header_parser=mocker.stub(), 177 | body_parser=mocker.Mock(side_effect=NotEnoughDataError), 178 | start=States.Body, 179 | ) 180 | p.result["headers"]["Content-length"] = ContentLengthValue(10) 181 | 182 | with pytest.raises(NotEnoughDataError): 183 | p.body() 184 | assert p.state == States.Body 185 | 186 | 187 | def test_body_too_much_data_and_transitions_to_done(delimiter, mocker): 188 | p = Parser( 189 | delimiter=delimiter, 190 | status_parser=mocker.stub(), 191 | header_parser=mocker.stub(), 192 | body_parser=mocker.Mock(side_effect=TooMuchDataError), 193 | start=States.Body, 194 | ) 195 | p.buffer = b"body value" 196 | p.result["headers"]["Content-length"] = ContentLengthValue(length=1) 197 | 198 | with pytest.raises(TooMuchDataError): 199 | p.body() 200 | assert p.state == States.Done 201 | 202 | 203 | def test_parse(delimiter, mocker): 204 | p = Parser( 205 | delimiter=delimiter, 206 | status_parser=mocker.Mock(return_value={"status": "status value"}), 207 | header_parser=mocker.Mock(return_value=("header key", "header value")), 208 | body_parser=mocker.Mock(return_value=b"body value"), 209 | ) 210 | result = p.parse( 211 | b"status line\r\nheader lines\r\nContent-length: 10\r\n\r\nbody value" 212 | ) 213 | 214 | assert result["status"] == "status value" 215 | assert result["headers"]["header key"] == "header value" 216 | assert result["body"] == b"body value" 217 | assert p.state == States.Done 218 | 219 | 220 | @pytest.mark.parametrize( 221 | "verb", 222 | [ 223 | "CHECK", 224 | "HEADERS", 225 | "PING", 226 | "PROCESS", 227 | "REPORT_IFSPAM", 228 | "REPORT", 229 | "SKIP", 230 | "SYMBOLS", 231 | "TELL", 232 | ], 233 | ) 234 | def test_parse_request_status_success(verb): 235 | result = parse_request_status(b"%s SPAMC/1.5" % verb.encode("ascii")) 236 | 237 | assert result["verb"] == verb 238 | assert result["protocol"] == "SPAMC" 239 | assert result["version"] == "1.5" 240 | 241 | 242 | @pytest.mark.parametrize( 243 | "test_input", 244 | [b"Unrecognizable format", b"NOTAVERB SPAMC/1.5", b"CHECK NOTAPROTOCOL/1.5"], 245 | ) 246 | def test_parse_request_status_raises_parseerror(test_input): 247 | with pytest.raises(ParseError): 248 | parse_request_status(test_input) 249 | 250 | 251 | def test_parse_response_status_success(): 252 | result = parse_response_status(b"SPAMD/1.5 0 EX_OK") 253 | 254 | assert result["protocol"] == "SPAMD" 255 | assert result["version"] == "1.5" 256 | assert result["status_code"] == 0 257 | assert result["message"] == "EX_OK" 258 | 259 | 260 | @pytest.mark.parametrize( 261 | "test_input", 262 | [ 263 | b"Unrecognizable format", 264 | b"NOTAPROTOCOL/1.5 0 EX_OK", 265 | b"SPAMD/1.5 NOTACODE EX_OK", 266 | ], 267 | ) 268 | def test_parse_response_status_raises_parseerror(test_input): 269 | with pytest.raises(ParseError): 270 | parse_response_status(test_input) 271 | 272 | 273 | def test_parse_content_length_value_success(): 274 | result = parse_content_length_value("42") 275 | 276 | assert result.length == 42 277 | 278 | 279 | def test_parse_content_length_value_raises_parse_error(): 280 | with pytest.raises(ParseError): 281 | parse_content_length_value("Invalid") 282 | 283 | 284 | @pytest.mark.parametrize( 285 | "test_input,expected", 286 | [ 287 | ["ham", MessageClassOption.ham], 288 | ["spam", MessageClassOption.spam], 289 | [MessageClassOption.ham, MessageClassOption.ham], 290 | [MessageClassOption.spam, MessageClassOption.spam], 291 | ], 292 | ) 293 | def test_parse_message_class_value_success(test_input, expected): 294 | result = parse_message_class_value(test_input) 295 | 296 | assert result.value == expected 297 | 298 | 299 | def test_parse_message_class_value_raises_parseerror(): 300 | with pytest.raises(ParseError): 301 | parse_message_class_value("invalid") 302 | 303 | 304 | @pytest.mark.parametrize( 305 | "test_input,local_expected,remote_expected", 306 | [ 307 | ["local, remote", True, True], 308 | ["remote, local", True, True], 309 | ["local", True, False], 310 | ["remote", False, True], 311 | ["", False, False], 312 | [ActionOption(local=True, remote=False), True, False], 313 | ], 314 | ) 315 | def test_parse_set_remove_value_success(test_input, local_expected, remote_expected): 316 | result = parse_set_remove_value(test_input) 317 | 318 | assert result.action.local == local_expected 319 | assert result.action.remote == remote_expected 320 | 321 | 322 | @pytest.mark.parametrize( 323 | "test_input,value,score,threshold", 324 | [ 325 | ["True ; 40.0 / 20.0", True, 40.0, 20.0], 326 | ["True ; -40.0 / 20.0", True, -40.0, 20.0], 327 | ["False ; 40.0 / 20.0", False, 40.0, 20.0], 328 | ["true ; 40.0 / 20.0", True, 40.0, 20.0], 329 | ["false ; 40.0 / 20.0", False, 40.0, 20.0], 330 | ["Yes ; 40.0 / 20.0", True, 40.0, 20.0], 331 | ["No ; 40.0 / 20.0", False, 40.0, 20.0], 332 | ["yes ; 40.0 / 20.0", True, 40.0, 20.0], 333 | ["no ; 40.0 / 20.0", False, 40.0, 20.0], 334 | ["True;40/20", True, 40.0, 20.0], 335 | ], 336 | ) 337 | def test_parse_spam_value_success(test_input, value, score, threshold): 338 | result = parse_spam_value(test_input) 339 | 340 | assert result.value == value 341 | assert result.score == pytest.approx(score) 342 | assert result.threshold == pytest.approx(threshold) 343 | 344 | 345 | @pytest.mark.parametrize( 346 | "test_input", 347 | [ 348 | "Unrecognizable spam", 349 | "NOTAVALUE ; 40.0 / 20.0", 350 | "True ; NOTASCORE / 20.0", 351 | "True ; 40.0 / NOTATHRESHOLD", 352 | ], 353 | ) 354 | def test_parse_spam_value_raises_parseerror(test_input): 355 | with pytest.raises(ParseError): 356 | parse_spam_value(test_input) 357 | 358 | 359 | def test_parse_header_value_standard_bytes(): 360 | result = parse_header_value("Content-length", b"42") 361 | 362 | assert isinstance(result, ContentLengthValue) 363 | assert 42 == result.length 364 | 365 | 366 | def test_parse_header_value_standard_raises(): 367 | with pytest.raises(ParseError): 368 | parse_header_value("Content-length", "value".encode("utf32")) 369 | 370 | 371 | def test_parse_header_value_standard_string(): 372 | result = parse_header_value("Content-length", "42") 373 | 374 | assert isinstance(result, ContentLengthValue) 375 | assert 42 == result.length 376 | 377 | 378 | def test_parse_header_value_generic(): 379 | result = parse_header_value("XHeader", "value") 380 | 381 | assert isinstance(result, GenericHeaderValue) 382 | 383 | 384 | def test_parse_header_value_bytes(): 385 | result = parse_header_value("XHeader", "value".encode("utf32")) 386 | 387 | assert isinstance(result, BytesHeaderValue) 388 | 389 | 390 | @pytest.mark.parametrize( 391 | "test_input,header,value", 392 | [ 393 | [b"Compress: zlib", "Compress", CompressValue(algorithm="zlib")], 394 | [b"Content-length: 42", "Content-length", ContentLengthValue(length=42)], 395 | [ 396 | b"DidRemove: local, remote", 397 | "DidRemove", 398 | SetOrRemoveValue(action=ActionOption(local=True, remote=True)), 399 | ], 400 | [ 401 | b"DidSet: local, remote", 402 | "DidSet", 403 | SetOrRemoveValue(action=ActionOption(local=True, remote=True)), 404 | ], 405 | [ 406 | b"Message-class: spam", 407 | "Message-class", 408 | MessageClassValue(value=MessageClassOption.spam), 409 | ], 410 | [ 411 | b"Remove: local, remote", 412 | "Remove", 413 | SetOrRemoveValue(action=ActionOption(local=True, remote=True)), 414 | ], 415 | [ 416 | b"Set: local, remote", 417 | "Set", 418 | SetOrRemoveValue(action=ActionOption(local=True, remote=True)), 419 | ], 420 | [ 421 | b"Spam: True ; 40 / 20", 422 | "Spam", 423 | SpamValue(value=True, score=40, threshold=20), 424 | ], 425 | [b"User: username", "User", UserValue(name="username")], 426 | [b"XHeader:x value", "XHeader", GenericHeaderValue("x value")], 427 | [ 428 | b"XHeader:%b" % "x value".encode("utf32"), 429 | "XHeader", 430 | BytesHeaderValue("x value".encode("utf32")), 431 | ], 432 | ], 433 | ) 434 | def test_parse_header_success(test_input, header, value): 435 | result = parse_header(test_input) 436 | 437 | assert result[0] == header 438 | assert result[1] == value 439 | 440 | 441 | def test_parse_body_success(): 442 | test_input = b"Test body" 443 | result = parse_body(test_input, len(test_input)) 444 | 445 | assert result == test_input 446 | 447 | 448 | def test_parse_body_raises_not_enough_data(): 449 | test_input = b"Test body" 450 | 451 | with pytest.raises(NotEnoughDataError): 452 | parse_body(test_input, len(test_input) + 1) 453 | 454 | 455 | def test_parse_body_raises_too_much_data(): 456 | test_input = b"Test body" 457 | 458 | with pytest.raises(TooMuchDataError): 459 | parse_body(test_input, len(test_input) - 1) 460 | 461 | 462 | def test_pong(response_pong): 463 | r = ResponseParser() 464 | result = r.parse(response_pong) 465 | 466 | assert result 467 | 468 | 469 | def test_tell_response(response_tell): 470 | r = ResponseParser() 471 | result = r.parse(response_tell) 472 | 473 | assert result 474 | 475 | 476 | def test_response_parser(): 477 | r = ResponseParser() 478 | 479 | assert r.delimiter == b"\r\n" 480 | assert r.status_parser == parse_response_status 481 | assert r.header_parser == parse_header 482 | assert r.body_parser == parse_body 483 | assert r.state == States.Status 484 | 485 | 486 | def test_response_from_bytes(response_with_body): 487 | r = ResponseParser() 488 | result = r.parse(response_with_body) 489 | 490 | assert result is not None 491 | 492 | 493 | def test_request_parser(): 494 | r = RequestParser() 495 | 496 | assert r.delimiter == b"\r\n" 497 | assert r.status_parser == parse_request_status 498 | assert r.header_parser == parse_header 499 | assert r.body_parser == parse_body 500 | assert r.state == States.Status 501 | 502 | 503 | def test_request_from_bytes(request_with_body): 504 | r = RequestParser() 505 | result = r.parse(bytes(request_with_body)) 506 | 507 | assert result is not None 508 | 509 | 510 | def test_timeout_response(response_timeout): 511 | r = ResponseParser() 512 | result = r.parse(response_timeout) 513 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aiospamc 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "func_name", 8 | [ 9 | "check", 10 | "headers", 11 | "ping", 12 | "process", 13 | "report", 14 | "report_if_spam", 15 | "symbols", 16 | "tell", 17 | ], 18 | ) 19 | def test_functions(func_name): 20 | assert hasattr(aiospamc, func_name) 21 | -------------------------------------------------------------------------------- /tests/test_integration_example_messages.py: -------------------------------------------------------------------------------- 1 | from email.message import EmailMessage 2 | 3 | import pytest 4 | 5 | import aiospamc 6 | 7 | 8 | @pytest.mark.integration 9 | async def test_spam(spamd_tcp, spam): 10 | result = await aiospamc.check(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 11 | 12 | assert 0 == result.status_code 13 | assert True is result.headers["Spam"].value 14 | 15 | 16 | @pytest.mark.integration 17 | async def test_gtk_encoding(spamd_tcp): 18 | message = EmailMessage() 19 | message.add_header("From", "wevsty ") 20 | message.add_header("Subject", "=?UTF-8?B?5Lit5paH5rWL6K+V?=") 21 | message.add_header("Message-ID", "") 22 | message.add_header("Date", "") 23 | message.add_header("X-Mozilla-Draft-Info", "") 24 | message.add_header("User-Agent", "") 25 | message.set_param("charset", "gbk") 26 | message.set_content("这是Unicode文字." "This is Unicode characters.") 27 | 28 | result = await aiospamc.check(message, host=spamd_tcp[0], port=spamd_tcp[1]) 29 | 30 | assert 0 == result.status_code 31 | -------------------------------------------------------------------------------- /tests/test_integration_ssl.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aiospamc 4 | 5 | 6 | @pytest.mark.integration 7 | async def test_verify_false(spamd_ssl): 8 | result = await aiospamc.ping(host=spamd_ssl[0], port=spamd_ssl[1], verify=False) 9 | 10 | assert 0 == result.status_code 11 | 12 | 13 | @pytest.mark.integration 14 | async def test_check(spamd_ssl, ca_cert_path, spam): 15 | result = await aiospamc.check( 16 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 17 | ) 18 | 19 | assert 0 == result.status_code 20 | 21 | 22 | @pytest.mark.integration 23 | async def test_headers(spamd_ssl, ca_cert_path, spam): 24 | result = await aiospamc.headers( 25 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 26 | ) 27 | 28 | assert 0 == result.status_code 29 | 30 | 31 | @pytest.mark.integration 32 | async def test_ping(spamd_ssl, ca_cert_path): 33 | result = await aiospamc.ping( 34 | host=spamd_ssl[0], 35 | port=spamd_ssl[1], 36 | verify=ca_cert_path, 37 | ) 38 | 39 | assert 0 == result.status_code 40 | 41 | 42 | @pytest.mark.integration 43 | async def test_process(spamd_ssl, ca_cert_path, spam): 44 | result = await aiospamc.process( 45 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 46 | ) 47 | 48 | assert 0 == result.status_code 49 | 50 | 51 | @pytest.mark.integration 52 | async def test_report(spamd_ssl, ca_cert_path, spam): 53 | result = await aiospamc.report( 54 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 55 | ) 56 | 57 | assert 0 == result.status_code 58 | 59 | 60 | @pytest.mark.integration 61 | async def test_report_if_spam(spamd_ssl, ca_cert_path, spam): 62 | result = await aiospamc.report_if_spam( 63 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 64 | ) 65 | 66 | assert 0 == result.status_code 67 | 68 | 69 | @pytest.mark.integration 70 | async def test_symbols(spamd_ssl, ca_cert_path, spam): 71 | result = await aiospamc.symbols( 72 | spam, host=spamd_ssl[0], port=spamd_ssl[1], verify=ca_cert_path 73 | ) 74 | 75 | assert 0 == result.status_code 76 | 77 | 78 | @pytest.mark.integration 79 | async def test_tell(spamd_ssl, ca_cert_path, spam): 80 | result = await aiospamc.tell( 81 | message=spam, 82 | message_class="spam", 83 | host=spamd_ssl[0], 84 | port=spamd_ssl[1], 85 | verify=ca_cert_path, 86 | ) 87 | 88 | assert 0 == result.status_code 89 | -------------------------------------------------------------------------------- /tests/test_integration_ssl_client.py: -------------------------------------------------------------------------------- 1 | from shutil import which 2 | from subprocess import PIPE, Popen 3 | 4 | import pytest 5 | 6 | import aiospamc 7 | 8 | 9 | def spamd_lt_4(): 10 | import re 11 | 12 | spamd_exe = which("spamd") 13 | if not spamd_exe: 14 | return True 15 | 16 | process = Popen([spamd_exe, "--version"], stdout=PIPE) 17 | process.wait(5) 18 | version = re.match(rb".*?(\d+)\.\d+\.\d+\n", process.stdout.read()) 19 | parsed = [int(i) for i in version.groups()] 20 | 21 | return parsed[0] < 4 22 | 23 | 24 | pytestmark = pytest.mark.skipif( 25 | spamd_lt_4(), 26 | reason="Only SpamAssassin 4+ supports client certificate authentication", 27 | ) 28 | 29 | 30 | @pytest.mark.integration 31 | async def test_check_client_auth( 32 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 33 | ): 34 | result = await aiospamc.check( 35 | spam, 36 | host=spamd_ssl_client[0], 37 | port=spamd_ssl_client[1], 38 | verify=ca_cert_path, 39 | cert=(client_cert_path, client_key_path), 40 | ) 41 | 42 | assert 0 == result.status_code 43 | 44 | 45 | @pytest.mark.integration 46 | async def test_headers_client_auth( 47 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 48 | ): 49 | result = await aiospamc.headers( 50 | spam, 51 | host=spamd_ssl_client[0], 52 | port=spamd_ssl_client[1], 53 | verify=ca_cert_path, 54 | cert=(client_cert_path, client_key_path), 55 | ) 56 | 57 | assert 0 == result.status_code 58 | 59 | 60 | @pytest.mark.integration 61 | async def test_ping_client_auth( 62 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path 63 | ): 64 | result = await aiospamc.ping( 65 | host=spamd_ssl_client[0], 66 | port=spamd_ssl_client[1], 67 | verify=ca_cert_path, 68 | cert=(client_cert_path, client_key_path), 69 | ) 70 | 71 | assert 0 == result.status_code 72 | 73 | 74 | @pytest.mark.integration 75 | async def test_process_client_auth( 76 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 77 | ): 78 | result = await aiospamc.process( 79 | spam, 80 | host=spamd_ssl_client[0], 81 | port=spamd_ssl_client[1], 82 | verify=ca_cert_path, 83 | cert=(client_cert_path, client_key_path), 84 | ) 85 | 86 | assert 0 == result.status_code 87 | 88 | 89 | @pytest.mark.integration 90 | async def test_report_client_auth( 91 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 92 | ): 93 | result = await aiospamc.report( 94 | spam, 95 | host=spamd_ssl_client[0], 96 | port=spamd_ssl_client[1], 97 | verify=ca_cert_path, 98 | cert=(client_cert_path, client_key_path), 99 | ) 100 | 101 | assert 0 == result.status_code 102 | 103 | 104 | @pytest.mark.integration 105 | async def test_report_if_spam_client_auth( 106 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 107 | ): 108 | result = await aiospamc.report_if_spam( 109 | spam, 110 | host=spamd_ssl_client[0], 111 | port=spamd_ssl_client[1], 112 | verify=ca_cert_path, 113 | cert=(client_cert_path, client_key_path), 114 | ) 115 | 116 | assert 0 == result.status_code 117 | 118 | 119 | @pytest.mark.integration 120 | async def test_symbols_client_auth( 121 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 122 | ): 123 | result = await aiospamc.symbols( 124 | spam, 125 | host=spamd_ssl_client[0], 126 | port=spamd_ssl_client[1], 127 | verify=ca_cert_path, 128 | cert=(client_cert_path, client_key_path), 129 | ) 130 | 131 | assert 0 == result.status_code 132 | 133 | 134 | @pytest.mark.integration 135 | async def test_tell_client_auth( 136 | spamd_ssl_client, ca_cert_path, client_cert_path, client_key_path, spam 137 | ): 138 | result = await aiospamc.tell( 139 | message=spam, 140 | message_class="spam", 141 | host=spamd_ssl_client[0], 142 | port=spamd_ssl_client[1], 143 | verify=ca_cert_path, 144 | cert=(client_cert_path, client_key_path), 145 | ) 146 | 147 | assert 0 == result.status_code 148 | 149 | 150 | @pytest.mark.integration 151 | async def test_check_client_encrypted( 152 | spamd_ssl_client, 153 | ca_cert_path, 154 | client_cert_path, 155 | client_encrypted_key_path, 156 | client_private_key_password, 157 | spam, 158 | ): 159 | result = await aiospamc.check( 160 | spam, 161 | host=spamd_ssl_client[0], 162 | port=spamd_ssl_client[1], 163 | verify=ca_cert_path, 164 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 165 | ) 166 | 167 | assert 0 == result.status_code 168 | 169 | 170 | @pytest.mark.integration 171 | async def test_headers_client_encrypted( 172 | spamd_ssl_client, 173 | ca_cert_path, 174 | client_cert_path, 175 | client_encrypted_key_path, 176 | client_private_key_password, 177 | spam, 178 | ): 179 | result = await aiospamc.headers( 180 | spam, 181 | host=spamd_ssl_client[0], 182 | port=spamd_ssl_client[1], 183 | verify=ca_cert_path, 184 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 185 | ) 186 | 187 | assert 0 == result.status_code 188 | 189 | 190 | @pytest.mark.integration 191 | async def test_ping_client_encrypted( 192 | spamd_ssl_client, 193 | ca_cert_path, 194 | client_cert_path, 195 | client_encrypted_key_path, 196 | client_private_key_password, 197 | ): 198 | result = await aiospamc.ping( 199 | host=spamd_ssl_client[0], 200 | port=spamd_ssl_client[1], 201 | verify=ca_cert_path, 202 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 203 | ) 204 | 205 | assert 0 == result.status_code 206 | 207 | 208 | @pytest.mark.integration 209 | async def test_process_client_encrypted( 210 | spamd_ssl_client, 211 | ca_cert_path, 212 | client_cert_path, 213 | client_encrypted_key_path, 214 | client_private_key_password, 215 | spam, 216 | ): 217 | result = await aiospamc.process( 218 | spam, 219 | host=spamd_ssl_client[0], 220 | port=spamd_ssl_client[1], 221 | verify=ca_cert_path, 222 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 223 | ) 224 | 225 | assert 0 == result.status_code 226 | 227 | 228 | @pytest.mark.integration 229 | async def test_report_client_encrypted( 230 | spamd_ssl_client, 231 | ca_cert_path, 232 | client_cert_path, 233 | client_encrypted_key_path, 234 | client_private_key_password, 235 | spam, 236 | ): 237 | result = await aiospamc.report( 238 | spam, 239 | host=spamd_ssl_client[0], 240 | port=spamd_ssl_client[1], 241 | verify=ca_cert_path, 242 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 243 | ) 244 | 245 | assert 0 == result.status_code 246 | 247 | 248 | @pytest.mark.integration 249 | async def test_report_if_spam_client_encrypted( 250 | spamd_ssl_client, 251 | ca_cert_path, 252 | client_cert_path, 253 | client_encrypted_key_path, 254 | client_private_key_password, 255 | spam, 256 | ): 257 | result = await aiospamc.report_if_spam( 258 | spam, 259 | host=spamd_ssl_client[0], 260 | port=spamd_ssl_client[1], 261 | verify=ca_cert_path, 262 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 263 | ) 264 | 265 | assert 0 == result.status_code 266 | 267 | 268 | @pytest.mark.integration 269 | async def test_symbols_client_encrypted( 270 | spamd_ssl_client, 271 | ca_cert_path, 272 | client_cert_path, 273 | client_encrypted_key_path, 274 | client_private_key_password, 275 | spam, 276 | ): 277 | result = await aiospamc.symbols( 278 | spam, 279 | host=spamd_ssl_client[0], 280 | port=spamd_ssl_client[1], 281 | verify=ca_cert_path, 282 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 283 | ) 284 | 285 | assert 0 == result.status_code 286 | 287 | 288 | @pytest.mark.integration 289 | async def test_tell_client_encrypted( 290 | spamd_ssl_client, 291 | ca_cert_path, 292 | client_cert_path, 293 | client_encrypted_key_path, 294 | client_private_key_password, 295 | spam, 296 | ): 297 | result = await aiospamc.tell( 298 | message=spam, 299 | message_class="spam", 300 | host=spamd_ssl_client[0], 301 | port=spamd_ssl_client[1], 302 | verify=ca_cert_path, 303 | cert=(client_cert_path, client_encrypted_key_path, client_private_key_password), 304 | ) 305 | 306 | assert 0 == result.status_code 307 | -------------------------------------------------------------------------------- /tests/test_integration_tcp.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aiospamc 4 | 5 | 6 | @pytest.mark.integration 7 | async def test_check(spamd_tcp, spam): 8 | result = await aiospamc.check(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 9 | 10 | assert 0 == result.status_code 11 | 12 | 13 | @pytest.mark.integration 14 | async def test_headers(spamd_tcp, spam): 15 | result = await aiospamc.headers(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 16 | 17 | assert 0 == result.status_code 18 | 19 | 20 | @pytest.mark.integration 21 | async def test_ping(spamd_tcp): 22 | result = await aiospamc.ping(host=spamd_tcp[0], port=spamd_tcp[1]) 23 | 24 | assert 0 == result.status_code 25 | 26 | 27 | @pytest.mark.integration 28 | async def test_process(spamd_tcp, spam): 29 | result = await aiospamc.process(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 30 | 31 | assert 0 == result.status_code 32 | 33 | 34 | @pytest.mark.integration 35 | async def test_report(spamd_tcp, spam): 36 | result = await aiospamc.report(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 37 | 38 | assert 0 == result.status_code 39 | 40 | 41 | @pytest.mark.integration 42 | async def test_report_if_spam(spamd_tcp, spam): 43 | result = await aiospamc.report_if_spam(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 44 | 45 | assert 0 == result.status_code 46 | 47 | 48 | @pytest.mark.integration 49 | async def test_symbols(spamd_tcp, spam): 50 | result = await aiospamc.symbols(spam, host=spamd_tcp[0], port=spamd_tcp[1]) 51 | 52 | assert 0 == result.status_code 53 | 54 | 55 | @pytest.mark.integration 56 | async def test_tell(spamd_tcp, spam): 57 | result = await aiospamc.tell( 58 | message=spam, 59 | message_class="spam", 60 | host=spamd_tcp[0], 61 | port=spamd_tcp[1], 62 | ) 63 | 64 | assert 0 == result.status_code 65 | -------------------------------------------------------------------------------- /tests/test_integration_unix.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import aiospamc 6 | 7 | pytestmark = pytest.mark.skipif( 8 | sys.platform == "win32", reason="Unix sockets not supported on Windows" 9 | ) 10 | 11 | 12 | @pytest.mark.integration 13 | async def test_check(spamd_unix, spam): 14 | result = await aiospamc.check(spam, socket_path=spamd_unix) 15 | 16 | assert 0 == result.status_code 17 | 18 | 19 | @pytest.mark.integration 20 | async def test_headers(spamd_unix, spam): 21 | result = await aiospamc.headers(spam, socket_path=spamd_unix) 22 | 23 | assert 0 == result.status_code 24 | 25 | 26 | @pytest.mark.integration 27 | async def test_ping(spamd_unix): 28 | result = await aiospamc.ping(socket_path=spamd_unix) 29 | 30 | assert 0 == result.status_code 31 | 32 | 33 | @pytest.mark.integration 34 | async def test_process(spamd_unix, spam): 35 | result = await aiospamc.process(spam, socket_path=spamd_unix) 36 | 37 | assert 0 == result.status_code 38 | 39 | 40 | @pytest.mark.integration 41 | async def test_report(spamd_unix, spam): 42 | result = await aiospamc.report(spam, socket_path=spamd_unix) 43 | 44 | assert 0 == result.status_code 45 | 46 | 47 | @pytest.mark.integration 48 | async def test_report_if_spam(spamd_unix, spam): 49 | result = await aiospamc.report_if_spam(spam, socket_path=spamd_unix) 50 | 51 | assert 0 == result.status_code 52 | 53 | 54 | @pytest.mark.integration 55 | async def test_symbols(spamd_unix, spam): 56 | result = await aiospamc.symbols(spam, socket_path=spamd_unix) 57 | 58 | assert 0 == result.status_code 59 | 60 | 61 | @pytest.mark.integration 62 | async def test_tell(spamd_unix, spam): 63 | result = await aiospamc.tell( 64 | message=spam, message_class="spam", socket_path=spamd_unix 65 | ) 66 | 67 | assert 0 == result.status_code 68 | 69 | 70 | @pytest.mark.integration 71 | async def test_message_without_newline(spamd_unix): 72 | result = await aiospamc.check(message=b"acb", socket_path=spamd_unix) 73 | 74 | assert 0 == result.status_code 75 | -------------------------------------------------------------------------------- /tests/test_requests.py: -------------------------------------------------------------------------------- 1 | import zlib 2 | from base64 import b64encode 3 | 4 | from aiospamc.header_values import CompressValue, ContentLengthValue, Headers 5 | from aiospamc.incremental_parser import RequestParser 6 | from aiospamc.requests import Request 7 | 8 | 9 | def test_init_verb(): 10 | r = Request(verb="TEST") 11 | 12 | assert r.verb == "TEST" 13 | 14 | 15 | def test_init_version(): 16 | r = Request(verb="TEST", version="4.2") 17 | 18 | assert r.version == "4.2" 19 | 20 | 21 | def test_init_headers(): 22 | r = Request(verb="TEST") 23 | 24 | assert hasattr(r, "headers") 25 | 26 | 27 | def test_init_headers_type(): 28 | headers = Headers() 29 | r = Request(verb="TEST", headers=headers) 30 | 31 | assert headers is r.headers 32 | 33 | 34 | def test_bytes_starts_with_verb(): 35 | r = Request(verb="TEST") 36 | result = bytes(r) 37 | 38 | assert result.startswith(b"TEST") 39 | 40 | 41 | def test_bytes_protocol(): 42 | r = Request(verb="TEST", version="4.2") 43 | result = bytes(r).split(b"\r\n", 1)[0] 44 | 45 | assert result.endswith(b" SPAMC/4.2") 46 | 47 | 48 | def test_bytes_headers(x_headers): 49 | r = Request(verb="TEST", headers=x_headers) 50 | result = bytes(r).partition(b"\r\n")[2] 51 | expected = b"\r\n".join( 52 | [ 53 | b"%b: %b" % (key.encode("ascii"), bytes(value)) 54 | for key, value in r.headers.items() 55 | ] 56 | ) 57 | 58 | assert result.startswith(expected) 59 | assert result.endswith(b"\r\n\r\n") 60 | 61 | 62 | def test_bytes_body(): 63 | test_input = b"Test body\n" 64 | r = Request(verb="TEST", body=test_input) 65 | result = bytes(r).rpartition(b"\r\n")[2] 66 | 67 | assert result == test_input 68 | 69 | 70 | def test_bytes_body_compressed(): 71 | test_input = b"Test body\n" 72 | r = Request(verb="TEST", headers={"Compress": CompressValue()}, body=test_input) 73 | result = bytes(r).rpartition(b"\r\n")[2] 74 | 75 | assert result == zlib.compress(test_input) 76 | 77 | 78 | def test_request_from_parser_result(request_with_body): 79 | p = RequestParser().parse(bytes(request_with_body)) 80 | r = Request(**p) 81 | 82 | assert r is not None 83 | 84 | 85 | def test_request_to_json(): 86 | test_body = b"Test body\n" 87 | request = Request( 88 | "CHECK", 89 | headers={"Content-length": ContentLengthValue(len(test_body))}, 90 | body=test_body, 91 | ) 92 | result = request.to_json() 93 | 94 | assert "CHECK" == result["verb"] 95 | assert "1.5" == result["version"] 96 | assert b64encode(b"Test body\n").decode() == result["body"] 97 | assert "Content-length" in result["headers"] 98 | assert len(request.body) == result["headers"]["Content-length"] 99 | -------------------------------------------------------------------------------- /tests/test_responses.py: -------------------------------------------------------------------------------- 1 | import zlib 2 | from base64 import b64encode 3 | 4 | import pytest 5 | 6 | from aiospamc.header_values import CompressValue, ContentLengthValue, Headers 7 | from aiospamc.incremental_parser import ResponseParser 8 | from aiospamc.responses import ( 9 | CantCreateException, 10 | ConfigException, 11 | DataErrorException, 12 | InternalSoftwareException, 13 | IOErrorException, 14 | NoHostException, 15 | NoInputException, 16 | NoPermissionException, 17 | NoUserException, 18 | OSErrorException, 19 | OSFileException, 20 | ProtocolException, 21 | Response, 22 | ResponseException, 23 | ServerTimeoutException, 24 | Status, 25 | TemporaryFailureException, 26 | UnavailableException, 27 | UsageException, 28 | ) 29 | 30 | 31 | def test_init_headers_type(): 32 | headers = Headers() 33 | r = Response(headers=headers) 34 | 35 | assert headers is r.headers 36 | 37 | 38 | def test_init_version(): 39 | r = Response(version="4.2", status_code=0, message="EX_OK") 40 | result = bytes(r).split(b" ")[0] 41 | 42 | assert result == b"SPAMD/4.2" 43 | 44 | 45 | def test_init_status_code(): 46 | r = Response(version="1.5", status_code=0, message="EX_OK") 47 | result = bytes(r).split(b" ")[1] 48 | 49 | assert result == str(0).encode() 50 | 51 | 52 | def test_init_message(): 53 | r = Response(version="1.5", status_code=0, message="EX_OK") 54 | result = bytes(r).split(b"\r\n")[0] 55 | 56 | assert result.endswith("EX_OK".encode()) 57 | 58 | 59 | def test_bytes_status(): 60 | r = Response(status_code=999, message="Test message") 61 | result = bytes(r).partition(b"\r\n")[0] 62 | 63 | assert b"999 Test message" in result 64 | 65 | 66 | def test_bytes_headers(x_headers): 67 | r = Response(version="1.5", status_code=0, message="EX_OK", headers=x_headers) 68 | result = bytes(r).partition(b"\r\n")[2] 69 | expected = b"".join( 70 | [ 71 | b"%b: %b\r\n" % (key.encode("ascii"), bytes(value)) 72 | for key, value in r.headers.items() 73 | ] 74 | ) 75 | 76 | assert result.startswith(expected) 77 | assert result.endswith(b"\r\n\r\n") 78 | 79 | 80 | def test_bytes_body(): 81 | test_input = b"Test body\n" 82 | r = Response(version="1.5", status_code=0, message="EX_OK", body=test_input) 83 | result = bytes(r).rpartition(b"\r\n")[2] 84 | 85 | assert result == test_input 86 | 87 | 88 | def test_bytes_body_compressed(): 89 | test_input = b"Test body\n" 90 | r = Response( 91 | version="1.5", 92 | status_code=0, 93 | message="EX_OK", 94 | headers={"Compress": CompressValue()}, 95 | body=test_input, 96 | ) 97 | result = bytes(r).rpartition(b"\r\n")[2] 98 | 99 | assert result == zlib.compress(test_input) 100 | 101 | 102 | def test_eq_other_obj_is_false(): 103 | r = Response() 104 | 105 | assert False is (r == "") 106 | 107 | 108 | def test_raise_for_status_ok(): 109 | r = Response(version="1.5", status_code=0, message="") 110 | 111 | assert r.raise_for_status() is None 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "status_code, exception", 116 | [ 117 | (64, UsageException), 118 | (65, DataErrorException), 119 | (66, NoInputException), 120 | (67, NoUserException), 121 | (68, NoHostException), 122 | (69, UnavailableException), 123 | (70, InternalSoftwareException), 124 | (71, OSErrorException), 125 | (72, OSFileException), 126 | (73, CantCreateException), 127 | (74, IOErrorException), 128 | (75, TemporaryFailureException), 129 | (76, ProtocolException), 130 | (77, NoPermissionException), 131 | (78, ConfigException), 132 | (79, ServerTimeoutException), 133 | ], 134 | ) 135 | def test_raise_for_status(status_code, exception): 136 | r = Response(version="1.5", status_code=status_code, message="") 137 | 138 | with pytest.raises(exception): 139 | r.raise_for_status() 140 | 141 | 142 | def test_raise_for_undefined_status(): 143 | r = Response(version="1.5", status_code=999, message="") 144 | 145 | with pytest.raises(ResponseException): 146 | r.raise_for_status() 147 | 148 | 149 | def test_response_from_parser_result(response_with_body): 150 | p = ResponseParser().parse(response_with_body) 151 | r = Response(**p) 152 | 153 | assert r is not None 154 | 155 | 156 | def test_response_to_json(): 157 | test_body = b"Test body\n" 158 | response = Response( 159 | status_code=Status.EX_OK, 160 | headers={"Content-length": ContentLengthValue(len(test_body))}, 161 | body=test_body, 162 | ) 163 | result = response.to_json() 164 | 165 | assert "1.5" == result["version"] 166 | assert int(Status.EX_OK) == result["status_code"] 167 | assert b64encode(test_body).decode() == result["body"] 168 | assert "Content-length" in result["headers"] 169 | assert len(response.body) == result["headers"]["Content-length"] 170 | -------------------------------------------------------------------------------- /tests/test_warnings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiospamc.connections import TcpConnectionManager 4 | from aiospamc.header_values import CompressValue 5 | from aiospamc.requests import Request 6 | from aiospamc.user_warnings import warn_spamd_bug_7183 7 | 8 | 9 | @pytest.fixture 10 | def ssl_connection_mock(mocker): 11 | connection_mock = mocker.Mock(spec=TcpConnectionManager) 12 | connection_mock.ssl_context = mocker.Mock() 13 | 14 | return connection_mock 15 | 16 | 17 | def test_spamd_bug_7183_warns(mocker, ssl_connection_mock): 18 | compressed_request = Request( 19 | "CHECK", headers={"Compress": CompressValue()}, body=b"Test body\n" 20 | ) 21 | 22 | with pytest.warns(UserWarning): 23 | warn_spamd_bug_7183(compressed_request, ssl_connection_mock) 24 | 25 | 26 | def test_spamd_bug_7183_doesnt_warn(request_with_body, ssl_connection_mock): 27 | warn_spamd_bug_7183(request_with_body, ssl_connection_mock) 28 | -------------------------------------------------------------------------------- /util/create_certs.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | from pathlib import Path 4 | 5 | import trustme 6 | import typer 7 | from cryptography.hazmat.primitives.serialization import ( 8 | BestAvailableEncryption, 9 | Encoding, 10 | PrivateFormat, 11 | load_pem_private_key, 12 | ) 13 | 14 | 15 | def main(dest: Path, private_key_password: str = "password"): 16 | """Generates certificates and private keys for local development testing.""" 17 | 18 | if not dest.exists(): 19 | dest.mkdir(parents=True) 20 | 21 | if dest.exists() and not dest.is_dir(): 22 | typer.echo("Destination is not a directory") 23 | raise typer.Exit(1) 24 | 25 | # Define paths 26 | ca_cert_file = dest / "ca.cert" 27 | server_cert_and_key_file = dest / "server_cert_and_key.pem" 28 | server_key_file = dest / "server.key" 29 | server_cert_file = dest / "server.cert" 30 | client_cert_and_key_file = dest / "client_cert_and_key.pem" 31 | client_key_file = dest / "client.key" 32 | client_cert_file = dest / "client.cert" 33 | client_encrypted_key_file = dest / "client_encrypted.key" 34 | 35 | # Certificate authority 36 | ca = trustme.CA() 37 | ca.cert_pem.write_to_path(ca_cert_file) 38 | typer.echo(f"CA certificate to: [{ca_cert_file}]") 39 | 40 | # Server certificate 41 | server = ca.issue_cert("localhost", "::1", "127.0.0.1") 42 | server.private_key_and_cert_chain_pem.write_to_path(server_cert_and_key_file) 43 | typer.echo(f"Server certificate and private key: [{server_cert_and_key_file}]") 44 | server.cert_chain_pems[0].write_to_path(server_cert_file) 45 | typer.echo(f"Server certificate: [{server_key_file}]") 46 | server.private_key_pem.write_to_path(server_key_file) 47 | typer.echo(f"Server private key: [{server_key_file}]") 48 | 49 | # Client certificate 50 | client = ca.issue_cert("localhost", "::1", "127.0.0.1") 51 | client.private_key_and_cert_chain_pem.write_to_path(client_cert_and_key_file) 52 | typer.echo(f"Client certificate and private key: [{client_cert_and_key_file}]") 53 | client.cert_chain_pems[0].write_to_path(client_cert_file) 54 | typer.echo(f"Client certificate: [{client_cert_file}]") 55 | client.private_key_pem.write_to_path(client_key_file) 56 | typer.echo(f"Client private key: [{client_key_file}]") 57 | client_private_key = load_pem_private_key( 58 | client.private_key_pem.bytes(), 59 | None, 60 | ) 61 | client_enc_key_bytes = client_private_key.private_bytes( 62 | Encoding.PEM, 63 | PrivateFormat.PKCS8, 64 | BestAvailableEncryption(private_key_password.encode()), 65 | ) 66 | client_encrypted_key_file.write_bytes(client_enc_key_bytes) 67 | typer.echo(f"Client private key (encrypted): [{client_encrypted_key_file}]") 68 | 69 | 70 | if __name__ == "__main__": 71 | typer.run(main) 72 | --------------------------------------------------------------------------------