diff --git a/README.md b/README.md index d559932..3ae0846 100644 --- a/README.md +++ b/README.md @@ -17,22 +17,23 @@ #### Python dependencies - `quart`: a micro-webframework, async version of Flask. -- `black`: Code formatter -- `isort`: Import formatter -- `mypy`: Type checking -- `flake8`: General Python bugs -- `vulture`: Find unused code in Python programs -- `pytest`: For testing (turbocharged with `async`) +- `black`: Code formatter. +- `isort`: Import formatter. +- `mypy`: Type checking. +- `flake8`: General Python bugs. +- `vulture`: Find unused code in Python programs. +- `pytest`: For testing (turbocharged with `async`). - `bcrypt`: Hashing and salting password. - `zxcvbn`: Test password strength. - `freezegun`: Check for expired token. -- `quart-rate-limiter`: Rate limiting -- `pydantic` and `quart-schema`: Request/Response validation +- `quart-rate-limiter`: Rate limiting. +- `pydantic` and `quart-schema`: Request/Response validation. +- `httpx`: send HTTP POST requests from our app. #### SQL Dev-deps -- `bandit`: Check for SQL injection vulnerabilities +- `bandit`: Check for SQL injection vulnerabilities. #### Miscs Dev-deps -- `djhtml`: Generate jinja templates html for emails +- `djhtml`: Generate jinja templates html for emails. diff --git a/backend/.gitignore b/backend/.gitignore index 2dc53ca..ca37439 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -14,7 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/backend/pdm.lock b/backend/pdm.lock index 38e5f34..951bc1f 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -4,6 +4,16 @@ version = "22.1.0" requires_python = ">=3.7,<4.0" summary = "File support for asyncio." +[[package]] +name = "anyio" +version = "3.6.2" +requires_python = ">=3.6.2" +summary = "High level compatibility layer for multiple asynchronous event loop implementations" +dependencies = [ + "idna>=2.8", + "sniffio>=1.1", +] + [[package]] name = "asyncpg" version = "0.27.0" @@ -59,6 +69,12 @@ version = "0.4" requires_python = ">=3.6" summary = "Query building for the postgresql prepared statements and asyncpg." +[[package]] +name = "certifi" +version = "2022.12.7" +requires_python = ">=3.6" +summary = "Python package for providing Mozilla's CA Bundle." + [[package]] name = "click" version = "8.1.3" @@ -161,6 +177,30 @@ version = "4.0.0" requires_python = ">=3.6.1" summary = "Pure-Python HPACK header compression" +[[package]] +name = "httpcore" +version = "0.16.2" +requires_python = ">=3.7" +summary = "A minimal low-level HTTP client." +dependencies = [ + "anyio<5.0,>=3.0", + "certifi", + "h11<0.15,>=0.13", + "sniffio==1.*", +] + +[[package]] +name = "httpx" +version = "0.23.1" +requires_python = ">=3.7" +summary = "The next generation HTTP client." +dependencies = [ + "certifi", + "httpcore<0.17.0,>=0.15.0", + "rfc3986[idna2008]<2,>=1.3", + "sniffio", +] + [[package]] name = "hypercorn" version = "0.14.3" @@ -427,6 +467,21 @@ dependencies = [ "quart>=0.18.1", ] +[[package]] +name = "rfc3986" +version = "1.5.0" +summary = "Validating URI References per RFC 3986" + +[[package]] +name = "rfc3986" +version = "1.5.0" +extras = ["idna2008"] +summary = "Validating URI References per RFC 3986" +dependencies = [ + "idna", + "rfc3986==1.5.0", +] + [[package]] name = "six" version = "1.16.0" @@ -439,6 +494,12 @@ version = "5.0.0" requires_python = ">=3.6" summary = "A pure Python implementation of a sliding window memory map manager" +[[package]] +name = "sniffio" +version = "1.3.0" +requires_python = ">=3.7" +summary = "Sniff out which async library your code is running under" + [[package]] name = "stevedore" version = "4.1.1" @@ -495,13 +556,17 @@ dependencies = [ [metadata] lock_version = "4.1" -content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c2474b52" +content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338ef1b985" [metadata.files] "aiofiles 22.1.0" = [ {url = "https://files.pythonhosted.org/packages/86/26/6e5060a159a6131c430e8a01ec8327405a19a449a506224b394e36f2ebc9/aiofiles-22.1.0.tar.gz", hash = "sha256:9107f1ca0b2a5553987a94a3c9959fe5b491fdf731389aa5b7b1bd0733e32de6"}, {url = "https://files.pythonhosted.org/packages/a0/48/d5d1ab7cfe46e573c3694fa1365442a7d7cadc3abb03d8507e58a3755bb2/aiofiles-22.1.0-py3-none-any.whl", hash = "sha256:1142fa8e80dbae46bb6339573ad4c8c0841358f79c6eb50a493dceca14621bad"}, ] +"anyio 3.6.2" = [ + {url = "https://files.pythonhosted.org/packages/77/2b/b4c0b7a3f3d61adb1a1e0b78f90a94e2b6162a043880704b7437ef297cad/anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, + {url = "https://files.pythonhosted.org/packages/8b/94/6928d4345f2bc1beecbff03325cad43d320717f51ab74ab5a571324f4f5a/anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, +] "asyncpg 0.27.0" = [ {url = "https://files.pythonhosted.org/packages/04/78/06b4979eb2b553a450fe38008353f5cba152a66de83c64b1639046e9ca0e/asyncpg-0.27.0.tar.gz", hash = "sha256:720986d9a4705dd8a40fdf172036f5ae787225036a7eb46e704c45aa8f62c054"}, {url = "https://files.pythonhosted.org/packages/05/46/250c9e277b77ccbfd1b1746c529fc61250280f854d686b2775f8e4d59be5/asyncpg-0.27.0-cp39-cp39-win32.whl", hash = "sha256:8934577e1ed13f7d2d9cea3cc016cc6f95c19faedea2c2b56a6f94f257cea672"}, @@ -593,6 +658,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c {url = "https://files.pythonhosted.org/packages/31/5a/c5ecd08a0c9b4dfece3b41aeefc3770968b4a2da1784941c9c8dd1c65347/buildpg-0.4-py3-none-any.whl", hash = "sha256:20d539976c81ea6f5529d3930016b0482ed0ff06def3d6da79d0fc0a3bbaeeb1"}, {url = "https://files.pythonhosted.org/packages/48/f2/ff0e51a3c2390538da6eb4f85e30d87aafbcc6d057c6c9bb9fa222c8f2fc/buildpg-0.4.tar.gz", hash = "sha256:3a6c1f40fb6c826caa819d84727e36a1372f7013ba696637b492e5935916d479"}, ] +"certifi 2022.12.7" = [ + {url = "https://files.pythonhosted.org/packages/37/f7/2b1b0ec44fdc30a3d31dfebe52226be9ddc40cd6c0f34ffc8923ba423b69/certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {url = "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, +] "click 8.1.3" = [ {url = "https://files.pythonhosted.org/packages/59/87/84326af34517fca8c58418d148f2403df25303e02736832403587318e9e8/click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, {url = "https://files.pythonhosted.org/packages/c2/f1/df59e28c642d583f7dacffb1e0965d0e00b218e0186d7858ac5233dce840/click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, @@ -644,6 +713,14 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c {url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, {url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, ] +"httpcore 0.16.2" = [ + {url = "https://files.pythonhosted.org/packages/91/52/93f22e5441539256c0d113faf17e45284aee16eebdd95089e3ca6f480b18/httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"}, + {url = "https://files.pythonhosted.org/packages/9b/20/26f6cc4fd00391f8f1c57b0020f5c6eec23904723db04b6f7608e222d815/httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"}, +] +"httpx 0.23.1" = [ + {url = "https://files.pythonhosted.org/packages/8a/df/a3e8b91dfb452e645ef110985a30f0915276a1a2144004c7671c07bb203c/httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"}, + {url = "https://files.pythonhosted.org/packages/e1/74/cdce73069e021ad5913451b86c2707b027975cf302016ca557686d87eb41/httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"}, +] "hypercorn 0.14.3" = [ {url = "https://files.pythonhosted.org/packages/2e/e8/2a927f26a0cb994451b07622bf778ac6b3cb9fe45216e13feaeebb27c9b0/Hypercorn-0.14.3.tar.gz", hash = "sha256:4a87a0b7bbe9dc75fab06dbe4b301b9b90416e9866c23a377df21a969d6ab8dd"}, {url = "https://files.pythonhosted.org/packages/be/8b/e856965857137bc594e8fd52fef1d00b79913a6a01033cba772276aff10b/Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"}, @@ -906,6 +983,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c {url = "https://files.pythonhosted.org/packages/dd/f3/f245c3bb75d0e166a87ff458ebc89489c210d235de5c50db4ef397295c79/quart-schema-0.14.3.tar.gz", hash = "sha256:c3105d61bf4fe9d8db30dc4fd9bdcc163fa739bf818c5d22537a89590b442fc7"}, {url = "https://files.pythonhosted.org/packages/ff/4d/8f646a8b7becb325311029fbc7b9d6bfd2dec7aab0ef675dffbcdba8efd1/quart_schema-0.14.3-py3-none-any.whl", hash = "sha256:538e4d2b7da93ad73cd0fa6301415c907f23ffb00d5772a8f802a18ba62cfc33"}, ] +"rfc3986 1.5.0" = [ + {url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, + {url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, +] "six 1.16.0" = [ {url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -914,6 +995,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c {url = "https://files.pythonhosted.org/packages/21/2d/39c6c57032f786f1965022563eec60623bb3e1409ade6ad834ff703724f3/smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, {url = "https://files.pythonhosted.org/packages/6d/01/7caa71608bc29952ae09b0be63a539e50d2484bc37747797a66a60679856/smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, ] +"sniffio 1.3.0" = [ + {url = "https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {url = "https://files.pythonhosted.org/packages/cd/50/d49c388cae4ec10e8109b1b833fd265511840706808576df3ada99ecb0ac/sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] "stevedore 4.1.1" = [ {url = "https://files.pythonhosted.org/packages/66/c0/26afabea111a642f33cfd15f54b3dbe9334679294ad5c0423c556b75eba2/stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"}, {url = "https://files.pythonhosted.org/packages/74/a3/72792236f981aee8bb1ef15e72a6f65444150834830f6a97178fe1e2cdf4/stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"}, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 13113f9..0826eea 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pydantic[email]>=1.10.2", "quart-schema>=0.14.3", "quart-db[postgresql]>=0.4.1", + "httpx>=0.23.1", ] [project.optional-dependencies] diff --git a/backend/src/backend/lib/__init__.py b/backend/src/backend/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/lib/api_error.py b/backend/src/backend/lib/api_error.py new file mode 100644 index 0000000..c37fd3b --- /dev/null +++ b/backend/src/backend/lib/api_error.py @@ -0,0 +1,4 @@ +class APIError(Exception): + def __init__(self, status_code: int, code: str) -> None: + self.status_code = status_code + self.code = code diff --git a/backend/src/backend/lib/email.py b/backend/src/backend/lib/email.py new file mode 100644 index 0000000..f7cbaa3 --- /dev/null +++ b/backend/src/backend/lib/email.py @@ -0,0 +1,40 @@ +import logging +from typing import Any, cast + +import httpx +from quart import current_app, render_template + +log = logging.getLogger(__name__) + + +class PostmarkError(Exception): + def __init__(self, error_code: int, message: str) -> None: + self.error_code = error_code + self.message = message + + +async def send_email( + to: str, + subject: str, + template: str, + ctx: dict[str, Any], +) -> None: + content = await render_template(template, **ctx) + log.info("Sending %s to %s\n%s", template, to, content) + token = current_app.config.get("POSTMARK_TOKEN") + if token is not None: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.postmarkapp.com/email", + json={ + "From": "Tozo ", + "To": to, + "Subject": subject, + "Tag": template, + "HtmlBody": content, + }, + headers={"X-Postmark-Server-Token": token}, + ) + data = cast(dict, response.json()) + if response.status_code != 200: + raise PostmarkError(data["ErrorCode"], data["Message"]) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/lib/__init__.py b/backend/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/lib/email.py b/backend/tests/lib/email.py new file mode 100644 index 0000000..c5e0f61 --- /dev/null +++ b/backend/tests/lib/email.py @@ -0,0 +1,10 @@ +from pytest import LogCaptureFixture +from quart import Quart + +from backend.lib.email import send_email + + +async def test_send_email(app: Quart, caplog: LogCaptureFixture) -> None: + async with app.app_context(): + await send_email("member@tozo.dev", "Welcome", "email.html", {}) + assert "Sending email.html to member@tozo.dev" in caplog.text