├── .bin └── bump.py ├── .copier └── package.yml ├── .dockerignore ├── .editorconfig ├── .env.example ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .just ├── copier.just ├── docker.just ├── documentation.just └── project.just ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── extensions.json └── settings.json.example ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── Justfile ├── LICENSE ├── README.md ├── RELEASING.md ├── docs ├── _static │ └── css │ │ └── custom.css ├── conf.py ├── configuration │ ├── index.md │ └── relay-service.md ├── contributing │ ├── index.md │ └── releasing.md ├── development │ └── just.md ├── index.md ├── installation │ ├── django-app.md │ ├── index.md │ └── relay-service.md ├── updating.md ├── usage │ ├── index.md │ └── relay-healthcheck.md └── why.md ├── noxfile.py ├── pyproject.toml ├── src └── email_relay │ ├── __init__.py │ ├── apps.py │ ├── backend.py │ ├── conf.py │ ├── db.py │ ├── email.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── runrelay.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20231030_1304.py │ └── __init__.py │ ├── models.py │ ├── py.typed │ ├── relay.py │ └── service.py ├── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_backend.py ├── test_conf.py ├── test_email.py ├── test_migrations.py ├── test_models.py ├── test_public_email_api.py ├── test_relay.py ├── test_router.py ├── test_runrelay.py ├── test_service.py └── test_version.py └── uv.lock /.bin/bump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --quiet 2 | # /// script 3 | # requires-python = ">=3.13" 4 | # dependencies = [ 5 | # "bumpver", 6 | # "typer", 7 | # ] 8 | # /// 9 | from __future__ import annotations 10 | 11 | import re 12 | import subprocess 13 | import sys 14 | from enum import Enum 15 | from pathlib import Path 16 | from typing import Annotated 17 | from typing import Any 18 | 19 | import typer 20 | from typer import Option 21 | 22 | 23 | class CommandRunner: 24 | def __init__(self, dry_run: bool = False): 25 | self.dry_run = dry_run 26 | 27 | def _quote_arg(self, arg: str) -> str: 28 | if " " in arg and not (arg.startswith('"') or arg.startswith("'")): 29 | return f"'{arg}'" 30 | return arg 31 | 32 | def _build_command_args(self, **params: Any) -> str: 33 | args = [] 34 | for key, value in params.items(): 35 | key = key.replace("_", "-") 36 | if isinstance(value, bool) and value: 37 | args.append(f"--{key}") 38 | elif value is not None: 39 | args.extend([f"--{key}", self._quote_arg(str(value))]) 40 | return " ".join(args) 41 | 42 | def run( 43 | self, cmd: str, name: str, *args: str, force_run: bool = False, **params: Any 44 | ) -> str: 45 | command_parts = [cmd, name] 46 | command_parts.extend(self._quote_arg(arg) for arg in args) 47 | if params: 48 | command_parts.append(self._build_command_args(**params)) 49 | command = " ".join(command_parts) 50 | print( 51 | f"would run command: {command}" 52 | if self.dry_run and not force_run 53 | else f"running command: {command}" 54 | ) 55 | 56 | if self.dry_run and not force_run: 57 | return "" 58 | 59 | success, output = self._run_command(command) 60 | if not success: 61 | print(f"{cmd} failed: {output}", file=sys.stderr) 62 | raise typer.Exit(1) 63 | return output 64 | 65 | def _run_command(self, command: str) -> tuple[bool, str]: 66 | try: 67 | output = subprocess.check_output( 68 | command, shell=True, text=True, stderr=subprocess.STDOUT 69 | ).strip() 70 | return True, output 71 | except subprocess.CalledProcessError as e: 72 | return False, e.output 73 | 74 | 75 | _runner: CommandRunner | None = None 76 | 77 | 78 | def run(cmd: str, name: str, *args: str, **params: Any) -> str: 79 | if _runner is None: 80 | raise RuntimeError("CommandRunner not initialized. Call init_runner first.") 81 | return _runner.run(cmd, name, *args, **params) 82 | 83 | 84 | def init_runner(dry_run: bool = False) -> None: 85 | global _runner 86 | _runner = CommandRunner(dry_run) 87 | 88 | 89 | def get_current_version(): 90 | tags = run("git", "tag", "--sort=-creatordate", force_run=True).splitlines() 91 | return tags[0] if tags else "" 92 | 93 | 94 | def get_new_version(version: Version, tag: Tag | None = None) -> str: 95 | output = run( 96 | "bumpver", "update", dry=True, tag=tag, force_run=True, **{version: True} 97 | ) 98 | if match := re.search(r"New Version: (.+)", output): 99 | return match.group(1) 100 | return typer.prompt("Failed to get new version. Enter manually") 101 | 102 | 103 | def get_release_version() -> str: 104 | log = run( 105 | "git", 106 | "log", 107 | "-1", 108 | "--pretty=format:%s", 109 | force_run=True, 110 | ) 111 | if match := re.search(r"bump version .* -> ([\d.]+)", log): 112 | return match.group(1) 113 | print("Could not find version in latest commit message") 114 | raise typer.Exit(1) 115 | 116 | 117 | def update_changelog(new_version: str) -> None: 118 | repo_url = run("git", "remote", "get-url", "origin").strip().replace(".git", "") 119 | changelog = Path("CHANGELOG.md") 120 | content = changelog.read_text() 121 | 122 | content = re.sub( 123 | r"## \[Unreleased\]", 124 | f"## [{new_version}]", 125 | content, 126 | count=1, 127 | ) 128 | content = re.sub( 129 | rf"## \[{new_version}\]", 130 | f"## [Unreleased]\n\n## [{new_version}]", 131 | content, 132 | count=1, 133 | ) 134 | content += f"[{new_version}]: {repo_url}/releases/tag/v{new_version}\n" 135 | content = re.sub( 136 | r"\[unreleased\]: .*\n", 137 | f"[unreleased]: {repo_url}/compare/v{new_version}...HEAD\n", 138 | content, 139 | count=1, 140 | ) 141 | 142 | changelog.write_text(content) 143 | run("git", "add", ".") 144 | run("git", "commit", "-m", f"update CHANGELOG for version {new_version}") 145 | 146 | 147 | def update_uv_lock(new_version: str) -> None: 148 | run("uv", "lock") 149 | 150 | changes = run("git", "status", "--porcelain", force_run=True) 151 | if "uv.lock" not in changes: 152 | print("No changes to uv.lock, skipping commit") 153 | return 154 | 155 | run("git", "add", "uv.lock") 156 | run("git", "commit", "-m", f"update uv.lock for version {new_version}") 157 | 158 | 159 | cli = typer.Typer() 160 | 161 | 162 | class Version(str, Enum): 163 | MAJOR = "major" 164 | MINOR = "minor" 165 | PATCH = "patch" 166 | 167 | 168 | class Tag(str, Enum): 169 | DEV = "dev" 170 | ALPHA = "alpha" 171 | BETA = "beta" 172 | RC = "rc" 173 | FINAL = "final" 174 | 175 | 176 | @cli.command() 177 | def version( 178 | version: Annotated[ 179 | Version, Option("--version", "-v", help="The tag to add to the new version") 180 | ], 181 | tag: Annotated[Tag, Option("--tag", "-t", help="The tag to add to the new version")] 182 | | None = None, 183 | dry_run: Annotated[ 184 | bool, Option("--dry-run", "-d", help="Show commands without executing") 185 | ] = False, 186 | ): 187 | init_runner(dry_run) 188 | 189 | current_version = get_current_version() 190 | changes = run( 191 | "git", 192 | "log", 193 | f"{current_version}..HEAD", 194 | "--pretty=format:- `%h`: %s", 195 | "--reverse", 196 | force_run=True, 197 | ) 198 | 199 | new_version = get_new_version(version, tag) 200 | release_branch = f"release-v{new_version}" 201 | 202 | try: 203 | run("git", "checkout", "-b", release_branch) 204 | except Exception: 205 | run("git", "checkout", release_branch) 206 | 207 | run("bumpver", "update", tag=tag, **{version: True}) 208 | 209 | title = run("git", "log", "-1", "--pretty=%s") 210 | 211 | update_changelog(new_version) 212 | update_uv_lock(new_version) 213 | 214 | run("git", "push", "--set-upstream", "origin", release_branch) 215 | run( 216 | "gh", 217 | "pr", 218 | "create", 219 | "--base", 220 | "main", 221 | "--head", 222 | release_branch, 223 | "--title", 224 | title, 225 | "--body", 226 | changes, 227 | ) 228 | 229 | 230 | @cli.command() 231 | def release( 232 | dry_run: Annotated[ 233 | bool, Option("--dry-run", "-d", help="Show commands without executing") 234 | ] = False, 235 | force: Annotated[bool, Option("--force", "-f", help="Skip safety checks")] = False, 236 | ): 237 | init_runner(dry_run) 238 | 239 | current_branch = run("git", "branch", "--show-current", force_run=True).strip() 240 | if current_branch != "main" and not force: 241 | print( 242 | f"Must be on main branch to create release (currently on {current_branch})" 243 | ) 244 | raise typer.Exit(1) 245 | 246 | if run("git", "status", "--porcelain") and not force: 247 | print("Working directory is not clean. Commit or stash changes first.") 248 | raise typer.Exit(1) 249 | 250 | run("git", "fetch", "origin", "main") 251 | local_sha = run("git", "rev-parse", "@").strip() 252 | remote_sha = run("git", "rev-parse", "@{u}").strip() 253 | if local_sha != remote_sha and not force: 254 | print("Local main is not up to date with remote. Pull changes first.") 255 | raise typer.Exit(1) 256 | 257 | version = get_release_version() 258 | 259 | try: 260 | run("gh", "release", "view", f"v{version}") 261 | if not force: 262 | print(f"Release v{version} already exists!") 263 | raise typer.Exit(1) 264 | except Exception: 265 | pass 266 | 267 | if not force and not dry_run: 268 | typer.confirm(f"Create release v{version}?", abort=True) 269 | 270 | run("gh", "release", "create", f"v{version}", "--generate-notes") 271 | 272 | 273 | if __name__ == "__main__": 274 | cli() 275 | -------------------------------------------------------------------------------- /.copier/package.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v2024.18 3 | _src_path: gh:westerveltco/django-twc-package 4 | author_email: josh@joshthomas.dev 5 | author_name: Josh Thomas 6 | current_version: 0.6.0 7 | django_versions: 8 | - '3.2' 9 | - '4.2' 10 | - '5.0' 11 | docs_domain: westervelt.dev 12 | github_owner: westerveltco 13 | github_repo: django-email-relay 14 | module_name: email_relay 15 | package_description: Centralize and relay email from multiple distributed Django projects 16 | to an internal SMTP server via a database queue. 17 | package_name: django-email-relay 18 | python_versions: 19 | - '3.8' 20 | - '3.9' 21 | - '3.10' 22 | - '3.11' 23 | - '3.12' 24 | test_django_main: true 25 | versioning_scheme: SemVer 26 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !src/ 4 | !LICENSE 5 | !README.md 6 | !pyproject.toml 7 | !uv.lock 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{,.}{j,J}ustfile] 12 | indent_size = 4 13 | 14 | [*.{py,rst,ini,md}] 15 | indent_size = 4 16 | 17 | [*.py] 18 | line_length = 120 19 | multi_line_output = 3 20 | 21 | [*.{css,html,js,json,jsx,sass,scss,svelte,ts,tsx,yml,yaml}] 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [{Makefile,*.bat}] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/.env.example -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @westerveltco/oss 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | timezone: America/Chicago 8 | labels: 9 | - 🤖 dependabot 10 | groups: 11 | gha: 12 | patterns: 13 | - "*" 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | test: 9 | uses: ./.github/workflows/test.yml 10 | secrets: inherit 11 | 12 | pypi: 13 | runs-on: ubuntu-latest 14 | needs: test 15 | environment: release 16 | permissions: 17 | contents: write 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | enable-cache: true 26 | pyproject-file: pyproject.toml 27 | 28 | - name: Build package 29 | run: | 30 | uv build 31 | 32 | - name: Upload release assets to GitHub 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: | 36 | gh release upload ${{ github.event.release.tag_name }} ./dist/* 37 | 38 | - name: Publish to PyPI 39 | run: | 40 | uv publish 41 | 42 | docker: 43 | runs-on: ubuntu-latest 44 | needs: test 45 | environment: release 46 | permissions: 47 | contents: write 48 | packages: write 49 | steps: 50 | - uses: actions/checkout@v4 51 | 52 | - name: Docker meta 53 | id: meta 54 | uses: docker/metadata-action@v5 55 | with: 56 | images: | 57 | ghcr.io/${{ github.repository }} 58 | tags: | 59 | type=ref,event=branch 60 | type=ref,event=tag 61 | type=ref,event=pr 62 | type=pep440,pattern={{version}} 63 | type=pep440,pattern={{major}}.{{minor}} 64 | type=pep440,pattern={{major}} 65 | type=sha,prefix=sha- 66 | type=raw,value=latest,enable={{is_default_branch}} 67 | 68 | - name: Set up QEMU 69 | uses: docker/setup-qemu-action@v3 70 | 71 | - name: Set up Docker Buildx 72 | uses: docker/setup-buildx-action@v3 73 | 74 | - name: Login to GitHub Container Registry 75 | uses: docker/login-action@v3 76 | with: 77 | registry: ghcr.io 78 | username: ${{ github.repository_owner }} 79 | password: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | - name: Build and publish Docker image 82 | uses: docker/build-push-action@v6 83 | with: 84 | context: . 85 | push: true 86 | tags: ${{ steps.meta.outputs.tags }} 87 | labels: ${{ steps.meta.outputs.labels }} 88 | cache-from: type=gha 89 | cache-to: type=gha,mode=max 90 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | 17 | jobs: 18 | generate-matrix: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | matrix: ${{ steps.set-matrix.outputs.matrix }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Install uv 26 | uses: astral-sh/setup-uv@v6 27 | with: 28 | enable-cache: true 29 | pyproject-file: pyproject.toml 30 | 31 | - id: set-matrix 32 | run: | 33 | uv run nox --session "gha_matrix" 34 | 35 | test: 36 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 37 | runs-on: ubuntu-latest 38 | needs: generate-matrix 39 | strategy: 40 | fail-fast: false 41 | matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Install uv 46 | uses: astral-sh/setup-uv@v6 47 | with: 48 | enable-cache: true 49 | pyproject-file: pyproject.toml 50 | 51 | - name: Run tests 52 | run: | 53 | uv run nox --session "tests(python='${{ matrix.python-version }}', django='${{ matrix.django-version }}')" 54 | 55 | tests: 56 | runs-on: ubuntu-latest 57 | needs: test 58 | if: always() 59 | steps: 60 | - name: OK 61 | if: ${{ !(contains(needs.*.result, 'failure')) }} 62 | run: exit 0 63 | - name: Fail 64 | if: ${{ contains(needs.*.result, 'failure') }} 65 | run: exit 1 66 | 67 | types: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - name: Install uv 73 | uses: astral-sh/setup-uv@v6 74 | with: 75 | enable-cache: true 76 | pyproject-file: pyproject.toml 77 | 78 | - name: Run type checks 79 | run: | 80 | uv run nox --session "mypy" 81 | 82 | coverage: 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - name: Install uv 88 | uses: astral-sh/setup-uv@v6 89 | with: 90 | enable-cache: true 91 | pyproject-file: pyproject.toml 92 | 93 | - name: Generate code coverage 94 | run: | 95 | uv run nox --session "coverage" 96 | 97 | docker: 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v4 101 | 102 | - name: Set up Docker Buildx 103 | uses: docker/setup-buildx-action@v3 104 | 105 | - name: Build Docker image 106 | uses: docker/build-push-action@v6 107 | with: 108 | context: . 109 | load: true 110 | tags: django-email-relay-test:latest 111 | push: false 112 | cache-from: type=gha 113 | cache-to: type=gha,mode=max 114 | 115 | - name: Run container and check 116 | run: | 117 | docker run --rm --name test-container django-email-relay-test:latest uv run -m email_relay.service --help 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Logs 163 | logs 164 | *.log 165 | npm-debug.log* 166 | yarn-debug.log* 167 | yarn-error.log* 168 | pnpm-debug.log* 169 | lerna-debug.log* 170 | 171 | node_modules 172 | dist 173 | dist-ssr 174 | *.local 175 | 176 | # Editor directories and files 177 | .vscode/* 178 | !.vscode/extensions.json 179 | !.vscode/*.example 180 | .idea 181 | .DS_Store 182 | *.suo 183 | *.ntvs* 184 | *.njsproj 185 | *.sln 186 | *.sw? 187 | 188 | staticfiles/ 189 | mediafiles/ 190 | 191 | # pyright config for nvim-lspconfig 192 | pyrightconfig.json 193 | 194 | # mise 195 | .mise*.toml 196 | -------------------------------------------------------------------------------- /.just/copier.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/copier.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Create a copier answers file 14 | [no-cd] 15 | copy TEMPLATE_PATH DESTINATION_PATH=".": 16 | uv run copier copy --trust {{ TEMPLATE_PATH }} {{ DESTINATION_PATH }} 17 | 18 | # Recopy the project from the original template 19 | [no-cd] 20 | recopy ANSWERS_FILE *ARGS: 21 | uv run copier recopy --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 22 | 23 | # Loop through all answers files and recopy the project using copier 24 | [no-cd] 25 | @recopy-all *ARGS: 26 | for file in `ls .copier/`; do just copier recopy .copier/$file "{{ ARGS }}"; done 27 | 28 | # Update the project using a copier answers file 29 | [no-cd] 30 | update ANSWERS_FILE *ARGS: 31 | uv run copier update --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 32 | 33 | # Loop through all answers files and update the project using copier 34 | [no-cd] 35 | @update-all *ARGS: 36 | for file in `ls .copier/`; do just copier update .copier/$file "{{ ARGS }}"; done 37 | -------------------------------------------------------------------------------- /.just/docker.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/docker.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | [no-cd] 14 | build: 15 | docker build --tag docker-email-relay:local . 16 | 17 | [no-cd] 18 | run *ARGS: build 19 | docker run --rm docker-email-relay:local {{ ARGS }} 20 | 21 | [no-cd] 22 | smoke: 23 | @just docker run uv run -m email_relay.service --help 24 | -------------------------------------------------------------------------------- /.just/documentation.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/documentation.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Build documentation using Sphinx 14 | [no-cd] 15 | build LOCATION="docs/_build/html": cog 16 | uv run --group docs sphinx-build docs {{ LOCATION }} 17 | 18 | # Serve documentation locally 19 | [no-cd] 20 | serve PORT="8000": cog 21 | #!/usr/bin/env sh 22 | HOST="localhost" 23 | if [ -f "/.dockerenv" ]; then 24 | HOST="0.0.0.0" 25 | fi 26 | uv run --group docs sphinx-autobuild docs docs/_build/html --host "$HOST" --port {{ PORT }} 27 | 28 | [no-cd] 29 | [private] 30 | cog: 31 | uv run --with cogapp cog -r CONTRIBUTING.md docs/development/just.md 32 | -------------------------------------------------------------------------------- /.just/project.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/project.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | [no-cd] 14 | @bump *ARGS: 15 | {{ justfile_directory() }}/.bin/bump.py version {{ ARGS }} 16 | 17 | [no-cd] 18 | @release *ARGS: 19 | {{ justfile_directory() }}/.bin/bump.py release {{ ARGS }} 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.13 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-toml 11 | - id: check-yaml 12 | 13 | - repo: https://github.com/adamchainz/django-upgrade 14 | rev: 1.25.0 15 | hooks: 16 | - id: django-upgrade 17 | args: [--target-version, "4.2"] 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.11.12 21 | hooks: 22 | - id: ruff 23 | args: [--fix] 24 | - id: ruff-format 25 | 26 | - repo: https://github.com/adamchainz/blacken-docs 27 | rev: 1.19.1 28 | hooks: 29 | - id: blacken-docs 30 | alias: autoformat 31 | additional_dependencies: 32 | - black==22.12.0 33 | 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v4.0.0-alpha.8 36 | hooks: 37 | - id: prettier 38 | # lint the following with prettier: 39 | # - javascript 40 | # - typescript 41 | # - JSX/TSX 42 | # - CSS 43 | # - yaml 44 | # ignore any minified code 45 | files: '^(?!.*\.min\..*)(?P[\w-]+(\.[\w-]+)*\.(js|jsx|ts|tsx|yml|yaml|css))$' 46 | 47 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 48 | rev: v2.14.0 49 | hooks: 50 | - id: pretty-format-toml 51 | args: [--autofix] 52 | 53 | - repo: https://github.com/abravalheri/validate-pyproject 54 | rev: v0.24.1 55 | hooks: 56 | - id: validate-pyproject 57 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.13" 10 | commands: 11 | - asdf plugin add just && asdf install just latest && asdf global just latest 12 | - asdf plugin add uv && asdf install uv latest && asdf global uv latest 13 | - just docs cog 14 | - uv run --group docs sphinx-build docs $READTHEDOCS_OUTPUT/html 15 | 16 | sphinx: 17 | configuration: docs/conf.py 18 | 19 | formats: 20 | - pdf 21 | - epub 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "monosans.djlint", 7 | "ms-python.black-formatter", 8 | "ms-python.pylint", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance", 11 | "skellock.just", 12 | "tamasfe.even-better-toml" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.associations": { 4 | "Justfile": "just" 5 | }, 6 | "ruff.organizeImports": true, 7 | "[django-html][handlebars][hbs][mustache][jinja][jinja-html][nj][njk][nunjucks][twig]": { 8 | "editor.defaultFormatter": "monosans.djlint" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | - Josh Thomas 4 | - Jeff Triplett [@jefftriplett](https://github.com/jefftriplett) 5 | 6 | ## Original Authors 7 | 8 | This project is inspired by [`django-mailer`](https://github.com/pinax/django-mailer). 9 | 10 | The authors of `django-mailer` at the time of this project's creation are listed at [pinax/django-mailer/AUTHORS](https://github.com/pinax/django-mailer/blob/15433786534d5d7d44f1d55b15a601d5d01bab42/AUTHORS). 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project attempts to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 18 | 19 | ## [Unreleased] 20 | 21 | ## [0.6.0] 22 | 23 | ### Added 24 | 25 | - Added support for Python 3.13. 26 | 27 | ### Changed 28 | 29 | - Now using `uv` for project management. 30 | - Email relay `Dockerfile` service reorganized to take advantage of `uv` and it's features. 31 | - Email relay service standalone package has been removed and moved to a module within the `email_relay` package. 32 | 33 | ### Removed 34 | 35 | - Dropped support for Django 5.0. 36 | - `[dev]`, `[docs]`, and `[lint]` extras have been removed and migrated to `[dependency-groups]`. 37 | 38 | ### Fixed 39 | 40 | - In the email relay service, any `Exception` beyond SMTP transport errors now fails the message instead of raising the `Exception`. This allows the relay service to stay operational and sending messages that are OK, while any messages that are causing errors will be failed immediately. 41 | 42 | ## [0.5.0] 43 | 44 | ### Added 45 | 46 | - Added support for Django 5.1 and 5.2. 47 | 48 | ### Changed 49 | 50 | - Now using v2024.18 of `django-twc-package`. 51 | 52 | ### Removed 53 | 54 | - Dropped support for Python 3.8. 55 | - Dropped support for Django 3.2. 56 | 57 | ## [0.4.3] 58 | 59 | ### Fixed 60 | 61 | - Added all documentation pages back to `toctree`. 62 | - Added back missing copyright information from `django-mailer`. Sorry to all the maintainers and contributors of that package! It was a major oversight. 63 | - Added back all missing extras: `hc`, `psycopg`, and `relay`. 64 | 65 | ## [0.4.2] 66 | 67 | ### Fixed 68 | 69 | - Added `LICENSE` to `Dockerfile`. 70 | 71 | ## [0.4.1] 72 | 73 | ### Fixed 74 | 75 | - Added back Docker publishing to `release.yml` GitHub Actions workflow. 76 | 77 | ## [0.4.0] 78 | 79 | ### Added 80 | 81 | - A `_email_relay_version` field to `RelayEmailData` to track the version of the schema used to serialize the data. This should allow for future changes to the schema to be handled more gracefully. 82 | 83 | ### Changed 84 | 85 | - Now using [`django-twc-package`](https://github.com/westerveltco/django-twc-package) template for repository and package structure. 86 | - This includes using `uv` internally for managing Python dependencies. 87 | 88 | ### Fixed 89 | 90 | - Resolved a type mismatch error in from_email_message method when encoding attachments to base64. Added type checking to confirm that the payload extracted from a MIMEBase object is of type bytes before passing it to base64.b64encode. 91 | 92 | ## [0.3.0] 93 | 94 | ### Added 95 | 96 | - Support for Django 5.0. 97 | 98 | ### Removed 99 | 100 | - Support for Django 4.1. 101 | 102 | ## [0.2.1] 103 | 104 | ### Fixed 105 | 106 | - Migration 0002 was not being applied to the `default` database, which is the norm when running the relay in Docker. 107 | 108 | ## [0.2.0] - **YANKED** 109 | 110 | This release has been yanked from PyPI due to a bug in the migration that was not caught. 111 | 112 | **This release involves migrations.** Please read below for more information. 113 | 114 | ### Added 115 | 116 | - A `RelayEmailData` dataclass for representing the `Message.data` field. 117 | - Documentation in the package's deprecation policy about the road to 1.0.0. 118 | - Complete test coverage for all of the public ways of sending emails that Django provides. 119 | 120 | ### Changed 121 | 122 | - **Breaking**: The internal JSON schema for the `Message.data` field has been updated to bring it more in line with all of the possible fields provided by Django's `EmailMessage` and `EmailMultiAlternatives`. This change involves a migration to update the `Message.data` field to the new schema. This is a one-way update and cannot be rolled back. Please take care when updating to this version and ensure that all projects using `django-email-relay` are updated at the same time. See the [updating](https://django-email-relay.westervelt.dev/en/latest/updating.html) documentation for more information. 123 | - The internal `AppSettings` dataclass is now a `frozen=True` dataclass. 124 | 125 | ## [0.1.1] 126 | 127 | ### Added 128 | 129 | - Moved a handful of common `Message` queries and actions from the `runrelay` management command to methods on the `MessageManager` class. 130 | 131 | ### Fixed 132 | 133 | - The relay service would crash if requests raised an `Exception` during the healthcheck ping. Now it will log the exception and continue processing the queue. 134 | 135 | ## [0.1.0] 136 | 137 | Initial release! 138 | 139 | ### Added 140 | 141 | - An email backend that stores emails in a database ala a Message model rather than sending them via SMTP or other means. 142 | - Designed to work seamlessly with Django's built-in ways of sending emails. 143 | - A database backend that routes writes to the Message model to a separate database. 144 | - A Message model that stores the contents of an email. 145 | - The Message model stores the contents of the email as a JSONField. 146 | - Attachments are stored in the database, under the 'attachments' key in the JSONField. 147 | - Should be able to handle anything that Django can by default. 148 | - A relay service intended to be run separately, either as a standalone Docker image or as a management command within a Django project. 149 | - A Docker image is provided for the relay service. Currently only PostgreSQL is supported as a database backend. 150 | - A management command is provided for the relay service. Any database backend supported by Django should work (minus SQLite which doesn't make sense for a relay service). 151 | - The relay service can be configured with a healthcheck url to ensure it is running. 152 | - Initial documentation. 153 | - Initial tests. 154 | - Initial CI/CD (GitHub Actions). 155 | 156 | ### New Contributors 157 | 158 | - Josh Thomas (maintainer) 159 | - Jeff Triplett [@jefftriplett](https://github.com/jefftriplett) 160 | 161 | ### Thanks ❤️ 162 | 163 | Big thank you to the original authors of [`django-mailer`](https://github.com/pinax/django-mailer) for the inspiration and for doing the hard work of figuring out a good way of queueing emails in a database in the first place. 164 | 165 | [unreleased]: https://github.com/westerveltco/django-email-relay/compare/v0.6.0...HEAD 166 | [0.1.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.1.0 167 | [0.1.1]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.1.1 168 | [0.2.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.2.0 169 | [0.2.1]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.2.1 170 | [0.3.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.3.0 171 | [0.4.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.4.0 172 | [0.4.1]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.4.1 173 | [0.4.2]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.4.2 174 | [0.4.3]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.4.3 175 | [0.5.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.5.0 176 | [0.6.0]: https://github.com/westerveltco/django-email-relay/releases/tag/v0.6.0 177 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests. 4 | 5 | You should first check if there is a [GitHub issue](https://github.com/westerveltco/django-email-relay/issues) already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution. 6 | 7 | Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first. 8 | 9 | We adhere to Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the [Code of Conduct](https://www.djangoproject.com/conduct/) before contributing. 10 | 11 | ## Requirements 12 | 13 | - [uv](https://github.com/astral-sh/uv) - Modern Python toolchain that handles: 14 | - Python version management and installation 15 | - Virtual environment creation and management 16 | - Fast, reliable dependency resolution and installation 17 | - Reproducible builds via lockfile 18 | - [direnv](https://github.com/direnv/direnv) (Optional) - Automatic environment variable loading 19 | - [just](https://github.com/casey/just) (Optional) - Command runner for development tasks 20 | 21 | ### `Justfile` 22 | 23 | The repository includes a `Justfile` that provides all common development tasks with a consistent interface. Running `just` without arguments shows all available commands and their descriptions. 24 | 25 | 47 | 48 | 49 | All commands below will contain the full command as well as its `just` counterpart. 50 | 51 | ## Setup 52 | 53 | The following instructions will use `uv` and assume a Unix-like operating system (Linux or macOS). 54 | 55 | Windows users will need to adjust commands accordingly, though the core workflow remains the same. 56 | 57 | Alternatively, any Python package manager that supports installing from `pyproject.toml` ([PEP 621](https://peps.python.org/pep-0621/)) can be used. If not using `uv`, ensure you have Python installed from [python.org](https://www.python.org/) or another source such as [`pyenv`](https://github.com/pyenv/pyenv). 58 | 59 | 1. Fork the repository and clone it locally. 60 | 61 | 2. Use `uv` to bootstrap your development environment. 62 | 63 | ```bash 64 | uv python install 65 | uv sync --locked 66 | # just bootstrap 67 | ``` 68 | 69 | This will install the correct Python version, create and configure a virtual environment, and install all dependencies. 70 | 71 | ## Tests 72 | 73 | The project uses [`pytest`](https://docs.pytest.org/) for testing and [`nox`](https://nox.thea.codes/) to run the tests in multiple environments. 74 | 75 | To run the test suite against the default versions of Python (lower bound of supported versions) and Django (lower bound of LTS versions): 76 | 77 | ```bash 78 | uv run nox --session test 79 | # just test 80 | ``` 81 | 82 | To run the test suite against the entire matrix of supported versions of Python and Django: 83 | 84 | ```bash 85 | uv run nox --session tests 86 | # just testall 87 | ``` 88 | 89 | Both can be passed additional arguments that will be provided to `pytest`. 90 | 91 | ```bash 92 | uv run nox --session test -- -v --last-failed 93 | uv run nox --session tests -- --failed-first --maxfail=1 94 | # just test -v --last-failed 95 | # just testall --failed-first --maxfail=1 96 | ``` 97 | 98 | ### Coverage 99 | 100 | The project uses [`coverage.py`](https://github.com/nedbat/coverage.py) to measure code coverage and aims to maintain 100% coverage across the codebase. 101 | 102 | To run the test suite and measure code coverage: 103 | 104 | ```bash 105 | uv run nox --session coverage 106 | # just coverage 107 | ``` 108 | 109 | All pull requests must include tests to maintain 100% coverage. Coverage configuration can be found in the `[tools.coverage.*]` sections of [`pyproject.toml`](pyproject.toml). 110 | 111 | ## Linting and Formatting 112 | 113 | This project enforces code quality standards using [`pre-commit`](https://github.com/pre-commit/pre-commit). 114 | 115 | To run all formatters and linters: 116 | 117 | ```bash 118 | uv run nox --session lint 119 | # just lint 120 | ``` 121 | 122 | The following checks are run: 123 | 124 | - [ruff](https://github.com/astral-sh/ruff) - Fast Python linter and formatter 125 | - Code formatting for Python files in documentation ([blacken-docs](https://github.com/adamchainz/blacken-docs)) 126 | - Django compatibility checks ([django-upgrade](https://github.com/adamchainz/django-upgrade)) 127 | - TOML and YAML validation 128 | - Basic file hygiene (trailing whitespace, file endings) 129 | 130 | To enable pre-commit hooks after cloning: 131 | 132 | ```bash 133 | uv run --with pre-commit pre-commit install 134 | ``` 135 | 136 | Configuration for these tools can be found in: 137 | 138 | - [`.pre-commit-config.yaml`](.pre-commit-config.yaml) - Pre-commit hook configuration 139 | - [`pyproject.toml`](pyproject.toml) - Ruff and other tool settings 140 | 141 | ## Continuous Integration 142 | 143 | This project uses GitHub Actions for CI/CD. The workflows can be found in [`.github/workflows/`](.github/workflows/). 144 | 145 | - [`test.yml`](.github/workflows/test.yml) - Runs on pushes to the `main` branch and on all PRs 146 | - Tests across Python/Django version matrix 147 | - Static type checking 148 | - Coverage reporting 149 | - [`release.yml`](.github/workflows/release.yml) - Runs on GitHub release creation 150 | - Runs the [`test.yml`](.github/workflows/test.yml) workflow 151 | - Builds package 152 | - Publishes to PyPI 153 | 154 | PRs must pass all CI checks before being merged. 155 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.13 2 | ARG UID=1000 3 | ARG GID=1000 4 | 5 | FROM python:${PYTHON_VERSION}-slim AS base 6 | ENV PYTHONDONTWRITEBYTECODE=1 7 | ENV PYTHONUNBUFFERED=1 8 | ENV UV_LINK_MODE=copy 9 | ENV UV_COMPILE_BYTECODE=1 10 | ENV UV_PYTHON_DOWNLOADS=never 11 | ENV UV_PYTHON=python${PYTHON_VERSION} 12 | ENV UV_PROJECT_ENVIRONMENT=/app 13 | ENV PATH=/app/bin:$PATH 14 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv 15 | 16 | 17 | FROM base AS builder 18 | RUN --mount=type=cache,target=/root/.cache \ 19 | --mount=type=bind,source=uv.lock,target=uv.lock \ 20 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 21 | uv sync --locked --no-dev --no-install-project 22 | COPY . /src 23 | WORKDIR /src 24 | RUN --mount=type=cache,target=/root/.cache \ 25 | uv sync --locked --no-dev --no-editable --extra hc --extra psycopg --extra relay 26 | 27 | 28 | FROM base AS final 29 | ARG UID 30 | ARG GID 31 | RUN mkdir -p /app 32 | COPY --from=builder /app /app 33 | RUN addgroup -gid "${GID}" --system django \ 34 | && adduser -uid "${UID}" -gid "${GID}" --home /home/django --system django \ 35 | && chown -R django:django /app 36 | USER django 37 | WORKDIR /app 38 | CMD ["uv", "run", "-m", "email_relay.service"] 39 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | set unstable := true 3 | 4 | mod copier ".just/copier.just" 5 | mod docker ".just/docker.just" 6 | mod docs ".just/documentation.just" 7 | mod project ".just/project.just" 8 | 9 | [private] 10 | default: 11 | @just --list --list-submodules 12 | 13 | [private] 14 | diff SHA="HEAD": 15 | #!/usr/bin/env bash 16 | LATEST_TAG=$(git describe --tags --abbrev=0) 17 | GIT_PAGER=cat git diff "$LATEST_TAG"..{{ SHA }} src/ 18 | 19 | [private] 20 | fmt: 21 | @just --fmt 22 | @just copier fmt 23 | @just docker fmt 24 | @just docs fmt 25 | @just project fmt 26 | 27 | [private] 28 | nox SESSION *ARGS: 29 | uv run nox --session "{{ SESSION }}" -- "{{ ARGS }}" 30 | 31 | bootstrap: 32 | uv sync --locked --extra hc --extra psycopg --extra relay 33 | 34 | coverage *ARGS: 35 | @just nox coverage {{ ARGS }} 36 | 37 | lint: 38 | @just nox lint 39 | 40 | lock *ARGS: 41 | uv lock {{ ARGS }} 42 | 43 | manage *COMMAND: 44 | #!/usr/bin/env python 45 | import sys 46 | 47 | try: 48 | from django.conf import settings 49 | from django.core.management import execute_from_command_line 50 | except ImportError as exc: 51 | raise ImportError( 52 | "Couldn't import Django. Are you sure it's installed and " 53 | "available on your PYTHONPATH environment variable? Did you " 54 | "forget to activate a virtual environment?" 55 | ) from exc 56 | 57 | settings.configure(INSTALLED_APPS=["email_relay"]) 58 | execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 59 | 60 | mypy *ARGS: 61 | @just nox mypy {{ ARGS }} 62 | 63 | test *ARGS: 64 | @just nox test {{ ARGS }} 65 | 66 | testall *ARGS: 67 | @just nox tests {{ ARGS }} 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Josh Thomas 4 | Copyright (c) 2009-2014 James Tauber and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # django-email-relay 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/django-email-relay)](https://pypi.org/project/django-email-relay/) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-email-relay) 6 | ![Django Version](https://img.shields.io/badge/django-3.2%20%7C%204.2%20%7C%205.0-%2344B78B?labelColor=%23092E20) 7 | 8 | 9 | 10 | 11 | `django-email-relay` enables Django projects without direct access to a preferred SMTP server to use that server for email dispatch. 12 | 13 | It consists of two parts: 14 | 15 | 1. A Django app with a custom email backend that stores emails in a central database queue. This is what you will use on all the distributed Django projects that you would like to give access to the preferred SMTP server. 16 | 17 | 2. A relay service that reads from this queue to orchestrate email sending. It is available as either a standalone Docker image or a management command to be used within a Django project that does have access to the preferred SMTP server. 18 | 19 | 20 | ## Requirements 21 | 22 | - Python 3.9, 3.10, 3.11, 3.12, 3.13 23 | - Django 4.2, 5.1, 5.2 24 | 25 | ## Getting Started 26 | 27 | Visit the [documentation](https://django-email-relay.westervelt.dev/) for more information. There you will find: 28 | 29 | - [Why](https://django-email-relay.westervelt.dev/en/latest/why.html) we created this package and how it can help you. 30 | - How to [install](https://django-email-relay.westervelt.dev/en/latest/installation/) and [configure](https://django-email-relay.westervelt.dev/en/latest/configuration/) the relay service and Django app. 31 | - How to [use](https://django-email-relay.westervelt.dev/en/latest/usage/) the Django app to send emails. 32 | - Things to be aware of when it comes time to [update](https://django-email-relay.westervelt.dev/en/latest/updating.html) the package. 33 | - How you can [contribute](https://django-email-relay.westervelt.dev/en/latest/contributing/) to the package. 34 | 35 | ## Inspiration 36 | 37 | This package is heavily inspired by the [`django-mailer`](https://github.com/pinax/django-mailer) package. `django-mailer` is licensed under the MIT license, which is also the license used for this package. The required copyright notice is included in the [`LICENSE`](LICENSE) file for this package. 38 | 39 | ## License 40 | 41 | `django-email-relay` is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. 42 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing a New Version 2 | 3 | When it comes time to cut a new release, follow these steps: 4 | 5 | 1. Create a new git branch off of `main` for the release. 6 | 7 | Prefer the convention `release-`, where `` is the next incremental version number (e.g. `release-v0.3.0` for version 0.3.0). 8 | 9 | ```shell 10 | git checkout -b release-v 11 | ``` 12 | 13 | However, the branch name is not *super* important, as long as it is not `main`. 14 | 15 | 2. Update the version number across the project using the `bumpver` tool. See [this section](#choosing-the-next-version-number) for more details about choosing the correct version number. 16 | 17 | The `pyproject.toml` in the base of the repository contains a `[tool.bumpver]` section that configures the `bumpver` tool to update the version number wherever it needs to be updated and to create a commit with the appropriate commit message. 18 | 19 | `bumpver` is included as a development dependency, so you should already have it installed if you have installed the development dependencies for this project. If you do not have the development dependencies installed, you can install them with either of the following commands: 20 | 21 | ```shell 22 | python -m pip install --editable '.[dev]' 23 | # or using [just](CONTRIBUTING.md#just) 24 | just bootstrap 25 | ``` 26 | 27 | Then, run `bumpver` to update the version number, with the appropriate command line arguments. See the [`bumpver` documentation](https://github.com/mbarkhau/bumpver) for more details. 28 | 29 | **Note**: For any of the following commands, you can add the command line flag `--dry` to preview the changes without actually making the changes. 30 | 31 | Here are the most common commands you will need to run: 32 | 33 | ```shell 34 | bumpver update --patch # for a patch release 35 | bumpver update --minor # for a minor release 36 | bumpver update --major # for a major release 37 | ``` 38 | 39 | To release a tagged version, such as a beta or release candidate, you can run: 40 | 41 | ```shell 42 | bumpver update --tag=beta 43 | # or 44 | bumpver update --tag=rc 45 | ``` 46 | 47 | Running these commands on a tagged version will increment the tag appropriately, but will not increment the version number. 48 | 49 | To go from a tagged release to a full release, you can run: 50 | 51 | ```shell 52 | bumpver update --tag=final 53 | ``` 54 | 55 | 3. Ensure the [CHANGELOG](CHANGELOG.md) is up to date. If updates are needed, add them now in the release branch. 56 | 57 | 4. Create a pull request from the release branch to `main`. 58 | 59 | 5. Once CI has passed and all the checks are green ✅, merge the pull request. 60 | 61 | 6. Draft a [new release](https://github.com/westerveltco/django-email-relay/releases/new) on GitHub. 62 | 63 | Use the version number with a leading `v` as the tag name (e.g. `v0.3.0`). 64 | 65 | Allow GitHub to generate the release title and release notes, using the 'Generate release notes' button above the text box. If this is a final release coming from a tagged release (or multiple tagged releases), make sure to copy the release notes from the previous tagged release(s) to the new release notes (after the release notes already generated for this final release). 66 | 67 | If this is a tagged release, make sure to check the 'Set as a pre-release' checkbox. 68 | 69 | 7. Once you are satisfied with the release, publish the release. As part of the publication process, GitHub Actions will automatically publish the new version of the package to PyPI. 70 | 71 | ## Choosing the Next Version Number 72 | 73 | We try our best to adhere to [Semantic Versioning](https://semver.org/), but we do not promise to follow it perfectly (and let's be honest, this is the case with a lot of projects using SemVer). 74 | 75 | In general, use your best judgement when choosing the next version number. If you are unsure, you can always ask for a second opinion from another contributor. 76 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | // no idea if this will screw a lot up with the furo theme 2 | // but this does fix the line of badges in the README. only 3 | // one of them has a link which furo makes the vertical-align 4 | // different than just a standard img 5 | p a.reference img { 6 | vertical-align: inherit; 7 | } 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import sys 11 | 12 | # import django 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Django setup ----------------------------------------------------------- 24 | # This is required to import Django code in Sphinx using autodoc. 25 | 26 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 27 | # django.setup() 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "django-email-relay" 33 | copyright = "2023, Josh Thomas" 34 | author = "Josh Thomas" 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 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 | "myst_parser", 44 | "sphinx_copybutton", 45 | "sphinx_inline_tabs", 46 | "sphinx.ext.autodoc", 47 | "sphinx.ext.napoleon", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = [] 57 | 58 | 59 | # -- MyST configuration ------------------------------------------------------ 60 | myst_heading_anchors = 3 61 | 62 | copybutton_selector = "div.copy pre" 63 | copybutton_prompt_text = "$ " 64 | 65 | 66 | # -- Options for HTML output ------------------------------------------------- 67 | 68 | # The theme to use for HTML and HTML Help pages. See the documentation for 69 | # a list of builtin themes. 70 | # 71 | html_theme = "furo" 72 | 73 | # Add any paths that contain custom static files (such as style sheets) here, 74 | # relative to this directory. They are copied after the builtin static files, 75 | # so a file named "default.css" will overwrite the builtin "default.css". 76 | html_static_path = ["_static"] 77 | 78 | html_css_files = [ 79 | "css/custom.css", 80 | ] 81 | 82 | html_title = project 83 | 84 | html_theme_options = { 85 | "footer_icons": [ 86 | { 87 | "name": "GitHub", 88 | "url": "https://github.com/westerveltco/django-email-relay", 89 | "html": """ 90 | 91 | 92 | 93 | """, 94 | "class": "", 95 | }, 96 | ], 97 | } 98 | 99 | html_sidebars = { 100 | "**": [ 101 | "sidebar/brand.html", 102 | "sidebar/search.html", 103 | "sidebar/scroll-start.html", 104 | "sidebar/navigation.html", 105 | "sidebar/scroll-end.html", 106 | "sidebar/variant-selector.html", 107 | ] 108 | } 109 | -------------------------------------------------------------------------------- /docs/configuration/index.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configuration of `django-email-relay` is done through the `DJANGO_EMAIL_RELAY` dictionary in your Django settings. 4 | 5 | Depending on whether you are configuring the relay service or the Django app, different settings are available. Some settings are available for both, while others are only available for one or the other. If you configure a setting that does not apply, for instance, if you configure something related to the relay service from one of the distributed Django apps, it will have no effect. See the individual sections for each setting below for more information. 6 | 7 | All settings are optional. Here is an example configuration with the default values shown: 8 | 9 | ```python 10 | DJANGO_EMAIL_RELAY = { 11 | "DATABASE_ALIAS": email_relay.conf.EMAIL_RELAY_DATABASE_ALIAS, # "email_relay_db" 12 | "EMAIL_MAX_BATCH": None, 13 | "EMAIL_MAX_DEFERRED": None, 14 | "EMAIL_MAX_RETRIES": None, 15 | "EMPTY_QUEUE_SLEEP": 30, 16 | "EMAIL_THROTTLE": 0, 17 | "MESSAGES_BATCH_SIZE": None, 18 | "MESSAGES_RETENTION_SECONDS": None, 19 | "RELAY_HEALTHCHECK_METHOD": "GET", 20 | "RELAY_HEALTHCHECK_STATUS_CODE": 200, 21 | "RELAY_HEALTHCHECK_TIMEOUT": 5.0, 22 | "RELAY_HEALTHCHECK_URL": None, 23 | } 24 | ``` 25 | 26 | ```{toctree} 27 | :hidden: 28 | 29 | relay-service 30 | ``` 31 | 32 | ## `DATABASE_ALIAS` 33 | 34 | ```{table} 35 | :align: left 36 | 37 | | Component | Configurable | 38 | |---------------|--------------| 39 | | Relay Service | Yes ✅ | 40 | | Django App | Yes ✅ | 41 | ``` 42 | 43 | The database alias to use for the email relay database. This must match the database alias used in your `DATABASES` setting. A default is provided at `email_relay.conf.EMAIL_RELAY_DATABASE_ALIAS`. You should only need to set this if you are using a different database alias. 44 | 45 | ## `EMAIL_MAX_BATCH` 46 | 47 | ```{table} 48 | :align: left 49 | 50 | | Component | Configurable | 51 | |---------------|--------------| 52 | | Relay Service | Yes ✅ | 53 | | Django App | No 🚫 | 54 | ``` 55 | 56 | The maximum number of emails to send in a single batch. The default is `None`, which means there is no limit. 57 | 58 | ## `EMAIL_MAX_DEFERRED` 59 | 60 | ```{table} 61 | :align: left 62 | 63 | | Component | Configurable | 64 | |---------------|--------------| 65 | | Relay Service | Yes ✅ | 66 | | Django App | No 🚫 | 67 | ``` 68 | 69 | The maximum number of emails that can be deferred before the relay service stops sending emails. The default is `None`, which means there is no limit. 70 | 71 | ## `EMAIL_MAX_RETRIES` 72 | 73 | ```{table} 74 | :align: left 75 | 76 | | Component | Configurable | 77 | |---------------|--------------| 78 | | Relay Service | Yes ✅ | 79 | | Django App | No 🚫 | 80 | ``` 81 | 82 | The maximum number of times an email can be deferred before being marked as failed. The default is `None`, which means there is no limit. 83 | 84 | ## `EMPTY_QUEUE_SLEEP` 85 | 86 | ```{table} 87 | :align: left 88 | 89 | | Component | Configurable | 90 | |---------------|--------------| 91 | | Relay Service | Yes ✅ | 92 | | Django App | No 🚫 | 93 | ``` 94 | 95 | The time in seconds to wait before checking the queue for new emails to send. The default is `30` seconds. 96 | 97 | ## `EMAIL_THROTTLE` 98 | 99 | ```{table} 100 | :align: left 101 | 102 | | Component | Configurable | 103 | |---------------|--------------| 104 | | Relay Service | Yes ✅ | 105 | | Django App | No 🚫 | 106 | ``` 107 | 108 | The time in seconds to sleep between sending emails to avoid potential rate limits or overloading your SMTP server. The default is `0` seconds. 109 | 110 | ## `MESSAGES_BATCH_SIZE` 111 | 112 | ```{table} 113 | :align: left 114 | 115 | | Component | Configurable | 116 | |---------------|--------------| 117 | | Relay Service | No 🚫 | 118 | | Django App | Yes ✅ | 119 | ``` 120 | 121 | The batch size to use when bulk creating `Messages` in the database. The default is `None`, which means Django's default batch size will be used. 122 | 123 | ## `MESSAGES_RETENTION_SECONDS` 124 | 125 | ```{table} 126 | :align: left 127 | 128 | | Component | Configurable | 129 | |---------------|--------------| 130 | | Relay Service | Yes ✅ | 131 | | Django App | No 🚫 | 132 | ``` 133 | 134 | The time in seconds to keep `Messages` in the database before deleting them. `None` means the messages will be kept indefinitely, `0` means no messages will be kept, and any other integer value will be the number of seconds to keep messages. The default is `None`. 135 | 136 | ## `RELAY_HEALTHCHECK_METHOD` 137 | 138 | ```{table} 139 | :align: left 140 | 141 | | Component | Configurable | 142 | |---------------|--------------| 143 | | Relay Service | Yes ✅ | 144 | | Django App | No 🚫 | 145 | ``` 146 | 147 | The HTTP method to use for the healthcheck endpoint. [`RELAY_HEALTHCHECK_URL`](#relay_healthcheck_url) must also be set for this to have any effect. The default is `"GET"`. 148 | 149 | ## `RELAY_HEALTHCHECK_STATUS_CODE` 150 | 151 | ```{table} 152 | :align: left 153 | 154 | | Component | Configurable | 155 | |---------------|--------------| 156 | | Relay Service | Yes ✅ | 157 | | Django App | No 🚫 | 158 | ``` 159 | 160 | The expected HTTP status code for the healthcheck endpoint. [`RELAY_HEALTHCHECK_URL`](#relay_healthcheck_url) must also be set for this to have any effect. The default is `200`. 161 | 162 | ## `RELAY_HEALTHCHECK_TIMEOUT` 163 | 164 | ```{table} 165 | :align: left 166 | 167 | | Component | Configurable | 168 | |---------------|--------------| 169 | | Relay Service | Yes ✅ | 170 | | Django App | No 🚫 | 171 | ``` 172 | 173 | The timeout in seconds for the healthcheck endpoint. [`RELAY_HEALTHCHECK_URL`](#relay_healthcheck_url) must also be set for this to have any effect. The default is `5.0` seconds. 174 | 175 | ## `RELAY_HEALTHCHECK_URL` 176 | 177 | ```{table} 178 | :align: left 179 | 180 | | Component | Configurable | 181 | |---------------|--------------| 182 | | Relay Service | Yes ✅ | 183 | | Django App | No 🚫 | 184 | ``` 185 | 186 | The URL to ping after a loop of sending emails is complete. This can be used to integrate with a service like [Healthchecks.io](https://healthchecks.io/) or [UptimeRobot](https://uptimerobot.com/). The default is `None`, which means no healthcheck will be performed. 187 | -------------------------------------------------------------------------------- /docs/configuration/relay-service.md: -------------------------------------------------------------------------------- 1 | # Configuring the Relay Service 2 | 3 | At a minimum, you should configure the relay service's connection to the database and how it will connect to your SMTP server, which, depending on your SMTP server, can include any or all the following Django settings: 4 | 5 | - `EMAIL_HOST` 6 | - `EMAIL_PORT` 7 | - `EMAIL_HOST_USER` 8 | - `EMAIL_HOST_PASSWORD` 9 | 10 | Additionally, the service can be configured using any setting available to Django by default, for example, if you want to set a default from email (`DEFAULT_FROM_EMAIL`) or a common subject prefix (`EMAIL_SUBJECT_PREFIX`). 11 | 12 | Configuration of the relay service differs depending on whether you are using the provided Docker image or the management command within a Django project. 13 | 14 | ## Docker 15 | 16 | When running the relay service using Docker, config values are set via environment variables. The names of the environment variables are the same as the Django settings, e.g., to set `DEBUG` to `True`, you would set `-e "DEBUG=True"` when running the container. 17 | 18 | For settings that are dictionaries, a `__` is used to separate the keys, e.g., to set `DATABASES["default"]["CONN_MAX_AGE"]` to `600` or 10 minutes, you would set `-e "DATABASES__default__CONN_MAX_AGE=600"`. 19 | 20 | For the database connection, [`dj-database-url`](https://github.com/jazzband/dj-database-url) is used to parse the `DATABASE_URL` environment variable, e.g., `-e "DATABASE_URL=postgres://email_relay_user:email_relay_password@localhost:5432/email_relay_db"`. 21 | 22 | ## Django 23 | 24 | When running the relay service from a Django project, config values are read from the Django settings for that project. 25 | -------------------------------------------------------------------------------- /docs/contributing/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CONTRIBUTING.md 2 | 3 | ``` 4 | 5 | ```{toctree} 6 | :hidden: 7 | 8 | releasing 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/contributing/releasing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../RELEASING.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/development/just.md: -------------------------------------------------------------------------------- 1 | # Justfile 2 | 3 | This project uses [Just](https://github.com/casey/just) as a command runner. 4 | 5 | The following commands are available: 6 | 7 | 19 | 20 | 21 | ## Commands 22 | 23 | ```{code-block} shell 24 | :class: copy 25 | 26 | $ just --list 27 | ``` 28 | 37 | 38 | 39 | 61 | 62 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 2 | ```{include} ../README.md 3 | :start-after: '' 4 | :end-before: '' 5 | ``` 6 | ## Table of Contents 7 | 8 | ```{toctree} 9 | :maxdepth: 3 10 | why 11 | installation/index 12 | usage/index 13 | configuration/index 14 | updating 15 | contributing/index 16 | development/just.md 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/installation/django-app.md: -------------------------------------------------------------------------------- 1 | # Django App 2 | 3 | For each distributed Django project that you would like to use the preferred SMTP server, you will need to install the `django-email-relay` package and do some basic configuration. 4 | 5 | 1. Install the package from PyPI: 6 | 7 | ```shell 8 | pip install django-email-relay 9 | ``` 10 | 11 | 2. Add `email_relay` to your `INSTALLED_APPS` setting: 12 | 13 | ```python 14 | INSTALLED_APPS = [ 15 | # ... 16 | "email_relay", 17 | # ... 18 | ] 19 | ``` 20 | 21 | 3. Add the `RelayDatabaseEmailBackend` to your `EMAIL_BACKEND` setting: 22 | 23 | ```python 24 | EMAIL_BACKEND = "email_relay.backend.RelayDatabaseEmailBackend" 25 | ``` 26 | 27 | 4. Add the email relay database to your `DATABASES` setting. A default database alias is provided at `email_relay.conf.EMAIL_RELAY_DATABASE_ALIAS` which you can import and use: 28 | 29 | ```python 30 | from email_relay.conf import EMAIL_RELAY_DATABASE_ALIAS 31 | 32 | DATABASES = { 33 | # ... 34 | EMAIL_RELAY_DATABASE_ALIAS: { 35 | "ENGINE": "django.db.backends.postgresql", 36 | "NAME": "email_relay_db", 37 | "USER": "email_relay_user", 38 | "PASSWORD": "email_relay_password", 39 | "HOST": "localhost", 40 | "PORT": "5432", 41 | }, 42 | # ... 43 | } 44 | ``` 45 | 46 | If you would like to use a different database alias, you will also need to set the `DATABASE_ALIAS` setting within your `DJANGO_EMAIL_RELAY` settings: 47 | 48 | ```python 49 | DATABASES = { 50 | # ... 51 | "some_alias": { 52 | "ENGINE": "django.db.backends.postgresql", 53 | "NAME": "email_relay_db", 54 | "USER": "email_relay_user", 55 | "PASSWORD": "email_relay_password", 56 | "HOST": "localhost", 57 | "PORT": "5432", 58 | }, 59 | # ... 60 | } 61 | 62 | DJANGO_EMAIL_RELAY = { 63 | # ... 64 | "DATABASE_ALIAS": "some_alias", 65 | # ... 66 | } 67 | ``` 68 | 69 | 4. Add the `EmailDatabaseRouter` to your `DATABASE_ROUTERS` setting: 70 | 71 | ```python 72 | DATABASE_ROUTERS = [ 73 | # ... 74 | "email_relay.db.EmailDatabaseRouter", 75 | # ... 76 | ] 77 | ``` 78 | 79 | See the documentation [here](../configuration/index.md) for general information about configuring `django-email-relay`. 80 | -------------------------------------------------------------------------------- /docs/installation/index.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | You should install and setup the relay service provided by `django-email-relay` first before installing and configuring the Django app on your distributed Django projects. In the setup for the relay service, the database will be created and migrations will be run, which will need to be done before the distributed Django apps can use the relay service to send emails. 4 | 5 | ```{toctree} 6 | :hidden: 7 | 8 | relay-service 9 | django-app 10 | ``` 11 | ## Requirements 12 | 13 | - Python 3.9, 3.10, 3.11, 3.12 or 3.13 14 | - Django 3.2, 5.1, or 5.2 15 | - PostgreSQL (for provided Docker image) 16 | -------------------------------------------------------------------------------- /docs/installation/relay-service.md: -------------------------------------------------------------------------------- 1 | # Relay Service 2 | 3 | The relay service provided by `django-email-relay` is responsible for reading a central database queue and sending emails from that queue through an SMTP server. As such, it should be run on infrastructure that has access to the SMTP server you would like to use. There are currently two ways to run the service: 4 | 5 | 1. A Docker image 6 | 2. A `runrelay` management command to be run from within a Django project 7 | 8 | If you are using the Docker image, only PostgreSQL is supported. However, when using the management command directly, you can use whatever database you are using with the Django project it is being run from, provided your other externally hosted Django projects that you would like to relay emails for also have access to the same database. If you would like the Docker image to support other databases, please [open an issue](https://github.com/westerveltco/django-email-relay/issues/new) and it will be considered. 9 | 10 | Installation of the relay service differs depending on whether you are using the provided Docker image or the management command. 11 | 12 | ## Docker 13 | 14 | A prebuilt Docker image is provided via the GitHub Container Registry, located here: 15 | 16 | ``` 17 | ghcr.io/westerveltco/django-email-relay 18 | ``` 19 | 20 | It can be run any way you would normally run a Docker container, for instance, through the CLI: 21 | 22 | ```shell 23 | docker run -d \ 24 | -e "DATABASE_URL=postgres://email_relay_user:email_relay_password@localhost:5432/email_relay_db" \ 25 | -e "EMAIL_HOST=smtp.example.com" \ 26 | -e "EMAIL_PORT=25" \ 27 | --restart unless-stopped \ 28 | ghcr.io/westerveltco/django-email-relay:latest 29 | ``` 30 | 31 | It is recommended to pin to a specific version, though if you prefer, you can ride the lightning by always pulling the `latest` image. 32 | 33 | The `migrate` step is baked into the image, so there is no need to run it yourself. 34 | 35 | See the documentation [here](../configuration/index.md) for general information about configuring `django-email-relay`, [here](../configuration/relay-service.md) for information about configuring the relay service, and [here](../configuration/relay-service.md#docker) for information specifically related to configuring the relay service as a Docker container. 36 | 37 | ## Django 38 | 39 | If you have a Django project already deployed that has access to the preferred SMTP server, you can skip using the Docker image and install the package and use the included `runrelay` management method instead. 40 | 41 | 1. Install the package from PyPI: 42 | 43 | ```shell 44 | pip install django-email-relay 45 | ``` 46 | 47 | 2. Add `email_relay` to your `INSTALLED_APPS` setting: 48 | 49 | ```python 50 | INSTALLED_APPS = [ 51 | # ... 52 | "email_relay", 53 | # ... 54 | ] 55 | ``` 56 | 57 | 3. Run the `migrate` management command to create the email relay database: 58 | 59 | ```shell 60 | python manage.py migrate 61 | ``` 62 | 63 | 4. Run the `runrelay` management command to start the relay service. This can be done in many different ways, for instance, via a task runner, such as Celery or Django-Q2, or using [supervisord](https://supervisord.org/) or systemd service unit file. 64 | 65 | ```shell 66 | python manage.py runrelay 67 | ``` 68 | 69 | See the documentation [here](../configuration/index.md) for general information about configuring `django-email-relay`, [here](../configuration/relay-service.md) for information about configuring the relay service, and [here](../configuration/relay-service.md#django) for information specifically related to configuring the relay service as a Django app. 70 | -------------------------------------------------------------------------------- /docs/updating.md: -------------------------------------------------------------------------------- 1 | # Updating 2 | 3 | As `django-email-relay` involves database models and the potential for migrations, care should be taken when updating to ensure that all Django projects using `django-email-relay` are upgraded at roughly the same time. See the [deprecation policy](#deprecation-policy) for more information regarding backward incompatible changes. 4 | 5 | When updating to a new version, it is recommended to follow the following steps: 6 | 7 | 1. Update the relay service to the new version. As part of the update process, the relay service should run any migrations that are needed. If using the provided Docker container, this is done automatically as Django's `migrate` command is baked into the image. When running the relay service from a Django project, you will need to run the `migrate` command yourself, either as part of your deployment strategy or manually. 8 | 2. Update all distributed projects to the new version. 9 | 10 | ## Deprecation Policy 11 | 12 | ```{admonition} Road to v1.0.0 13 | :class: warning 14 | 15 | Before `django-email-relay` reaches version 1.0.0, the deprecation policy is a little more relaxed. See the [changelog](https://github.com/westerveltco/django-email-relay/blob/main/CHANGELOG.md) for more information regarding backward incompatible changes. 16 | ``` 17 | 18 | Any changes that involve models and/or migrations, or anything else that is potentially backward incompatible, will be split across two or more releases: 19 | 20 | 1. A release that adds the changes in a backward compatible way, with a deprecation warning. This release will be tagged with a minor version bump, e.g., `0.1.0` to `0.2.0`. 21 | 2. A release that removes the backward compatible changes and removes the deprecation warning. This release will be tagged with a major version bump, e.g., `0.2.0` to `1.0.0`. 22 | 23 | This is unlikely to happen often, but it is important to keep in mind when updating. 24 | 25 | A major release does not necessarily mean that there are breaking changes or ones involving models and migrations. You should always check the [changelog](CHANGELOG.md) and a version's release notes for more information. 26 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Once your Django project is configured to use `email_relay.backend.RelayDatabaseEmailBackend` as its `EMAIL_BACKEND`, sending email is as simple as using Django's built-in ways of sending email, such as the `send_mail` method: 4 | 5 | ```python 6 | from django.core.mail import send_mail 7 | 8 | send_mail( 9 | "Subject here", 10 | "Here is the message.", 11 | "from@example.com", 12 | ["to@example.com"], 13 | fail_silently=False, 14 | ) 15 | ``` 16 | 17 | Any emails sent this way, or one of the other ways Django provides, will be stored in the database queue and sent by the relay service. 18 | 19 | See the Django documentation on [sending email](https://docs.djangoproject.com/en/dev/topics/email/) for more information. 20 | 21 | ```{toctree} 22 | :hidden: 23 | 24 | relay-healthcheck 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/usage/relay-healthcheck.md: -------------------------------------------------------------------------------- 1 | # Relay Service Health Check 2 | 3 | As mentioned in [limitations](../index.md#limitations), if the relay service is not running, or otherwise not operational, emails will not be sent out. To help with this, `django-email-relay` provides a way to send a health check ping to a URL of your choosing after each loop of sending emails is complete. This can be used to integrate with a service like [Healthchecks.io](https://healthchecks.io/) or [UptimeRobot](https://uptimerobot.com/). 4 | 5 | To get started, you will need to install the package with the `hc` extra. If you are using the included Docker image, this is done automatically. If you are using the management command directly from a Django project, you will need to adjust your installation command: 6 | 7 | ```shell 8 | pip install django-email-relay[hc] 9 | ``` 10 | 11 | At a minimum, you will need to configure which URL to ping after a loop of sending emails is complete. This can be done by setting the [`RELAY_HEALTHCHECK_URL`](../configuration/index.md#relay_healthcheck_url) setting in your `DJANGO_EMAIL_RELAY` settings: 12 | 13 | ```python 14 | DJANGO_EMAIL_RELAY = { 15 | "RELAY_HEALTHCHECK_URL": "https://example.com/healthcheck", 16 | } 17 | ``` 18 | 19 | It should be set to the URL provided by your health check service. If available, you should set the schedule of the health check within the service to what you have configured for the relay service's [`EMPTY_QUEUE_SLEEP`](../configuration/index.md#empty_queue_sleep) setting, which is `30` seconds by default. 20 | 21 | There are also a few other settings that can be configured, such as the HTTP method to use, the expected HTTP status code, and the timeout. See the [configuration](../configuration/index.md) section for more information. 22 | -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | # Why? 2 | 3 | At [The Westervelt Company](https://github.com/westerveltco), we primarily host our Django applications in the cloud. The majority of the emails we send are to internal Westervelt employees. Prior to developing and using `django-email-relay`, we were using an external Email Service Provider (ESP) to send these emails. This worked well enough, but we ran into a few issues: 4 | 5 | - Emails sent by our applications had a tendency to sometimes be marked as spam or otherwise filtered by our company's email provider, which makes using an ESP essentially pointless. 6 | - As a way to combat phishing emails, we treat and process internal and external emails differently. This meant that in order for our applications' transactional emails to be treated as internal, adjustments and exceptions would need to be made, which our security team was not comfortable with. 7 | 8 | We have an internal SMTP server that can be used for any application deployed on-premises, which bypasses most of these issues. However, it is not, and there are no plans to make it publicly accessible -- either through opening firewall ports or by using a service like Tailscale. This meant that we needed to find another way to route emails from our cloud-hosted Django applications to this internal SMTP server. 9 | 10 | After discussing with our infrastructure and security team, we thought about what would be the simplest and most straightforward to develop and deploy while also not compromising on security. Taking inspiration from another Django package, [`django-mailer`](https://github.com/pinax/django-mailer/), we decided that a solution utilizing a central database queue that our cloud-hosted Django applications can use to store emails to be sent and a relay service that can be run on-premises that reads from that queue would fulfill those requirements. This is what `django-email-relay` is. 11 | 12 | ## Benefits 13 | 14 | - By utilizing an internal SMTP server for email dispatch, `django-email-relay` reduces the security risks associated with using external ESPs, aligning with stringent corporate security policies. 15 | - The relay service is available as either a standalone Docker image or a Django management command, giving you the flexibility to choose the deployment method that suits your infrastructure. 16 | - Emails are stored in the database as part of a transaction. If a transaction fails, the associated email records can be rolled back, ensuring data consistency and integrity. 17 | - By using an internal SMTP server, you are less likely to have your emails marked as spam or filtered by company-specific policies, ensuring more reliable delivery, especially if your primary audience is internal employees. 18 | - By utilizing an existing internal SMTP server, you can potentially reduce costs associated with third-party ESPs. 19 | 20 | ## Limitations 21 | 22 | As `django-email-relay` is based on `django-mailer`, it shares a lot of the same limitations, detailed [here](https://github.com/pinax/django-mailer/blob/863a99752e6928f9825bae275f69bf8696b836cb/README.rst#limitations). Namely: 23 | 24 | - Since file attachments are stored in a database, large attachments can potentially cause space and query issues. 25 | - From the Django applications sending emails, it is not possible to know whether an email has been sent or not, only whether it has been successfully queued for sending. 26 | - Emails are not sent immediately but instead saved in a database queue to be used by the relay service. This means that emails will not be sent unless the relay service is started and running. 27 | - Due to the distributed nature of the package and the fact that there are database models, and thus potentially migrations to apply, care should be taken when upgrading to ensure that all Django projects using `django-email-relay` are upgraded at roughly the same time. See the [Updating](updating.md) section of the documentation for more information. 28 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | 7 | import nox 8 | 9 | nox.options.default_venv_backend = "uv|virtualenv" 10 | nox.options.reuse_existing_virtualenvs = True 11 | 12 | PY39 = "3.9" 13 | PY310 = "3.10" 14 | PY311 = "3.11" 15 | PY312 = "3.12" 16 | PY313 = "3.13" 17 | PY_VERSIONS = [PY39, PY310, PY311, PY312, PY313] 18 | PY_DEFAULT = PY_VERSIONS[0] 19 | PY_LATEST = PY_VERSIONS[-1] 20 | 21 | DJ42 = "4.2" 22 | DJ51 = "5.1" 23 | DJ52 = "5.2" 24 | DJMAIN = "main" 25 | DJMAIN_MIN_PY = PY312 26 | DJ_VERSIONS = [DJ42, DJ51, DJ52, DJMAIN] 27 | DJ_LTS = [ 28 | version for version in DJ_VERSIONS if version.endswith(".2") and version != DJMAIN 29 | ] 30 | DJ_DEFAULT = DJ_LTS[0] 31 | DJ_LATEST = DJ_VERSIONS[-2] 32 | 33 | 34 | def version(ver: str) -> tuple[int, ...]: 35 | """Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)""" 36 | return tuple(map(int, ver.split("."))) 37 | 38 | 39 | def should_skip(python: str, django: str) -> bool: 40 | """Return True if the test should be skipped""" 41 | 42 | if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY): 43 | # Django main requires Python 3.12+ 44 | return True 45 | 46 | if django == DJ52 and version(python) < version(PY310): 47 | # Django 5.2 requires Python 3.10+ 48 | return True 49 | 50 | if django == DJ51 and version(python) < version(PY310): 51 | # Django 5.1 requires Python 3.10+ 52 | return True 53 | 54 | return False 55 | 56 | 57 | @nox.session 58 | def test(session): 59 | session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')") 60 | 61 | 62 | @nox.session 63 | @nox.parametrize( 64 | "python,django", 65 | [ 66 | (python, django) 67 | for python in PY_VERSIONS 68 | for django in DJ_VERSIONS 69 | if not should_skip(python, django) 70 | ], 71 | ) 72 | def tests(session, django): 73 | session.run_install( 74 | "uv", 75 | "sync", 76 | "--frozen", 77 | "--inexact", 78 | "--no-install-package", 79 | "django", 80 | "--python", 81 | session.python, 82 | "--extra", 83 | "relay", 84 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 85 | ) 86 | 87 | if django == DJMAIN: 88 | session.install( 89 | "django @ https://github.com/django/django/archive/refs/heads/main.zip" 90 | ) 91 | else: 92 | session.install(f"django=={django}") 93 | 94 | command = ["python", "-m", "pytest"] 95 | if session.posargs: 96 | args = [] 97 | for arg in session.posargs: 98 | if arg: 99 | args.extend(arg.split(" ")) 100 | command.extend(args) 101 | session.run(*command) 102 | 103 | 104 | @nox.session 105 | def coverage(session): 106 | session.run_install( 107 | "uv", 108 | "sync", 109 | "--frozen", 110 | "--python", 111 | PY_DEFAULT, 112 | "--extra", 113 | "relay", 114 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 115 | ) 116 | 117 | try: 118 | command = ["python", "-m", "pytest", "--cov", "--cov-report="] 119 | if session.posargs: 120 | args = [] 121 | for arg in session.posargs: 122 | if arg: 123 | args.extend(arg.split(" ")) 124 | command.extend(args) 125 | session.run(*command) 126 | finally: 127 | # 0 -> OK 128 | # 2 -> code coverage percent unmet 129 | success_codes = [0, 2] 130 | 131 | report_cmd = ["python", "-m", "coverage", "report"] 132 | session.run(*report_cmd, success_codes=success_codes) 133 | 134 | if summary := os.getenv("GITHUB_STEP_SUMMARY"): 135 | report_cmd.extend(["--skip-covered", "--skip-empty", "--format=markdown"]) 136 | 137 | with Path(summary).open("a") as output_buffer: 138 | output_buffer.write("") 139 | output_buffer.write("### Coverage\n\n") 140 | output_buffer.flush() 141 | session.run( 142 | *report_cmd, stdout=output_buffer, success_codes=success_codes 143 | ) 144 | else: 145 | session.run( 146 | "python", 147 | "-m", 148 | "coverage", 149 | "html", 150 | "--skip-covered", 151 | "--skip-empty", 152 | success_codes=success_codes, 153 | ) 154 | 155 | 156 | @nox.session 157 | def lint(session): 158 | session.run( 159 | "uv", 160 | "run", 161 | "--with", 162 | "pre-commit-uv", 163 | "--python", 164 | PY_LATEST, 165 | "pre-commit", 166 | "run", 167 | "--all-files", 168 | ) 169 | 170 | 171 | @nox.session 172 | def mypy(session): 173 | session.run_install( 174 | "uv", 175 | "sync", 176 | "--group", 177 | "types", 178 | "--frozen", 179 | "--python", 180 | PY_LATEST, 181 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 182 | ) 183 | 184 | command = ["python", "-m", "mypy", "."] 185 | if session.posargs: 186 | args = [] 187 | for arg in session.posargs: 188 | if arg: 189 | args.extend(arg.split(" ")) 190 | command.extend(args) 191 | session.run(*command) 192 | 193 | 194 | @nox.session 195 | def gha_matrix(session): 196 | sessions = session.run("nox", "-l", "--json", silent=True) 197 | matrix = { 198 | "include": [ 199 | { 200 | "python-version": session["python"], 201 | "django-version": session["call_spec"]["django"], 202 | } 203 | for session in json.loads(sessions) 204 | if session["name"] == "tests" 205 | ] 206 | } 207 | with Path(os.environ["GITHUB_OUTPUT"]).open("a") as fh: 208 | print(f"matrix={matrix}", file=fh) 209 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [dependency-groups] 6 | dev = [ 7 | "bumpver", 8 | "copier", 9 | "copier-templates-extensions", 10 | "coverage[toml]", 11 | "dirty-equals", 12 | "django-stubs", 13 | "django-stubs-ext", 14 | "environs", 15 | "faker", 16 | "hatch", 17 | "model-bakery", 18 | "nox[uv]", 19 | "pytest", 20 | "pytest-cov", 21 | "pytest-django", 22 | "pytest-randomly", 23 | "pytest-reverse", 24 | "pytest-xdist", 25 | "responses" 26 | ] 27 | docs = [ 28 | "cogapp", 29 | "furo", 30 | "myst-parser", 31 | "sphinx", 32 | "sphinx-autobuild", 33 | "sphinx-copybutton", 34 | "sphinx-inline-tabs" 35 | ] 36 | types = [ 37 | "django-stubs", 38 | "django-stubs-ext", 39 | "mypy", 40 | "types-requests" 41 | ] 42 | 43 | [project] 44 | authors = [{name = "Josh Thomas", email = "josh@joshthomas.dev"}] 45 | classifiers = [ 46 | "Development Status :: 4 - Beta", 47 | "Framework :: Django", 48 | "Framework :: Django :: 4.2", 49 | "Framework :: Django :: 5.1", 50 | "Framework :: Django :: 5.2", 51 | "License :: OSI Approved :: MIT License", 52 | "Operating System :: OS Independent", 53 | "Programming Language :: Python", 54 | "Programming Language :: Python :: 3", 55 | "Programming Language :: Python :: 3 :: Only", 56 | "Programming Language :: Python :: 3.9", 57 | "Programming Language :: Python :: 3.10", 58 | "Programming Language :: Python :: 3.11", 59 | "Programming Language :: Python :: 3.12", 60 | "Programming Language :: Python :: Implementation :: CPython" 61 | ] 62 | dependencies = ["django>=4.2"] 63 | description = "Centralize and relay email from multiple distributed Django projects to an internal SMTP server via a database queue." 64 | dynamic = ["version"] 65 | keywords = [] 66 | license = {file = "LICENSE"} 67 | name = "django-email-relay" 68 | readme = "README.md" 69 | requires-python = ">=3.9" 70 | 71 | [project.optional-dependencies] 72 | hc = ["requests"] 73 | psycopg = ["psycopg[binary]"] 74 | relay = ["environs[django]"] 75 | 76 | [project.urls] 77 | Documentation = "https://django-email-relay.westervelt.dev/" 78 | Issues = "https://github.com/westerveltco/django-email-relay/issues" 79 | Source = "https://github.com/westerveltco/django-email-relay" 80 | 81 | [tool.bumpver] 82 | commit = true 83 | commit_message = ":bookmark: bump version {old_version} -> {new_version}" 84 | current_version = "0.6.0" 85 | push = false # set to false for CI 86 | tag = false 87 | version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" 88 | 89 | [tool.bumpver.file_patterns] 90 | ".copier/package.yml" = ['current_version: {version}'] 91 | "src/email_relay/__init__.py" = ['__version__ = "{version}"'] 92 | "tests/test_version.py" = ['assert __version__ == "{version}"'] 93 | 94 | [tool.coverage.paths] 95 | source = ["src"] 96 | 97 | [tool.coverage.report] 98 | exclude_lines = [ 99 | "pragma: no cover", 100 | "if DEBUG:", 101 | "if not DEBUG:", 102 | "if settings.DEBUG:", 103 | "if TYPE_CHECKING:", 104 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:' 105 | ] 106 | fail_under = 75 107 | 108 | [tool.coverage.run] 109 | omit = ["src/email_relay/migrations/*", "tests/*"] 110 | source = ["email_relay"] 111 | 112 | [tool.django-stubs] 113 | django_settings_module = "tests.settings" 114 | strict_settings = false 115 | 116 | [tool.hatch.build] 117 | exclude = [".*", "Justfile"] 118 | 119 | [tool.hatch.build.targets.wheel] 120 | packages = ["src/email_relay"] 121 | 122 | [tool.hatch.version] 123 | path = "src/email_relay/__init__.py" 124 | 125 | [tool.mypy] 126 | check_untyped_defs = true 127 | exclude = "docs/.*\\.py$" 128 | mypy_path = "src/" 129 | no_implicit_optional = true 130 | plugins = ["mypy_django_plugin.main"] 131 | warn_redundant_casts = true 132 | warn_unused_configs = true 133 | warn_unused_ignores = true 134 | 135 | [[tool.mypy.overrides]] 136 | ignore_errors = true 137 | ignore_missing_imports = true 138 | module = ["email_relay.*.migrations.*", "tests.*"] 139 | 140 | [tool.mypy_django_plugin] 141 | ignore_missing_model_attributes = true 142 | 143 | [tool.pytest.ini_options] 144 | addopts = "--create-db -n auto --dist loadfile --doctest-modules" 145 | django_find_project = false 146 | norecursedirs = ".* bin build dist *.egg htmlcov logs node_modules templates venv" 147 | python_files = "tests.py test_*.py *_tests.py" 148 | pythonpath = "src" 149 | testpaths = ["tests"] 150 | 151 | [tool.ruff] 152 | # Exclude a variety of commonly ignored directories. 153 | exclude = [ 154 | ".bzr", 155 | ".direnv", 156 | ".eggs", 157 | ".git", 158 | ".github", 159 | ".hg", 160 | ".mypy_cache", 161 | ".ruff_cache", 162 | ".svn", 163 | ".tox", 164 | ".venv", 165 | "__pypackages__", 166 | "_build", 167 | "build", 168 | "dist", 169 | "migrations", 170 | "node_modules", 171 | "venv" 172 | ] 173 | extend-include = ["*.pyi?"] 174 | indent-width = 4 175 | # Same as Black. 176 | line-length = 88 177 | # Assume Python >3.8 178 | target-version = "py38" 179 | 180 | [tool.ruff.format] 181 | # Like Black, indent with spaces, rather than tabs. 182 | indent-style = "space" 183 | # Like Black, automatically detect the appropriate line ending. 184 | line-ending = "auto" 185 | # Like Black, use double quotes for strings. 186 | quote-style = "double" 187 | 188 | [tool.ruff.lint] 189 | # Allow unused variables when underscore-prefixed. 190 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 191 | # Allow autofix for all enabled rules (when `--fix`) is provided. 192 | fixable = ["A", "B", "C", "D", "E", "F", "I"] 193 | ignore = ["E501", "E741"] # temporary 194 | select = [ 195 | "B", # flake8-bugbear 196 | "E", # Pycodestyle 197 | "F", # Pyflakes 198 | "I", # isort 199 | "UP" # pyupgrade 200 | ] 201 | unfixable = [] 202 | 203 | [tool.ruff.lint.isort] 204 | force-single-line = true 205 | known-first-party = ["email_relay"] 206 | required-imports = ["from __future__ import annotations"] 207 | 208 | [tool.ruff.lint.per-file-ignores] 209 | # Tests can use magic values, assertions, and relative imports 210 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 211 | 212 | [tool.ruff.lint.pyupgrade] 213 | # Preserve types, even if a file imports `from __future__ import annotations`. 214 | keep-runtime-typing = true 215 | -------------------------------------------------------------------------------- /src/email_relay/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __version__ = "0.6.0" 4 | -------------------------------------------------------------------------------- /src/email_relay/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class EmailRelayConfig(AppConfig): 7 | name = "email_relay" 8 | default_auto_field = "django.db.models.BigAutoField" 9 | -------------------------------------------------------------------------------- /src/email_relay/backend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Sequence 4 | 5 | from django.core.mail import EmailMessage 6 | from django.core.mail.backends.base import BaseEmailBackend 7 | 8 | from email_relay.conf import app_settings 9 | from email_relay.models import Message 10 | 11 | 12 | class RelayDatabaseEmailBackend(BaseEmailBackend): 13 | def send_messages(self, email_messages: Sequence[EmailMessage]) -> int: 14 | messages = Message.objects.bulk_create( 15 | [Message(email=email) for email in email_messages], 16 | app_settings.MESSAGES_BATCH_SIZE, 17 | ) 18 | return len(messages) 19 | -------------------------------------------------------------------------------- /src/email_relay/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | from django.conf import settings 7 | 8 | EMAIL_RELAY_SETTINGS_NAME = "DJANGO_EMAIL_RELAY" 9 | EMAIL_RELAY_DATABASE_ALIAS = "email_relay_db" 10 | 11 | 12 | @dataclass(frozen=True) 13 | class AppSettings: 14 | DATABASE_ALIAS: str = EMAIL_RELAY_DATABASE_ALIAS 15 | EMAIL_MAX_BATCH: int | None = None 16 | EMAIL_MAX_DEFERRED: int | None = None 17 | EMAIL_MAX_RETRIES: int | None = None 18 | EMPTY_QUEUE_SLEEP: int = 30 19 | EMAIL_THROTTLE: int = 0 20 | MESSAGES_BATCH_SIZE: int | None = None 21 | MESSAGES_RETENTION_SECONDS: int | None = None 22 | RELAY_HEALTHCHECK_METHOD: str = "GET" 23 | RELAY_HEALTHCHECK_STATUS_CODE: int = 200 24 | RELAY_HEALTHCHECK_TIMEOUT: float | tuple[float, float] | tuple[float, None] = 5.0 25 | RELAY_HEALTHCHECK_URL: str | None = None 26 | 27 | def __getattribute__(self, __name: str) -> Any: 28 | user_settings = getattr(settings, EMAIL_RELAY_SETTINGS_NAME, {}) 29 | return user_settings.get(__name, super().__getattribute__(__name)) 30 | 31 | 32 | app_settings = AppSettings() 33 | -------------------------------------------------------------------------------- /src/email_relay/db.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from email_relay.conf import app_settings 4 | 5 | 6 | class EmailDatabaseRouter: 7 | def db_for_read(self, model, **hints): 8 | if model._meta.app_label == "email_relay": 9 | return app_settings.DATABASE_ALIAS 10 | return "default" 11 | 12 | def db_for_write(self, model, **hints): 13 | if model._meta.app_label == "email_relay": 14 | return app_settings.DATABASE_ALIAS 15 | return "default" 16 | 17 | def allow_relation(self, obj1, obj2, **hints): 18 | if ( 19 | obj1._meta.app_label == "email_relay" 20 | and obj2._meta.app_label == "email_relay" 21 | ): 22 | return True 23 | return None 24 | 25 | def allow_migrate(self, db, app_label, model_name=None, **hints): 26 | if app_label == "email_relay": 27 | return db == app_settings.DATABASE_ALIAS 28 | return None 29 | -------------------------------------------------------------------------------- /src/email_relay/email.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import binascii 5 | from dataclasses import asdict 6 | from dataclasses import dataclass 7 | from dataclasses import field 8 | from email.mime.base import MIMEBase 9 | 10 | from django.core.mail import EmailMessage 11 | from django.core.mail import EmailMultiAlternatives 12 | 13 | from email_relay import __version__ 14 | 15 | 16 | @dataclass(frozen=True) 17 | class RelayEmailData: 18 | subject: str = "" 19 | body: str = "" 20 | from_email: str = "" 21 | to: list[str] = field(default_factory=list) 22 | cc: list[str] = field(default_factory=list) 23 | bcc: list[str] = field(default_factory=list) 24 | reply_to: list[str] = field(default_factory=list) 25 | extra_headers: dict[str, str] = field(default_factory=dict) 26 | alternatives: list[tuple[str, str]] = field(default_factory=list) 27 | attachments: list[dict[str, str]] = field(default_factory=list) 28 | _email_relay_version: str = __version__ 29 | 30 | def to_dict(self) -> dict[str, str]: 31 | return asdict(self) 32 | 33 | def to_email_message(self) -> EmailMultiAlternatives: 34 | email = EmailMultiAlternatives( 35 | subject=self.subject, 36 | body=self.body, 37 | from_email=self.from_email, 38 | to=self.to, 39 | cc=self.cc, 40 | bcc=self.bcc, 41 | reply_to=self.reply_to, 42 | headers=self.extra_headers, 43 | ) 44 | 45 | for alternative in self.alternatives: 46 | email.attach_alternative(alternative[0], alternative[1]) 47 | 48 | for attachment in self.attachments: 49 | content = attachment.get("content", "") 50 | try: 51 | # Attempt to decode the base64 string into bytes 52 | decoded_content = base64.b64decode(content) 53 | except binascii.Error: 54 | # Fallback to assuming it's plain text, encoded as bytes 55 | decoded_content = content.encode("utf-8") 56 | 57 | email.attach( 58 | filename=attachment.get("filename", ""), 59 | content=decoded_content, 60 | mimetype=attachment.get("mimetype", ""), 61 | ) 62 | 63 | return email 64 | 65 | @classmethod 66 | def from_email_message( 67 | cls, email_message: EmailMessage | EmailMultiAlternatives 68 | ) -> RelayEmailData: 69 | attachments = [] 70 | for attachment in email_message.attachments: 71 | if isinstance(attachment, MIMEBase): 72 | payload = attachment.get_payload(decode=True) 73 | if not isinstance(payload, bytes): 74 | raise TypeError("Payload must be bytes for base64 encoding") 75 | 76 | attachments.append( 77 | { 78 | "filename": attachment.get_filename( 79 | failobj="filename_not_found" 80 | ), 81 | "content": base64.b64encode(payload).decode(), 82 | "mimetype": attachment.get_content_type(), 83 | } 84 | ) 85 | else: 86 | if isinstance(attachment[1], bytes): 87 | content = base64.b64encode(attachment[1]).decode("utf-8") 88 | else: 89 | content = attachment[1] 90 | 91 | attachments.append( 92 | { 93 | "filename": attachment[0], 94 | "content": content, 95 | "mimetype": attachment[2], 96 | } 97 | ) 98 | 99 | return cls( 100 | subject=str(email_message.subject), 101 | body=str(email_message.body), 102 | from_email=email_message.from_email, 103 | to=email_message.to, 104 | cc=email_message.cc, 105 | bcc=email_message.bcc, 106 | reply_to=email_message.reply_to, 107 | extra_headers=email_message.extra_headers, 108 | alternatives=getattr(email_message, "alternatives", []), 109 | attachments=attachments, 110 | ) 111 | -------------------------------------------------------------------------------- /src/email_relay/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/src/email_relay/management/__init__.py -------------------------------------------------------------------------------- /src/email_relay/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/src/email_relay/management/commands/__init__.py -------------------------------------------------------------------------------- /src/email_relay/management/commands/runrelay.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | import time 6 | 7 | from django.core.management import BaseCommand 8 | from django.utils import timezone 9 | 10 | from email_relay.conf import app_settings 11 | from email_relay.models import Message 12 | from email_relay.relay import send_all 13 | 14 | try: 15 | import requests 16 | except ImportError: # pragma: no cover 17 | requests = None # type: ignore[assignment] 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class Command(BaseCommand): 23 | def handle(self, *args, _loop_count: int | None = None, **options) -> None: 24 | # _loop_count is used to make testing a bit easier 25 | # it is not intended to be used in production 26 | loop_count = 0 if _loop_count is not None else None 27 | 28 | logger.info("starting relay") 29 | 30 | while True: 31 | if Message.objects.messages_available_to_send(): 32 | send_all() 33 | 34 | self.delete_old_messages() 35 | self.ping_healthcheck() 36 | 37 | msg = "loop complete" 38 | if app_settings.EMPTY_QUEUE_SLEEP > 0: 39 | msg += f", sleeping for {app_settings.EMPTY_QUEUE_SLEEP} seconds before next loop" 40 | logger.debug(msg) 41 | 42 | if _loop_count is not None and loop_count is not None: 43 | loop_count += 1 44 | if loop_count >= _loop_count: 45 | break 46 | 47 | time.sleep(app_settings.EMPTY_QUEUE_SLEEP) 48 | 49 | def delete_old_messages(self) -> None: 50 | if app_settings.MESSAGES_RETENTION_SECONDS is not None: 51 | logger.debug("deleting old messages") 52 | if app_settings.MESSAGES_RETENTION_SECONDS == 0: 53 | deleted_messages = Message.objects.delete_all_sent_messages() 54 | else: 55 | deleted_messages = Message.objects.delete_messages_sent_before( 56 | timezone.now() 57 | - datetime.timedelta( 58 | seconds=app_settings.MESSAGES_RETENTION_SECONDS 59 | ) 60 | ) 61 | logger.debug(f"deleted {deleted_messages} messages") 62 | 63 | def ping_healthcheck(self) -> None: 64 | if app_settings.RELAY_HEALTHCHECK_URL is not None: 65 | if requests is None: 66 | logger.warning( 67 | "Healthcheck URL configured but requests is not installed. " 68 | "Please install requests to use the healthcheck feature." 69 | ) 70 | return 71 | 72 | logger.debug("pinging healthcheck") 73 | try: 74 | response = requests.request( 75 | method=app_settings.RELAY_HEALTHCHECK_METHOD, 76 | url=app_settings.RELAY_HEALTHCHECK_URL, 77 | timeout=app_settings.RELAY_HEALTHCHECK_TIMEOUT, 78 | ) 79 | except requests.exceptions.RequestException as e: 80 | logger.warning(f"healthcheck failed, got exception: {e}") 81 | return 82 | 83 | if response.status_code == app_settings.RELAY_HEALTHCHECK_STATUS_CODE: 84 | logger.debug("healthcheck ping successful") 85 | else: 86 | logger.warning( 87 | f"healthcheck failed, got {response.status_code}, expected {app_settings.RELAY_HEALTHCHECK_STATUS_CODE}" 88 | ) 89 | -------------------------------------------------------------------------------- /src/email_relay/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-09-27 18:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Message", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("data", models.JSONField()), 25 | ( 26 | "priority", 27 | models.PositiveSmallIntegerField( 28 | choices=[(1, "Low"), (2, "Medium"), (3, "High")], default=1 29 | ), 30 | ), 31 | ( 32 | "status", 33 | models.PositiveSmallIntegerField( 34 | choices=[ 35 | (1, "Queued"), 36 | (2, "Deferred"), 37 | (3, "Failed"), 38 | (4, "Sent"), 39 | ], 40 | default=1, 41 | ), 42 | ), 43 | ("retry_count", models.PositiveSmallIntegerField(default=0)), 44 | ( 45 | "log", 46 | models.TextField( 47 | blank=True, 48 | help_text="Most recent log message from the email backend, if any.", 49 | ), 50 | ), 51 | ("created_at", models.DateTimeField(auto_now_add=True)), 52 | ("updated_at", models.DateTimeField(auto_now=True)), 53 | ("sent_at", models.DateTimeField(blank=True, null=True)), 54 | ], 55 | options={ 56 | "ordering": ["created_at"], 57 | }, 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /src/email_relay/migrations/0002_auto_20231030_1304.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-10-30 13:04 2 | 3 | from django.db import migrations 4 | 5 | 6 | def migrate_message_data_to_new_schema(apps, schema_editor): 7 | """Migrate the JSON data from the old schema to the new schema. 8 | 9 | Changes: 10 | - "message" is now "body" 11 | - "recipient_list" is now "to" 12 | - "html_message" is now a part of "alternatives", a list of tuples of (content, mimetype) 13 | 14 | Adds: 15 | - "cc" 16 | - "bcc" 17 | - "reply_to" 18 | - "extra_headers" 19 | - "alternatives" 20 | """ 21 | Message = apps.get_model("email_relay", "Message") 22 | 23 | for message in Message.objects.all(): 24 | data = message.data 25 | if not data: 26 | continue 27 | data["body"] = data.pop("message", "") 28 | data["to"] = data.pop("recipient_list", []) 29 | data["cc"] = [] 30 | data["bcc"] = [] 31 | data["reply_to"] = [] 32 | data["extra_headers"] = {} 33 | data["alternatives"] = [] 34 | html_message = data.pop("html_message", None) 35 | if html_message: 36 | data["alternatives"].append((html_message, "text/html")) 37 | message.save() 38 | 39 | 40 | class Migration(migrations.Migration): 41 | dependencies = [ 42 | ("email_relay", "0001_initial"), 43 | ] 44 | 45 | operations = [ 46 | migrations.RunPython( 47 | migrate_message_data_to_new_schema, migrations.RunPython.noop 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /src/email_relay/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/src/email_relay/migrations/__init__.py -------------------------------------------------------------------------------- /src/email_relay/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | from itertools import chain 6 | 7 | from django.core.mail import EmailMessage 8 | from django.core.mail import EmailMultiAlternatives 9 | from django.db import models 10 | from django.utils import timezone 11 | 12 | from email_relay.conf import app_settings 13 | from email_relay.email import RelayEmailData 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Priority(models.IntegerChoices): 19 | LOW = 1, "Low" 20 | MEDIUM = 2, "Medium" 21 | HIGH = 3, "High" 22 | 23 | 24 | class Status(models.IntegerChoices): 25 | QUEUED = 1, "Queued" 26 | DEFERRED = 2, "Deferred" 27 | FAILED = 3, "Failed" 28 | SENT = 4, "Sent" 29 | 30 | 31 | class MessageManager(models.Manager["Message"]): 32 | def get_message_batch(self) -> list[Message]: 33 | message_batch = list( 34 | chain( 35 | self.queued().prioritized(), # type: ignore[attr-defined] 36 | self.deferred().prioritized(), # type: ignore[attr-defined] 37 | ) 38 | ) 39 | logger.debug(f"found {len(message_batch)} messages to send") 40 | if app_settings.EMAIL_MAX_BATCH is not None: 41 | msg = f"max batch size is {app_settings.EMAIL_MAX_BATCH}" 42 | if len(message_batch) > app_settings.EMAIL_MAX_BATCH: 43 | msg += ", truncating" 44 | logger.debug(msg) 45 | message_batch = message_batch[: app_settings.EMAIL_MAX_BATCH] 46 | return message_batch 47 | 48 | def get_message_for_sending(self, id: int) -> Message: 49 | return self.filter(id=id).select_for_update(skip_locked=True).get() 50 | 51 | def messages_available_to_send(self) -> bool: 52 | return self.queued().exists() or self.deferred().exists() # type: ignore[attr-defined] 53 | 54 | def delete_all_sent_messages(self) -> int: 55 | return self.sent().delete()[0] # type: ignore[attr-defined] 56 | 57 | def delete_messages_sent_before(self, dt: datetime.datetime) -> int: 58 | return self.sent_before(dt).delete()[0] # type: ignore[attr-defined] 59 | 60 | 61 | class MessageQuerySet(models.QuerySet["Message"]): 62 | def prioritized(self): 63 | return self.order_by("-priority", "created_at") 64 | 65 | def high_priority(self): 66 | return self.filter(priority=Priority.HIGH) 67 | 68 | def medium_priority(self): 69 | return self.filter(priority=Priority.MEDIUM) 70 | 71 | def low_priority(self): 72 | return self.filter(priority=Priority.LOW) 73 | 74 | def queued(self): 75 | return self.filter(status=Status.QUEUED) 76 | 77 | def deferred(self): 78 | return self.filter(status=Status.DEFERRED) 79 | 80 | def failed(self): 81 | return self.filter(status=Status.FAILED) 82 | 83 | def sent(self): 84 | return self.filter(status=Status.SENT) 85 | 86 | def sent_before(self, dt: datetime.datetime): 87 | return self.sent().filter(sent_at__lte=dt) 88 | 89 | 90 | # This is a workaround to make `mypy` happy 91 | _MessageManager = MessageManager.from_queryset(MessageQuerySet) 92 | 93 | 94 | class Message(models.Model): 95 | id: int 96 | data = models.JSONField() 97 | priority = models.PositiveSmallIntegerField( 98 | choices=Priority.choices, default=Priority.LOW 99 | ) 100 | status = models.PositiveSmallIntegerField( 101 | choices=Status.choices, default=Status.QUEUED 102 | ) 103 | retry_count = models.PositiveSmallIntegerField(default=0) 104 | log = models.TextField( 105 | blank=True, help_text="Most recent log message from the email backend, if any." 106 | ) 107 | 108 | created_at = models.DateTimeField(auto_now_add=True, editable=False) 109 | updated_at = models.DateTimeField(auto_now=True, editable=False) 110 | sent_at = models.DateTimeField(null=True, blank=True) 111 | 112 | objects = _MessageManager() 113 | 114 | class Meta: 115 | ordering = ["created_at"] 116 | 117 | def __str__(self): 118 | try: 119 | return f'{self.created_at} "{self.data["subject"]}" to {", ".join(self.data["to"])}' 120 | except Exception: 121 | return f"{self.created_at} " 122 | 123 | def save(self, *args, **kwargs): 124 | # Overriding the save method in order to make sure that 125 | # modified field is updated even if it is not given as 126 | # a parameter to the update field argument. 127 | update_fields = kwargs.get("update_fields", None) 128 | if update_fields: 129 | kwargs["update_fields"] = set(update_fields).union({"updated_at"}) 130 | 131 | super().save(*args, **kwargs) 132 | 133 | def mark_sent(self): 134 | self.status = Status.SENT 135 | self.sent_at = timezone.now() 136 | self.save() 137 | 138 | def defer(self, log: str = ""): 139 | self.status = Status.DEFERRED 140 | self.log = log 141 | self.retry_count += 1 142 | self.save() 143 | 144 | def fail(self, log: str = ""): 145 | self.status = Status.FAILED 146 | self.log = log 147 | self.save() 148 | 149 | @property 150 | def email(self) -> EmailMultiAlternatives | None: 151 | data = self.data 152 | if not data: 153 | return None 154 | 155 | return RelayEmailData(**data).to_email_message() 156 | 157 | @email.setter 158 | def email(self, email_message: EmailMessage | EmailMultiAlternatives) -> None: 159 | self.data = RelayEmailData.from_email_message(email_message).to_dict() 160 | -------------------------------------------------------------------------------- /src/email_relay/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/src/email_relay/py.typed -------------------------------------------------------------------------------- /src/email_relay/relay.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import smtplib 5 | import time 6 | 7 | from django.conf import settings 8 | from django.core.mail import get_connection 9 | from django.db import transaction 10 | 11 | from email_relay.conf import app_settings 12 | from email_relay.models import Message 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def send_all(): 18 | logger.info("sending emails") 19 | 20 | counts = { 21 | "deferred": 0, 22 | "failed": 0, 23 | "sent": 0, 24 | } 25 | 26 | message_batch = Message.objects.get_message_batch() 27 | 28 | connection = None 29 | 30 | for message in message_batch: 31 | with transaction.atomic(): 32 | try: 33 | message = Message.objects.get_message_for_sending(message.id) 34 | except Message.DoesNotExist: 35 | continue 36 | try: 37 | if connection is None: 38 | relay_email_backend = getattr( 39 | settings, 40 | "EMAIL_BACKEND", 41 | "django.core.mail.backends.smtp.EmailBackend", 42 | ) 43 | connection = get_connection(backend=relay_email_backend) 44 | email = message.email 45 | if email is not None: 46 | email.connection = connection 47 | email.send() 48 | logger.debug(f"sent message {message.id}") 49 | message.mark_sent() 50 | counts["sent"] += 1 51 | else: 52 | msg = f"Message {message.id} has no email object" 53 | message.fail(log=msg) 54 | counts["failed"] += 1 55 | logger.warning(msg) 56 | except ( 57 | smtplib.SMTPAuthenticationError, 58 | smtplib.SMTPDataError, 59 | smtplib.SMTPRecipientsRefused, 60 | smtplib.SMTPSenderRefused, 61 | OSError, 62 | ) as err: 63 | if ( 64 | app_settings.EMAIL_MAX_RETRIES is not None 65 | and message.retry_count >= app_settings.EMAIL_MAX_RETRIES 66 | ): 67 | logger.warning( 68 | f"max retries reached, marking message {message.id} as failed" 69 | ) 70 | message.fail(log=str(err)) 71 | connection = None 72 | counts["failed"] += 1 73 | continue 74 | 75 | logger.debug( 76 | f"deferring message {message.id} due to {err}", exc_info=True 77 | ) 78 | message.defer(log=str(err)) 79 | connection = None 80 | counts["deferred"] += 1 81 | except Exception as err: 82 | logger.error( 83 | f"unexpected error processing message {message.id}, marking as failed.", 84 | exc_info=True, 85 | ) 86 | message.fail(log=str(err)) 87 | connection = None 88 | counts["failed"] += 1 89 | 90 | if ( 91 | app_settings.EMAIL_MAX_DEFERRED is not None 92 | and counts["deferred"] >= app_settings.EMAIL_MAX_DEFERRED 93 | ): 94 | logger.debug( 95 | f"max deferred emails reached ({app_settings.EMAIL_MAX_DEFERRED}), stopping" 96 | ) 97 | break 98 | 99 | if app_settings.EMAIL_THROTTLE > 0: 100 | logger.debug( 101 | f"throttling enabled, sleeping for {app_settings.EMAIL_THROTTLE} seconds" 102 | ) 103 | time.sleep(app_settings.EMAIL_THROTTLE) 104 | 105 | logger.info( 106 | f"sent {counts['sent']} emails, deferred {counts['deferred']} emails, failed {counts['failed']} emails" 107 | ) 108 | -------------------------------------------------------------------------------- /src/email_relay/service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import copy 5 | import os 6 | from typing import Any 7 | 8 | import django 9 | from django.conf import global_settings 10 | from django.conf import settings 11 | from django.core.management import call_command 12 | from environs import Env 13 | 14 | from .conf import EMAIL_RELAY_SETTINGS_NAME 15 | 16 | 17 | def get_user_settings_from_env() -> dict[str, Any]: 18 | """Get user-defined Django settings from environment variables. 19 | 20 | Returns: 21 | dict[str, Any]: Filtered and coerced dictionary containing valid Django settings. 22 | 23 | Example: 24 | >>> import os 25 | >>> os.environ['DEBUG'] = 'False' 26 | >>> get_user_settings_from_env() 27 | {'DEBUG': False} 28 | """ 29 | all_env_vars = {k: v for k, v in os.environ.items()} 30 | env_vars_dict = env_vars_to_nested_dict(all_env_vars) 31 | valid_settings = filter_valid_django_settings(env_vars_dict) 32 | return coerce_dict_values(valid_settings) 33 | 34 | 35 | def env_vars_to_nested_dict(env_vars: dict[str, Any]) -> dict[str, Any]: 36 | """Convert environment variables to a nested dictionary. 37 | 38 | Args: 39 | env_vars (dict[str, Any]): Dictionary of environment variables. 40 | 41 | Returns: 42 | dict[str, Any]: Nested dictionary derived from environment variables. 43 | 44 | Example: 45 | >>> env_vars_to_nested_dict({'DATABASES__default__ENGINE': 'django.db.backends.sqlite3'}) 46 | {'DATABASES': {'default': {'ENGINE': 'django.db.backends.sqlite3'}}} 47 | """ 48 | config: dict[str, Any] = {} 49 | for key, value in env_vars.items(): 50 | keys = key.split("__") 51 | d = config 52 | for k in keys[:-1]: 53 | d = d.setdefault(k, {}) 54 | d[keys[-1]] = value 55 | return config 56 | 57 | 58 | def filter_valid_django_settings(d: dict[str, Any]) -> dict[str, Any]: 59 | """Filter dictionary to only include valid Django settings. 60 | 61 | Args: 62 | d (dict[str, Any]): Input dictionary. 63 | 64 | Returns: 65 | dict[str, Any]: Filtered dictionary. 66 | 67 | Example: 68 | >>> filter_valid_django_settings({'DEBUG': True, 'INVALID_KEY': "invalid value"}) 69 | {'DEBUG': True} 70 | """ 71 | valid_settings = set(dir(global_settings)) 72 | valid_settings.add(EMAIL_RELAY_SETTINGS_NAME) 73 | return {k: v for k, v in d.items() if k in valid_settings} 74 | 75 | 76 | def coerce_dict_values(d: dict[str, Any]) -> dict[str, Any]: 77 | """Recursively coerces dictionary values to the appropriate type. 78 | 79 | Args: 80 | d (dict[str, Any]): Input dictionary. 81 | 82 | Returns: 83 | dict[str, Any]: Dictionary with values coerced. 84 | 85 | Example: 86 | >>> coerce_dict_values({'DEBUG': 'True', 'DATABASES': {'default': {'ENGINE': 'str', 'CONN_MAX_AGE': '600'}}}) 87 | {'DEBUG': True, 'DATABASES': {'default': {'ENGINE': 'str', 'CONN_MAX_AGE': 600}}} 88 | """ 89 | for key, value in d.items(): 90 | if isinstance(value, dict): 91 | d[key] = coerce_dict_values(value) 92 | else: 93 | if value.lower() == "true": 94 | d[key] = True 95 | elif value.lower() == "false": 96 | d[key] = False 97 | elif value.lower() == "none": 98 | d[key] = None 99 | elif value.isdigit(): 100 | d[key] = int(value) 101 | elif value.replace(".", "", 1).isdigit(): 102 | d[key] = float(value) 103 | else: 104 | d[key] = value 105 | return d 106 | 107 | 108 | def merge_with_defaults( 109 | default_dict: dict[str, Any], override_dict: dict[str, Any] 110 | ) -> dict[str, Any]: 111 | """Merge two dictionaries, updating and merging nested dictionaries. The override_dict takes precedence. 112 | 113 | Args: 114 | default_dict (dict[str, Any]): The dictionary containing default settings. 115 | override_dict (dict[str, Any]): The dictionary containing settings that should override the defaults. 116 | 117 | Returns: 118 | dict[str, Any]: Merged dictionary. 119 | 120 | Example: 121 | >>> default_dict = {'DEBUG': False, 'DATABASES': {'default': {'ENGINE': 'django.db.backends.sqlite3', 'CONN_MAX_AGE': 600}}} 122 | >>> override_dict = {'DEBUG': True, 'DATABASES': {'default': {'ENGINE': 'django.db.backends.postgresql'}}} 123 | >>> merge_with_defaults(default_dict, override_dict) 124 | {'DEBUG': True, 'DATABASES': {'default': {'ENGINE': 'django.db.backends.postgresql', 'CONN_MAX_AGE': 600}}} 125 | """ 126 | return_dict = copy.deepcopy(default_dict) 127 | 128 | for key, value in override_dict.items(): 129 | if isinstance(value, dict) and isinstance(return_dict.get(key), dict): 130 | return_dict[key] = merge_with_defaults(return_dict[key], value) 131 | else: 132 | return_dict[key] = value 133 | 134 | return return_dict 135 | 136 | 137 | env = Env() 138 | 139 | default_settings = { 140 | "DATABASES": { 141 | "default": env.dj_db_url("DATABASE_URL", default="sqlite://:memory:") 142 | }, 143 | "LOGGING": { 144 | "version": 1, 145 | "disable_existing_loggers": False, 146 | "handlers": { 147 | "console": { 148 | "class": "logging.StreamHandler", 149 | }, 150 | }, 151 | "root": { 152 | "handlers": ["console"], 153 | "level": env("LOG_LEVEL", "INFO"), 154 | }, 155 | }, 156 | "INSTALLED_APPS": [ 157 | "email_relay", 158 | ], 159 | } 160 | 161 | 162 | def run_relay_service() -> int: 163 | """Main entrypoint for the email relay service, designed to be run independently of a Django project. 164 | 165 | Returns: 166 | int: Exit code. Should always return 0 as `runrelay` is expected to run indefinitely. 167 | """ 168 | parser = argparse.ArgumentParser( 169 | description="Run the Django Email Relay service.", 170 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 171 | ) 172 | # just here so we get a --help for testing 173 | parser.parse_args() 174 | user_settings = get_user_settings_from_env() 175 | SETTINGS = merge_with_defaults(default_settings, user_settings) 176 | settings.configure(**SETTINGS) 177 | django.setup() 178 | call_command("migrate") 179 | print("Starting email relay service...") 180 | call_command("runrelay") 181 | # should never get here, `runrelay` is an infinite loop 182 | # but if it does, exit with 0 183 | return 0 184 | 185 | 186 | if __name__ == "__main__": 187 | raise SystemExit(run_relay_service()) 188 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-email-relay/3a5dc2af6c1fbd21d444a3a102c220e3ba2bd3ed/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from django.conf import settings 6 | 7 | from email_relay.conf import EMAIL_RELAY_DATABASE_ALIAS 8 | 9 | from .settings import DEFAULT_SETTINGS 10 | 11 | pytest_plugins = [] # type: ignore 12 | 13 | 14 | def pytest_configure(config): 15 | logging.disable(logging.CRITICAL) 16 | 17 | DEFAULT_SETTINGS.pop("DATABASES", None) 18 | 19 | settings.configure( 20 | **DEFAULT_SETTINGS, 21 | **TEST_SETTINGS, 22 | ) 23 | 24 | 25 | TEST_SETTINGS = { 26 | "DATABASES": { 27 | "default": { 28 | "ENGINE": "django.db.backends.sqlite3", 29 | "NAME": ":memory:", 30 | }, 31 | EMAIL_RELAY_DATABASE_ALIAS: { 32 | "ENGINE": "django.db.backends.sqlite3", 33 | "NAME": ":memory:", 34 | }, 35 | }, 36 | "DATABASE_ROUTERS": [ 37 | "email_relay.db.EmailDatabaseRouter", 38 | ], 39 | "INSTALLED_APPS": [ 40 | "django.contrib.contenttypes", 41 | "email_relay", 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEFAULT_SETTINGS = { 4 | "ALLOWED_HOSTS": ["*"], 5 | "DEBUG": False, 6 | "CACHES": { 7 | "default": { 8 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 9 | } 10 | }, 11 | "DATABASES": { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | } 16 | }, 17 | "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", 18 | "LOGGING_CONFIG": None, 19 | "PASSWORD_HASHERS": [ 20 | "django.contrib.auth.hashers.MD5PasswordHasher", 21 | ], 22 | "SECRET_KEY": "not-a-secret", 23 | } 24 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.core.mail import EmailMessage 6 | from django.core.mail import send_mail 7 | from django.test.utils import override_settings 8 | 9 | from email_relay.models import Message 10 | 11 | 12 | @pytest.fixture(scope="module", autouse=True) 13 | def relay_backend(): 14 | with override_settings( 15 | EMAIL_BACKEND="email_relay.backend.RelayDatabaseEmailBackend" 16 | ): 17 | yield 18 | 19 | 20 | def test_fixture(): 21 | assert settings.EMAIL_BACKEND == "email_relay.backend.RelayDatabaseEmailBackend" 22 | 23 | 24 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 25 | def test_send_mail(): 26 | assert Message.objects.count() == 0 27 | 28 | send_mail( 29 | "Subject here", 30 | "Here is the message.", 31 | "from_test@example.com", 32 | ["to_test@example.com"], 33 | ) 34 | 35 | assert Message.objects.count() == 1 36 | 37 | 38 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 39 | def test_email_message(): 40 | assert Message.objects.count() == 0 41 | 42 | email = EmailMessage( 43 | "Subject here", 44 | "Here is the message.", 45 | "from_test@example.com", 46 | ["to_test@example.com"], 47 | ) 48 | 49 | email.send() 50 | 51 | assert Message.objects.count() == 1 52 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.test import override_settings 6 | 7 | from email_relay.conf import app_settings 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "setting,default_setting", 12 | [ 13 | ("DATABASE_ALIAS", "email_relay_db"), 14 | ("EMAIL_MAX_BATCH", None), 15 | ("EMAIL_MAX_DEFERRED", None), 16 | ("EMAIL_MAX_RETRIES", None), 17 | ("EMPTY_QUEUE_SLEEP", 30), 18 | ("EMAIL_THROTTLE", 0), 19 | ("MESSAGES_BATCH_SIZE", None), 20 | ("MESSAGES_RETENTION_SECONDS", None), 21 | ("RELAY_HEALTHCHECK_METHOD", "GET"), 22 | ("RELAY_HEALTHCHECK_STATUS_CODE", 200), 23 | ("RELAY_HEALTHCHECK_TIMEOUT", 5.0), 24 | ("RELAY_HEALTHCHECK_URL", None), 25 | ], 26 | ) 27 | def test_default_settings(setting, default_setting): 28 | user_settings = getattr(settings, "DJANGO_EMAIL_RELAY", {}) 29 | 30 | assert user_settings == {} 31 | assert getattr(app_settings, setting) == default_setting 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "setting,user_setting", 36 | [ 37 | ("DATABASE_ALIAS", "custom_db_name"), 38 | ("EMAIL_MAX_BATCH", 10), 39 | ("EMAIL_MAX_DEFERRED", 10), 40 | ("EMAIL_MAX_RETRIES", 10), 41 | ("EMPTY_QUEUE_SLEEP", 1), 42 | ("EMAIL_THROTTLE", 1), 43 | ("MESSAGES_BATCH_SIZE", 10), 44 | ("MESSAGES_RETENTION_SECONDS", 10), 45 | ("RELAY_HEALTHCHECK_METHOD", "POST"), 46 | ("RELAY_HEALTHCHECK_STATUS_CODE", 201), 47 | ("RELAY_HEALTHCHECK_TIMEOUT", 10.0), 48 | ("RELAY_HEALTHCHECK_URL", "http://example.com/healthcheck"), 49 | ], 50 | ) 51 | def test_custom_settings(setting, user_setting): 52 | with override_settings( 53 | DJANGO_EMAIL_RELAY={ 54 | setting: user_setting, 55 | }, 56 | ): 57 | assert getattr(app_settings, setting) == user_setting 58 | -------------------------------------------------------------------------------- /tests/test_email.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dirty_equals import IsPartialDict 4 | from django.core.mail import EmailMessage 5 | from django.core.mail import EmailMultiAlternatives 6 | 7 | from email_relay.email import RelayEmailData 8 | from email_relay.email import __version__ 9 | 10 | 11 | def test_from_email_message(): 12 | email_message = EmailMessage( 13 | "Subject here", 14 | "Here is the message.", 15 | "from@example.com", 16 | ["to@example.com"], 17 | cc=["cc@example.com"], 18 | bcc=["bcc@example.com"], 19 | reply_to=["reply_to@example.com"], 20 | headers={"Test-Header": "Test Value"}, 21 | ) 22 | 23 | relay_email_data = RelayEmailData.from_email_message(email_message) 24 | 25 | assert relay_email_data.subject == email_message.subject 26 | assert relay_email_data.body == email_message.body 27 | assert relay_email_data.from_email == email_message.from_email 28 | assert relay_email_data.to == email_message.to 29 | assert relay_email_data.cc == email_message.cc 30 | assert relay_email_data.bcc == email_message.bcc 31 | assert relay_email_data.reply_to == email_message.reply_to 32 | assert relay_email_data.extra_headers == email_message.extra_headers 33 | assert relay_email_data.alternatives == [] 34 | assert relay_email_data.attachments == [] 35 | 36 | 37 | def test_from_email_message_multi_alternatives(): 38 | email_multi_alternatives = EmailMultiAlternatives( 39 | "Subject here", 40 | "Here is the message.", 41 | "from@example.com", 42 | ["to@example.com"], 43 | cc=["cc@example.com"], 44 | bcc=["bcc@example.com"], 45 | reply_to=["reply_to@example.com"], 46 | headers={"Test-Header": "Test Value"}, 47 | ) 48 | email_multi_alternatives.attach_alternative( 49 | "

Here is the message.

", "text/html" 50 | ) 51 | 52 | relay_email_data = RelayEmailData.from_email_message(email_multi_alternatives) 53 | 54 | assert relay_email_data.subject == email_multi_alternatives.subject 55 | assert relay_email_data.body == email_multi_alternatives.body 56 | assert relay_email_data.from_email == email_multi_alternatives.from_email 57 | assert relay_email_data.to == email_multi_alternatives.to 58 | assert relay_email_data.cc == email_multi_alternatives.cc 59 | assert relay_email_data.bcc == email_multi_alternatives.bcc 60 | assert relay_email_data.reply_to == email_multi_alternatives.reply_to 61 | assert relay_email_data.extra_headers == email_multi_alternatives.extra_headers 62 | assert relay_email_data.alternatives == email_multi_alternatives.alternatives 63 | assert relay_email_data.attachments == [] 64 | 65 | 66 | def test_to_dict(): 67 | email_message = EmailMessage( 68 | "Subject here", 69 | "Here is the message.", 70 | "from@example.com", 71 | ["to@example.com"], 72 | cc=["cc@example.com"], 73 | bcc=["bcc@example.com"], 74 | reply_to=["reply_to@example.com"], 75 | headers={"Test-Header": "Test Value"}, 76 | ) 77 | 78 | email_dict = RelayEmailData.from_email_message(email_message).to_dict() 79 | 80 | assert email_dict == IsPartialDict( 81 | **{ 82 | "subject": email_message.subject, 83 | "body": email_message.body, 84 | "from_email": email_message.from_email, 85 | "to": email_message.to, 86 | "cc": email_message.cc, 87 | "bcc": email_message.bcc, 88 | "reply_to": email_message.reply_to, 89 | "extra_headers": email_message.extra_headers, 90 | "alternatives": [], 91 | "attachments": [], 92 | } 93 | ) 94 | 95 | 96 | def test_to_dict_multi_alternatives(): 97 | email_multi_alternatives = EmailMultiAlternatives( 98 | "Subject here", 99 | "Here is the message.", 100 | "from@example.com", 101 | ["to@example.com"], 102 | cc=["cc@example.com"], 103 | bcc=["bcc@example.com"], 104 | reply_to=["reply_to@example.com"], 105 | headers={"Test-Header": "Test Value"}, 106 | ) 107 | email_multi_alternatives.attach_alternative( 108 | "

Here is the message.

", "text/html" 109 | ) 110 | 111 | email_dict = RelayEmailData.from_email_message(email_multi_alternatives).to_dict() 112 | 113 | assert email_dict == IsPartialDict( 114 | **{ 115 | "subject": email_multi_alternatives.subject, 116 | "body": email_multi_alternatives.body, 117 | "from_email": email_multi_alternatives.from_email, 118 | "to": email_multi_alternatives.to, 119 | "cc": email_multi_alternatives.cc, 120 | "bcc": email_multi_alternatives.bcc, 121 | "reply_to": email_multi_alternatives.reply_to, 122 | "extra_headers": email_multi_alternatives.extra_headers, 123 | "alternatives": email_multi_alternatives.alternatives, 124 | "attachments": [], 125 | } 126 | ) 127 | 128 | 129 | def test_to_dict_with_attachment(): 130 | email = EmailMessage( 131 | "Subject here", 132 | "Here is the message.", 133 | "from@example.com", 134 | ["to@example.com"], 135 | cc=["cc@example.com"], 136 | bcc=["bcc@example.com"], 137 | reply_to=["reply_to@example.com"], 138 | headers={"Test-Header": "Test Value"}, 139 | ) 140 | attachment_content = b"Hello World!" 141 | email.attach( 142 | filename="test.txt", 143 | content=attachment_content, 144 | mimetype="text/plain", 145 | ) 146 | 147 | email_dict = RelayEmailData.from_email_message(email).to_dict() 148 | 149 | assert email_dict == IsPartialDict( 150 | **{ 151 | "subject": email.subject, 152 | "body": email.body, 153 | "from_email": email.from_email, 154 | "to": email.to, 155 | "cc": email.cc, 156 | "bcc": email.bcc, 157 | "reply_to": email.reply_to, 158 | "extra_headers": email.extra_headers, 159 | "alternatives": [], 160 | "attachments": [ 161 | { 162 | "filename": "test.txt", 163 | "content": attachment_content.decode(), 164 | "mimetype": "text/plain", 165 | } 166 | ], 167 | } 168 | ) 169 | 170 | 171 | def test_email_message_version(): 172 | email_message = EmailMessage( 173 | "Subject here", 174 | "Here is the message.", 175 | "from@example.com", 176 | ["to@example.com"], 177 | cc=["cc@example.com"], 178 | bcc=["bcc@example.com"], 179 | reply_to=["reply_to@example.com"], 180 | headers={"Test-Header": "Test Value"}, 181 | ) 182 | 183 | relay_email_data = RelayEmailData.from_email_message(email_message) 184 | 185 | assert relay_email_data._email_relay_version == __version__ 186 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | 5 | import pytest 6 | from django.apps import apps 7 | from django.db import connections 8 | from model_bakery import baker 9 | 10 | from email_relay.conf import EMAIL_RELAY_DATABASE_ALIAS 11 | from email_relay.models import Message 12 | 13 | 14 | @pytest.fixture 15 | def migrate_message_data_to_new_schema(): 16 | return importlib.import_module( 17 | "email_relay.migrations.0002_auto_20231030_1304" 18 | ).migrate_message_data_to_new_schema 19 | 20 | 21 | @pytest.mark.django_db(databases=["default", EMAIL_RELAY_DATABASE_ALIAS]) 22 | def test_migrate_message_data_to_new_schema(migrate_message_data_to_new_schema): 23 | class MockSchemaEditor: 24 | connection = connections[EMAIL_RELAY_DATABASE_ALIAS] 25 | 26 | baker.make( 27 | "email_relay.Message", 28 | data={ 29 | "message": "Here is the message.", 30 | "recipient_list": ["to@example.com"], 31 | "html_message": "

HTML

", 32 | }, 33 | _quantity=3, 34 | ) 35 | 36 | for message in Message.objects.all(): 37 | assert message.data["message"] == "Here is the message." 38 | assert message.data["recipient_list"] == ["to@example.com"] 39 | assert message.data["html_message"] == "

HTML

" 40 | assert not message.data.get("to") 41 | assert not message.data.get("cc") 42 | assert not message.data.get("bcc") 43 | assert not message.data.get("reply_to") 44 | assert not message.data.get("extra_headers") 45 | assert not message.data.get("alternatives") 46 | 47 | migrate_message_data_to_new_schema(apps, MockSchemaEditor()) 48 | 49 | assert Message.objects.count() == 3 50 | 51 | for message in Message.objects.all(): 52 | assert not message.data.get("message") 53 | assert not message.data.get("recipient_list") 54 | assert not message.data.get("html_message") 55 | assert message.data["body"] == "Here is the message." 56 | assert message.data["to"] == ["to@example.com"] 57 | assert message.data["cc"] == [] 58 | assert message.data["bcc"] == [] 59 | assert message.data["reply_to"] == [] 60 | assert message.data["extra_headers"] == {} 61 | assert message.data["alternatives"] == [ 62 | ["

HTML

", "text/html"], 63 | ] 64 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import datetime 5 | from email.mime.base import MIMEBase 6 | 7 | import pytest 8 | from django.core.mail import EmailMessage 9 | from django.core.mail import EmailMultiAlternatives 10 | from django.test import override_settings 11 | from django.utils import timezone 12 | from model_bakery import baker 13 | 14 | from email_relay.models import Message 15 | from email_relay.models import Priority 16 | from email_relay.models import Status 17 | 18 | 19 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 20 | def test_message(): 21 | baker.make("email_relay.Message") 22 | assert Message.objects.all().count() == 1 23 | 24 | 25 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 26 | class TestMessageManager: 27 | def test_get_message_batch(self): 28 | baker.make("email_relay.Message", status=Status.QUEUED, _quantity=5) 29 | baker.make("email_relay.Message", status=Status.DEFERRED, _quantity=5) 30 | 31 | message_batch = Message.objects.get_message_batch() 32 | 33 | assert len(message_batch) == 10 34 | 35 | @override_settings( 36 | DJANGO_EMAIL_RELAY={ 37 | "EMAIL_MAX_BATCH": 1, 38 | } 39 | ) 40 | def test_get_message_batch_with_max_batch_size(self): 41 | baker.make("email_relay.Message", status=Status.QUEUED, _quantity=5) 42 | baker.make("email_relay.Message", status=Status.DEFERRED, _quantity=5) 43 | 44 | message_batch = Message.objects.get_message_batch() 45 | 46 | assert len(message_batch) == 1 47 | 48 | def test_get_message_for_sending(self): 49 | message = baker.make("email_relay.Message", status=Status.QUEUED) 50 | 51 | message_for_sending = Message.objects.get_message_for_sending(message.id) 52 | 53 | assert message_for_sending == message 54 | 55 | @pytest.mark.parametrize( 56 | "status, expected", 57 | [ 58 | (Status.QUEUED, True), 59 | (Status.DEFERRED, True), 60 | (Status.FAILED, False), 61 | (Status.SENT, False), 62 | ], 63 | ) 64 | def test_messages_available_to_send(self, status, expected): 65 | baker.make("email_relay.Message", status=status) 66 | 67 | assert Message.objects.messages_available_to_send() == expected 68 | 69 | def test_messages_available_to_send_with_no_messages(self): 70 | assert not Message.objects.messages_available_to_send() 71 | 72 | def test_delete_all_sent_messages(self): 73 | baker.make("email_relay.Message", status=Status.SENT, _quantity=5) 74 | 75 | deleted_messages = Message.objects.delete_all_sent_messages() 76 | 77 | assert deleted_messages == 5 78 | assert Message.objects.count() == 0 79 | 80 | def test_delete_messages_sent_before(self): 81 | one_week = baker.make( 82 | "email_relay.Message", 83 | status=Status.SENT, 84 | sent_at=timezone.now() - datetime.timedelta(days=7), 85 | ) 86 | now = baker.make( 87 | "email_relay.Message", 88 | status=Status.SENT, 89 | sent_at=timezone.now(), 90 | ) 91 | not_sent = baker.make( 92 | "email_relay.Message", 93 | status=Status.QUEUED, 94 | sent_at=None, 95 | ) 96 | 97 | deleted_messages = Message.objects.delete_messages_sent_before( 98 | timezone.now() - datetime.timedelta(days=1) 99 | ) 100 | 101 | assert deleted_messages == 1 102 | assert Message.objects.count() == 2 103 | 104 | messages = Message.objects.all() 105 | assert one_week not in messages 106 | assert now in messages 107 | assert not_sent in messages 108 | 109 | 110 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 111 | class TestMessageQuerySet: 112 | @pytest.fixture 113 | def messages_with_priority(self): 114 | low = baker.make("email_relay.Message", priority=Priority.LOW) 115 | medium = baker.make("email_relay.Message", priority=Priority.MEDIUM) 116 | high = baker.make("email_relay.Message", priority=Priority.HIGH) 117 | return { 118 | "low": low, 119 | "medium": medium, 120 | "high": high, 121 | } 122 | 123 | @pytest.fixture 124 | def messages_with_status(self): 125 | queued = baker.make("email_relay.Message", status=Status.QUEUED) 126 | deferred = baker.make("email_relay.Message", status=Status.DEFERRED) 127 | failed = baker.make("email_relay.Message", status=Status.FAILED) 128 | sent = baker.make("email_relay.Message", status=Status.SENT) 129 | return { 130 | "queued": queued, 131 | "deferred": deferred, 132 | "failed": failed, 133 | "sent": sent, 134 | } 135 | 136 | def test_prioritized(self, messages_with_priority): 137 | queryset = Message.objects.prioritized() 138 | 139 | assert queryset.count() == 3 140 | assert queryset[0] == messages_with_priority["high"] 141 | assert queryset[1] == messages_with_priority["medium"] 142 | assert queryset[2] == messages_with_priority["low"] 143 | 144 | def test_high_priority(self, messages_with_priority): 145 | queryset = Message.objects.high_priority() 146 | 147 | assert queryset.count() == 1 148 | assert queryset[0] == messages_with_priority["high"] 149 | 150 | def test_medium_priority(self, messages_with_priority): 151 | queryset = Message.objects.medium_priority() 152 | 153 | assert queryset.count() == 1 154 | assert queryset[0] == messages_with_priority["medium"] 155 | 156 | def test_low_priority(self, messages_with_priority): 157 | queryset = Message.objects.low_priority() 158 | 159 | assert queryset.count() == 1 160 | assert queryset[0] == messages_with_priority["low"] 161 | 162 | def test_queued(self, messages_with_status): 163 | queryset = Message.objects.queued() 164 | 165 | assert queryset.count() == 1 166 | assert queryset[0] == messages_with_status["queued"] 167 | 168 | def test_deferred(self, messages_with_status): 169 | queryset = Message.objects.deferred() 170 | 171 | assert queryset.count() == 1 172 | assert queryset[0] == messages_with_status["deferred"] 173 | 174 | def test_failed(self, messages_with_status): 175 | queryset = Message.objects.failed() 176 | 177 | assert queryset.count() == 1 178 | assert queryset[0] == messages_with_status["failed"] 179 | 180 | def test_sent(self, messages_with_status): 181 | queryset = Message.objects.sent() 182 | 183 | assert queryset.count() == 1 184 | assert queryset[0] == messages_with_status["sent"] 185 | 186 | def test_sent_before(self): 187 | one_week = baker.make( 188 | "email_relay.Message", 189 | status=Status.SENT, 190 | sent_at=timezone.now() - datetime.timedelta(days=7), 191 | ) 192 | now = baker.make( 193 | "email_relay.Message", 194 | status=Status.SENT, 195 | sent_at=timezone.now(), 196 | ) 197 | not_sent = baker.make( 198 | "email_relay.Message", 199 | status=Status.QUEUED, 200 | sent_at=None, 201 | ) 202 | 203 | queryset = Message.objects.sent_before( 204 | timezone.now() - datetime.timedelta(days=1) 205 | ) 206 | 207 | assert queryset.count() == 1 208 | assert one_week in queryset 209 | assert now not in queryset 210 | assert not_sent not in queryset 211 | 212 | 213 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 214 | class TestMessageModel: 215 | @pytest.fixture 216 | def data(self): 217 | return { 218 | "subject": "Test", 219 | "body": "Test", 220 | "from_email": "from@example.com", 221 | "to": ["to@example.com"], 222 | } 223 | 224 | @pytest.fixture 225 | def email(self): 226 | return EmailMultiAlternatives( 227 | subject="Test", 228 | body="Test", 229 | from_email="from@example.com", 230 | to=["to@example.com"], 231 | ) 232 | 233 | @pytest.fixture 234 | def queued_message(self, data): 235 | return baker.make("email_relay.Message", data=data, status=Status.QUEUED) 236 | 237 | def test_create(self, data): 238 | message = baker.make("email_relay.Message", data=data) 239 | 240 | assert message.data == data 241 | assert message.priority == Priority.LOW 242 | assert message.status == Status.QUEUED 243 | assert message.retry_count == 0 244 | assert message.log == "" 245 | assert message.sent_at is None 246 | 247 | def test_str(self, data): 248 | message = baker.make("email_relay.Message", data=data) 249 | 250 | assert data["subject"] in str(message) 251 | 252 | def test_str_invalid_data(self): 253 | message = baker.make("email_relay.Message", data={}) 254 | 255 | assert "invalid message" in str(message) 256 | 257 | def test_update_with_update_fields(self, data): 258 | message = baker.make("email_relay.Message", data=data) 259 | updated_at_original = message.updated_at 260 | 261 | message.retry_count = 1 262 | message.save(update_fields=["retry_count"]) 263 | 264 | assert message.updated_at != updated_at_original 265 | 266 | def test_mark_sent(self, queued_message): 267 | queued_message.mark_sent() 268 | 269 | assert queued_message.status == Status.SENT 270 | 271 | def test_defer(self, queued_message): 272 | queued_message.defer() 273 | 274 | assert queued_message.status == Status.DEFERRED 275 | 276 | def test_fail(self, queued_message): 277 | queued_message.fail() 278 | 279 | assert queued_message.status == Status.FAILED 280 | 281 | def test_no_data(self): 282 | message = baker.make("email_relay.Message", data={}) 283 | 284 | assert message.data == {} 285 | assert message.email is None 286 | 287 | def test_email_property(self, data): 288 | message = Message.objects.create(data=data) 289 | 290 | email = message.email 291 | 292 | assert isinstance(email, EmailMessage) 293 | assert email.subject == data["subject"] 294 | assert email.body == data["body"] 295 | assert email.from_email == data["from_email"] 296 | assert email.to == data["to"] 297 | 298 | def test_email_setter(self, data): 299 | message = Message.objects.create(data=data) 300 | email = EmailMultiAlternatives( 301 | subject="Test 2", 302 | body="Test 2", 303 | from_email="from2@example.com", 304 | to=["to2@example.com"], 305 | ) 306 | 307 | message.email = email 308 | message.save() 309 | 310 | assert message.data["subject"] == email.subject 311 | assert message.data["body"] == email.body 312 | assert message.data["from_email"] == email.from_email 313 | assert message.data["to"] == email.to 314 | 315 | def test_email_with_plain_text_attachment(self, email): 316 | attachment_content = b"Hello World!" 317 | email.attach( 318 | filename="test.txt", 319 | content=attachment_content, 320 | mimetype="text/plain", 321 | ) 322 | 323 | message = Message() 324 | message.email = email 325 | message.save() 326 | 327 | assert Message.objects.count() == 1 328 | 329 | saved_message = Message.objects.first() 330 | assert saved_message.data["attachments"][0]["filename"] == "test.txt" 331 | assert saved_message.data["attachments"][0][ 332 | "content" 333 | ] == attachment_content.decode("utf-8") 334 | assert saved_message.data["attachments"][0]["mimetype"] == "text/plain" 335 | 336 | email_from_db = saved_message.email 337 | assert email_from_db.attachments[0][0] == "test.txt" 338 | assert email_from_db.attachments[0][1] == attachment_content.decode("utf-8") 339 | assert email_from_db.attachments[0][2] == "text/plain" 340 | 341 | def test_email_with_binary_attachment(self, email, faker): 342 | attachment_content = faker.binary(length=10) 343 | email.attach( 344 | filename="test.zip", 345 | content=attachment_content, 346 | mimetype="application/zip", 347 | ) 348 | 349 | message = Message() 350 | message.email = email 351 | message.save() 352 | 353 | assert Message.objects.count() == 1 354 | 355 | saved_message = Message.objects.first() 356 | assert saved_message.data["attachments"][0]["filename"] == "test.zip" 357 | assert saved_message.data["attachments"][0]["content"] == base64.b64encode( 358 | attachment_content 359 | ).decode("utf-8") 360 | assert saved_message.data["attachments"][0]["mimetype"] == "application/zip" 361 | 362 | email_from_db = saved_message.email 363 | assert email_from_db.attachments[0][0] == "test.zip" 364 | assert email_from_db.attachments[0][1] == attachment_content 365 | assert email_from_db.attachments[0][2] == "application/zip" 366 | 367 | def test_email_with_mimebase_attachment(self, email): 368 | attachment_content = b"Hello World!" 369 | attachment = MIMEBase("application", "octet-stream") 370 | attachment["Content-Disposition"] = 'attachment; filename="test.txt"' 371 | attachment.set_payload(attachment_content) 372 | email.attach(attachment) 373 | 374 | message = Message() 375 | message.email = email 376 | message.save() 377 | 378 | assert Message.objects.count() == 1 379 | 380 | saved_message = Message.objects.first() 381 | assert saved_message.data["attachments"][0]["filename"] == "test.txt" 382 | assert saved_message.data["attachments"][0]["content"] == base64.b64encode( 383 | attachment_content 384 | ).decode("utf-8") 385 | assert ( 386 | saved_message.data["attachments"][0]["mimetype"] 387 | == "application/octet-stream" 388 | ) 389 | 390 | email_from_db = saved_message.email 391 | assert email_from_db.attachments[0][0] == "test.txt" 392 | assert email_from_db.attachments[0][1] == attachment_content 393 | assert email_from_db.attachments[0][2] == "application/octet-stream" 394 | 395 | def test_email_send(self, email, mailoutbox): 396 | message = Message() 397 | message.email = email 398 | message.save() 399 | 400 | message.email.send() 401 | 402 | assert len(mailoutbox) == 1 403 | 404 | def test_email_send_with_plain_text_attachment(self, email, mailoutbox): 405 | email.attach( 406 | filename="test.txt", 407 | content=b"Hello World!", 408 | mimetype="text/plain", 409 | ) 410 | message = Message() 411 | message.email = email 412 | message.save() 413 | 414 | message.email.send() 415 | 416 | assert len(mailoutbox) == 1 417 | 418 | def test_email_send_with_binary_attachment(self, email, faker, mailoutbox): 419 | email.attach( 420 | filename="test.zip", 421 | content=faker.binary(length=10), 422 | mimetype="application/zip", 423 | ) 424 | message = Message() 425 | message.email = email 426 | message.save() 427 | 428 | message.email.send() 429 | 430 | assert len(mailoutbox) == 1 431 | 432 | def test_email_send_with_mimebase_attachment(self, email, mailoutbox): 433 | attachment = MIMEBase("application", "octet-stream") 434 | attachment["Content-Disposition"] = 'attachment; filename="test.txt"' 435 | attachment.set_payload(b"Hello World!") 436 | email.attach(attachment) 437 | 438 | message = Message() 439 | message.email = email 440 | message.save() 441 | 442 | message.email.send() 443 | 444 | assert len(mailoutbox) == 1 445 | -------------------------------------------------------------------------------- /tests/test_public_email_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.core.mail import EmailMessage 6 | from django.core.mail import EmailMultiAlternatives 7 | from django.core.mail import mail_admins 8 | from django.core.mail import mail_managers 9 | from django.core.mail import send_mail 10 | from django.core.mail import send_mass_mail 11 | from django.test import override_settings 12 | 13 | from email_relay.conf import EMAIL_RELAY_DATABASE_ALIAS 14 | from email_relay.models import Message 15 | 16 | pytestmark = pytest.mark.django_db(databases=["default", EMAIL_RELAY_DATABASE_ALIAS]) 17 | 18 | 19 | @pytest.fixture(autouse=True, scope="module") 20 | def relay_email_backend(): 21 | with override_settings( 22 | EMAIL_BACKEND="email_relay.backend.RelayDatabaseEmailBackend", 23 | ): 24 | yield 25 | 26 | 27 | def test_send_mail(mailoutbox): 28 | send_mail( 29 | "Subject here", 30 | "Here is the message.", 31 | "from@example.com", 32 | ["to@example.com"], 33 | html_message="

Here is the message.

", 34 | ) 35 | 36 | assert Message.objects.count() == 1 37 | assert len(mailoutbox) == 0 38 | 39 | email = Message.objects.get().email 40 | assert email.subject == "Subject here" 41 | assert email.body == "Here is the message." 42 | assert email.from_email == "from@example.com" 43 | assert email.to == ["to@example.com"] 44 | assert email.alternatives == [("

Here is the message.

", "text/html")] 45 | 46 | 47 | def test_send_mass_mail(mailoutbox): 48 | send_mass_mail( 49 | ( 50 | ( 51 | "Subject here", 52 | "Here is the message.", 53 | "from@example.com", 54 | ["to@example.com"], 55 | ), 56 | ( 57 | "Another subject", 58 | "Here is another message.", 59 | "from@example.com", 60 | ["to@example.com"], 61 | ), 62 | ), 63 | ) 64 | 65 | assert Message.objects.count() == 2 66 | assert len(mailoutbox) == 0 67 | 68 | 69 | @override_settings(ADMINS=[("Admin", "admin@example.com")]) 70 | def test_mail_admins(mailoutbox): 71 | mail_admins( 72 | "Subject here", 73 | "Here is the message.", 74 | ) 75 | 76 | assert Message.objects.count() == 1 77 | assert len(mailoutbox) == 0 78 | 79 | email = Message.objects.get().email 80 | assert email.subject == f"{settings.EMAIL_SUBJECT_PREFIX}Subject here" 81 | assert email.body == "Here is the message." 82 | assert email.from_email == settings.SERVER_EMAIL 83 | assert email.to == ["admin@example.com"] 84 | 85 | 86 | @override_settings(MANAGERS=[("Manager", "manager@example.com")]) 87 | def test_mail_managers(mailoutbox): 88 | mail_managers( 89 | "Subject here", 90 | "Here is the message.", 91 | ) 92 | 93 | assert Message.objects.count() == 1 94 | assert len(mailoutbox) == 0 95 | 96 | email = Message.objects.get().email 97 | assert email.subject == f"{settings.EMAIL_SUBJECT_PREFIX}Subject here" 98 | assert email.body == "Here is the message." 99 | assert email.from_email == settings.SERVER_EMAIL 100 | assert email.to == ["manager@example.com"] 101 | 102 | 103 | def test_emailmessage(mailoutbox): 104 | email_message = EmailMessage( 105 | "Subject here", 106 | "Here is the message.", 107 | "from@example.com", 108 | ["to@example.com"], 109 | cc=["cc@example.com"], 110 | bcc=["bcc@example.com"], 111 | reply_to=["reply_to@example.com"], 112 | headers={"Test-Header": "Test Value"}, 113 | ) 114 | 115 | email_message.send() 116 | 117 | assert Message.objects.count() == 1 118 | assert len(mailoutbox) == 0 119 | 120 | email = Message.objects.get().email 121 | assert email.subject == "Subject here" 122 | assert email.body == "Here is the message." 123 | assert email.from_email == "from@example.com" 124 | assert email.to == ["to@example.com"] 125 | assert email.cc == ["cc@example.com"] 126 | assert email.bcc == ["bcc@example.com"] 127 | assert email.reply_to == ["reply_to@example.com"] 128 | assert email.extra_headers == {"Test-Header": "Test Value"} 129 | 130 | 131 | def test_emailmessage_attach(mailoutbox): 132 | email_message = EmailMessage( 133 | "Subject here", 134 | "Here is the message.", 135 | "from@example.com", 136 | ["to@example.com"], 137 | cc=["cc@example.com"], 138 | bcc=["bcc@example.com"], 139 | reply_to=["reply_to@example.com"], 140 | headers={"Test-Header": "Test Value"}, 141 | ) 142 | email_message.attach("attachment.txt", "Here is the attachment.") 143 | 144 | email_message.send() 145 | 146 | assert Message.objects.count() == 1 147 | assert len(mailoutbox) == 0 148 | 149 | email = Message.objects.get().email 150 | assert email.subject == "Subject here" 151 | assert email.body == "Here is the message." 152 | assert email.from_email == "from@example.com" 153 | assert email.to == ["to@example.com"] 154 | assert email.cc == ["cc@example.com"] 155 | assert email.bcc == ["bcc@example.com"] 156 | assert email.reply_to == ["reply_to@example.com"] 157 | assert email.extra_headers == {"Test-Header": "Test Value"} 158 | assert email.attachments == [ 159 | ("attachment.txt", "Here is the attachment.", "text/plain"), 160 | ] 161 | 162 | 163 | def test_emailmessage_attach_file(tmp_path, mailoutbox): 164 | email_message = EmailMessage( 165 | "Subject here", 166 | "Here is the message.", 167 | "from@example.com", 168 | ["to@example.com"], 169 | cc=["cc@example.com"], 170 | bcc=["bcc@example.com"], 171 | reply_to=["reply_to@example.com"], 172 | headers={"Test-Header": "Test Value"}, 173 | ) 174 | file_path = tmp_path / "attachment.txt" 175 | file_path.write_text("Here is the attachment.") 176 | email_message.attach_file(str(file_path)) 177 | 178 | email_message.send() 179 | 180 | assert Message.objects.count() == 1 181 | assert len(mailoutbox) == 0 182 | 183 | email = Message.objects.get().email 184 | assert email.subject == "Subject here" 185 | assert email.body == "Here is the message." 186 | assert email.from_email == "from@example.com" 187 | assert email.to == ["to@example.com"] 188 | assert email.cc == ["cc@example.com"] 189 | assert email.bcc == ["bcc@example.com"] 190 | assert email.reply_to == ["reply_to@example.com"] 191 | assert email.extra_headers == {"Test-Header": "Test Value"} 192 | assert email.attachments == [ 193 | ("attachment.txt", "Here is the attachment.", "text/plain"), 194 | ] 195 | 196 | 197 | def test_emailmessagealternatives(mailoutbox): 198 | email_multi_alternatives = EmailMultiAlternatives( 199 | "Subject here", 200 | "Here is the message.", 201 | "from@example.com", 202 | ["to@example.com"], 203 | cc=["cc@example.com"], 204 | bcc=["bcc@example.com"], 205 | reply_to=["reply_to@example.com"], 206 | headers={"Test-Header": "Test Value"}, 207 | ) 208 | email_multi_alternatives.attach_alternative( 209 | "

Here is the message.

", "text/html" 210 | ) 211 | 212 | email_multi_alternatives.send() 213 | 214 | assert Message.objects.count() == 1 215 | assert len(mailoutbox) == 0 216 | 217 | email = Message.objects.get().email 218 | assert email.subject == "Subject here" 219 | assert email.body == "Here is the message." 220 | assert email.from_email == "from@example.com" 221 | assert email.to == ["to@example.com"] 222 | assert email.cc == ["cc@example.com"] 223 | assert email.bcc == ["bcc@example.com"] 224 | assert email.reply_to == ["reply_to@example.com"] 225 | assert email.extra_headers == {"Test-Header": "Test Value"} 226 | assert email.alternatives == [ 227 | ("

Here is the message.

", "text/html"), 228 | ] 229 | -------------------------------------------------------------------------------- /tests/test_relay.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import smtplib 5 | from unittest import mock 6 | 7 | import pytest 8 | from django.core.mail import EmailMultiAlternatives 9 | from django.test import override_settings 10 | from model_bakery import baker 11 | 12 | from email_relay.conf import EMAIL_RELAY_DATABASE_ALIAS 13 | from email_relay.models import Message 14 | from email_relay.models import Priority 15 | from email_relay.models import Status 16 | from email_relay.relay import send_all 17 | 18 | pytestmark = pytest.mark.django_db(databases=["default", EMAIL_RELAY_DATABASE_ALIAS]) 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def caplog_level(caplog): 23 | with caplog.at_level(logging.DEBUG): 24 | yield 25 | 26 | 27 | def test_send_all_empty_queue(mailoutbox, caplog): 28 | send_all() 29 | 30 | assert len(mailoutbox) == 0 31 | assert Message.objects.count() == 0 32 | assert "sending emails" in caplog.text 33 | assert "sent 0 emails, deferred 0 emails, failed 0 emails" in caplog.text 34 | 35 | 36 | def test_send_all_single_message(mailoutbox, caplog): 37 | queued = baker.make( 38 | "email_relay.Message", 39 | data={ 40 | "subject": "Test Subject", 41 | "body": "Test Body", 42 | "from_email": "from@example.com", 43 | "to": ["to@example.com"], 44 | }, 45 | status=Status.QUEUED, 46 | ) 47 | 48 | send_all() 49 | 50 | assert len(mailoutbox) == 1 51 | assert mailoutbox[0].subject == "Test Subject" 52 | assert mailoutbox[0].to == ["to@example.com"] 53 | 54 | queued.refresh_from_db() 55 | 56 | assert queued.status == Status.SENT 57 | assert queued.sent_at is not None 58 | 59 | assert f"sent message {queued.id}" in caplog.text 60 | assert "sent 1 emails, deferred 0 emails, failed 0 emails" in caplog.text 61 | 62 | 63 | def test_send_all_multiple_messages(mailoutbox, caplog): 64 | high_priority = baker.make( 65 | "email_relay.Message", 66 | data={"subject": "High Priority", "to": ["high@example.com"]}, 67 | status=Status.QUEUED, 68 | priority=Priority.HIGH, 69 | ) 70 | low_priority = baker.make( 71 | "email_relay.Message", 72 | data={"subject": "Low Priority", "to": ["low@example.com"]}, 73 | status=Status.QUEUED, 74 | priority=Priority.LOW, 75 | ) 76 | medium_priority_deferred = baker.make( 77 | "email_relay.Message", 78 | data={ 79 | "subject": "Medium Priority, Deferred", 80 | "to": ["medium+deferred@example.com"], 81 | }, 82 | status=Status.DEFERRED, 83 | priority=Priority.MEDIUM, 84 | ) 85 | 86 | send_all() 87 | 88 | assert len(mailoutbox) == 3 89 | assert Message.objects.sent().count() == 3 90 | 91 | high_priority.refresh_from_db() 92 | low_priority.refresh_from_db() 93 | medium_priority_deferred.refresh_from_db() 94 | 95 | assert high_priority.status == Status.SENT 96 | assert low_priority.status == Status.SENT 97 | assert medium_priority_deferred.status == Status.SENT 98 | 99 | assert "sent 3 emails, deferred 0 emails, failed 0 emails" in caplog.text 100 | 101 | 102 | @override_settings(DJANGO_EMAIL_RELAY={"EMAIL_MAX_BATCH": 2}) 103 | def test_send_all_respects_max_batch(mailoutbox, caplog): 104 | baker.make( 105 | "email_relay.Message", 106 | data={ 107 | "subject": "Test Subject", 108 | "body": "Test Body", 109 | "from_email": "from@example.com", 110 | "to": ["to@example.com"], 111 | }, 112 | status=Status.QUEUED, 113 | _quantity=5, 114 | ) 115 | 116 | send_all() 117 | 118 | assert len(mailoutbox) == 2 119 | assert Message.objects.sent().count() == 2 120 | assert Message.objects.queued().count() == 3 121 | 122 | assert "sent 2 emails, deferred 0 emails, failed 0 emails" in caplog.text 123 | 124 | 125 | @override_settings(DJANGO_EMAIL_RELAY={"EMAIL_THROTTLE": 0.01}) 126 | def test_send_all_respects_throttle(mailoutbox, caplog): 127 | baker.make( 128 | "email_relay.Message", 129 | data={ 130 | "subject": "Test Subject", 131 | "body": "Test Body", 132 | "from_email": "from@example.com", 133 | "to": ["to@example.com"], 134 | }, 135 | status=Status.QUEUED, 136 | ) 137 | 138 | send_all() 139 | 140 | assert len(mailoutbox) == 1 141 | assert Message.objects.sent().count() == 1 142 | assert "throttling enabled, sleeping for 0.01 seconds" in caplog.text 143 | assert "sent 1 emails, deferred 0 emails, failed 0 emails" in caplog.text 144 | 145 | 146 | def test_send_all_sends_email_multi_alternatives(mailoutbox, caplog): 147 | email = EmailMultiAlternatives( 148 | subject="HTML Test", 149 | body="Text Body", 150 | from_email="html@example.com", 151 | to=["recipient@example.com"], 152 | ) 153 | email.attach_alternative("

HTML Body

", "text/html") 154 | 155 | message = baker.make("email_relay.Message", status=Status.QUEUED) 156 | message.email = email 157 | message.save() 158 | 159 | send_all() 160 | 161 | assert len(mailoutbox) == 1 162 | 163 | sent_email = mailoutbox[0] 164 | 165 | assert sent_email.subject == "HTML Test" 166 | assert sent_email.body == "Text Body" 167 | assert sent_email.alternatives == [("

HTML Body

", "text/html")] 168 | 169 | message.refresh_from_db() 170 | 171 | assert message.status == Status.SENT 172 | assert f"sent message {message.id}" in caplog.text 173 | assert "sent 1 emails, deferred 0 emails, failed 0 emails" in caplog.text 174 | 175 | 176 | @mock.patch("django.core.mail.message.EmailMultiAlternatives.send") 177 | def test_send_all_defer_on_smtp_error(mock_send, mailoutbox, caplog): 178 | mock_send.side_effect = smtplib.SMTPSenderRefused( 179 | 550, b"Test SMTP Error", "sender@example.com" 180 | ) 181 | queued = baker.make( 182 | "email_relay.Message", 183 | data={ 184 | "subject": "Test Subject", 185 | "body": "Test Body", 186 | "from_email": "from@example.com", 187 | "to": ["to@example.com"], 188 | }, 189 | status=Status.QUEUED, 190 | ) 191 | 192 | send_all() 193 | 194 | assert len(mailoutbox) == 0 195 | 196 | queued.refresh_from_db() 197 | 198 | assert queued.status == Status.DEFERRED 199 | assert queued.retry_count == 1 200 | assert "Test SMTP Error" in queued.log 201 | assert f"deferring message {queued.id} due to" in caplog.text 202 | assert "sent 0 emails, deferred 1 emails, failed 0 emails" in caplog.text 203 | 204 | 205 | @mock.patch("django.core.mail.message.EmailMultiAlternatives.send") 206 | def test_send_all_defer_on_os_error(mock_send, mailoutbox, caplog): 207 | mock_send.side_effect = OSError("Test Network Error") 208 | queued = baker.make( 209 | "email_relay.Message", 210 | data={ 211 | "subject": "Test Subject", 212 | "body": "Test Body", 213 | "from_email": "from@example.com", 214 | "to": ["to@example.com"], 215 | }, 216 | status=Status.QUEUED, 217 | ) 218 | 219 | send_all() 220 | 221 | assert len(mailoutbox) == 0 222 | 223 | queued.refresh_from_db() 224 | 225 | assert queued.status == Status.DEFERRED 226 | assert queued.retry_count == 1 227 | assert "Test Network Error" in queued.log 228 | assert f"deferring message {queued.id} due to" in caplog.text 229 | assert "sent 0 emails, deferred 1 emails, failed 0 emails" in caplog.text 230 | 231 | 232 | @mock.patch("django.core.mail.message.EmailMultiAlternatives.send") 233 | def test_send_all_fail_after_max_retries(mock_send, mailoutbox, caplog): 234 | mock_send.side_effect = smtplib.SMTPSenderRefused( 235 | 550, b"Test SMTP Error", "sender@example.com" 236 | ) 237 | queued = baker.make( 238 | "email_relay.Message", 239 | data={ 240 | "subject": "Test Subject", 241 | "body": "Test Body", 242 | "from_email": "from@example.com", 243 | "to": ["to@example.com"], 244 | }, 245 | retry_count=2, 246 | status=Status.DEFERRED, 247 | ) 248 | 249 | with override_settings(DJANGO_EMAIL_RELAY={"EMAIL_MAX_RETRIES": 2}): 250 | send_all() 251 | 252 | assert len(mailoutbox) == 0 253 | 254 | queued.refresh_from_db() 255 | 256 | assert queued.status == Status.FAILED 257 | assert queued.retry_count == 2 258 | assert "Test SMTP Error" in queued.log 259 | assert f"max retries reached, marking message {queued.id} as failed" in caplog.text 260 | assert "sent 0 emails, deferred 0 emails, failed 1 emails" in caplog.text 261 | 262 | 263 | @mock.patch("django.core.mail.message.EmailMultiAlternatives.send") 264 | def test_send_all_fail_on_value_error(mock_send, mailoutbox, caplog): 265 | mock_send.side_effect = ValueError("Test Value Error") 266 | queued = baker.make( 267 | "email_relay.Message", 268 | data={ 269 | "subject": "Test Subject", 270 | "body": "Test Body", 271 | "from_email": "from@example.com", 272 | "to": ["to@example.com"], 273 | }, 274 | status=Status.QUEUED, 275 | ) 276 | 277 | send_all() 278 | 279 | assert len(mailoutbox) == 0 280 | 281 | queued.refresh_from_db() 282 | 283 | assert queued.status == Status.FAILED 284 | assert queued.retry_count == 0 285 | assert "Test Value Error" in queued.log 286 | assert ( 287 | f"unexpected error processing message {queued.id}, marking as failed" 288 | in caplog.text 289 | ) 290 | assert "sent 0 emails, deferred 0 emails, failed 1 emails" in caplog.text 291 | 292 | 293 | @mock.patch("django.core.mail.message.EmailMultiAlternatives.send") 294 | def test_send_all_fail_on_type_error(mock_send, mailoutbox, caplog): 295 | mock_send.side_effect = TypeError("Test Type Error") 296 | queued = baker.make( 297 | "email_relay.Message", 298 | data={ 299 | "subject": "Test Subject", 300 | "body": "Test Body", 301 | "from_email": "from@example.com", 302 | "to": ["to@example.com"], 303 | }, 304 | status=Status.QUEUED, 305 | ) 306 | 307 | send_all() 308 | 309 | assert len(mailoutbox) == 0 310 | 311 | queued.refresh_from_db() 312 | 313 | assert queued.status == Status.FAILED 314 | assert queued.retry_count == 0 315 | assert "Test Type Error" in queued.log 316 | assert ( 317 | f"unexpected error processing message {queued.id}, marking as failed" 318 | in caplog.text 319 | ) 320 | assert "sent 0 emails, deferred 0 emails, failed 1 emails" in caplog.text 321 | 322 | 323 | def test_send_all_continue_after_failure(mailoutbox, caplog): 324 | success = baker.make( 325 | "email_relay.Message", 326 | data={"subject": "Success", "to": ["ok@example.com"]}, 327 | status=Status.QUEUED, 328 | priority=Priority.LOW, 329 | ) 330 | fail = baker.make( 331 | "email_relay.Message", 332 | data={"subject": "Fail"}, 333 | status=Status.QUEUED, 334 | priority=Priority.HIGH, 335 | ) 336 | 337 | original_email_prop = Message.email.fget 338 | 339 | @property 340 | def mock_email_prop(self): 341 | if self.id == fail.id: 342 | # Simulate an error during email object creation/retrieval 343 | raise ValueError("Simulated property error") 344 | 345 | return original_email_prop(self) 346 | 347 | with mock.patch("email_relay.models.Message.email", mock_email_prop, create=True): 348 | send_all() 349 | 350 | assert len(mailoutbox) == 1 351 | assert mailoutbox[0].subject == "Success" 352 | 353 | success.refresh_from_db() 354 | fail.refresh_from_db() 355 | 356 | assert success.status == Status.SENT 357 | assert fail.status == Status.FAILED 358 | assert "Simulated property error" in fail.log 359 | 360 | assert f"sent message {success.id}" in caplog.text 361 | assert ( 362 | f"unexpected error processing message {fail.id}, marking as failed" 363 | in caplog.text 364 | ) 365 | assert "sent 1 emails, deferred 0 emails, failed 1 emails" in caplog.text 366 | 367 | 368 | @mock.patch("email_relay.models.Message.email", new_callable=mock.PropertyMock) 369 | def test_send_all_fail_message_no_email_object(mock_email, mailoutbox, caplog): 370 | mock_email.return_value = None 371 | queued = baker.make( 372 | "email_relay.Message", 373 | data={ 374 | "subject": "Test Subject", 375 | "body": "Test Body", 376 | "from_email": "from@example.com", 377 | "to": ["to@example.com"], 378 | }, 379 | status=Status.QUEUED, 380 | ) 381 | 382 | send_all() 383 | 384 | assert len(mailoutbox) == 0 385 | 386 | queued.refresh_from_db() 387 | 388 | assert queued.status == Status.FAILED 389 | 390 | error_msg = f"Message {queued.id} has no email object" 391 | 392 | assert error_msg in queued.log 393 | assert error_msg in caplog.text 394 | assert "sent 0 emails, deferred 0 emails, failed 1 emails" in caplog.text 395 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from email_relay.conf import app_settings 6 | from email_relay.db import EmailDatabaseRouter 7 | 8 | 9 | # Mock model with app_label "email_relay" 10 | class MockModel: 11 | class _meta: 12 | app_label = "email_relay" 13 | 14 | 15 | # Mock model with app_label "some_other_app" 16 | class MockModelOther: 17 | class _meta: 18 | app_label = "some_other_app" 19 | 20 | 21 | @pytest.fixture 22 | def router(): 23 | return EmailDatabaseRouter() 24 | 25 | 26 | def test_db_for_read(router): 27 | assert router.db_for_read(MockModel) == app_settings.DATABASE_ALIAS 28 | assert router.db_for_read(MockModelOther) == "default" 29 | 30 | 31 | def test_db_for_write(router): 32 | assert router.db_for_write(MockModel) == app_settings.DATABASE_ALIAS 33 | assert router.db_for_write(MockModelOther) == "default" 34 | 35 | 36 | def test_allow_relation(router): 37 | assert router.allow_relation(MockModel, MockModel) 38 | 39 | 40 | def test_allow_relation_other(router): 41 | assert router.allow_relation(MockModel, MockModelOther) is None 42 | 43 | 44 | def test_allow_migrate(router): 45 | assert router.allow_migrate(app_settings.DATABASE_ALIAS, "email_relay") 46 | -------------------------------------------------------------------------------- /tests/test_runrelay.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | from unittest import mock 6 | 7 | import pytest 8 | import responses 9 | from django.core.management import call_command 10 | from django.test.utils import override_settings 11 | from django.utils import timezone 12 | from model_bakery import baker 13 | 14 | from email_relay.management.commands.runrelay import Command 15 | from email_relay.models import Message 16 | from email_relay.models import Status 17 | 18 | 19 | def test_runrelay_help(): 20 | # We'll capture the output of the command 21 | with pytest.raises(SystemExit) as exec_info: 22 | # call_command will execute our command as if we ran it from the command line 23 | # the 'stdout' argument captures the command output 24 | call_command("runrelay", "--help") 25 | 26 | # Asserting that the command exits with a successful exit code (0 for help command) 27 | assert exec_info.value.code == 0 28 | 29 | 30 | @pytest.fixture 31 | def runrelay(): 32 | return Command() 33 | 34 | 35 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 36 | def test_command_with_empty_queue(runrelay, mailoutbox): 37 | runrelay.handle(_loop_count=1) 38 | 39 | assert len(mailoutbox) == 0 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "status,quantity,expected_sent", 44 | [ 45 | (Status.QUEUED, 10, 10), 46 | (Status.DEFERRED, 10, 10), 47 | (Status.FAILED, 10, 0), 48 | (Status.SENT, 10, 0), 49 | ], 50 | ) 51 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 52 | def test_command_with_messages_in_queue( 53 | status, quantity, expected_sent, runrelay, mailoutbox 54 | ): 55 | baker.make( 56 | "email_relay.Message", 57 | data={ 58 | "subject": "Test", 59 | "body": "Test", 60 | "from_email": "from@example.com", 61 | "to": ["to@example.com"], 62 | }, 63 | status=status, 64 | _quantity=quantity, 65 | ) 66 | 67 | runrelay.handle(_loop_count=1) 68 | 69 | assert len(mailoutbox) == expected_sent 70 | 71 | 72 | @override_settings( 73 | DJANGO_EMAIL_RELAY={ 74 | "EMPTY_QUEUE_SLEEP": 0.1, 75 | }, 76 | ) 77 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 78 | def test_command_with_sleep(runrelay, mailoutbox): 79 | baker.make( 80 | "email_relay.Message", 81 | data={ 82 | "subject": "Test", 83 | "body": "Test", 84 | "from_email": "from@example.com", 85 | "to": ["to@example.com"], 86 | }, 87 | status=Status.QUEUED, 88 | ) 89 | 90 | runrelay.handle(_loop_count=2) 91 | 92 | assert len(mailoutbox) == 1 93 | 94 | 95 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 96 | def test_delete_sent_messages_based_on_retention_default(runrelay): 97 | baker.make( 98 | "email_relay.Message", 99 | status=Status.SENT, 100 | sent_at=timezone.now(), 101 | _quantity=10, 102 | ) 103 | 104 | runrelay.delete_old_messages() 105 | 106 | assert Message.objects.count() == 10 107 | 108 | 109 | @override_settings( 110 | DJANGO_EMAIL_RELAY={ 111 | "MESSAGES_RETENTION_SECONDS": 0, 112 | } 113 | ) 114 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 115 | def test_delete_sent_messages_based_on_retention_zero(runrelay): 116 | baker.make( 117 | "email_relay.Message", 118 | status=Status.SENT, 119 | sent_at=timezone.now(), 120 | _quantity=10, 121 | ) 122 | 123 | runrelay.delete_old_messages() 124 | 125 | assert Message.objects.count() == 0 126 | 127 | 128 | @override_settings( 129 | DJANGO_EMAIL_RELAY={ 130 | "MESSAGES_RETENTION_SECONDS": 600, 131 | } 132 | ) 133 | @pytest.mark.django_db(databases=["default", "email_relay_db"]) 134 | def test_delete_sent_messages_based_on_retention_non_zero(runrelay): 135 | baker.make( 136 | "email_relay.Message", 137 | status=Status.SENT, 138 | sent_at=timezone.now(), 139 | _quantity=5, 140 | ) 141 | baker.make( 142 | "email_relay.Message", 143 | status=Status.SENT, 144 | sent_at=timezone.now() - datetime.timedelta(seconds=601), 145 | _quantity=5, 146 | ) 147 | 148 | runrelay.delete_old_messages() 149 | 150 | assert Message.objects.count() == 5 151 | 152 | 153 | @override_settings( 154 | DJANGO_EMAIL_RELAY={ 155 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 156 | } 157 | ) 158 | @responses.activate 159 | def test_relay_healthcheck_url(runrelay, caplog): 160 | caplog.set_level(logging.DEBUG) 161 | responses.add(responses.GET, "http://example.com/healthcheck", status=200) 162 | 163 | runrelay.ping_healthcheck() 164 | 165 | assert len(responses.calls) == 1 166 | assert "healthcheck ping successful" in caplog.text 167 | 168 | 169 | @responses.activate 170 | def test_relay_healthcheck_url_not_configured(runrelay): 171 | responses.add(responses.GET, "http://example.com/healthcheck", status=200) 172 | 173 | runrelay.ping_healthcheck() 174 | 175 | assert len(responses.calls) == 0 176 | 177 | 178 | @override_settings( 179 | DJANGO_EMAIL_RELAY={ 180 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 181 | "RELAY_HEALTHCHECK_STATUS_CODE": 201, 182 | } 183 | ) 184 | @responses.activate 185 | def test_relay_healthcheck_status_code(runrelay, caplog): 186 | caplog.set_level(logging.DEBUG) 187 | responses.add(responses.GET, "http://example.com/healthcheck", status=201) 188 | 189 | runrelay.ping_healthcheck() 190 | 191 | assert len(responses.calls) == 1 192 | assert "healthcheck ping successful" in caplog.text 193 | 194 | 195 | @override_settings( 196 | DJANGO_EMAIL_RELAY={ 197 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 198 | "RELAY_HEALTHCHECK_METHOD": "POST", 199 | } 200 | ) 201 | @responses.activate 202 | def test_relay_healthcheck_method(runrelay, caplog): 203 | caplog.set_level(logging.DEBUG) 204 | responses.add(responses.POST, "http://example.com/healthcheck", status=200) 205 | 206 | runrelay.ping_healthcheck() 207 | 208 | assert len(responses.calls) == 1 209 | assert "healthcheck ping successful" in caplog.text 210 | 211 | 212 | @override_settings( 213 | DJANGO_EMAIL_RELAY={ 214 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 215 | } 216 | ) 217 | @responses.activate 218 | def test_relay_healthcheck_failure(runrelay, caplog): 219 | caplog.set_level(logging.WARNING) 220 | responses.add(responses.GET, "http://example.com/healthcheck", status=500) 221 | 222 | runrelay.ping_healthcheck() 223 | 224 | assert len(responses.calls) == 1 225 | assert "healthcheck ping successful" not in caplog.text 226 | 227 | 228 | @override_settings( 229 | DJANGO_EMAIL_RELAY={ 230 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 231 | } 232 | ) 233 | def test_relay_healthcheck_no_requests(runrelay, caplog): 234 | caplog.set_level(logging.WARNING) 235 | 236 | with mock.patch("email_relay.management.commands.runrelay.requests", None): 237 | runrelay.ping_healthcheck() 238 | 239 | assert "Healthcheck URL configured but requests is not installed." in caplog.text 240 | 241 | 242 | @override_settings( 243 | DJANGO_EMAIL_RELAY={ 244 | "RELAY_HEALTHCHECK_URL": "http://example.com/healthcheck", 245 | } 246 | ) 247 | def test_relay_healthcheck_requests_exception(runrelay, caplog): 248 | caplog.set_level(logging.WARNING) 249 | 250 | with mock.patch( 251 | "email_relay.management.commands.runrelay.requests" 252 | ) as mock_requests: 253 | mock_requests.exceptions.RequestException = Exception 254 | mock_requests.request.side_effect = Exception("test") 255 | 256 | runrelay.ping_healthcheck() 257 | 258 | assert "healthcheck failed, got exception" in caplog.text 259 | -------------------------------------------------------------------------------- /tests/test_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from email_relay.service import coerce_dict_values 6 | from email_relay.service import env_vars_to_nested_dict 7 | from email_relay.service import filter_valid_django_settings 8 | from email_relay.service import get_user_settings_from_env 9 | from email_relay.service import merge_with_defaults 10 | 11 | 12 | def test_env_vars_to_nested_dict(): 13 | env_vars = { 14 | "DATABASES__default__CONN_MAX_AGE": 600, 15 | "DEBUG": "True", 16 | } 17 | 18 | assert env_vars_to_nested_dict(env_vars) == { 19 | "DATABASES": { 20 | "default": { 21 | "CONN_MAX_AGE": 600, 22 | } 23 | }, 24 | "DEBUG": "True", 25 | } 26 | 27 | 28 | def test_merge_with_defaults(): 29 | default_settings = { 30 | "DATABASES": { 31 | "default": { 32 | "CONN_MAX_AGE": 600, 33 | } 34 | }, 35 | "DEBUG": False, 36 | } 37 | user_settings = { 38 | "DATABASES": { 39 | "default": { 40 | "CONN_MAX_AGE": 300, 41 | } 42 | }, 43 | "DEBUG": True, 44 | } 45 | 46 | assert merge_with_defaults(default_settings, user_settings) == { 47 | "DATABASES": { 48 | "default": { 49 | "CONN_MAX_AGE": 300, 50 | } 51 | }, 52 | "DEBUG": True, 53 | } 54 | 55 | 56 | def test_get_user_settings_from_env(): 57 | env_vars = { 58 | "DATABASES__default__CONN_MAX_AGE": "600", 59 | "DEBUG": "True", 60 | "INVALID_KEY": "True", 61 | } 62 | for k, v in env_vars.items(): 63 | os.environ[k] = v 64 | 65 | assert get_user_settings_from_env() == { 66 | "DATABASES": { 67 | "default": { 68 | "CONN_MAX_AGE": 600, 69 | } 70 | }, 71 | "DEBUG": True, 72 | } 73 | 74 | for k in env_vars.keys(): 75 | del os.environ[k] 76 | 77 | 78 | def test_coerce_dict_values(): 79 | types_dict = { 80 | "BOOLEAN": "True", 81 | "INTEGER": "600", 82 | "STRING": "str", 83 | "FLOAT": "3.14", 84 | "NONE": "None", 85 | } 86 | 87 | d = { 88 | **types_dict, 89 | "NESTED": types_dict, 90 | } 91 | 92 | assert coerce_dict_values(d) == { 93 | "BOOLEAN": True, 94 | "INTEGER": 600, 95 | "STRING": "str", 96 | "FLOAT": 3.14, 97 | "NONE": None, 98 | "NESTED": { 99 | "BOOLEAN": True, 100 | "INTEGER": 600, 101 | "STRING": "str", 102 | "FLOAT": 3.14, 103 | "NONE": None, 104 | }, 105 | } 106 | 107 | 108 | def test_filter_valid_django_settings(): 109 | d = { 110 | "DEBUG": True, 111 | "INVALID_KEY": "invalid value", 112 | "DJANGO_EMAIL_RELAY": { 113 | "VALID_KEY": "valid value", 114 | }, 115 | } 116 | 117 | assert filter_valid_django_settings(d) == { 118 | "DEBUG": True, 119 | "DJANGO_EMAIL_RELAY": { 120 | "VALID_KEY": "valid value", 121 | }, 122 | } 123 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from email_relay import __version__ 4 | 5 | 6 | def test_version(): 7 | assert __version__ == "0.6.0" 8 | --------------------------------------------------------------------------------