Feat & Fix(API): Sending emails to users
- Send emails to users when they register or when they reset their password. - Fix `.gitignore` not recoginizing changes in `lib` folder
This commit is contained in:
parent
69debdb213
commit
61bac977ff
21
README.md
21
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.
|
||||
|
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -14,7 +14,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
87
backend/pdm.lock
generated
87
backend/pdm.lock
generated
@ -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"},
|
||||
|
@ -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]
|
||||
|
||||
|
0
backend/src/backend/lib/__init__.py
Normal file
0
backend/src/backend/lib/__init__.py
Normal file
4
backend/src/backend/lib/api_error.py
Normal file
4
backend/src/backend/lib/api_error.py
Normal file
@ -0,0 +1,4 @@
|
||||
class APIError(Exception):
|
||||
def __init__(self, status_code: int, code: str) -> None:
|
||||
self.status_code = status_code
|
||||
self.code = code
|
40
backend/src/backend/lib/email.py
Normal file
40
backend/src/backend/lib/email.py
Normal file
@ -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 <help@tozo.dev>",
|
||||
"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"])
|
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
0
backend/tests/lib/__init__.py
Normal file
0
backend/tests/lib/__init__.py
Normal file
10
backend/tests/lib/email.py
Normal file
10
backend/tests/lib/email.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user