├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── tox.yml ├── .gitignore ├── .pyup.yml ├── LICENSE ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.rst ├── async_sender ├── __init__.py ├── _version.py └── api.py ├── docs ├── async_sender.rst ├── changelog.rst ├── conf.py ├── index.rst └── modules.rst ├── scripts └── make_release.sh ├── setup.cfg ├── setup.py ├── tests ├── conftest.py └── test_async_sender.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: pytest-asyncio 10 | versions: 11 | - 0.15.0 12 | - dependency-name: sphinx 13 | versions: 14 | - 3.4.3 15 | - 3.5.0 16 | - 3.5.1 17 | - 3.5.3 18 | - dependency-name: pytest 19 | versions: 20 | - 6.2.2 21 | - dependency-name: coverage 22 | versions: 23 | - "5.4" 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "32 8 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Tox 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install 'tox<4' 'tox-gh-actions<3' 24 | - name: Test with tox 25 | run: tox -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | *.pyc 4 | *.pyo 5 | *.egg-info 6 | dist 7 | build 8 | docs/_build 9 | .tox/* 10 | .idea 11 | .coverage -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # Label PRs with `deps-update` label 2 | label_prs: deps-update 3 | 4 | schedule: every week -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bakhtiyor Ruziev 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 | -------------------------------------------------------------------------------- /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 | SOURCEDIR = docs 8 | BUILDDIR = $(SOURCEDIR)/_build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | 21 | 22 | cov-report = true 23 | 24 | lint: 25 | pipenv run flake8 async_sender 26 | pipenv run black -l 100 --check async_sender tests 27 | 28 | format: 29 | pipenv run black -l 100 async_sender tests 30 | 31 | install-dev: 32 | pipenv install --skip-lock -d 33 | 34 | test: 35 | pipenv run coverage run -m pytest tests 36 | @if [ $(cov-report) = true ]; then\ 37 | pipenv run coverage combine;\ 38 | pipenv run coverage report;\ 39 | fi 40 | 41 | freeze: 42 | pipenv lock -d 43 | 44 | mock: 45 | docker start mailcatcher || docker run -d -p 1080:1080 -p 1025:1025 --name mailcatcher schickling/mailcatcher 46 | 47 | _release: 48 | scripts/make_release.sh 49 | 50 | release: test _release -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | verify_ssl = true 3 | url = "https://pypi.org/simple" 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | "flake8" = "*" 8 | pytest = "*" 9 | pytest-asyncio = "*" 10 | black = "==24.4.2" 11 | codecov = "*" 12 | coverage = "*" 13 | aiosmtplib = "*" 14 | httpx = "*" 15 | sphinx = "*" 16 | typing-extensions = "*" 17 | attr = "*" 18 | 19 | [packages] 20 | httpx = "*" 21 | 22 | [requires] 23 | python_version = "3.8" 24 | 25 | [pipenv] 26 | allow_prereleases = true 27 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "131756cadeb0ffd0d2f8f5a1582e86abe474c50fb1e2be68a9555a3953189f55" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "anyio": { 20 | "hashes": [ 21 | "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94", 22 | "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7" 23 | ], 24 | "markers": "python_version >= '3.8'", 25 | "version": "==4.4.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", 30 | "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" 31 | ], 32 | "index": "pypi", 33 | "markers": "python_version >= '3.6'", 34 | "version": "==2024.7.4" 35 | }, 36 | "exceptiongroup": { 37 | "hashes": [ 38 | "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", 39 | "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" 40 | ], 41 | "markers": "python_version < '3.11'", 42 | "version": "==1.2.2" 43 | }, 44 | "h11": { 45 | "hashes": [ 46 | "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", 47 | "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" 48 | ], 49 | "markers": "python_version >= '3.7'", 50 | "version": "==0.14.0" 51 | }, 52 | "httpcore": { 53 | "hashes": [ 54 | "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", 55 | "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5" 56 | ], 57 | "markers": "python_version >= '3.8'", 58 | "version": "==1.0.5" 59 | }, 60 | "httpx": { 61 | "hashes": [ 62 | "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5", 63 | "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5" 64 | ], 65 | "index": "pypi", 66 | "markers": "python_version >= '3.8'", 67 | "version": "==0.27.0" 68 | }, 69 | "idna": { 70 | "hashes": [ 71 | "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", 72 | "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" 73 | ], 74 | "markers": "python_version >= '3.5'", 75 | "version": "==3.7" 76 | }, 77 | "sniffio": { 78 | "hashes": [ 79 | "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", 80 | "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" 81 | ], 82 | "markers": "python_version >= '3.7'", 83 | "version": "==1.3.1" 84 | }, 85 | "typing-extensions": { 86 | "hashes": [ 87 | "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", 88 | "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" 89 | ], 90 | "markers": "python_version < '3.11'", 91 | "version": "==4.12.2" 92 | } 93 | }, 94 | "develop": { 95 | "aiosmtplib": { 96 | "hashes": [ 97 | "sha256:2f619f900d1bfe25a8f453005958ba78870abbfeeffb2fdef11265be0df26913", 98 | "sha256:a20aae28f67169dd761beef4c62e4bb3073d035f9ee20c2e4f18baa1a73e7c88" 99 | ], 100 | "index": "pypi", 101 | "version": "==2.0.1" 102 | }, 103 | "alabaster": { 104 | "hashes": [ 105 | "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", 106 | "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2" 107 | ], 108 | "markers": "python_version >= '3.6'", 109 | "version": "==0.7.13" 110 | }, 111 | "anyio": { 112 | "hashes": [ 113 | "sha256:691adfc3c36c0d922c69a8d4105e73cf4cf697f7e66a1baf04347bf5f1a0d6a9", 114 | "sha256:8ffa2a3572d4a9852481fb6f8b7fd3c678b27860e07b8789da4ddb06675aa219" 115 | ], 116 | "markers": "python_version >= '3.7'", 117 | "version": "==3.7.0rc1" 118 | }, 119 | "attrs": { 120 | "hashes": [ 121 | "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", 122 | "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" 123 | ], 124 | "markers": "python_version >= '3.7'", 125 | "version": "==23.1.0" 126 | }, 127 | "babel": { 128 | "hashes": [ 129 | "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610", 130 | "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455" 131 | ], 132 | "markers": "python_version >= '3.7'", 133 | "version": "==2.12.1" 134 | }, 135 | "black": { 136 | "hashes": [ 137 | "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474", 138 | "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1", 139 | "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0", 140 | "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8", 141 | "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96", 142 | "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1", 143 | "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04", 144 | "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021", 145 | "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94", 146 | "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d", 147 | "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c", 148 | "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7", 149 | "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c", 150 | "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc", 151 | "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7", 152 | "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d", 153 | "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c", 154 | "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741", 155 | "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce", 156 | "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb", 157 | "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063", 158 | "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e" 159 | ], 160 | "index": "pypi", 161 | "markers": "python_version >= '3.8'", 162 | "version": "==24.4.2" 163 | }, 164 | "certifi": { 165 | "hashes": [ 166 | "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f", 167 | "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1" 168 | ], 169 | "markers": "python_version >= '3.6'", 170 | "version": "==2024.2.2" 171 | }, 172 | "charset-normalizer": { 173 | "hashes": [ 174 | "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", 175 | "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", 176 | "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", 177 | "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", 178 | "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", 179 | "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", 180 | "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", 181 | "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", 182 | "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", 183 | "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", 184 | "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", 185 | "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", 186 | "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", 187 | "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", 188 | "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", 189 | "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", 190 | "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", 191 | "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", 192 | "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", 193 | "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", 194 | "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", 195 | "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", 196 | "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", 197 | "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", 198 | "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", 199 | "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", 200 | "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", 201 | "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", 202 | "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", 203 | "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", 204 | "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", 205 | "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", 206 | "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", 207 | "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", 208 | "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", 209 | "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", 210 | "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", 211 | "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", 212 | "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", 213 | "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", 214 | "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", 215 | "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", 216 | "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", 217 | "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", 218 | "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", 219 | "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", 220 | "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", 221 | "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", 222 | "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", 223 | "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", 224 | "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", 225 | "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", 226 | "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", 227 | "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", 228 | "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", 229 | "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", 230 | "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", 231 | "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", 232 | "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", 233 | "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", 234 | "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", 235 | "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", 236 | "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", 237 | "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", 238 | "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", 239 | "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", 240 | "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", 241 | "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", 242 | "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", 243 | "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", 244 | "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", 245 | "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", 246 | "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", 247 | "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", 248 | "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", 249 | "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", 250 | "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", 251 | "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", 252 | "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", 253 | "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", 254 | "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", 255 | "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", 256 | "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", 257 | "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", 258 | "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", 259 | "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", 260 | "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", 261 | "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", 262 | "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", 263 | "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" 264 | ], 265 | "markers": "python_full_version >= '3.7.0'", 266 | "version": "==3.3.2" 267 | }, 268 | "click": { 269 | "hashes": [ 270 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 271 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 272 | ], 273 | "markers": "python_version >= '3.7'", 274 | "version": "==8.1.7" 275 | }, 276 | "codecov": { 277 | "hashes": [ 278 | "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", 279 | "sha256:7d2b16c1153d01579a89a94ff14f9dbeb63634ee79e18c11036f34e7de66cbc9", 280 | "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5" 281 | ], 282 | "index": "pypi", 283 | "version": "==2.1.13" 284 | }, 285 | "coverage": { 286 | "hashes": [ 287 | "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382", 288 | "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1", 289 | "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac", 290 | "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee", 291 | "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166", 292 | "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57", 293 | "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c", 294 | "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b", 295 | "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51", 296 | "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da", 297 | "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450", 298 | "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2", 299 | "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd", 300 | "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d", 301 | "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d", 302 | "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6", 303 | "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca", 304 | "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169", 305 | "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1", 306 | "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713", 307 | "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b", 308 | "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6", 309 | "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c", 310 | "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605", 311 | "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463", 312 | "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b", 313 | "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6", 314 | "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5", 315 | "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63", 316 | "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c", 317 | "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783", 318 | "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44", 319 | "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca", 320 | "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8", 321 | "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d", 322 | "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390", 323 | "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933", 324 | "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67", 325 | "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b", 326 | "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03", 327 | "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b", 328 | "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791", 329 | "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb", 330 | "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807", 331 | "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6", 332 | "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2", 333 | "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428", 334 | "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd", 335 | "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c", 336 | "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94", 337 | "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8", 338 | "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b" 339 | ], 340 | "index": "pypi", 341 | "markers": "python_version >= '3.8'", 342 | "version": "==7.6.0" 343 | }, 344 | "docutils": { 345 | "hashes": [ 346 | "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", 347 | "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc" 348 | ], 349 | "markers": "python_version >= '3.7'", 350 | "version": "==0.19" 351 | }, 352 | "exceptiongroup": { 353 | "hashes": [ 354 | "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", 355 | "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" 356 | ], 357 | "markers": "python_version < '3.11'", 358 | "version": "==1.2.2" 359 | }, 360 | "flake8": { 361 | "hashes": [ 362 | "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a", 363 | "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5" 364 | ], 365 | "index": "pypi", 366 | "markers": "python_full_version >= '3.8.1'", 367 | "version": "==7.1.0" 368 | }, 369 | "h11": { 370 | "hashes": [ 371 | "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", 372 | "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" 373 | ], 374 | "markers": "python_version >= '3.7'", 375 | "version": "==0.14.0" 376 | }, 377 | "httpcore": { 378 | "hashes": [ 379 | "sha256:628e768aaeec1f7effdc6408ba1c3cdbd7487c1fc570f7d66844ec4f003e1ca4", 380 | "sha256:caf508597c525f9b8bfff187e270666309f63115af30f7d68b16143a403c8356" 381 | ], 382 | "markers": "python_version >= '3.7'", 383 | "version": "==0.17.1" 384 | }, 385 | "httpx": { 386 | "hashes": [ 387 | "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", 388 | "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd" 389 | ], 390 | "index": "pypi", 391 | "version": "==0.24.1" 392 | }, 393 | "idna": { 394 | "hashes": [ 395 | "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", 396 | "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" 397 | ], 398 | "markers": "python_version >= '3.5'", 399 | "version": "==3.7" 400 | }, 401 | "imagesize": { 402 | "hashes": [ 403 | "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", 404 | "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" 405 | ], 406 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 407 | "version": "==1.4.1" 408 | }, 409 | "importlib-metadata": { 410 | "hashes": [ 411 | "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", 412 | "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" 413 | ], 414 | "markers": "python_version < '3.10'", 415 | "version": "==6.6.0" 416 | }, 417 | "iniconfig": { 418 | "hashes": [ 419 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 420 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 421 | ], 422 | "markers": "python_version >= '3.7'", 423 | "version": "==2.0.0" 424 | }, 425 | "jinja2": { 426 | "hashes": [ 427 | "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", 428 | "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" 429 | ], 430 | "index": "pypi", 431 | "markers": "python_version >= '3.7'", 432 | "version": "==3.1.4" 433 | }, 434 | "markupsafe": { 435 | "hashes": [ 436 | "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", 437 | "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", 438 | "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", 439 | "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", 440 | "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", 441 | "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", 442 | "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", 443 | "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", 444 | "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", 445 | "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", 446 | "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", 447 | "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", 448 | "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", 449 | "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", 450 | "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", 451 | "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", 452 | "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", 453 | "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", 454 | "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", 455 | "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", 456 | "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", 457 | "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", 458 | "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", 459 | "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", 460 | "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", 461 | "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", 462 | "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", 463 | "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", 464 | "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", 465 | "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", 466 | "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", 467 | "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", 468 | "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", 469 | "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", 470 | "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", 471 | "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", 472 | "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", 473 | "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", 474 | "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", 475 | "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", 476 | "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", 477 | "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", 478 | "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", 479 | "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", 480 | "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", 481 | "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", 482 | "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", 483 | "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", 484 | "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", 485 | "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", 486 | "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", 487 | "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", 488 | "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", 489 | "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", 490 | "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", 491 | "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", 492 | "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", 493 | "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", 494 | "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", 495 | "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" 496 | ], 497 | "markers": "python_version >= '3.7'", 498 | "version": "==2.1.5" 499 | }, 500 | "mccabe": { 501 | "hashes": [ 502 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 503 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 504 | ], 505 | "markers": "python_version >= '3.6'", 506 | "version": "==0.7.0" 507 | }, 508 | "mypy-extensions": { 509 | "hashes": [ 510 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 511 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 512 | ], 513 | "markers": "python_version >= '3.5'", 514 | "version": "==1.0.0" 515 | }, 516 | "packaging": { 517 | "hashes": [ 518 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 519 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 520 | ], 521 | "markers": "python_version >= '3.8'", 522 | "version": "==24.1" 523 | }, 524 | "pathspec": { 525 | "hashes": [ 526 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 527 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 528 | ], 529 | "markers": "python_version >= '3.8'", 530 | "version": "==0.12.1" 531 | }, 532 | "platformdirs": { 533 | "hashes": [ 534 | "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf", 535 | "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1" 536 | ], 537 | "markers": "python_version >= '3.8'", 538 | "version": "==4.2.1" 539 | }, 540 | "pluggy": { 541 | "hashes": [ 542 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 543 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 544 | ], 545 | "markers": "python_version >= '3.8'", 546 | "version": "==1.5.0" 547 | }, 548 | "pycodestyle": { 549 | "hashes": [ 550 | "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c", 551 | "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4" 552 | ], 553 | "markers": "python_version >= '3.8'", 554 | "version": "==2.12.0" 555 | }, 556 | "pyflakes": { 557 | "hashes": [ 558 | "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", 559 | "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" 560 | ], 561 | "markers": "python_version >= '3.8'", 562 | "version": "==3.2.0" 563 | }, 564 | "pygments": { 565 | "hashes": [ 566 | "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", 567 | "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" 568 | ], 569 | "markers": "python_version >= '3.7'", 570 | "version": "==2.15.1" 571 | }, 572 | "pytest": { 573 | "hashes": [ 574 | "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", 575 | "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce" 576 | ], 577 | "index": "pypi", 578 | "markers": "python_version >= '3.8'", 579 | "version": "==8.3.2" 580 | }, 581 | "pytest-asyncio": { 582 | "hashes": [ 583 | "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2", 584 | "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3" 585 | ], 586 | "index": "pypi", 587 | "markers": "python_version >= '3.8'", 588 | "version": "==0.23.8" 589 | }, 590 | "pytz": { 591 | "hashes": [ 592 | "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", 593 | "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" 594 | ], 595 | "markers": "python_version < '3.9'", 596 | "version": "==2023.3" 597 | }, 598 | "requests": { 599 | "hashes": [ 600 | "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5", 601 | "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8" 602 | ], 603 | "index": "pypi", 604 | "markers": "python_version >= '3.8'", 605 | "version": "==2.32.0" 606 | }, 607 | "sniffio": { 608 | "hashes": [ 609 | "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", 610 | "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" 611 | ], 612 | "markers": "python_version >= '3.7'", 613 | "version": "==1.3.0" 614 | }, 615 | "snowballstemmer": { 616 | "hashes": [ 617 | "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", 618 | "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" 619 | ], 620 | "version": "==2.2.0" 621 | }, 622 | "sphinx": { 623 | "hashes": [ 624 | "sha256:0dac3b698538ffef41716cf97ba26c1c7788dba73ce6f150c1ff5b4720786dd2", 625 | "sha256:807d1cb3d6be87eb78a381c3e70ebd8d346b9a25f3753e9947e866b2786865fc" 626 | ], 627 | "index": "pypi", 628 | "version": "==6.1.3" 629 | }, 630 | "sphinxcontrib-applehelp": { 631 | "hashes": [ 632 | "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228", 633 | "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e" 634 | ], 635 | "markers": "python_version >= '3.8'", 636 | "version": "==1.0.4" 637 | }, 638 | "sphinxcontrib-devhelp": { 639 | "hashes": [ 640 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 641 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 642 | ], 643 | "markers": "python_version >= '3.5'", 644 | "version": "==1.0.2" 645 | }, 646 | "sphinxcontrib-htmlhelp": { 647 | "hashes": [ 648 | "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff", 649 | "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903" 650 | ], 651 | "markers": "python_version >= '3.8'", 652 | "version": "==2.0.1" 653 | }, 654 | "sphinxcontrib-jsmath": { 655 | "hashes": [ 656 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 657 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 658 | ], 659 | "markers": "python_version >= '3.5'", 660 | "version": "==1.0.1" 661 | }, 662 | "sphinxcontrib-qthelp": { 663 | "hashes": [ 664 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 665 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 666 | ], 667 | "markers": "python_version >= '3.5'", 668 | "version": "==1.0.3" 669 | }, 670 | "sphinxcontrib-serializinghtml": { 671 | "hashes": [ 672 | "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", 673 | "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" 674 | ], 675 | "markers": "python_version >= '3.5'", 676 | "version": "==1.1.5" 677 | }, 678 | "tomli": { 679 | "hashes": [ 680 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 681 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 682 | ], 683 | "markers": "python_version < '3.11'", 684 | "version": "==2.0.1" 685 | }, 686 | "typing-extensions": { 687 | "hashes": [ 688 | "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", 689 | "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" 690 | ], 691 | "index": "pypi", 692 | "markers": "python_version >= '3.8'", 693 | "version": "==4.12.2" 694 | }, 695 | "urllib3": { 696 | "hashes": [ 697 | "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", 698 | "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" 699 | ], 700 | "index": "pypi", 701 | "markers": "python_version >= '3.8'", 702 | "version": "==2.2.2" 703 | }, 704 | "zipp": { 705 | "hashes": [ 706 | "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091", 707 | "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f" 708 | ], 709 | "index": "pypi", 710 | "markers": "python_version >= '3.8'", 711 | "version": "==3.19.1" 712 | } 713 | } 714 | } 715 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/travis/com/theruziev/async_sender.svg?style=flat-square 2 | :target: https://travis-ci.com/theruziev/async_sender 3 | .. image:: https://img.shields.io/codecov/c/github/theruziev/async_sender.svg?style=flat-square 4 | :target: https://codecov.io/gh/theruziev/async_sender 5 | .. image:: https://img.shields.io/pypi/v/async_sender.svg?style=flat-square 6 | :alt: PyPI 7 | :target: https://pypi.org/project/async_sender/ 8 | 9 | 10 | AsyncSender provides a simple interface to set up a SMTP connection and send email messages asynchronously. 11 | 12 | 13 | Installation 14 | ------------ 15 | 16 | Install with the following command 17 | 18 | .. code-block:: bash 19 | 20 | pip install async_sender 21 | 22 | 23 | Quickstart 24 | ---------- 25 | 26 | AsyncSender is really easy to use. Emails are managed through a `Mail` 27 | instance 28 | 29 | .. code-block:: python 30 | 31 | from async_sender import Mail 32 | import asyncio 33 | 34 | 35 | async def run(): 36 | mail = Mail() 37 | 38 | await mail.send_message("Hello", from_address="from@example.com", 39 | to="to@example.com", body="Hello world!") 40 | 41 | 42 | asyncio.run(run()) 43 | 44 | 45 | Message 46 | ------- 47 | 48 | To send one message, we need to create a `Message` instance 49 | 50 | .. code-block:: python 51 | 52 | from async_sender import Message 53 | 54 | msg = Message("demo subject", from_address="from@example.com", 55 | to="to@example.com") 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /async_sender/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import Mail, Attachment, Connection, Message, SenderError 2 | from ._version import __version__ 3 | 4 | __all__ = [SenderError, Mail, Attachment, Connection, Message, __version__] 5 | -------------------------------------------------------------------------------- /async_sender/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.0" # pragma: no cover 2 | -------------------------------------------------------------------------------- /async_sender/api.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import time 3 | from typing import Union, Iterable, Sequence, Optional 4 | from email import charset as ch 5 | from email.encoders import encode_base64 6 | from email.mime.base import MIMEBase 7 | from email.mime.text import MIMEText 8 | from email.mime.multipart import MIMEMultipart 9 | from email.utils import make_msgid, formatdate 10 | from email.header import Header 11 | 12 | USASCII = ch.Charset("us-ascii") 13 | 14 | try: 15 | import aiosmtplib 16 | except ImportError: # pragma: no cover 17 | aiosmtplib = None 18 | 19 | ch.add_charset("utf-8", ch.SHORTEST, None, "utf-8") 20 | 21 | 22 | class SenderError(Exception): 23 | pass 24 | 25 | 26 | class Mail: 27 | """AsyncSender Mail main class. This class is used for manage SMTP server 28 | connections and send messages. 29 | 30 | :param hostname: Server name (or IP) to connect to 31 | :param port: Server port. Defaults to ``25`` if ``use_tls`` is 32 | ``False``, ``465`` if ``use_tls`` is ``True``. 33 | :param source_address: The hostname of the client. Defaults to the 34 | result of :func:`socket.getfqdn`. Note that this call blocks. 35 | :param timeout: Default timeout value for the connection, in seconds. 36 | Defaults to 60. 37 | :param use_tls: If True, make the initial connection to the server 38 | over TLS/SSL. Note that if the server supports STARTTLS only, this 39 | should be False. 40 | :param use_ehlo: If True, it sends a "Extended Hello" to the server, 41 | trading info about options and capacities of the channel. 42 | :param use_starttls: If True, make the initial connection without encrypt to the server 43 | over TCP and upgrade plain connection to an encrypted (TLS or SSL) connection. 44 | :param validate_certs: Determines if server certificates are 45 | validated. Defaults to True. 46 | :param client_cert: Path to client side certificate, for TLS 47 | verification. 48 | :param client_key: Path to client side key, for TLS verification. 49 | :param tls_context: An existing :class:`ssl.SSLContext`, for TLS 50 | verification. Mutually exclusive with ``client_cert``/ 51 | ``client_key``. 52 | :param cert_bundle: Path to certificate bundle, for TLS verification. 53 | """ 54 | 55 | def __init__( 56 | self, 57 | hostname: str = "", 58 | port: int = None, 59 | use_tls: bool = False, 60 | use_ehlo: bool = False, 61 | use_starttls: bool = False, 62 | username: str = None, 63 | password: str = None, 64 | from_address: str = None, 65 | timeout: Union[int, float] = None, 66 | source_address: str = None, 67 | validate_certs: bool = True, 68 | client_cert: str = None, 69 | client_key: str = None, 70 | tls_context: ssl.SSLContext = None, 71 | cert_bundle: str = None, 72 | ): 73 | self.host = hostname 74 | self.port = port 75 | self.username = username 76 | self.password = password 77 | self.use_tls = use_tls 78 | self.use_ehlo = use_ehlo 79 | self.use_starttls = use_starttls 80 | self.from_address = from_address 81 | self.timeout = timeout 82 | self.source_address = source_address 83 | self.validate_certs = validate_certs 84 | self.client_cert = client_cert 85 | self.client_key = client_key 86 | self.tls_context = tls_context 87 | self.cert_bundle = cert_bundle 88 | 89 | @property 90 | def connection(self) -> "Connection": 91 | """ 92 | Open one connection to the SMTP server. 93 | """ 94 | return Connection(self) 95 | 96 | async def send(self, *messages: "Message"): 97 | """ 98 | Sends a single or multiple messages. 99 | 100 | :param messages: Message instance. 101 | """ 102 | 103 | async with self.connection as connection: 104 | for message in messages: 105 | if self.from_address and not message.from_address: 106 | message.from_address = self.from_address 107 | message.validate() 108 | await connection.send(message) 109 | 110 | async def send_message(self, *args, **kwargs): 111 | """Shortcut for send.""" 112 | await self.send(Message(*args, **kwargs)) 113 | 114 | 115 | class Message: 116 | """One email message. 117 | 118 | :param subject: message subject 119 | :param to: message recipient, should be one or a list of addresses 120 | :param body: plain text content body 121 | :param html: HTML content body 122 | :param from_address: message sender, can be one address or a two-element tuple 123 | :param cc: CC list, should be one or a list of addresses 124 | :param bcc: BCC list, should be one or a list of addresses 125 | :param attachments: a list of attachment instances 126 | :param reply_to: reply-to address 127 | :param date: message send date, seconds since the Epoch, 128 | default to be time.time() 129 | :param charset: message charset, default to be 'utf-8' 130 | :param extra_headers: a dictionary of extra headers 131 | :param mail_options: a list of ESMTP options used in MAIL FROM commands 132 | :param rcpt_options: a list of ESMTP options used in RCPT commands 133 | """ 134 | 135 | def __init__( 136 | self, 137 | subject: str = None, 138 | to: Union[str, Iterable] = None, 139 | body: str = None, 140 | html: str = None, 141 | from_address: Union[str, Iterable] = None, 142 | cc: Union[str, Iterable] = None, 143 | bcc: Union[str, Iterable] = None, 144 | attachments: Union["Attachment", Sequence["Attachment"]] = None, 145 | reply_to: Union[str, Iterable] = None, 146 | date: Optional[int] = None, 147 | charset: str = "utf-8", 148 | extra_headers: dict = None, 149 | mail_options: list = None, 150 | rcpt_options: list = None, 151 | ): 152 | self.message_id = make_msgid() 153 | self.subject = subject 154 | self.body = body 155 | self.html = html 156 | self.attachments = attachments or [] 157 | self.date = date 158 | self.charset = charset 159 | self.extra_headers = extra_headers 160 | self.mail_options = mail_options or [] 161 | self.rcpt_options = rcpt_options or [] 162 | 163 | self.to = set([to] if isinstance(to, str) else to or []) 164 | self.from_address = from_address 165 | self.cc = set([cc] if isinstance(cc, str) else cc or []) 166 | self.bcc = set([bcc] if isinstance(bcc, str) else bcc or []) 167 | self.reply_to = reply_to 168 | 169 | @property 170 | def to_address(self): 171 | return self.to | self.cc | self.bcc 172 | 173 | def validate(self): 174 | """Do email message validation.""" 175 | if not (self.to or self.cc or self.bcc): 176 | raise SenderError("Does not specify any recipients(to,cc,bcc)") 177 | if not self.from_address: 178 | raise SenderError("Does not specify from_address(sender)") 179 | 180 | if any(self.subject and (c in self.subject) for c in "\n\r"): 181 | raise SenderError("newline is not allowed in subject") 182 | 183 | def as_string(self) -> str: 184 | """The message string.""" 185 | if self.date is None: 186 | self.date = time.time() 187 | 188 | msg = MIMEText(self.body, "plain", self.charset) 189 | if not self.html: 190 | if len(self.attachments) > 0: 191 | # plain text with attachments 192 | msg = MIMEMultipart() 193 | msg.attach(MIMEText(self.body, "plain", self.charset)) 194 | else: 195 | msg = MIMEMultipart() 196 | alternative = MIMEMultipart("alternative") 197 | alternative.attach(MIMEText(self.body, "plain", self.charset)) 198 | alternative.attach(MIMEText(self.html, "html", self.charset)) 199 | msg.attach(alternative) 200 | 201 | # For improve deliver-ability 202 | # https://github.com/theruziev/async_sender/issues/228 203 | if self.subject is not None and self.subject.isascii(): 204 | msg["Subject"] = Header(self.subject, USASCII) 205 | else: 206 | msg["Subject"] = Header(self.subject, self.charset) 207 | 208 | msg["From"] = self.from_address 209 | msg["To"] = ", ".join(self.to) 210 | msg["Date"] = formatdate(self.date, localtime=True) 211 | msg["Message-ID"] = self.message_id 212 | if self.cc: 213 | msg["Cc"] = ", ".join(self.cc) 214 | if self.reply_to: 215 | msg["Reply-To"] = self.reply_to 216 | if self.extra_headers: 217 | for key, value in self.extra_headers.items(): 218 | msg[key] = value 219 | 220 | for attachment in self.attachments: 221 | f = MIMEBase(*attachment.content_type.split("/")) 222 | f.set_payload(attachment.data) 223 | encode_base64(f) 224 | if attachment.filename is None: 225 | filename = str(None) 226 | else: 227 | filename = attachment.filename 228 | try: 229 | filename.encode("ascii") 230 | except UnicodeEncodeError: 231 | filename = ("UTF8", "", filename) 232 | f.add_header("Content-Disposition", attachment.disposition, filename=filename) 233 | for key, value in attachment.headers.items(): 234 | f.add_header(key, value) 235 | msg.attach(f) 236 | 237 | return msg.as_string() 238 | 239 | def as_bytes(self) -> bytes: 240 | return self.as_string().encode(self.charset or "utf-8") 241 | 242 | def __str__(self): 243 | return self.as_string() # pragma: no cover 244 | 245 | def attach(self, *attachment: "Attachment"): 246 | """Adds one or a list of attachments to the message. 247 | 248 | :param attachment: Attachment instance. 249 | """ 250 | self.attachments.extend(attachment) 251 | 252 | def attach_attachment(self, *args, **kwargs): 253 | """Shortcut for attach.""" 254 | self.attach(Attachment(*args, **kwargs)) 255 | 256 | 257 | class Attachment: 258 | """File attachment information. 259 | 260 | :param filename: filename 261 | :param content_type: file mimetype 262 | :param data: raw data 263 | :param disposition: content-disposition, default to be 'attachment' 264 | :param headers: a dictionary of headers, default to be {} 265 | """ 266 | 267 | def __init__( 268 | self, 269 | filename: str = None, 270 | content_type: str = None, 271 | data=None, 272 | disposition: str = "attachment", 273 | headers: dict = None, 274 | ): 275 | self.filename = filename 276 | self.content_type = content_type 277 | self.data = data 278 | self.disposition = disposition 279 | self.headers = headers if headers else {} 280 | 281 | 282 | class Connection: 283 | """This class handles connection to the SMTP server. Instance of this 284 | class would be one context manager so that you do not have to manage 285 | connection close manually. 286 | 287 | :param mail: one mail instance 288 | """ 289 | 290 | def __init__(self, mail): 291 | self.mail = mail 292 | if aiosmtplib is None: 293 | raise RuntimeError("Please install 'aiosmtplib'") # pragma: no cover 294 | 295 | async def __aenter__(self): 296 | server = aiosmtplib.SMTP( 297 | hostname=self.mail.host, 298 | port=self.mail.port, 299 | use_tls=self.mail.use_tls, 300 | timeout=self.mail.timeout, 301 | source_address=self.mail.source_address, 302 | validate_certs=self.mail.validate_certs, 303 | client_cert=self.mail.client_cert, 304 | client_key=self.mail.client_key, 305 | tls_context=self.mail.tls_context, 306 | cert_bundle=self.mail.cert_bundle, 307 | ) 308 | 309 | await server.connect() 310 | 311 | if self.mail.use_ehlo: 312 | await server.ehlo() 313 | 314 | if self.mail.use_starttls: 315 | await server.starttls() 316 | 317 | if self.mail.username and self.mail.password: 318 | await server.login(self.mail.username, self.mail.password) 319 | 320 | self.server = server 321 | 322 | return self 323 | 324 | async def __aexit__(self, exc_type, exc_value, exc_tb): 325 | await self.server.quit() 326 | 327 | async def send(self, message: "Message"): 328 | """Send one message instance. 329 | 330 | :param message: one message instance. 331 | """ 332 | await self.server.sendmail( 333 | message.from_address, 334 | message.to_address, 335 | message.as_bytes(), 336 | mail_options=message.mail_options, 337 | rcpt_options=message.rcpt_options, 338 | ) 339 | -------------------------------------------------------------------------------- /docs/async_sender.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | 5 | .. automodule:: async_sender.api 6 | :members: 7 | :undoc-members: 8 | 9 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | 2.0.0 6 | ----- 7 | 8 | - BREAKING: update dependency aionsmtplib==2.0.1 9 | - BREAKING: drop python 3.6, 3.7 support 10 | - Bugfix: subject charset issue #228 11 | - Changes: move to github action 12 | 13 | 14 | 1.4.4 15 | ----- 16 | 17 | - bump aiosmtplib -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('.')) 18 | sys.path.insert(0, os.path.abspath('..')) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'AsyncSender' 24 | copyright = '2023, Bakhtiyor Ruziev' 25 | author = 'Bakhtiyor Ruziev' 26 | 27 | # The short X.Y version 28 | version = '' 29 | # The full version, including alpha/beta/rc tags 30 | release = '' 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = "en" 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path. 68 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = None 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ['_static'] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = 'AsyncSenderdoc' 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'AsyncSender.tex', 'AsyncSender Documentation', 134 | 'Bakhtiyor Ruziev', '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 = [ 143 | (master_doc, 'asyncsender', 'AsyncSender Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'AsyncSender', 'AsyncSender Documentation', 155 | author, 'AsyncSender', 'One line description of project.', 156 | 'Miscellaneous'), 157 | ] 158 | 159 | 160 | # -- Options for Epub output ------------------------------------------------- 161 | 162 | # Bibliographic Dublin Core info. 163 | epub_title = project 164 | 165 | # The unique identifier of the text. This can be a ISBN number 166 | # or the project homepage. 167 | # 168 | # epub_identifier = '' 169 | 170 | # A unique identification for the text. 171 | # 172 | # epub_uid = '' 173 | 174 | # A list of files that should not be packed into the epub file. 175 | epub_exclude_files = ['search.html'] 176 | 177 | 178 | # -- Extension configuration ------------------------------------------------- -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. AsyncSender documentation master file, created by 2 | sphinx-quickstart on Fri Nov 2 12:53:23 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to AsyncSender's documentation! 7 | ======================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :hidden: 12 | 13 | async_sender 14 | changelog 15 | 16 | 17 | AsyncSender provides a simple interface to set up a SMTP connection and send email messages asynchronously. 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | Install with the following command:: 24 | 25 | $ pip install async_sender 26 | 27 | 28 | Quickstart 29 | ---------- 30 | 31 | AsyncSender is really easy to use. Emails are managed through a :class:`Mail` 32 | instance:: 33 | 34 | from async_sender import Mail 35 | import asyncio 36 | 37 | 38 | async def run(): 39 | mail = Mail() 40 | 41 | await mail.send_message("Hello", from_address="from@example.com", 42 | to="to@example.com", body="Hello world!") 43 | 44 | 45 | asyncio.run(run()) 46 | 47 | Message 48 | ------- 49 | 50 | To send one message, we need to create a :class:`Message` instance:: 51 | 52 | from async_sender import Message 53 | 54 | msg = Message("demo subject", from_address="from@example.com", 55 | to="to@example.com") 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | async_sender 2 | ============ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | async_sender 8 | -------------------------------------------------------------------------------- /scripts/make_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # https://github.com/argaen/aiocache/blob/master/scripts/make_release 3 | 4 | version=$(grep -o -E "([0-9]+\.[0-9]+\.[0-9]+)" async_sender/_version.py) 5 | echo -n "New version number (current is $version): " 6 | read new_version 7 | 8 | echo -n "Are you sure? (y/n) " 9 | read answer 10 | 11 | if echo "$answer" | grep -iq "^y" ;then 12 | echo "Generating new release..." 13 | sed -i "s/$version/$new_version/" async_sender/_version.py 14 | git commit -a -m "Bump version $new_version" 15 | git tag -a "$new_version" -m "$new_version" 16 | git push --follow-tags 17 | 18 | else 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length=100 6 | 7 | [pep8] 8 | max-line-length=100 9 | 10 | [coverage:run] 11 | branch = True 12 | parallel = True 13 | source = async_sender 14 | 15 | [coverage:report] 16 | show_missing = true 17 | skip_covered = true 18 | exclude_lines = 19 | @abstract 20 | pragma: no cover 21 | 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | 4 | from async_sender import __version__ 5 | 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | try: 9 | README = open(os.path.join(here, 'README.rst')).read() 10 | except IOError: 11 | README = '' 12 | 13 | 14 | REQUIRED = [ 15 | 'aiosmtplib==3.0.1' 16 | ] 17 | 18 | setup( 19 | name='async_sender', 20 | version=__version__, 21 | description="AsyncSender is a tiny module for SMTP mail sending, Inspired by Sender.", 22 | long_description=README, 23 | # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 24 | classifiers=[ 25 | 'Development Status :: 3 - Alpha', 26 | "Intended Audience :: Developers", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: Implementation :: CPython", 33 | 'Topic :: Software Development :: Libraries :: Python Modules', 34 | 'License :: OSI Approved :: MIT License', 35 | ], 36 | keywords='email, sender, smtp, asyncio', 37 | author='Bakhtiyor Ruziev', 38 | author_email='rbakhtiyor+github@gmail.com', 39 | url='https://github.com/theruziev/async_sender', 40 | packages=find_packages(), 41 | include_package_data=True, 42 | zip_safe=False, 43 | install_requires=REQUIRED, 44 | ) 45 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theruziev/async_sender/9551b5b7cf29c2d55f63d6dbd74991167108c2de/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_async_sender.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from async_sender import Message, SenderError, Attachment, Mail 4 | 5 | 6 | @pytest.fixture() 7 | def clear_inbox(): 8 | async def factory(): 9 | async with httpx.AsyncClient() as client: 10 | r = await client.delete(f"http://localhost:1080/messages") 11 | assert r.status_code == 204 12 | 13 | return factory 14 | 15 | 16 | @pytest.fixture() 17 | def get_emails(): 18 | async def factory(): 19 | async with httpx.AsyncClient() as client: 20 | r = await client.get(f"http://localhost:1080/messages/1.json") 21 | assert r.status_code == 200 22 | data = r.json() 23 | r = await client.get(f"http://localhost:1080/messages/1.source") 24 | assert r.status_code == 200 25 | data["source"] = r.text 26 | return data 27 | 28 | return factory 29 | 30 | 31 | def test_subject(): 32 | msg = Message("test") 33 | assert msg.subject in "test" 34 | msg = Message("test", from_address="from@example.com", to="to@example.com") 35 | assert msg.subject in str(msg) 36 | 37 | 38 | def test_to(): 39 | msg = Message(from_address="from@example.com", to="to@example.com") 40 | assert msg.to == {"to@example.com"} 41 | assert "to@example.com" in str(msg) 42 | msg = Message(to=["to01@example.com", "to02@example.com"]) 43 | assert msg.to == {"to01@example.com", "to02@example.com"} 44 | 45 | 46 | def test_from_address(): 47 | msg = Message(from_address="from@example.com", to="to@example.com") 48 | assert msg.from_address == "from@example.com" 49 | assert "from@example.com" in str(msg) 50 | 51 | 52 | def test_cc(): 53 | msg = Message(from_address="from@example.com", to="to@example.com", cc="cc@example.com") 54 | assert "cc@example.com" in str(msg) 55 | 56 | 57 | def test_bcc(): 58 | msg = Message(from_address="from@example.com", to="to@example.com", bcc="bcc@example.com") 59 | assert "bcc@example.com" not in str(msg) 60 | 61 | 62 | def test_to_address(): 63 | msg = Message(to="to@example.com") 64 | assert msg.to_address == {"to@example.com"} 65 | msg = Message( 66 | to="to@example.com", cc="cc@example.com", bcc=["bcc01@example.com", "bcc02@example.com"] 67 | ) 68 | expected_to_address = { 69 | "to@example.com", 70 | "cc@example.com", 71 | "bcc01@example.com", 72 | "bcc02@example.com", 73 | } 74 | assert msg.to_address == expected_to_address 75 | msg = Message(to="to@example.com", cc="to@example.com") 76 | assert msg.to_address == {"to@example.com"} 77 | 78 | 79 | def test_reply_to(): 80 | msg = Message( 81 | from_address="from@example.com", to="to@example.com", reply_to="reply-to@example.com" 82 | ) 83 | assert msg.reply_to == "reply-to@example.com" 84 | assert "reply-to@example.com" in str(msg) 85 | 86 | 87 | def test_charset(): 88 | msg = Message() 89 | assert msg.charset == "utf-8" 90 | msg = Message(charset="ascii") 91 | assert msg.charset == "ascii" 92 | 93 | 94 | def test_extra_headers(): 95 | msg = Message( 96 | from_address="from@example.com", 97 | to="to@example.com", 98 | extra_headers={"Extra-Header-Test": "Test"}, 99 | ) 100 | assert "Extra-Header-Test: Test" in str(msg) 101 | 102 | def test_message_subject_charset(): 103 | msg = Message(subject="hello world") 104 | assert "hello world" in msg.as_string() 105 | msg_utf8_subject = Message(subject="Привет мир") 106 | assert "=?utf-8?b?0J/RgNC40LLQtdGCINC80LjRgA==?=" in msg_utf8_subject.as_string() 107 | 108 | def test_mail_and_rcpt_options(): 109 | msg = Message() 110 | assert msg.mail_options == [] 111 | assert msg.rcpt_options == [] 112 | msg = Message(mail_options=["BODY=8BITMIME"]) 113 | assert msg.mail_options == ["BODY=8BITMIME"] 114 | msg = Message(rcpt_options=["NOTIFY=OK"]) 115 | assert msg.rcpt_options == ["NOTIFY=OK"] 116 | 117 | 118 | def test_validate(): 119 | msg = Message(from_address="from@example.com") 120 | with pytest.raises(SenderError): 121 | msg.validate() 122 | 123 | msg = Message(to="to@example.com") 124 | with pytest.raises(SenderError): 125 | msg.validate() 126 | 127 | msg = Message(subject="subject\r", from_address="from@example.com", to="to@example.com") 128 | with pytest.raises(SenderError): 129 | msg.validate() 130 | msg = Message(subject="subject\n", from_address="from@example.com", to="to@example.com") 131 | with pytest.raises(SenderError): 132 | msg.validate() 133 | 134 | 135 | def test_attach(): 136 | msg = Message() 137 | att = Attachment() 138 | atts = [Attachment() for i in range(3)] 139 | msg.attach(att) 140 | assert msg.attachments == [att] 141 | msg.attach(*atts) 142 | assert msg.attachments == [att] + atts 143 | 144 | 145 | def test_attach_attachment(): 146 | msg = Message() 147 | msg.attach_attachment("test.txt", "text/plain", "this is test") 148 | assert msg.attachments[0].filename == "test.txt" 149 | assert msg.attachments[0].content_type == "text/plain" 150 | assert msg.attachments[0].data == "this is test" 151 | 152 | 153 | def test_plain_text(): 154 | plain_text = "Hello!\nIt works." 155 | msg = Message(from_address="from@example.com", to="to@example.com", body=plain_text) 156 | assert msg.body == plain_text 157 | assert "Content-Type: text/plain" in str(msg) 158 | 159 | 160 | def test_plain_text_with_attachments(): 161 | msg = Message( 162 | from_address="from@example.com", to="to@example.com", subject="hello", body="hello world" 163 | ) 164 | msg.attach_attachment(content_type="text/plain", data=b"this is test") 165 | assert "Content-Type: multipart/mixed" in str(msg) 166 | 167 | 168 | def test_html(): 169 | html_text = "Hello
It works." 170 | msg = Message(from_address="from@example.com", to="to@example.com", html=html_text) 171 | assert msg.html == html_text 172 | assert "Content-Type: multipart/alternative" in str(msg) 173 | 174 | 175 | def test_message_id(): 176 | msg = Message(from_address="from@example.com", to="to@example.com") 177 | assert f"Message-ID: {msg.message_id}" in str(msg) 178 | 179 | 180 | def test_attachment_ascii_filename(): 181 | msg = Message(from_address="from@example.com", to="to@example.com") 182 | msg.attach_attachment("my test doc.txt", "text/plain", b"this is test") 183 | assert "Content-Disposition: attachment; filename=" '"my test doc.txt"' in str(msg) 184 | 185 | 186 | def test_attachment_unicode_filename(): 187 | msg = Message(from_address="from@example.com", to="to@example.com") 188 | # Chinese filename :) 189 | msg.attach_attachment("我的测试文档.txt", "text/plain", "this is test") 190 | assert "UTF8''%E6%88%91%E7%9A%84%E6%B5%8B%E8%AF" "%95%E6%96%87%E6%A1%A3.txt" in str(msg) 191 | 192 | 193 | @pytest.mark.asyncio 194 | async def test_send_email(clear_inbox, get_emails): 195 | await clear_inbox() 196 | 197 | mail = Mail(hostname="localhost", port=1025) 198 | msg = Message( 199 | from_address="from@example.com", 200 | subject="Hello Subject", 201 | to="to@example.com", 202 | body="Hello World", 203 | ) 204 | 205 | await mail.send(msg) 206 | email = await get_emails() 207 | assert msg.from_address in email["sender"] 208 | assert msg.subject == email["subject"] 209 | assert msg.body in email["source"] 210 | assert msg.message_id in email["source"] 211 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py38 4 | py39 5 | py310 6 | py311 7 | syntax 8 | 9 | 10 | [testenv] 11 | usedevelop = true 12 | allowlist_externals = 13 | make 14 | bash 15 | 16 | commands = 17 | pip install pipenv 18 | make install-dev 19 | pipenv graph 20 | make mock 21 | make test cov-report=false 22 | 23 | [gh-actions] 24 | python = 25 | 3.8: py38 26 | 3.9: py39 27 | 3.10: py310 28 | 3.11: py311 29 | 30 | 31 | [testenv:syntax] 32 | deps = 33 | flake8 34 | black 35 | whitelist_externals = make 36 | commands = 37 | pip install pipenv 38 | make lint 39 | 40 | 41 | --------------------------------------------------------------------------------