├── .dockerignore ├── .example.env ├── .flake8 ├── .github └── workflows │ ├── ci-cd.yml │ └── cleanup.yml ├── .gitignore ├── .pre-commit-config.yml ├── .vercelignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── Procfile ├── README.md ├── SECURITY.md ├── app.json ├── docs ├── changelog.md ├── contributing.md ├── deployment.md ├── examples │ ├── simple-ajax-pgp.html │ ├── simple-ajax-recaptcha.html │ └── simple-ajax.html ├── index.md └── usage.md ├── mailer ├── __about__.py ├── __init__.py ├── api.py ├── home.py ├── mailer.py ├── recaptcha.py ├── sentry.py └── settings.py ├── mkdocs.yml ├── mypy.ini ├── renovate.json ├── tasks.py ├── templates └── homepage.html ├── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_home.py ├── test_mailer.py ├── test_settings.py └── utils.py └── vercel.json /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !mailer 3 | !templates 4 | !LICENSE 5 | !Pipfile 6 | !Pipfile.lock 7 | !Procfile -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | APP_ENVIRONMENT=development 2 | 3 | SENDER_EMAIL=no-reply@domain.com 4 | TO_EMAIL=me@domain.com 5 | TO_NAME=Me 6 | SUCCESS_REDIRECT_URL=https://domain.com/contact/success 7 | ERROR_REDIRECT_URL=https://domain.com/contact/error 8 | 9 | SMTP_HOST=smtp.sendgrid.net 10 | SMTP_PORT=587 11 | SMTP_TLS=true 12 | SMTP_SSL=false 13 | SMTP_USER= 14 | SMTP_PASSWORD= 15 | 16 | FORCE_HTTPS=false 17 | CORS_ORIGINS='["https://domain.com"]' 18 | RECAPTCHA_SECRET_KEY= 19 | PGP_PUBLIC_KEY='$(cat | base64)' 20 | SENTRY_DSN= -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B 4 | ignore = E501,W503,E203 -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: [push] 4 | 5 | env: 6 | BASE_URL_PREVIEW: mailer-romainclement.vercel.app 7 | PYTHON_VERSION: '3.11.4' 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ env.PYTHON_VERSION }} 20 | - name: Cache Python modules 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/.cache/pip 24 | key: ${{ runner.os }}-pip-${{ hashFiles('**/Pipfile.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-pip- 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install --upgrade pipenv 31 | pipenv install --dev --deploy 32 | - name: Run QA 33 | run: | 34 | pipenv run inv qa 35 | pipenv run coverage xml 36 | - name: Publish code coverage to Codecov 37 | uses: codecov/codecov-action@v3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | file: ./coverage.xml 41 | 42 | build-docker: 43 | name: Docker build 44 | runs-on: ubuntu-latest 45 | needs: test 46 | 47 | env: 48 | IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/mailer 49 | IMAGE_TAG: latest 50 | 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Select Docker image tag (production only) 54 | if: contains(github.ref, 'tags') 55 | run: echo "IMAGE_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV 56 | - name: Pull latest Docker image 57 | run: docker pull $IMAGE_NAME:latest || true 58 | - name: Build Docker image (${{ env.IMAGE_TAG }}) 59 | run: docker build -t $IMAGE_NAME:$IMAGE_TAG --cache-from $IMAGE_NAME:latest . 60 | - name: Log into Docker Registry 61 | if: contains(github.ref, 'master') || contains(github.ref, 'tags') 62 | run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin 63 | - name: Push Docker image 64 | if: contains(github.ref, 'master') || contains(github.ref, 'tags') 65 | run: | 66 | docker push $IMAGE_NAME:$IMAGE_TAG 67 | 68 | build-docs: 69 | name: Build Documentation 70 | runs-on: ubuntu-latest 71 | needs: test 72 | 73 | steps: 74 | - uses: actions/checkout@v4 75 | - name: Set up Python 76 | uses: actions/setup-python@v5 77 | with: 78 | python-version: ${{ env.PYTHON_VERSION }} 79 | - name: Cache Python modules 80 | uses: actions/cache@v3 81 | with: 82 | path: ~/.cache/pip 83 | key: ${{ runner.os }}-pip-${{ hashFiles('**/Pipfile.lock') }} 84 | restore-keys: | 85 | ${{ runner.os }}-pip- 86 | - name: Install dependencies 87 | run: | 88 | python -m pip install --upgrade pip 89 | python -m pip install --upgrade pipenv 90 | pipenv install --dev --deploy 91 | - name: Build documentation 92 | run: | 93 | pipenv run mkdocs build 94 | - name: Upload build artifacts 95 | uses: actions/upload-artifact@v3 96 | with: 97 | name: build-docs 98 | path: site 99 | 100 | deploy-docs: 101 | name: Deploy Documentation 102 | runs-on: ubuntu-latest 103 | needs: build-docs 104 | if: contains(github.ref, 'master') 105 | 106 | steps: 107 | - uses: actions/checkout@v4 108 | - name: Download build artifacts 109 | uses: actions/download-artifact@v3 110 | with: 111 | name: build-docs 112 | path: site 113 | - name: Deploy to GitHub Pages 114 | uses: peaceiris/actions-gh-pages@v3 115 | with: 116 | personal_token: ${{ secrets.GH_PERSONAL_TOKEN }} 117 | publish_dir: ./site 118 | publish_branch: gh-pages 119 | 120 | deploy-vercel-setup: 121 | name: Deployment setup 122 | runs-on: ubuntu-latest 123 | needs: build-docker 124 | 125 | outputs: 126 | github_ref_slug: ${{ steps.output_step.outputs.github_ref_slug }} 127 | deployment_url: ${{ steps.output_step.outputs.deployment_url }} 128 | 129 | steps: 130 | - name: Inject slug/short variables 131 | uses: rlespinasse/github-slug-action@v4 132 | - name: Set preview deployment url variable 133 | if: ${{ !contains(github.ref, 'tags') }} 134 | run: echo "DEPLOYMENT_URL=https://${GITHUB_REF_SLUG_URL}-${BASE_URL_PREVIEW}" >> $GITHUB_ENV 135 | - name: Set production deployment url variable 136 | if: ${{ contains(github.ref, 'tags') }} 137 | run: echo "DEPLOYMENT_URL=${{ secrets.MAILER_URL }}" >> $GITHUB_ENV 138 | - id: output_step 139 | run: | 140 | echo "::set-output name=github_ref_slug::${GITHUB_REF_SLUG_URL}" 141 | echo "::set-output name=deployment_url::${DEPLOYMENT_URL}" 142 | 143 | deploy-vercel-preview: 144 | name: Vercel preview deployment 145 | runs-on: ubuntu-latest 146 | needs: deploy-vercel-setup 147 | if: ${{ !contains(github.ref, 'tags') }} 148 | environment: 149 | name: preview/${{ needs.deploy-vercel-setup.outputs.github_ref_slug }} 150 | url: ${{ needs.deploy-vercel-setup.outputs.deployment_url }} 151 | 152 | env: 153 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 154 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 155 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 156 | 157 | steps: 158 | - uses: actions/checkout@v4 159 | - name: Inject slug/short variables 160 | uses: rlespinasse/github-slug-action@v4 161 | - name: Deploy to Vercel 162 | run: | 163 | VERCEL_ALIAS=${GITHUB_REF_SLUG_URL}-${BASE_URL_PREVIEW} 164 | VERCEL_URL=$(vercel deploy --confirm --token $VERCEL_TOKEN) 165 | vercel alias --token $VERCEL_TOKEN set $VERCEL_URL $VERCEL_ALIAS 166 | 167 | deploy-vercel-production: 168 | name: Vercel production deployment 169 | runs-on: ubuntu-latest 170 | needs: deploy-vercel-setup 171 | if: contains(github.ref, 'tags') 172 | 173 | environment: 174 | name: production 175 | url: ${{ needs.deploy-vercel-setup.outputs.deployment_url }} 176 | 177 | env: 178 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 179 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 180 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 181 | 182 | steps: 183 | - uses: actions/checkout@v4 184 | - name: Deploy to Vercel 185 | run: | 186 | vercel deploy --confirm --token $VERCEL_TOKEN --prod 187 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup 2 | 3 | on: [delete] 4 | 5 | env: 6 | BASE_URL_PREVIEW: mailer-romainclement.vercel.app 7 | 8 | jobs: 9 | cleanup-preview-deployment: 10 | name: Cleanup Preview Deployment 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 15 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 16 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 17 | 18 | steps: 19 | - name: Inject slug/short variables 20 | uses: rlespinasse/github-slug-action@v4 21 | - name: Remove Vercel alias 22 | run: | 23 | VERCEL_ALIAS=${GITHUB_EVENT_REF_SLUG_URL}-${BASE_URL_PREVIEW} 24 | vercel alias --token $VERCEL_TOKEN rm $VERCEL_ALIAS -y 25 | - name: Delete GitHub environment 26 | uses: actions/github-script@v7 27 | with: 28 | github-token: ${{ secrets.GH_PERSONAL_TOKEN }} 29 | script: | 30 | github.rest.repos.deleteAnEnvironment({ 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | environment_name: `preview/${process.env.GITHUB_EVENT_REF_SLUG_URL}`, 34 | }) 35 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | coverage.json 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # pytest 105 | .pytest_cache/ 106 | 107 | # Sphinx documentation 108 | docs/_build/ 109 | 110 | # OS generated files # 111 | .DS_Store 112 | .DS_Store? 113 | ._* 114 | .Spotlight-V100 115 | .Trashes 116 | ehthumbs.db 117 | Thumbs.db 118 | 119 | # IDEs and editors 120 | .idea/ 121 | .vscode/ 122 | *.swp 123 | .now 124 | .vercel 125 | 126 | # PGP 127 | *.asc -------------------------------------------------------------------------------- /.pre-commit-config.yml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: reformat 5 | name: reformat 6 | description: "Code formatter" 7 | entry: pipenv run inv reformat 8 | language: python 9 | types: [python] 10 | require_serial: true 11 | pass_filenames: false 12 | 13 | - id: lint 14 | name: lint 15 | description: "Code linter" 16 | entry: pipenv run inv lint 17 | language: python 18 | types: [python] 19 | require_serial: true 20 | pass_filenames: false 21 | 22 | - id: static-check 23 | name: static-check 24 | description: "Static type checker" 25 | entry: pipenv run inv static-check 26 | language: python 27 | types: [python] 28 | require_serial: true 29 | pass_filenames: false 30 | 31 | - id: security-check 32 | name: security-check 33 | description: "Security checker" 34 | entry: pipenv run inv security-check 35 | language: python 36 | types: [python] 37 | require_serial: true 38 | pass_filenames: false 39 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | /* 2 | !mailer 3 | !templates 4 | !LICENSE 5 | !vercel.json 6 | !Pipfile 7 | !Pipfile.lock -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.10.3] - 2023-06-16 10 | ### Changed 11 | - Use Python 3.11.4 12 | - Update dependencies 13 | 14 | ## [0.10.2] - 2022-08-28 15 | ### Changed 16 | - Use Python 3.10.6 17 | - Update dependencies 18 | 19 | ## [0.10.1] - 2022-02-18 20 | ### Changed 21 | - Use Python 3.9.10 22 | - Fix form link in homepage template 23 | - Remove duplicate honeypot validation 24 | - Move documentation to https://rclement.github.io/mailer/ 25 | - Disable Renovate dependency dashboard 26 | - Update dependencies 27 | 28 | ## [0.10.0] - 2021-09-18 29 | ### Added 30 | - New API endpoint for URL-encoded form requests 31 | - Type hints for tests 32 | 33 | ### Changed 34 | - Use Python 3.9.7 35 | - Update dependencies 36 | 37 | ## [0.9.1] - 2021-06-17 38 | ### Added 39 | - 1-click deployment buttons to README and documentation (Vercel and Heroku) 40 | 41 | ### Changed 42 | - Use Python 3.8.10 43 | - Update dependencies 44 | 45 | ## [0.9.0] - 2021-05-01 46 | ### Added 47 | - Allow to force HTTPS redirect using `FORCE_HTTPS` (enabled by default) 48 | 49 | ### Changed 50 | - Use Python 3.8.9 51 | - Use `python-slim` instead of `python-alpine` base for Docker image 52 | - Use `mkdocs` for documentation 53 | - Rename all Zeit Now references to Vercel 54 | - Update dependencies 55 | 56 | ## [0.8.1] - 2020-05-21 57 | ### Added 58 | - OpenAPI documentation for return model in `/api/` route 59 | 60 | ### Changed 61 | - Use Python 3.8.3 62 | - Update dependencies 63 | - More robust CORS origins testing 64 | - Stricter `mypy` rules 65 | 66 | ### Fixed 67 | - Better handling of `.env` file loading for development and testing 68 | 69 | ## [0.8.0] - 2020-04-11 70 | ### Changed 71 | - Use Python 3.8.2 72 | - Use [FastAPI](https://fastapi.tiangolo.com) instead of [Flask](https://flask.palletsprojects.com) 73 | - Renamed API parameter `recaptcha` to `g-recaptcha-response` (default name from Google ReCaptcha) 74 | - Set maximum message length to 1000 characters 75 | - Disable Swagger UI in production 76 | - Licensed under AGPLv3 77 | 78 | ### Added 79 | - [Docsify](https://docsify.js.org) documentation 80 | - Simple homepage with API documentation link 81 | - SMTP mailing backend support (all `SMTP_*` configurations) 82 | - PGP encryption support using PGP/MIME (with optional contact PGP public key attachment) 83 | - Static typing analysis using [mypy](https://mypy.readthedocs.io) 84 | - Security checks using [bandit](https://bandit.readthedocs.io) 85 | - Exhaustive testing 86 | - Simple examples (ajax, ajax with recaptcha, ajax with pgp) 87 | - GitHub Action workflows support 88 | - Security notice in `SECURITY.md` 89 | 90 | ### Removed 91 | - **BREAKING**: removed rate-limiting feature (all `RATELIMIT_*` configurations) 92 | - **BREAKING**: removed mailer provider feature (`MAILER_PROVIDER` configuration) 93 | - **BREAKING**: removed sendgrid provider feature (all `SENDGRID_*` configurations) 94 | - Removed `RECAPTCHA_SITE_KEY` from configuration 95 | - Removed `RECAPTCHA_ENABLED` from configuration (automatically enabled when `RECAPTCHA_SECRET_KEY` is set) 96 | - Removed `SENTRY_ENABLED` from configuration (automatically enabled when `SENTRY_DSN` is set) 97 | - Travis-CI support 98 | 99 | ### Fixed 100 | - Use non-root user in `Dockerfile` 101 | - Use allowlist mode for `.nowignore` 102 | 103 | ## [0.7.1] - 2019-07-22 104 | ### Changed 105 | - Update Python dependencies 106 | 107 | ### Added 108 | - Use `flake8` as linter, `black` as code formatter 109 | - Use `pre-commit` for git hooks support 110 | 111 | ## [0.7.0] - 2019-06-23 112 | ### Fixed 113 | - Force HTTPS protocol even behind reverse-proxies 114 | 115 | ### Changed 116 | - Update Python dependencies 117 | - Rename `mailer.services` package to `mailer.providers` 118 | - BREAKING: rename `MAILER_SERVICE` config to `MAILER_PROVIDER` 119 | 120 | ### Added 121 | - BREAKING: add `SENDER_EMAIL` config to specify the e-mail to send from (e.g. `no-reply@domain.me`) 122 | 123 | ## [0.6.2] - 2019-05-30 124 | ### Changed 125 | - Update Python dependencies 126 | - Migrate to Zeit Now official Python WSGI builder 127 | 128 | ## [0.6.1] - 2019-04-29 129 | ### Security 130 | - Update Python dependencies 131 | - Fix `jinja2` vulnerability ([CVE-2019-10906](https://nvd.nist.gov/vuln/detail/CVE-2019-10906)) 132 | - Fix `urllib3` vulnerability ([CVE-2019-11324](https://nvd.nist.gov/vuln/detail/CVE-2019-11324)) 133 | 134 | ### Fixed 135 | - Fix `sendgrid` >= `6.0.0` breaking changes 136 | 137 | ### Added 138 | - Travis-CI deployment to Zeit Now serverless platform 139 | 140 | ## [0.6.0] - 2019-03-28 141 | ### Security 142 | - Fix `webargs` vulnerability ([CVE-2019-9710](https://nvd.nist.gov/vuln/detail/CVE-2019-9710)) 143 | 144 | ### Fixed 145 | - Werkzeug deprecation warning for `ProxyFix` 146 | 147 | ### Added 148 | - Swagger OpenAPI documentation 149 | - Sentry crash reporting support 150 | 151 | ### Removed 152 | - `SECRET_KEY` secret config (unused) 153 | - `SERVER_NAME` config (unused) 154 | 155 | ## [0.5.0] - 2019-01-20 156 | ### Added 157 | - Google ReCaptcha v2 validation 158 | - Disabling rate-limiting 159 | 160 | ### Changed 161 | - README.md with complete list of environment variables for configuration 162 | 163 | ## [0.4.0] - 2019-01-10 164 | ### Added 165 | - Zeit Now 2.0 serverless/lambda deployment compatibility! 166 | 167 | ### Changed 168 | - Update Python dependencies 169 | - API info endpoint (`/api`) exempted from rate-limiting and returns some more information 170 | 171 | ## [0.3.0] - 2019-01-10 172 | ### Added 173 | - Add optional `honeypot` param for spam-bot protection 174 | 175 | ## [0.2.0] - 2019-01-09 176 | ### Added 177 | - Add CHANGELOG.md 178 | 179 | ### Changed 180 | - Update Python dependencies 181 | - Update .example.env 182 | - Update default route rate-limiting rule to 10 per hour 183 | 184 | ## [0.1.0] - 2018-12-21 185 | ### Added 186 | - Initial release of `mailer` 187 | - Sendgrid mailing provider support 188 | 189 | [Unreleased]: https://github.com/rclement/mailer/compare/0.10.3...HEAD 190 | [0.10.3]: https://github.com/rclement/mailer/compare/0.10.2...0.10.3 191 | [0.10.2]: https://github.com/rclement/mailer/compare/0.10.1...0.10.2 192 | [0.10.1]: https://github.com/rclement/mailer/compare/0.10.0...0.10.1 193 | [0.10.0]: https://github.com/rclement/mailer/compare/0.9.1...0.10.0 194 | [0.9.1]: https://github.com/rclement/mailer/compare/0.9.0...0.9.1 195 | [0.9.0]: https://github.com/rclement/mailer/compare/0.8.1...0.9.0 196 | [0.8.1]: https://github.com/rclement/mailer/compare/0.8.0...0.8.1 197 | [0.8.0]: https://github.com/rclement/mailer/compare/0.7.1...0.8.0 198 | [0.7.1]: https://github.com/rclement/mailer/compare/0.7.0...0.7.1 199 | [0.7.0]: https://github.com/rclement/mailer/compare/0.6.2...0.7.0 200 | [0.6.2]: https://github.com/rclement/mailer/compare/0.6.1...0.6.2 201 | [0.6.1]: https://github.com/rclement/mailer/compare/0.6.0...0.6.1 202 | [0.6.0]: https://github.com/rclement/mailer/compare/0.5.0...0.6.0 203 | [0.5.0]: https://github.com/rclement/mailer/compare/0.4.0...0.5.0 204 | [0.4.0]: https://github.com/rclement/mailer/compare/0.3.0...0.4.0 205 | [0.3.0]: https://github.com/rclement/mailer/compare/0.2.0...0.3.0 206 | [0.2.0]: https://github.com/rclement/mailer/compare/0.1.0...0.2.0 207 | [0.1.0]: https://github.com/rclement/mailer/releases/tag/0.1.0 208 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.4-slim 2 | 3 | ENV APP_USER=app 4 | ENV APP_GROUP=app 5 | ENV APP_ROOT=/home/${APP_USER} 6 | 7 | RUN mkdir -p ${APP_ROOT} 8 | WORKDIR ${APP_ROOT} 9 | 10 | RUN set -ex && pip install --upgrade pip && pip install pipenv 11 | 12 | COPY Pipfile Pipfile 13 | COPY Pipfile.lock Pipfile.lock 14 | 15 | RUN set -ex && pipenv install --deploy --system 16 | 17 | RUN groupadd -r ${APP_GROUP} && useradd --no-log-init -r -g ${APP_GROUP} ${APP_USER} 18 | RUN chown -R ${APP_USER}:${APP_GROUP} ${APP_ROOT} 19 | USER ${APP_USER} 20 | 21 | COPY --chown=app:app . ${APP_ROOT} 22 | 23 | ENV HOST 0.0.0.0 24 | ENV PORT 5000 25 | 26 | EXPOSE ${PORT} 27 | 28 | ENTRYPOINT ["honcho", "start"] 29 | CMD ["web"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | Mailer: dead-simple mailer micro-service for static websites 633 | Copyright (C) 2018 - present Romain Clement 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | aiocontextvars = "==0.2.2" 8 | cryptography = "==41.0.6" 9 | fastapi = {extras = ["all"],version = "==0.99.1"} 10 | gunicorn = "==21.2.0" 11 | honcho = "==1.1.0" 12 | pgpy = "==0.6.0" 13 | requests = "==2.31.0" 14 | sentry-sdk = {extras = ["fastapi"],version = "==1.39.1"} 15 | 16 | [dev-packages] 17 | bandit = "==1.7.6" 18 | black = "==22.12.0" 19 | faker = "==19.13.0" 20 | flake8 = "==6.1.0" 21 | invoke = "==2.2.0" 22 | mkdocs = "==1.5.3" 23 | mkdocs-material = "==9.4.14" 24 | mypy = "==1.8.0" 25 | pre-commit = "==3.5.0" 26 | pytest = "==7.4.3" 27 | pytest-cov = "==4.1.0" 28 | pytest-mock = "==3.12.0" 29 | python-dotenv = "==1.0.0" 30 | responses = "==0.25.0" 31 | safety = "==2.3.5" 32 | types-certifi = "==2021.10.8.3" 33 | types-requests = "==2.31.0.10" 34 | 35 | [requires] 36 | python_full_version = "3.11.4" 37 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn --name=mailer --worker-class=uvicorn.workers.UvicornWorker --access-logfile=- --error-logfile=- mailer:app 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mailer 2 | 3 | > Dead-simple mailer micro-service for static websites 4 | 5 | [![Github Tag](https://img.shields.io/github/tag/rclement/mailer.svg)](https://github.com/rclement/mailer/releases/latest) 6 | [![CI/CD](https://github.com/rclement/mailer/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/rclement/mailer/actions/workflows/ci-cd.yml) 7 | [![Coverage Status](https://img.shields.io/codecov/c/github/rclement/mailer)](https://codecov.io/gh/rclement/mailer) 8 | [![License](https://img.shields.io/github/license/rclement/mailer)](https://github.com/rmnclmnt/mailer/blob/master/LICENSE) 9 | [![Docker Pulls](https://img.shields.io/docker/pulls/rmnclmnt/mailer.svg)](https://hub.docker.com/r/rmnclmnt/mailer) 10 | 11 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/rclement/mailer&env=SENDER_EMAIL,TO_EMAIL,TO_NAME,SMTP_HOST,SMTP_PORT,SMTP_TLS,SMTP_SSL,SMTP_USER,SMTP_PASSWORD&envDescription=Configuration&envLink=https://rclement.github.io/mailer/deployment/#configuration) 12 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/rclement/mailer) 13 | 14 | A free and open-source software alternative to contact form services such as FormSpree, 15 | to integrate a contact form seamlessly within your next static website! 16 | 17 | When building static websites and [JAMStack](https://jamstack.org/) web applications, 18 | the need for a contact form arises pretty often but requires some server-side processing. 19 | `mailer` provides a dead-simple micro-service (usable as a serverless function) for this purpose, 20 | enabling one to receive e-mails from a simple form using a single request, be it URL-encoded 21 | or AJAX. 22 | 23 | Proudly developed in Python using the [FastAPI](https://fastapi.tiangolo.com) ASGI framework. 24 | 25 | ## Features 26 | 27 | - Self-hostable micro-service 28 | - Docker and serverless support 29 | - Unicode message support 30 | - OpenAPI documentation (Swagger and ReDoc) 31 | - CORS domain validation 32 | - Spam-bot filtering with honeypot field 33 | - Google ReCaptcha v2 validation 34 | - Sentry crash reporting 35 | - Any SMTP-compatible back-end is supported 36 | - PGP encryption support using PGP/MIME 37 | 38 | ## License 39 | 40 | Licensed under GNU Affero General Public License v3.0 (AGPLv3) 41 | 42 | Copyright (c) 2018 - present Romain Clement 43 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | If you discover a security vulnerability within `mailer`, please send an e-mail 4 | to Romain Clement at [contact@romain-clement.net](mailto:contact@romain-clement.net) 5 | and **DO NOT DISCLOSE IT PUBLICLY** until a fix for it can be provided. We take security 6 | reports very seriously and intend to respond in a timely manner. 7 | 8 | You can use the following PGP key to encrypt your e-mail: 9 | 10 | ``` 11 | -----BEGIN PGP PUBLIC KEY BLOCK----- 12 | Version: OpenPGP.js v4.10.1 13 | Comment: https://openpgpjs.org 14 | 15 | xjMEXQ5AMBYJKwYBBAHaRw8BAQdANPb7Hqa4RnHTeaR9d1mqA2KzvEmlTflW 16 | mtQLVsa5dUHNN2NvbnRhY3RAcm9tYWluLWNsZW1lbnQubmV0IDxjb250YWN0 17 | QHJvbWFpbi1jbGVtZW50Lm5ldD7CdwQQFgoAHwUCXQ5AMAYLCQcIAwIEFQgK 18 | AgMWAgECGQECGwMCHgEACgkQ+fsBVGVH04R0RgEA+NWOfZlvcoYMw5rlsNw6 19 | vIcJBKF0pyAlLP/7dDyg7qEA/jQUlzZmtSx9/QxS9yr5rFQPhM4VfiI+9P4m 20 | YRrVABgGzjgEXQ5AMBIKKwYBBAGXVQEFAQEHQJHoZzLf2epi3P0SiyNaD7R6 21 | 8KUgiIqU4uTtfgabKmNcAwEIB8JhBBgWCAAJBQJdDkAwAhsMAAoJEPn7AVRl 22 | R9OEH0EBAJOnJfQ9GVQl9LxVwgTMq8xT/Tkc+iem8MFT4PaFsMMLAP9ck8TX 23 | s61TSQgYfzzAsCQ33dxiSvI3gYdw39T5hGYSBQ== 24 | =4sal 25 | -----END PGP PUBLIC KEY BLOCK----- 26 | ``` 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mailer", 3 | "description": "Dead-simple mailer micro-service for static websites", 4 | "website": "https://rclement.github.io/mailer/", 5 | "repository": "https://github.com/rclement/mailer", 6 | "keywords": ["python", "fastapi", "mailer", "smtp", "pgp"], 7 | "env": { 8 | "SENDER_EMAIL": { 9 | "description": "E-mail address to send e-mail from" 10 | }, 11 | "TO_EMAIL": { 12 | "description": "E-mail address of the recipient" 13 | }, 14 | "TO_NAME": { 15 | "description": "Name of the recipient" 16 | }, 17 | "SMTP_HOST": { 18 | "description": "SMTP host URL" 19 | }, 20 | "SMTP_PORT": { 21 | "description": "SMTP host port" 22 | }, 23 | "SMTP_TLS": { 24 | "description": "SMTP host use TLS (mutually exclusive with SSL)" 25 | }, 26 | "SMTP_SSL": { 27 | "description": "SMTP host use SSL (mutually exclusive with TLS)" 28 | }, 29 | "SMTP_USER": { 30 | "description": "SMTP host user" 31 | }, 32 | "SMTP_PASSWORD": { 33 | "description": "SMTP host password (or API key)" 34 | }, 35 | "FORCE_HTTPS": { 36 | "description": "Force HTTPS redirect", 37 | "required": false 38 | }, 39 | "CORS_ORIGINS": { 40 | "description": "List (JSON string) of authorized origins for CORS origins and Origin request header validation", 41 | "required": false 42 | }, 43 | "RECAPTCHA_SECRET_KEY": { 44 | "description": "Google ReCaptcha v2 secret key", 45 | "required": false 46 | }, 47 | "PGP_PUBLIC_KEY": { 48 | "description": "Base64-encoded PGP public key to encrypt e-mails with before sending to SMTP backend (generate with cat | base64)", 49 | "required": false 50 | }, 51 | "SENTRY_DSN": { 52 | "description": "Sentry crash reporting DSN", 53 | "required": false 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | ```bash 6 | pipenv install -d 7 | pipenv run pre-commit install --config .pre-commit-config.yml 8 | pipenv run inv qa 9 | ``` 10 | 11 | ## Running locally 12 | 13 | 1. Set and load environment variables: 14 | ```bash 15 | cp .example.env .env 16 | edit .env 17 | pipenv shell 18 | ``` 19 | 20 | 2. Run dev server: 21 | ```bash 22 | uvicorn mailer.app:app --host 0.0.0.0 --port 8000 23 | ``` 24 | or if using VSCode, use the following configuration in `.vscode/launch.json`: 25 | ```json 26 | { 27 | "version": "0.2.0", 28 | "configurations": [ 29 | { 30 | "name": "mailer:app", 31 | "type": "python", 32 | "request": "launch", 33 | "module": "uvicorn", 34 | "args": ["--host=0.0.0.0", "--port=8000", "mailer:app"], 35 | "envFile": "", 36 | "justMyCode": false 37 | }, 38 | { 39 | "name": "mailer:tests", 40 | "type": "python", 41 | "request": "test", 42 | "justMyCode": false 43 | } 44 | ] 45 | } 46 | ``` 47 | 48 | 3. Try it: 49 | ```bash 50 | http GET http://localhost:8000/ 51 | http POST http://localhost:8000/api/mail \ 52 | Origin:http://localhost:8000 \ 53 | email="john@doe.com" \ 54 | name="John Doe" \ 55 | subject="Test 💫" \ 56 | message="Hello 👋" \ 57 | honeypot="" 58 | ``` 59 | 60 | 4. Open the Swagger OpenAPI documentation at [http://localhost:8000/docs](http://localhost:8000/docs) 61 | 62 | ## Examples 63 | 64 | Run the examples: 65 | 66 | ``` 67 | cd examples 68 | pipenv run python -m http.server 5000 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | You will need to choose: 4 | 5 | - A mailing provider 6 | - A cloud provider 7 | 8 | Most mailing providers offer a generous free-tier to get started 9 | ([Sendgrid](https://sendgrid.com), [Mailjet](https://mailjet.com), etc.) 10 | and allow usage via SMTP. 11 | 12 | Regarding cloud providers, you can start deploying with [Vercel](https://vercel.com) 13 | serverless platform within minutes! But any PaaS and/or Docker-compatible provider will do! 14 | 15 | ## Configuration 16 | 17 | The following environment variables are available: 18 | 19 | | Variable | Default | Format | Description | 20 | | ---------------------- | :------: | :----------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 21 | | `SENDER_EMAIL` | `""` | `no-reply@domain.me` | (**required**) E-mail address to send e-mail from | 22 | | `TO_EMAIL` | `""` | `contact@domain.me` | (**required**) E-mail address of the recipient | 23 | | `TO_NAME` | `""` | `My Name` | (**required**) Name of the recipient | 24 | | `SMTP_HOST` | `""` | `smtp.host.com` | (**required**) SMTP host URL | 25 | | `SMTP_PORT` | `""` | `587` | (**required**) SMTP host port | 26 | | `SMTP_TLS` | `""` | `true` | (**required**) SMTP host use TLS (mutually exclusive with SSL) | 27 | | `SMTP_SSL` | `""` | `false` | (**required**) SMTP host use SSL (mutually exclusive with TLS) | 28 | | `SMTP_USER` | `""` | `smtp-user` | (**required**) SMTP host user | 29 | | `SMTP_PASSWORD` | `""` | `smtp-password` | (**required**) SMTP host password (or API key) | 30 | | `SUCCESS_REDIRECT_URL` | `""` | `https://domain.me/contact/success` | (**optional**) Redirect to this URL after an e-mail has been successfully submitted using the url-encoded form API. If not set, the default behaviour is to redirect to the `Origin` URL from the request header | 31 | | `ERROR_REDIRECT_URL` | `""` | `https://domain.me/contact/error` | (**optional**) Redirect to this URL if an error occurred when submitting an e-mail using the url-encoded form API. If not set, the default behaviour is to redirect to the `Origin` URL from the request header | 32 | | `FORCE_HTTPS` | `'true'` | `'true'` | (**optional**) Force HTTPS redirect | 33 | | `CORS_ORIGINS` | `'[]'` | `'["https://domain.me", "https://mydomain.me"]'` | (**optional**) List (JSON string) of authorized origins for CORS origins and Origin request header validation | 34 | | `RECAPTCHA_SECRET_KEY` | `""` | `string` | (**optional**) Google ReCaptcha v2 secret key | 35 | | `PGP_PUBLIC_KEY` | `""` | `base64` | (**optional**) Base64-encoded PGP public key to encrypt e-mails with before sending to SMTP backend (generate with `cat \| base64`) | 36 | | `SENTRY_DSN` | `""` | `string` | (**optional**) Sentry crash reporting DSN | 37 | 38 | ## Verification 39 | 40 | In order to verify that `mailer` is properly deployed, go to the domain or 41 | sub-domain pointing to your deployment (e.g. `https://mailer.domain.me`). 42 | 43 | You should be able to display the homepage and the API documentation 44 | (e.g. `https://mailer.domain.me/redoc`). 45 | If either the homepage or the API documentation do not display properly, 46 | check the logs according to your deployment method. 47 | 48 | If something feels fishy, you can always post an issue on 49 | [GitHub](https://github.com/rclement/mailer/issues). 50 | 51 | ## Serverless (e.g. Vercel) 52 | 53 | The easiest way to get started with serverless deployment is to use [Vercel](https://vercel.com). 54 | You will need to create a Vercel account and to install the [Vercel CLI](https://vercel.com/cli). 55 | 56 | 1. From the `mailer` codebase, create a new project on Vercel: `vercel` 57 | 58 | 2. Open the project in the webapp and configure required and optional environment variables 59 | per environment (Production, Preview, Development): 60 | 61 | - `SENDER_EMAIL` 62 | - `TO_EMAIL` 63 | - `TO_NAME` 64 | - `SMTP_HOST` 65 | - `SMTP_PORT` 66 | - `SMTP_TLS` 67 | - `SMTP_SSL` 68 | - `SMTP_USER` 69 | - `SMTP_PASSWORD` 70 | 71 | 3. Deploy to preview environment: 72 | 73 | ```bash 74 | vercel 75 | ``` 76 | 77 | 4. Deploy to production environment: 78 | 79 | ```bash 80 | vercel --prod 81 | ``` 82 | 83 | ## PaaS (e.g. Heroku, CleverCloud) 84 | 85 | The easiest way to get started with PaaS deployment is to use [Heroku](https://heroku.com). 86 | You will need to create a Heroku account and to install the 87 | [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli). 88 | 89 | 1. Create a project on the Heroku dashboard and add your configuration in environment variables 90 | 91 | 2. Login to Heroku: `heroku login` 92 | 93 | 3. From the `mailer` codebase, add the Git remote: `heroku git:remote -a ` 94 | 95 | 4. Deploy: `git push heroku master:master` 96 | 97 | Or you can also use the containerized version! 98 | 99 | ## Docker 100 | 101 | The Docker image is publicly available on [Docker Hub](https://hub.docker.com/r/rmnclmnt/mailer). 102 | 103 | All stable versions are automatically deployed and available after each release. 104 | The `latest` tag will allow to retrieve non-stable changes 105 | 106 | If you want to quickly try the Docker image: 107 | 108 | ```bash 109 | docker run --env-file .env -p 5000:5000 rmnclmnt/mailer:latest 110 | ``` 111 | 112 | ## VPS 113 | 114 | If you're feeling nerdy or a bit old-school, you are more than welcome to 115 | deploy `mailer` using a standard VPS from any cloud provider (AWS, OVH, etc.). 116 | 117 | We still recommend you deploy `mailer` using the provided Docker image for 118 | reproducibility reasons. 119 | 120 | This kind of deployment will also require some extra steps, such as setting up: 121 | 122 | - A reverse-proxy link [Nginx](https://www.nginx.com) 123 | - Automatic SSL/TLS certificate generation using [Let's Encrypt](https://letsencrypt.org) 124 | - A firewall with sensitive rules (only allow ports 80 and 443) 125 | - Security policies (only allow SSH access using public key, disable root user over SSH, etc.) 126 | - External intrusion protection using [fail2ban](https://www.fail2ban.org) 127 | - etc. 128 | 129 | If you do not know (nor want to know) how to perform this kind of setup, use more 130 | developer-friendly deployment options! 131 | -------------------------------------------------------------------------------- /docs/examples/simple-ajax-pgp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mailer: simple AJAX example with PGP encryption 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |

19 | Mailer: simple AJAX example with PGP encryption 20 |

21 | 22 |

23 | Demonstrate how to use mailer using a simple form and an AJAX request, 24 | using Vue.js. 25 |
26 | If enabled, PGP encryption of the e-mail will be performed server-side automatically. 27 |
28 | An optional PGP public key can be attached with the PGP-encrypted e-mail: this way 29 | the receiver can also respond with an end-to-end encrypted message! 30 |

31 | 32 |
33 |
34 |

35 | 36 | Mailer URL 37 | 38 |

39 |
40 | 46 |
47 |
48 | 49 |
50 |
51 | 63 |
64 |
65 | 66 |
67 |
68 | 72 |
73 |
74 | 75 |
76 | 77 |
78 | 84 |
85 |
86 | 87 |
88 | 89 |
90 | 97 |
98 |
99 | 100 |
101 | 102 |
103 | 110 |
111 |
112 | 113 |
114 | 115 |
116 | 122 |
123 |
124 | 125 |
126 |
127 | 128 |
129 |
130 | 131 |
132 |
133 | 134 |
135 |
136 | 137 |
138 | 139 | Message sent successfully! 140 |
141 | 142 |
143 | 144 | Failed to send message! 145 |
146 |
147 |
148 |
149 |
150 |
151 | 152 |
153 |
154 |
155 |

156 | Source-code available on GitHub 157 |
158 | Free open-source software under AGPLv3 License 159 |
160 | Copyright (c) 2018-present Romain Clement 161 |

162 |
163 |
164 |
165 |
166 | 167 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /docs/examples/simple-ajax-recaptcha.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mailer: simple AJAX example with Google ReCaptcha 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 |

20 | Mailer: simple AJAX example with Google ReCaptcha 21 |

22 | 23 |

24 | Demonstrate how to use mailer using a simple form and an AJAX request, 25 | using Vue.js 26 | and Google ReCaptcha v2. 27 |

28 | 29 |
30 |
31 |

32 | 33 | Mailer URL 34 | 35 |

36 |
37 | 43 |
44 |
45 | 46 |
47 |

48 | 49 | Google ReCaptcha Site Key 50 | 51 |

52 |
53 | 59 |
60 |
61 | 62 |
63 | 64 |
65 | 71 |
72 |
73 | 74 |
75 | 76 |
77 | 84 |
85 |
86 | 87 |
88 | 89 |
90 | 97 |
98 |
99 | 100 |
101 | 102 |
103 | 109 |
110 |
111 | 112 |
113 |
114 | 115 |
116 |
117 | 118 |
119 |
120 | 121 |
122 |
123 | 124 |
125 | 126 | Message sent successfully! 127 |
128 | 129 |
130 | 131 | Failed to send message! 132 |
133 |
134 |
135 |
136 |
137 |
138 | 139 |
140 |
141 |
142 |

143 | Source-code available on GitHub 144 |
145 | Free open-source software under AGPLv3 License 146 |
147 | Copyright (c) 2018-present Romain Clement 148 |

149 |
150 |
151 |
152 | 153 |
154 |
155 | 156 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /docs/examples/simple-ajax.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mailer: simple AJAX example 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |

19 | Mailer: simple AJAX example 20 |

21 | 22 |

23 | Demonstrate how to use mailer using a simple form and an AJAX request, 24 | using Vue.js 25 |

26 | 27 |
28 |
29 |

30 | 31 | Mailer URL 32 | 33 |

34 |
35 | 41 |
42 |
43 | 44 |
45 | 46 |
47 | 53 |
54 |
55 | 56 |
57 | 58 |
59 | 66 |
67 |
68 | 69 |
70 | 71 |
72 | 79 |
80 |
81 | 82 |
83 | 84 |
85 | 91 |
92 |
93 | 94 |
95 |
96 | 97 |
98 |
99 | 100 |
101 |
102 | 103 |
104 |
105 | 106 |
107 | 108 | Message sent successfully! 109 |
110 | 111 |
112 | 113 | Failed to send message! 114 |
115 |
116 |
117 |
118 |
119 |
120 | 121 |
122 |
123 |
124 |

125 | Source-code available on GitHub 126 |
127 | Free open-source software under AGPLv3 License 128 |
129 | Copyright (c) 2018-present Romain Clement 130 |

131 |
132 |
133 |
134 |
135 | 136 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Once `mailer` is deployed, either on your own custom domain or locally, you can 4 | start receiving e-mails, using a URL-encoded or an AJAX request. 5 | 6 | Given that these features are enabled with your deployment, the following options are available: 7 | 8 | - [Google ReCaptcha v2](https://developers.google.com/recaptcha/docs/display): make sure only your domains can send e-mails through `mailer` 9 | - PGP public key attachment: so that you can respond with end-to-end encryption to your contacts! 10 | 11 | ## Parameters 12 | 13 | You can find the documentation for the request parameters in the API documention 14 | of your deployment (e.g. `https://mailer.domain.me/redoc`). 15 | 16 | | Variable | Default | Format | Description | 17 | | ---------------------- | :-----: | :------------------------: | ---------------------------------------------------------------- | 18 | | `name` | `""` | `string, max length: 50` | (**required**) Name of the contact sending the message | 19 | | `email` | `""` | `string, valid e-mail` | (**required**) E-mail of the contact sending the message | 20 | | `subject` | `""` | `string, max length: 100` | (**required**) Subject of the message to send | 21 | | `message` | `""` | `string, max length: 1000` | (**required**) Body of the message to send | 22 | | `honeypot` | `""` | `string, empty` | (**required**) Body of the message to send | 23 | | `g-recaptcha-response` | `""` | `string` | (**optional**) Google ReCaptcha v2 response | 24 | | `public_key` | `""` | `string` | (**optional**) PGP public key of the contact sending the message | 25 | 26 | ## Headers 27 | 28 | When using AJAX requests, make sure the `Origin` header matches one of the domains 29 | specified in the `CORS_ORIGINS` configuration of your deployment. 30 | 31 | ## HTML Form 32 | 33 | Using a standard HTML form to perform an URL-encoded request: 34 | 35 | ```html 36 |
37 | 38 | 39 |
40 | 41 | 42 |
43 | 44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 |
52 | ``` 53 | 54 | ## Fetch API 55 | 56 | Using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to perform an AJAX request: 57 | 58 | ```js 59 | fetch("https://mailer.domain.me/api/mail", { 60 | method: "POST", 61 | headers: { 62 | "Content-Type": "application/json", 63 | }, 64 | body: JSON.stringify({ 65 | email: "john@doe.com", 66 | name: "John Doe", 67 | subject: "Contact", 68 | message: "Hey there! Up for a coffee?", 69 | "g-recaptcha-response": "azertyuiopqsdfghjklmwxcvbn", 70 | public_key: 71 | "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n", 72 | honeypot: "", 73 | }), 74 | }); 75 | ``` 76 | 77 | ## Axios 78 | 79 | Using [Axios](https://github.com/axios/axios) to perform an AJAX request: 80 | 81 | ```js 82 | axios.post("https://mailer.domain.me/api/mail", { 83 | email: "john@doe.com", 84 | name: "John Doe", 85 | subject: "Contact", 86 | message: "Hey there! Up for a coffee?", 87 | "g-recaptcha-response": "azertyuiopqsdfghjklmwxcvbn", 88 | public_key: 89 | "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n", 90 | honeypot: "", 91 | }); 92 | ``` 93 | 94 | ## Examples 95 | 96 | Explore ready-to-use examples to demonstrate how to use `mailer` in the `docs/examples` folder. 97 | 98 | To run the examples properly, do not just open the `.html` files, but be sure 99 | to server them with a basic HTTP server, for instance: 100 | 101 | ```bash 102 | python -m http.server 103 | ``` 104 | 105 | The reason for this requirement is that if you enable CORS protection on `mailer`, 106 | opening files directly in a web-browser will not set the `Origin` header properly, 107 | thus all requests will fail. 108 | 109 | Be sure to configure and run an instance of `mailer` before using them! 110 | 111 | - [Simple AJAX form](examples/simple-ajax.html) 112 | - [Simple AJAX form with Google ReCaptcha](examples/simple-ajax-recaptcha.html) 113 | - [Simple AJAX form with PGP encryption](examples/simple-ajax-pgp.html) 114 | -------------------------------------------------------------------------------- /mailer/__about__.py: -------------------------------------------------------------------------------- 1 | version = (0, 10, 3) 2 | __version__ = ".".join(str(v) for v in version) 3 | __title__ = "Mailer" 4 | __description__ = "Dead-simple mailer micro-service for static websites" 5 | __author__ = "Romain Clement" 6 | __author_email__ = "contact@romain-clement.net" 7 | __url__ = "https://github.com/rclement/mailer" 8 | __license__ = "AGPLv3" 9 | -------------------------------------------------------------------------------- /mailer/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi import FastAPI 3 | 4 | 5 | def create_app(env_file: Optional[str] = ".env") -> FastAPI: 6 | from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from . import api, home, sentry 9 | from .settings import Settings 10 | 11 | settings = Settings(_env_file=env_file) # type: ignore 12 | 13 | app = FastAPI( 14 | title=settings.app_title, 15 | description=settings.app_description, 16 | version=settings.app_version, 17 | docs_url=None if settings.app_environment == "production" else "/docs", 18 | ) 19 | app.state.settings = settings 20 | 21 | if settings.force_https: 22 | app.add_middleware(HTTPSRedirectMiddleware) 23 | 24 | app.add_middleware( 25 | CORSMiddleware, 26 | allow_origins=settings.cors_origins, 27 | allow_credentials=True, 28 | allow_methods=["*"], 29 | allow_headers=["*"], 30 | ) 31 | 32 | app.include_router(home.router, prefix="", tags=["home"]) 33 | app.include_router(api.router, prefix="/api", tags=["api"]) 34 | 35 | sentry.init(app) 36 | 37 | return app 38 | 39 | 40 | app = create_app() 41 | -------------------------------------------------------------------------------- /mailer/api.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | from http import HTTPStatus 3 | from fastapi import APIRouter, Depends, Header, HTTPException, Request 4 | from fastapi.responses import RedirectResponse 5 | from pydantic import BaseModel, EmailStr, Field, ValidationError, validator 6 | 7 | from . import recaptcha 8 | from .mailer import Mailer 9 | from .settings import Settings 10 | 11 | 12 | router = APIRouter() 13 | 14 | 15 | class ApiInfoSchema(BaseModel): 16 | name: str = Field(..., title="Name", description="Name of the app") 17 | version: str = Field(..., title="Version", description="Version of the app") 18 | api_version: str = Field(..., title="API Version", description="Version of the API") 19 | 20 | 21 | class MailSchema(BaseModel): 22 | email: EmailStr = Field( 23 | ..., 24 | title="E-mail", 25 | description="E-mail address of the contact sending the message", 26 | ) 27 | name: str = Field( 28 | ..., 29 | title="Name", 30 | description="Name of the contact sending the message", 31 | min_length=1, 32 | max_length=50, 33 | ) 34 | subject: str = Field( 35 | ..., 36 | title="Subject", 37 | description="Subject of the message to be sent", 38 | min_length=1, 39 | max_length=100, 40 | ) 41 | message: str = Field( 42 | ..., 43 | title="Message", 44 | description="Content of the message to be sent", 45 | min_length=1, 46 | max_length=1000, 47 | ) 48 | honeypot: str = Field( 49 | ..., 50 | title="Honeypot", 51 | description="Spam-bot filtering honeypot: if your are not a bot, just send an empty string!", 52 | min_length=0, 53 | max_length=0, 54 | ) 55 | g_recaptcha_response: Optional[str] = Field( 56 | None, 57 | alias="g-recaptcha-response", 58 | title="Google ReCaptcha Response", 59 | description="Obtained response from Google ReCaptcha v2 widget (or invisible)", 60 | ) 61 | public_key: Optional[str] = Field( 62 | None, 63 | title="PGP public key", 64 | description="ASCII-armored PGP public of the contact sending the message, to be attached within the e-mail", 65 | ) 66 | 67 | @validator("public_key") 68 | def validate_public_key(cls, v: Optional[str]) -> Optional[str]: 69 | from pgpy import PGPKey 70 | from pgpy.errors import PGPError 71 | 72 | if v: 73 | try: 74 | key, _ = PGPKey.from_blob(v.encode("utf-8")) 75 | except (ValueError, PGPError): 76 | raise ValueError("Invalid PGP public key: cannot load the key") 77 | 78 | if not key.is_public: 79 | raise ValueError("Invalid PGP public key: key is private") 80 | 81 | return v 82 | 83 | 84 | def check_origin(req: Request, origin: str = Header(None)) -> None: 85 | settings: Settings = req.app.state.settings 86 | if len(settings.cors_origins) > 0: 87 | if origin not in settings.cors_origins: 88 | raise HTTPException(HTTPStatus.UNAUTHORIZED, detail="Unauthorized origin") 89 | 90 | 91 | @router.get( 92 | "/", 93 | summary="Information", 94 | description="Obtain API information", 95 | response_model=ApiInfoSchema, 96 | ) 97 | def get_api_info(req: Request) -> Dict[str, str]: 98 | settings: Settings = req.app.state.settings 99 | data = { 100 | "name": settings.app_title, 101 | "version": settings.app_version, 102 | "api_version": "v1", 103 | } 104 | 105 | return data 106 | 107 | 108 | @router.post( 109 | "/mail", 110 | summary="Send e-mail", 111 | description="Send an e-mail from a contact", 112 | dependencies=[Depends(check_origin)], 113 | response_model=MailSchema, 114 | responses={ 115 | str(int(HTTPStatus.UNAUTHORIZED)): {"description": "Unauthorized operation"} 116 | }, 117 | ) 118 | def post_mail(req: Request, mail: MailSchema) -> MailSchema: 119 | settings: Settings = req.app.state.settings 120 | 121 | mailer = Mailer( 122 | settings.sender_email, 123 | settings.to_email, 124 | settings.to_name, 125 | settings.smtp_host, 126 | settings.smtp_port, 127 | settings.smtp_tls, 128 | settings.smtp_ssl, 129 | settings.smtp_user, 130 | settings.smtp_password, 131 | settings.pgp_public_key, 132 | ) 133 | 134 | try: 135 | recaptcha.verify( 136 | secret_key=settings.recaptcha_secret_key, response=mail.g_recaptcha_response 137 | ) 138 | 139 | mailer.send_email( 140 | from_email=mail.email, 141 | from_name=mail.name, 142 | subject=mail.subject, 143 | message=mail.message, 144 | public_key=mail.public_key, 145 | ) 146 | except RuntimeError: 147 | raise HTTPException(HTTPStatus.UNAUTHORIZED) 148 | 149 | return mail 150 | 151 | 152 | @router.post( 153 | "/mail/form", 154 | summary="Send e-mail (url-encoded form)", 155 | description="Send an e-mail from an URL-encoded contact form", 156 | dependencies=[Depends(check_origin)], 157 | responses={ 158 | str(int(HTTPStatus.UNAUTHORIZED)): {"description": "Unauthorized operation"} 159 | }, 160 | ) 161 | async def post_mail_form(req: Request) -> RedirectResponse: 162 | settings: Settings = req.app.state.settings 163 | 164 | mailer = Mailer( 165 | settings.sender_email, 166 | settings.to_email, 167 | settings.to_name, 168 | settings.smtp_host, 169 | settings.smtp_port, 170 | settings.smtp_tls, 171 | settings.smtp_ssl, 172 | settings.smtp_user, 173 | settings.smtp_password, 174 | settings.pgp_public_key, 175 | ) 176 | 177 | try: 178 | form = await req.form() 179 | mail = MailSchema(**form) 180 | 181 | recaptcha.verify( 182 | secret_key=settings.recaptcha_secret_key, response=mail.g_recaptcha_response 183 | ) 184 | 185 | mailer.send_email( 186 | from_email=mail.email, 187 | from_name=mail.name, 188 | subject=mail.subject, 189 | message=mail.message, 190 | public_key=mail.public_key, 191 | ) 192 | except (ValidationError, RuntimeError): 193 | return RedirectResponse( 194 | settings.error_redirect_url or req.headers["Origin"], 195 | status_code=HTTPStatus.FOUND, 196 | ) 197 | 198 | return RedirectResponse( 199 | settings.success_redirect_url or req.headers["Origin"], 200 | status_code=HTTPStatus.FOUND, 201 | ) 202 | -------------------------------------------------------------------------------- /mailer/home.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from fastapi.responses import HTMLResponse 3 | from fastapi.templating import Jinja2Templates 4 | 5 | from .settings import Settings 6 | 7 | 8 | router = APIRouter() 9 | templates = Jinja2Templates(directory="templates") 10 | 11 | 12 | @router.get( 13 | "/", 14 | summary="Homepage", 15 | description="Display homepage", 16 | response_class=HTMLResponse, 17 | include_in_schema=False, 18 | ) 19 | def get_homepage(req: Request) -> HTMLResponse: 20 | settings: Settings = req.app.state.settings 21 | return templates.TemplateResponse( 22 | "homepage.html", dict(request=req, settings=settings) 23 | ) 24 | -------------------------------------------------------------------------------- /mailer/mailer.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import smtplib 3 | 4 | from email import encoders 5 | from email.header import Header 6 | from email.message import EmailMessage, Message 7 | from email.mime.base import MIMEBase 8 | from email.mime.multipart import MIMEMultipart 9 | from email.mime.text import MIMEText 10 | from email.utils import formataddr, formatdate 11 | from typing import Any, Dict, Optional 12 | from pgpy import PGPKey, PGPMessage 13 | from pgpy.errors import PGPError 14 | 15 | 16 | @dataclasses.dataclass 17 | class Mailer: 18 | sender_email: str 19 | to_email: str 20 | to_name: str 21 | smtp_host: dataclasses.InitVar[str] 22 | smtp_port: dataclasses.InitVar[int] 23 | smtp_tls: dataclasses.InitVar[bool] 24 | smtp_ssl: dataclasses.InitVar[bool] 25 | smtp_user: dataclasses.InitVar[str] 26 | smtp_password: dataclasses.InitVar[str] 27 | smtp_config: Dict[str, Any] = dataclasses.field(init=False) 28 | pgp_public_key: Optional[PGPKey] 29 | 30 | def __post_init__( 31 | self, 32 | smtp_host: str, 33 | smtp_port: int, 34 | smtp_tls: bool, 35 | smtp_ssl: bool, 36 | smtp_user: str, 37 | smtp_password: str, 38 | ) -> None: 39 | self.smtp_config = dict( 40 | host=smtp_host, 41 | port=smtp_port, 42 | tls=smtp_tls, 43 | ssl=smtp_ssl, 44 | user=smtp_user, 45 | password=smtp_password, 46 | ) 47 | 48 | def send_email( 49 | self, 50 | from_email: str, 51 | from_name: str, 52 | subject: str, 53 | message: str, 54 | public_key: Optional[str], 55 | ) -> None: 56 | if self.pgp_public_key: 57 | return self._send_encrypted_email( 58 | from_email, from_name, subject, message, public_key, self.pgp_public_key 59 | ) 60 | else: 61 | return self._send_plain_email(from_email, from_name, subject, message) 62 | 63 | def _send_plain_email( 64 | self, from_email: str, from_name: str, subject: str, message: str 65 | ) -> None: 66 | msg = MIMEMultipart("mixed") 67 | msg["Date"] = formatdate() 68 | msg["From"] = formataddr((from_name, self.sender_email)) 69 | msg["To"] = formataddr((self.to_name, self.to_email)) 70 | msg["Reply-To"] = formataddr((from_name, from_email)) 71 | msg["Subject"] = Header(subject, "utf-8") 72 | msg.preamble = "This is a multi-part message in MIME format.\n" 73 | 74 | msg_text = MIMEText(message, _subtype="plain", _charset="utf-8") 75 | msg.attach(msg_text) 76 | 77 | self._send_smtp(msg) 78 | 79 | def _send_encrypted_email( 80 | self, 81 | from_email: str, 82 | from_name: str, 83 | subject: str, 84 | message: str, 85 | public_key: Optional[str], 86 | pgp_public_key: PGPKey, 87 | ) -> None: 88 | # Sources: 89 | # - [ProtonMail](https://protonmail.com/support/knowledge-base/pgp-mime-pgp-inline/) 90 | # - [StackOverflow](https://stackoverflow.com/questions/54486279/how-to-send-gpg-encrypted-email-with-attachment-using-python?answertab=active#tab-top) 91 | 92 | msg = EmailMessage() 93 | msg.add_header(_name="Content-Type", _value="multipart/mixed") 94 | 95 | msg_text = MIMEText(message, _subtype="plain", _charset="utf-8") 96 | msg.attach(msg_text) 97 | 98 | if public_key: 99 | msg_attachment = EmailMessage() 100 | msg_attachment.add_header( 101 | _name="Content-Type", 102 | _value="application/pgp-keys", 103 | name="publickey.asc", 104 | ) 105 | msg_attachment.add_header( 106 | _name="Content-Disposition", 107 | _value="attachment", 108 | filename="publickey.asc", 109 | ) 110 | msg_attachment.set_payload(public_key) 111 | encoders.encode_base64(msg_attachment) 112 | msg.attach(msg_attachment) 113 | 114 | try: 115 | encrypted_message = pgp_public_key.encrypt(PGPMessage.new(msg.as_string())) 116 | except PGPError: 117 | raise RuntimeError 118 | 119 | pgp_msg = MIMEBase( 120 | _maintype="multipart", 121 | _subtype="encrypted", 122 | protocol="application/pgp-encrypted", 123 | charset="UTF-8", 124 | ) 125 | 126 | pgp_msg["Date"] = formatdate() 127 | pgp_msg["From"] = formataddr((from_name, self.sender_email)) 128 | pgp_msg["To"] = formataddr((self.to_name, self.to_email)) 129 | pgp_msg["Reply-To"] = formataddr((from_name, from_email)) 130 | pgp_msg["Subject"] = Header(subject, "utf-8") 131 | 132 | pgp_msg_part1 = EmailMessage() 133 | pgp_msg_part1.add_header( 134 | _name="Content-Type", _value="application/pgp-encrypted" 135 | ) 136 | pgp_msg_part1.add_header( 137 | _name="Content-Description", _value="PGP/MIME version identification" 138 | ) 139 | pgp_msg_part1.set_payload("Version: 1\n") 140 | pgp_msg.attach(pgp_msg_part1) 141 | 142 | pgp_msg_part2 = EmailMessage() 143 | pgp_msg_part2.add_header( 144 | _name="Content-Type", 145 | _value="application/octet-stream", 146 | name="encrypted.asc", 147 | ) 148 | pgp_msg_part2.add_header( 149 | _name="Content-Description", _value="OpenPGP encrypted message" 150 | ) 151 | pgp_msg_part2.add_header( 152 | _name="Content-Disposition", _value="inline", filename="encrypted.asc" 153 | ) 154 | pgp_msg_part2.set_payload(str(encrypted_message)) 155 | pgp_msg.attach(pgp_msg_part2) 156 | 157 | return self._send_smtp(pgp_msg) 158 | 159 | def _get_smtp_handler(self) -> smtplib.SMTP: 160 | host = self.smtp_config["host"] 161 | port = self.smtp_config["port"] 162 | ssl = self.smtp_config["ssl"] 163 | 164 | if ssl: 165 | return smtplib.SMTP_SSL(host=host, port=port) 166 | 167 | return smtplib.SMTP(host=host, port=port) 168 | 169 | def _send_smtp(self, message: Message) -> None: 170 | try: 171 | s = self._get_smtp_handler() 172 | 173 | if self.smtp_config["tls"]: 174 | s.starttls() 175 | s.ehlo() 176 | 177 | s.login( 178 | user=self.smtp_config["user"], password=self.smtp_config["password"] 179 | ) 180 | s.send_message(message) 181 | s.quit() 182 | except smtplib.SMTPException: 183 | raise RuntimeError 184 | -------------------------------------------------------------------------------- /mailer/recaptcha.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from typing import Optional 4 | 5 | 6 | verify_url = "https://www.google.com/recaptcha/api/siteverify" 7 | 8 | 9 | def verify(secret_key: Optional[str], response: Optional[str]) -> None: 10 | if secret_key: 11 | params = {"secret": secret_key, "response": response} 12 | rv = requests.post(verify_url, data=params, timeout=30) 13 | rv_json = rv.json() 14 | 15 | if rv.status_code != 200 or not rv_json.get("success", False): 16 | raise RuntimeError 17 | -------------------------------------------------------------------------------- /mailer/sentry.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | 3 | from fastapi import FastAPI 4 | from sentry_sdk.integrations.starlette import StarletteIntegration 5 | from sentry_sdk.integrations.fastapi import FastApiIntegration 6 | 7 | from .settings import Settings 8 | 9 | 10 | def init(app: FastAPI) -> None: 11 | settings: Settings = app.state.settings 12 | sentry_sdk.init( 13 | dsn=settings.sentry_dsn, 14 | environment=settings.app_environment, 15 | release=settings.app_version, 16 | integrations=[ 17 | StarletteIntegration(), 18 | FastApiIntegration(), 19 | ], 20 | ) 21 | -------------------------------------------------------------------------------- /mailer/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set 2 | from pydantic import BaseSettings, EmailStr, AnyHttpUrl, validator 3 | from pgpy import PGPKey 4 | 5 | from . import __about__ 6 | 7 | 8 | class Settings(BaseSettings): 9 | app_title: str = __about__.__title__ 10 | app_description: str = __about__.__description__ 11 | app_version: str = __about__.__version__ 12 | app_environment: str = "production" 13 | 14 | sender_email: EmailStr 15 | to_email: EmailStr 16 | to_name: str 17 | success_redirect_url: Optional[AnyHttpUrl] = None 18 | error_redirect_url: Optional[AnyHttpUrl] = None 19 | 20 | smtp_host: str 21 | smtp_port: int 22 | smtp_tls: bool 23 | smtp_ssl: bool 24 | smtp_user: str 25 | smtp_password: str 26 | 27 | pgp_public_key: Optional[PGPKey] = None 28 | 29 | force_https: bool = True 30 | cors_origins: Set[AnyHttpUrl] = set() 31 | 32 | recaptcha_secret_key: Optional[str] 33 | 34 | sentry_dsn: Optional[str] = None 35 | 36 | @validator("pgp_public_key", pre=True) 37 | def validate_pgp_public_key(cls, v: Optional[str]) -> Optional[PGPKey]: 38 | from base64 import urlsafe_b64decode 39 | from pgpy.errors import PGPError 40 | 41 | if v: 42 | try: 43 | public_key_str = urlsafe_b64decode(v) 44 | key, _ = PGPKey.from_blob(public_key_str) 45 | except (ValueError, PGPError): 46 | raise ValueError("Invalid PGP public key: cannot load the key") 47 | 48 | if not key.is_public: 49 | raise ValueError("Invalid PGP public key: key is private") 50 | 51 | return key 52 | 53 | return None 54 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Mailer Documentation 2 | site_description: Dead-simple mailer micro-service for static websites 3 | site_author: Romain Clement 4 | 5 | site_url: https://rclement.github.io/mailer/ 6 | repo_name: rclement/mailer 7 | repo_url: https://github.com/rclement/mailer 8 | edit_uri: edit/master/docs/ 9 | docs_dir: docs 10 | site_dir: site 11 | 12 | extra: 13 | version: 0.10.3 14 | 15 | nav: 16 | - Home: 'index.md' 17 | - 'usage.md' 18 | - 'deployment.md' 19 | - 'contributing.md' 20 | - 'changelog.md' 21 | 22 | theme: 23 | name: material 24 | language: en 25 | palette: 26 | - media: "(prefers-color-scheme: light)" 27 | scheme: default 28 | primary: blue 29 | accent: blue 30 | toggle: 31 | icon: material/weather-sunny 32 | name: Switch to dark mode 33 | - media: "(prefers-color-scheme: dark)" 34 | scheme: slate 35 | primary: blue 36 | accent: blue 37 | toggle: 38 | icon: material/weather-night 39 | name: Switch to light mode 40 | 41 | plugins: 42 | - search 43 | 44 | markdown_extensions: 45 | - pymdownx.highlight 46 | - pymdownx.inlinehilite 47 | - pymdownx.superfences 48 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | show_error_codes = True 4 | pretty = True 5 | follow_imports = silent 6 | strict_optional = True 7 | warn_redundant_casts = True 8 | warn_unused_ignores = True 9 | disallow_any_generics = True 10 | check_untyped_defs = True 11 | no_implicit_reexport = True 12 | disallow_untyped_defs = True 13 | 14 | [pydantic-mypy] 15 | init_forbid_extra = True 16 | init_typed = True 17 | warn_required_dynamic_aliases = True 18 | warn_untyped_fields = True 19 | 20 | [mypy-mailer.*] 21 | disallow_untyped_decorators = False 22 | 23 | [mypy-faker.*] 24 | ignore_missing_imports = True 25 | 26 | [mypy-fastapi.*] 27 | follow_imports = skip 28 | 29 | [mypy-pgpy.*] 30 | ignore_missing_imports = True 31 | 32 | [mypy-sentry_sdk.*] 33 | no_implicit_reexport = False -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges", 5 | ":disableDependencyDashboard" 6 | ], 7 | "rangeStrategy": "bump", 8 | "labels": ["dependencies"], 9 | "assignees": ["rclement"], 10 | "reviewers": ["rclement"] 11 | } 12 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from invoke import task 2 | 3 | 4 | app_path = "mailer" 5 | tests_path = "tests" 6 | 7 | 8 | @task 9 | def audit(ctx): 10 | ctx.run("safety check --full-report", pty=True) 11 | 12 | 13 | @task 14 | def lint(ctx): 15 | ctx.run(f"black --check {app_path} {tests_path}", pty=True) 16 | ctx.run(f"flake8 {app_path} {tests_path}", pty=True) 17 | 18 | 19 | @task 20 | def static_check(ctx): 21 | ctx.run(f"mypy --strict {app_path} {tests_path}", pty=True) 22 | 23 | 24 | @task 25 | def security_check(ctx): 26 | ctx.run(f"bandit -v -r {app_path}", pty=True) 27 | 28 | 29 | @task 30 | def test(ctx): 31 | ctx.run( 32 | f"py.test -v --cov={app_path} --cov={tests_path} --cov-branch --cov-report=term-missing {tests_path}", 33 | pty=True, 34 | ) 35 | 36 | 37 | @task(audit, lint, static_check, security_check, test) 38 | def qa(ctx): 39 | pass 40 | 41 | 42 | @task 43 | def reformat(ctx): 44 | ctx.run(f"black {app_path} {tests_path}", pty=True) 45 | 46 | 47 | @task 48 | def generate_pgp_key_pair(ctx, name, email, filename): 49 | from tests import utils 50 | 51 | key = utils.generate_pgp_key_pair(name, email) 52 | 53 | with open(f"{filename}.pub.asc", mode="w") as f: 54 | f.write(str(key.pubkey)) 55 | 56 | with open(f"{filename}.asc", mode="w") as f: 57 | f.write(str(key)) 58 | 59 | 60 | @task 61 | def encrypt_pgp_message(ctx, public_key_file_path, message): 62 | from tests import utils 63 | 64 | with open(public_key_file_path, mode="r") as f: 65 | public_key = f.read() 66 | 67 | encrypted_message = utils.encrypt_pgp_message(public_key, message) 68 | 69 | print(encrypted_message) 70 | 71 | 72 | @task 73 | def decrypt_pgp_message(ctx, private_key_file_path, encrypted_file_path): 74 | from tests import utils 75 | 76 | with open(private_key_file_path, mode="r") as f: 77 | private_key = f.read() 78 | 79 | with open(encrypted_file_path, mode="r") as f: 80 | encrypted_message = f.read() 81 | 82 | message = utils.decrypt_pgp_message(private_key, encrypted_message) 83 | 84 | print(message) 85 | -------------------------------------------------------------------------------- /templates/homepage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mailer 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |

18 | {{ settings.app_title }} ({{ settings.app_version }}) 19 |

20 | 21 |

22 | {{ settings.app_description }} 23 |

24 | 25 | {%- if request.app.docs_url %} 26 | Swagger 27 | {%- endif %} 28 | API Documentation 29 | 30 |
31 | 32 |

33 | Using an AJAX request 34 |

35 | 36 |
 37 |               
 38 | fetch('{{ url_for('post_mail') }}', {
 39 |   method: 'POST',
 40 |   headers: {
 41 |     'Content-Type': 'application/json'
 42 |   },
 43 |   body: JSON.stringify({
 44 |     email: 'john@doe.com',
 45 |     name: 'John Doe',
 46 |     subject: 'Contact',
 47 |     message: 'Hey there! Up for a coffee?',
 48 |     {%- if settings.recaptcha_secret_key %}
 49 |     'g-recaptcha-response': 'azertyuiopqsdfghjklmwxcvbn',
 50 |     {%- endif %},
 51 |     {%- if settings.pgp_public_key %}
 52 |     public_key: '-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n',
 53 |     {%- endif %}
 54 |     honeypot: ''
 55 |   })
 56 | })
 57 |               
 58 |             
59 | 60 |
61 | 62 |

63 | Using an HTML form 64 |

65 | 66 |
 67 |               
 68 | <form action="{{ url_for('post_mail_form') }}" method="POST">
 69 |   <input type="text" name="name" required >
 70 |   <input type="email" name="email" required >
 71 |   <input type="text" name="subject" required >
 72 |   <input type="text" name="message" required >
 73 |   {%- if settings.pgp_public_key %}
 74 |   <input type="text" name="public_key" >
 75 |   {%- endif %}
 76 |   {%- if settings.recaptcha_secret_key %}
 77 |   <input type="hidden" name="g-recaptcha-response" >
 78 |   {%- endif %}
 79 |   <input type="hidden" name="honeypot" >
 80 |   <input type="submit" value="Send" >
 81 | </form>
 82 |               
 83 |             
84 |
85 |
86 |
87 |
88 | 89 |
90 | 103 |
104 |
105 | 106 | 107 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rclement/mailer/c45d580bca28daa30a524b36a1b9ad0afeef6150/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from typing import Generator 5 | from fastapi import FastAPI 6 | from responses import RequestsMock 7 | from starlette.testclient import TestClient 8 | 9 | 10 | # ------------------------------------------------------------------------------ 11 | 12 | 13 | os.environ["APP_ENVIRONMENT"] = "testing" 14 | os.environ["SENDER_EMAIL"] = "no-reply@test.com" 15 | os.environ["TO_EMAIL"] = "contact@test.com" 16 | os.environ["TO_NAME"] = "Test" 17 | os.environ["SUCCESS_REDIRECT_URL"] = "https://domain.com/contact/success" 18 | os.environ["ERROR_REDIRECT_URL"] = "https://domain.com/contact/error" 19 | os.environ["SMTP_HOST"] = "localhost" 20 | os.environ["SMTP_PORT"] = "587" 21 | os.environ["SMTP_TLS"] = "true" 22 | os.environ["SMTP_SSL"] = "false" 23 | os.environ["SMTP_USER"] = "user" 24 | os.environ["SMTP_PASSWORD"] = "password" 25 | os.environ["FORCE_HTTPS"] = "false" 26 | os.environ["CORS_ORIGINS"] = "[]" 27 | os.environ["RECAPTCHA_SECRET_KEY"] = "" 28 | os.environ["PGP_PUBLIC_KEY"] = "" 29 | os.environ["SENTRY_DSN"] = "" 30 | 31 | 32 | # ------------------------------------------------------------------------------ 33 | 34 | 35 | @pytest.fixture(scope="function") 36 | def responses() -> Generator[RequestsMock, None, None]: 37 | with RequestsMock(assert_all_requests_are_fired=False) as rsps: 38 | yield rsps 39 | 40 | 41 | @pytest.fixture(scope="function") 42 | def app() -> FastAPI: 43 | from mailer import create_app 44 | 45 | return create_app(None) 46 | 47 | 48 | @pytest.fixture(scope="function") 49 | def app_client(app: FastAPI) -> Generator[TestClient, None, None]: 50 | with TestClient(app) as test_client: 51 | yield test_client 52 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from base64 import urlsafe_b64encode 5 | from http import HTTPStatus 6 | from typing import Any, Dict, List, Tuple 7 | from unittest.mock import MagicMock 8 | from faker import Faker 9 | from fastapi import FastAPI 10 | from pgpy.pgp import PGPKey 11 | from pytest_mock import MockerFixture 12 | from responses import RequestsMock 13 | from starlette.testclient import TestClient 14 | 15 | from . import utils 16 | 17 | 18 | # ------------------------------------------------------------------------------ 19 | 20 | 21 | @pytest.fixture(scope="function") 22 | def enable_cors_origins_single(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> str: 23 | origin: str = faker.url() 24 | monkeypatch.setenv("CORS_ORIGINS", f'["{origin}"]') 25 | return origin 26 | 27 | 28 | @pytest.fixture(scope="function") 29 | def enable_cors_origins_multiple( 30 | monkeypatch: pytest.MonkeyPatch, faker: Faker 31 | ) -> List[str]: 32 | import json 33 | 34 | origins: List[str] = [faker.url(), faker.url()] 35 | monkeypatch.setenv("CORS_ORIGINS", f"{json.dumps(origins)}") 36 | 37 | return origins 38 | 39 | 40 | # ------------------------------------------------------------------------------ 41 | 42 | 43 | @pytest.fixture(scope="function") 44 | def enable_smtp_ssl(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> None: 45 | monkeypatch.setenv("SMTP_PORT", "465") 46 | monkeypatch.setenv("SMTP_TLS", "false") 47 | monkeypatch.setenv("SMTP_SSL", "true") 48 | 49 | 50 | @pytest.fixture(scope="function") 51 | def mock_smtp_ssl(mocker: MockerFixture) -> MagicMock: 52 | mock_client = mocker.patch("smtplib.SMTP_SSL", autospec=True) 53 | 54 | return mock_client 55 | 56 | 57 | @pytest.fixture(scope="function") 58 | def mock_smtp(mocker: MockerFixture) -> MagicMock: 59 | mock_client = mocker.patch("smtplib.SMTP", autospec=True) 60 | 61 | return mock_client 62 | 63 | 64 | @pytest.fixture(scope="function") 65 | def mock_smtp_connect_error(mock_smtp: MagicMock) -> MagicMock: 66 | from smtplib import SMTPConnectError 67 | 68 | mock_smtp.side_effect = SMTPConnectError(400, "error") 69 | 70 | return mock_smtp 71 | 72 | 73 | @pytest.fixture(scope="function") 74 | def mock_smtp_tls_error(mock_smtp: MagicMock, mocker: MockerFixture) -> MagicMock: 75 | from smtplib import SMTPNotSupportedError 76 | 77 | mock_smtp.return_value.starttls = mocker.Mock( 78 | side_effect=SMTPNotSupportedError(400, "error") 79 | ) 80 | 81 | return mock_smtp 82 | 83 | 84 | @pytest.fixture(scope="function") 85 | def mock_smtp_auth_error(mock_smtp: MagicMock, mocker: MockerFixture) -> MagicMock: 86 | from smtplib import SMTPAuthenticationError 87 | 88 | mock_smtp.return_value.login = mocker.Mock( 89 | side_effect=SMTPAuthenticationError(401, "error") 90 | ) 91 | 92 | return mock_smtp 93 | 94 | 95 | @pytest.fixture(scope="function") 96 | def mock_smtp_send_error(mock_smtp: MagicMock, mocker: MockerFixture) -> MagicMock: 97 | from smtplib import SMTPDataError 98 | 99 | mock_smtp.return_value.send_message = mocker.Mock( 100 | side_effect=SMTPDataError(402, "error") 101 | ) 102 | 103 | return mock_smtp 104 | 105 | 106 | # ------------------------------------------------------------------------------ 107 | 108 | 109 | valid_recaptcha_secret = "valid-recaptcha-secret" 110 | valid_recaptcha_response = "valid-recaptcha-response" 111 | 112 | 113 | @pytest.fixture(scope="function") 114 | def enable_recaptcha(monkeypatch: pytest.MonkeyPatch) -> None: 115 | monkeypatch.setenv("RECAPTCHA_SECRET_KEY", valid_recaptcha_secret) 116 | 117 | 118 | @pytest.fixture(scope="function") 119 | def enable_recaptcha_invalid_secret( 120 | monkeypatch: pytest.MonkeyPatch, faker: Faker 121 | ) -> None: 122 | monkeypatch.setenv("RECAPTCHA_SECRET_KEY", faker.pystr()) 123 | 124 | 125 | @pytest.fixture(scope="function") 126 | def mock_recaptcha_verify_api(responses: RequestsMock, faker: Faker) -> RequestsMock: 127 | from requests.models import PreparedRequest 128 | from mailer import recaptcha 129 | 130 | def request_callback( 131 | request: PreparedRequest, 132 | ) -> Tuple[HTTPStatus, Dict[str, Any], str]: 133 | import json 134 | from urllib.parse import parse_qs 135 | 136 | headers: Dict[str, Any] = {} 137 | 138 | params = parse_qs(str(request.body)) 139 | secret = params.get("secret") 140 | response = params.get("response") 141 | 142 | errors = [] 143 | if secret and secret != [valid_recaptcha_secret]: 144 | errors.append("invalid-input-secret") 145 | 146 | if not response: 147 | errors.append("missing-input-response") 148 | elif response != [valid_recaptcha_response]: 149 | errors.append("invalid-input-response") 150 | 151 | body: Dict[str, Any] = {} 152 | if len(errors) > 0: 153 | body["success"] = False 154 | body["error-codes"] = errors 155 | else: 156 | body["success"] = True 157 | body["challenge_ts"] = faker.iso8601() 158 | body["hostname"] = faker.hostname() 159 | 160 | return (HTTPStatus.OK, headers, json.dumps(body)) 161 | 162 | responses.add_callback( 163 | responses.POST, 164 | recaptcha.verify_url, 165 | callback=request_callback, 166 | content_type="application/json", 167 | ) 168 | 169 | return responses 170 | 171 | 172 | # ------------------------------------------------------------------------------ 173 | 174 | 175 | @pytest.fixture(scope="function") 176 | def enable_pgp_public_key(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> PGPKey: 177 | pgp_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 178 | pub_key = urlsafe_b64encode(str(pgp_key.pubkey).encode("utf-8")).decode("utf-8") 179 | monkeypatch.setenv("PGP_PUBLIC_KEY", pub_key) 180 | 181 | return pgp_key 182 | 183 | 184 | # ------------------------------------------------------------------------------ 185 | 186 | 187 | @pytest.fixture(scope="function") 188 | def no_success_redirect_url(monkeypatch: pytest.MonkeyPatch) -> None: 189 | monkeypatch.delenv("SUCCESS_REDIRECT_URL") 190 | 191 | 192 | @pytest.fixture(scope="function") 193 | def no_error_redirect_url(monkeypatch: pytest.MonkeyPatch) -> None: 194 | monkeypatch.delenv("ERROR_REDIRECT_URL") 195 | 196 | 197 | # ------------------------------------------------------------------------------ 198 | 199 | 200 | @pytest.fixture(scope="function") 201 | def params_success(faker: Faker) -> Dict[str, str]: 202 | return { 203 | "email": faker.email(), 204 | "name": faker.name(), 205 | "subject": faker.text(max_nb_chars=100), 206 | "message": faker.text(max_nb_chars=1000), 207 | "honeypot": "", 208 | } 209 | 210 | 211 | # ------------------------------------------------------------------------------ 212 | 213 | 214 | def test_get_api_info_success(app_client: TestClient) -> None: 215 | from mailer import __about__ 216 | 217 | response = app_client.get("/api/") 218 | assert response.status_code == HTTPStatus.OK 219 | 220 | data = response.json() 221 | assert data["name"] == __about__.__title__ 222 | assert data["version"] == __about__.__version__ 223 | assert data["api_version"] == "v1" 224 | 225 | 226 | # ------------------------------------------------------------------------------ 227 | 228 | 229 | def test_send_mail_success( 230 | app: FastAPI, 231 | app_client: TestClient, 232 | mock_smtp: MagicMock, 233 | params_success: Dict[str, str], 234 | ) -> None: 235 | params = params_success 236 | 237 | response = app_client.post("/api/mail", json=params) 238 | assert response.status_code == HTTPStatus.OK 239 | 240 | data = response.json() 241 | assert data["email"] == params["email"] 242 | assert data["name"] == params["name"] 243 | assert data["subject"] == params["subject"] 244 | assert data["message"] == params["message"] 245 | assert data["honeypot"] == params["honeypot"] 246 | 247 | assert mock_smtp.call_count == 1 248 | assert mock_smtp.return_value.starttls.call_count == 1 249 | assert mock_smtp.return_value.login.call_count == 1 250 | assert mock_smtp.return_value.send_message.call_count == 1 251 | assert mock_smtp.return_value.quit.call_count == 1 252 | 253 | sent_msg = mock_smtp.return_value.send_message.call_args.args[0].as_string() 254 | app_settings = app.state.settings 255 | utils.assert_plain_email( 256 | sent_msg, 257 | params["email"], 258 | params["name"], 259 | params["subject"], 260 | params["message"], 261 | app_settings.sender_email, 262 | app_settings.to_email, 263 | app_settings.to_name, 264 | ) 265 | 266 | 267 | def test_send_mail_ssl_success( 268 | enable_smtp_ssl: None, 269 | app: FastAPI, 270 | app_client: TestClient, 271 | mock_smtp_ssl: MagicMock, 272 | params_success: Dict[str, str], 273 | ) -> None: 274 | params = params_success 275 | 276 | response = app_client.post("/api/mail", json=params) 277 | assert response.status_code == HTTPStatus.OK 278 | 279 | data = response.json() 280 | assert data["email"] == params["email"] 281 | assert data["name"] == params["name"] 282 | assert data["subject"] == params["subject"] 283 | assert data["message"] == params["message"] 284 | assert data["honeypot"] == params["honeypot"] 285 | 286 | assert mock_smtp_ssl.call_count == 1 287 | 288 | sent_msg = mock_smtp_ssl.return_value.send_message.call_args.args[0].as_string() 289 | app_settings = app.state.settings 290 | utils.assert_plain_email( 291 | sent_msg, 292 | params["email"], 293 | params["name"], 294 | params["subject"], 295 | params["message"], 296 | app_settings.sender_email, 297 | app_settings.to_email, 298 | app_settings.to_name, 299 | ) 300 | 301 | 302 | def test_send_mail_smtp_connect_failed( 303 | app_client: TestClient, 304 | mock_smtp_connect_error: MagicMock, 305 | params_success: Dict[str, str], 306 | ) -> None: 307 | params = params_success 308 | 309 | response = app_client.post("/api/mail", json=params) 310 | assert response.status_code == HTTPStatus.UNAUTHORIZED 311 | 312 | assert mock_smtp_connect_error.call_count == 1 313 | assert mock_smtp_connect_error.return_value.starttls.call_count == 0 314 | assert mock_smtp_connect_error.return_value.login.call_count == 0 315 | assert mock_smtp_connect_error.return_value.send_message.call_count == 0 316 | assert mock_smtp_connect_error.return_value.quit.call_count == 0 317 | 318 | 319 | def test_send_mail_smtp_tls_failed( 320 | app_client: TestClient, 321 | mock_smtp_tls_error: MagicMock, 322 | params_success: Dict[str, str], 323 | ) -> None: 324 | params = params_success 325 | 326 | response = app_client.post("/api/mail", json=params) 327 | assert response.status_code == HTTPStatus.UNAUTHORIZED 328 | 329 | assert mock_smtp_tls_error.call_count == 1 330 | assert mock_smtp_tls_error.return_value.starttls.call_count == 1 331 | assert mock_smtp_tls_error.return_value.login.call_count == 0 332 | assert mock_smtp_tls_error.return_value.send_message.call_count == 0 333 | assert mock_smtp_tls_error.return_value.quit.call_count == 0 334 | 335 | 336 | def test_send_mail_smtp_login_failed( 337 | app_client: TestClient, 338 | mock_smtp_auth_error: MagicMock, 339 | params_success: Dict[str, str], 340 | ) -> None: 341 | params = params_success 342 | 343 | response = app_client.post("/api/mail", json=params) 344 | assert response.status_code == HTTPStatus.UNAUTHORIZED 345 | 346 | assert mock_smtp_auth_error.call_count == 1 347 | assert mock_smtp_auth_error.return_value.starttls.call_count == 1 348 | assert mock_smtp_auth_error.return_value.login.call_count == 1 349 | assert mock_smtp_auth_error.return_value.send_message.call_count == 0 350 | assert mock_smtp_auth_error.return_value.quit.call_count == 0 351 | 352 | 353 | def test_send_mail_smtp_send_failed( 354 | app_client: TestClient, 355 | mock_smtp_send_error: MagicMock, 356 | params_success: Dict[str, str], 357 | ) -> None: 358 | params = params_success 359 | 360 | response = app_client.post("/api/mail", json=params) 361 | assert response.status_code == HTTPStatus.UNAUTHORIZED 362 | 363 | assert mock_smtp_send_error.call_count == 1 364 | assert mock_smtp_send_error.return_value.starttls.call_count == 1 365 | assert mock_smtp_send_error.return_value.login.call_count == 1 366 | assert mock_smtp_send_error.return_value.send_message.call_count == 1 367 | assert mock_smtp_send_error.return_value.quit.call_count == 0 368 | 369 | 370 | def test_send_mail_none(app_client: TestClient, mock_smtp: MagicMock) -> None: 371 | params: Dict[str, str] = {} 372 | 373 | response = app_client.post("/api/mail", json=params) 374 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 375 | 376 | 377 | def test_send_mail_empty_fields(app_client: TestClient, mock_smtp: MagicMock) -> None: 378 | params = {"email": "", "name": "", "subject": "", "message": "", "honeypot": ""} 379 | 380 | response = app_client.post("/api/mail", json=params) 381 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 382 | 383 | 384 | def test_send_mail_empty_email( 385 | app_client: TestClient, mock_smtp: MagicMock, params_success: Dict[str, str] 386 | ) -> None: 387 | params = params_success 388 | params["email"] = "" 389 | 390 | response = app_client.post("/api/mail", json=params) 391 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 392 | 393 | 394 | def test_send_mail_empty_name( 395 | app_client: TestClient, mock_smtp: MagicMock, params_success: Dict[str, str] 396 | ) -> None: 397 | params = params_success 398 | params["name"] = "" 399 | 400 | response = app_client.post("/api/mail", json=params) 401 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 402 | 403 | 404 | def test_send_mail_empty_subject( 405 | app_client: TestClient, mock_smtp: MagicMock, params_success: Dict[str, str] 406 | ) -> None: 407 | params = params_success 408 | params["subject"] = "" 409 | 410 | response = app_client.post("/api/mail", json=params) 411 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 412 | 413 | 414 | def test_send_mail_empty_message( 415 | app_client: TestClient, mock_smtp: MagicMock, params_success: Dict[str, str] 416 | ) -> None: 417 | params = params_success 418 | params["message"] = "" 419 | 420 | response = app_client.post("/api/mail", json=params) 421 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 422 | 423 | 424 | def test_send_mail_bad_email( 425 | app_client: TestClient, mock_smtp: MagicMock, params_success: Dict[str, str] 426 | ) -> None: 427 | params = params_success 428 | params["email"] = "joe@doe" 429 | 430 | response = app_client.post("/api/mail", json=params) 431 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 432 | 433 | 434 | def test_send_mail_too_long_name( 435 | app_client: TestClient, 436 | mock_smtp: MagicMock, 437 | params_success: Dict[str, str], 438 | faker: Faker, 439 | ) -> None: 440 | params = params_success 441 | params["name"] = faker.text(max_nb_chars=100) 442 | 443 | response = app_client.post("/api/mail", json=params) 444 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 445 | 446 | 447 | def test_send_mail_too_long_subject( 448 | app_client: TestClient, 449 | mock_smtp: MagicMock, 450 | params_success: Dict[str, str], 451 | faker: Faker, 452 | ) -> None: 453 | params = params_success 454 | params["subject"] = faker.text(max_nb_chars=2000) 455 | 456 | response = app_client.post("/api/mail", json=params) 457 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 458 | 459 | 460 | def test_send_mail_too_long_message( 461 | app_client: TestClient, 462 | mock_smtp: MagicMock, 463 | params_success: Dict[str, str], 464 | faker: Faker, 465 | ) -> None: 466 | params = params_success 467 | params["message"] = faker.text(max_nb_chars=2000) 468 | 469 | response = app_client.post("/api/mail", json=params) 470 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 471 | 472 | 473 | def test_send_mail_non_empty_honeypot( 474 | app_client: TestClient, 475 | mock_smtp: MagicMock, 476 | params_success: Dict[str, str], 477 | faker: Faker, 478 | ) -> None: 479 | params = params_success 480 | params["honeypot"] = faker.text() 481 | 482 | response = app_client.post("/api/mail", json=params) 483 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 484 | 485 | 486 | def test_send_mail_cors_origin_single( 487 | enable_cors_origins_single: str, 488 | app_client: TestClient, 489 | mock_smtp: MagicMock, 490 | params_success: Dict[str, str], 491 | faker: Faker, 492 | ) -> None: 493 | params = params_success 494 | 495 | response = app_client.options( 496 | "/api/mail", 497 | headers={ 498 | "Origin": enable_cors_origins_single, 499 | "Access-Control-Request-Method": "GET", 500 | }, 501 | ) 502 | assert response.status_code == HTTPStatus.OK 503 | assert response.headers["access-control-allow-origin"] == enable_cors_origins_single 504 | 505 | response = app_client.post( 506 | "/api/mail", json=params, headers={"Origin": enable_cors_origins_single} 507 | ) 508 | assert response.status_code == HTTPStatus.OK 509 | assert response.headers["access-control-allow-origin"] == enable_cors_origins_single 510 | 511 | response = app_client.post( 512 | "/api/mail", json=params, headers={"Origin": faker.url()} 513 | ) 514 | assert response.status_code == HTTPStatus.UNAUTHORIZED 515 | 516 | 517 | def test_send_mail_cors_origin_multiple( 518 | enable_cors_origins_multiple: List[str], 519 | app_client: TestClient, 520 | mock_smtp: MagicMock, 521 | params_success: Dict[str, str], 522 | faker: Faker, 523 | ) -> None: 524 | params = params_success 525 | 526 | for origin in enable_cors_origins_multiple: 527 | response = app_client.options( 528 | "/api/mail", 529 | headers={"Origin": origin, "Access-Control-Request-Method": "GET"}, 530 | ) 531 | assert response.status_code == HTTPStatus.OK 532 | assert response.headers["access-control-allow-origin"] == origin 533 | 534 | response = app_client.post("/api/mail", json=params, headers={"Origin": origin}) 535 | assert response.status_code == HTTPStatus.OK 536 | assert response.headers["access-control-allow-origin"] == origin 537 | 538 | response = app_client.post( 539 | "/api/mail", json=params, headers={"Origin": faker.url()} 540 | ) 541 | assert response.status_code == HTTPStatus.UNAUTHORIZED 542 | 543 | 544 | def test_send_mail_recaptcha_success( 545 | enable_recaptcha: None, 546 | app_client: TestClient, 547 | mock_smtp: MagicMock, 548 | mock_recaptcha_verify_api: RequestsMock, 549 | params_success: Dict[str, str], 550 | ) -> None: 551 | params = params_success 552 | params["g-recaptcha-response"] = valid_recaptcha_response 553 | 554 | response = app_client.post("/api/mail", json=params) 555 | assert response.status_code == HTTPStatus.OK 556 | 557 | 558 | def test_send_mail_recaptcha_invalid_secret( 559 | enable_recaptcha_invalid_secret: None, 560 | app_client: TestClient, 561 | mock_smtp: MagicMock, 562 | mock_recaptcha_verify_api: RequestsMock, 563 | params_success: Dict[str, str], 564 | ) -> None: 565 | params = params_success 566 | params["g-recaptcha-response"] = valid_recaptcha_response 567 | 568 | response = app_client.post("/api/mail", json=params) 569 | assert response.status_code == HTTPStatus.UNAUTHORIZED 570 | 571 | 572 | def test_send_mail_recaptcha_no_response( 573 | enable_recaptcha: None, 574 | app_client: TestClient, 575 | mock_smtp: MagicMock, 576 | mock_recaptcha_verify_api: RequestsMock, 577 | params_success: Dict[str, str], 578 | ) -> None: 579 | params = params_success 580 | params["g-recaptcha-response"] = "" 581 | 582 | response = app_client.post("/api/mail", json=params) 583 | assert response.status_code == HTTPStatus.UNAUTHORIZED 584 | 585 | 586 | def test_send_mail_recaptcha_invalid_response( 587 | enable_recaptcha: None, 588 | app_client: TestClient, 589 | mock_smtp: MagicMock, 590 | mock_recaptcha_verify_api: RequestsMock, 591 | params_success: Dict[str, str], 592 | faker: Faker, 593 | ) -> None: 594 | params = params_success 595 | params["g-recaptcha-response"] = faker.pystr() 596 | 597 | response = app_client.post("/api/mail", json=params) 598 | assert response.status_code == HTTPStatus.UNAUTHORIZED 599 | 600 | 601 | def test_send_pgp_mail_success( 602 | enable_pgp_public_key: PGPKey, 603 | app: FastAPI, 604 | app_client: TestClient, 605 | mock_smtp: MagicMock, 606 | params_success: Dict[str, str], 607 | ) -> None: 608 | params = params_success 609 | 610 | response = app_client.post("/api/mail", json=params) 611 | assert response.status_code == HTTPStatus.OK 612 | 613 | data = response.json() 614 | assert data["email"] == params["email"] 615 | assert data["name"] == params["name"] 616 | assert data["subject"] == params["subject"] 617 | assert data["message"] == params["message"] 618 | assert data["honeypot"] == params["honeypot"] 619 | 620 | assert mock_smtp.call_count == 1 621 | assert mock_smtp.return_value.login.call_count == 1 622 | assert mock_smtp.return_value.send_message.call_count == 1 623 | assert mock_smtp.return_value.quit.call_count == 1 624 | 625 | message = mock_smtp.return_value.send_message.call_args.args[0] 626 | sent_msg = message.as_string() 627 | app_settings = app.state.settings 628 | embedded_pub_key = utils.assert_pgp_email( 629 | sent_msg, 630 | params["email"], 631 | params["name"], 632 | params["subject"], 633 | params["message"], 634 | app_settings.sender_email, 635 | app_settings.to_email, 636 | app_settings.to_name, 637 | enable_pgp_public_key, 638 | None, 639 | ) 640 | assert embedded_pub_key is None 641 | 642 | 643 | def test_send_pgp_mail_with_attached_public_key_success( 644 | enable_pgp_public_key: PGPKey, 645 | app: FastAPI, 646 | app_client: TestClient, 647 | mock_smtp: MagicMock, 648 | params_success: Dict[str, str], 649 | faker: Faker, 650 | ) -> None: 651 | sender_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 652 | 653 | params = params_success 654 | params["public_key"] = str(sender_key.pubkey) 655 | 656 | response = app_client.post("/api/mail", json=params) 657 | assert response.status_code == HTTPStatus.OK 658 | 659 | data = response.json() 660 | assert data["email"] == params["email"] 661 | assert data["name"] == params["name"] 662 | assert data["subject"] == params["subject"] 663 | assert data["message"] == params["message"] 664 | assert data["honeypot"] == params["honeypot"] 665 | 666 | assert mock_smtp.call_count == 1 667 | assert mock_smtp.return_value.login.call_count == 1 668 | assert mock_smtp.return_value.send_message.call_count == 1 669 | assert mock_smtp.return_value.quit.call_count == 1 670 | 671 | message = mock_smtp.return_value.send_message.call_args.args[0] 672 | sent_msg = message.as_string() 673 | app_settings = app.state.settings 674 | embedded_pub_key = utils.assert_pgp_email( 675 | sent_msg, 676 | params["email"], 677 | params["name"], 678 | params["subject"], 679 | params["message"], 680 | app_settings.sender_email, 681 | app_settings.to_email, 682 | app_settings.to_name, 683 | enable_pgp_public_key, 684 | sender_key.pubkey, 685 | ) 686 | assert embedded_pub_key == str(sender_key.pubkey) 687 | 688 | email_response = faker.text() 689 | pgp_response = utils.encrypt_pgp_message(embedded_pub_key, email_response) 690 | plain_response = utils.decrypt_pgp_message(str(sender_key), pgp_response) 691 | assert plain_response == email_response 692 | 693 | 694 | def test_send_pgp_mail_with_attached_public_key_private( 695 | enable_pgp_public_key: PGPKey, 696 | app_client: TestClient, 697 | mock_smtp: MagicMock, 698 | params_success: Dict[str, str], 699 | faker: Faker, 700 | ) -> None: 701 | sender_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 702 | 703 | params = params_success 704 | params["public_key"] = str(sender_key) 705 | 706 | response = app_client.post("/api/mail", json=params) 707 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 708 | 709 | 710 | def test_send_pgp_mail_with_attached_public_key_invalid( 711 | enable_pgp_public_key: PGPKey, 712 | app_client: TestClient, 713 | mock_smtp: MagicMock, 714 | params_success: Dict[str, str], 715 | faker: Faker, 716 | ) -> None: 717 | pgp_key = urlsafe_b64encode(faker.binary()).decode("utf-8") 718 | 719 | params = params_success 720 | params["public_key"] = pgp_key 721 | 722 | response = app_client.post("/api/mail", json=params) 723 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 724 | 725 | 726 | # ------------------------------------------------------------------------------ 727 | 728 | 729 | def test_send_mail_form_success( 730 | app: FastAPI, 731 | app_client: TestClient, 732 | mock_smtp: MagicMock, 733 | params_success: Dict[str, str], 734 | ) -> None: 735 | params = params_success 736 | 737 | response = app_client.post("/api/mail/form", data=params, follow_redirects=False) 738 | assert response.status_code == HTTPStatus.FOUND 739 | assert response.headers["Location"] == os.environ["SUCCESS_REDIRECT_URL"] 740 | 741 | assert mock_smtp.call_count == 1 742 | assert mock_smtp.return_value.starttls.call_count == 1 743 | assert mock_smtp.return_value.login.call_count == 1 744 | assert mock_smtp.return_value.send_message.call_count == 1 745 | assert mock_smtp.return_value.quit.call_count == 1 746 | 747 | sent_msg = mock_smtp.return_value.send_message.call_args.args[0].as_string() 748 | app_settings = app.state.settings 749 | utils.assert_plain_email( 750 | sent_msg, 751 | params["email"], 752 | params["name"], 753 | params["subject"], 754 | params["message"], 755 | app_settings.sender_email, 756 | app_settings.to_email, 757 | app_settings.to_name, 758 | ) 759 | 760 | 761 | def test_send_mail_form_redirect_origin( 762 | no_success_redirect_url: None, 763 | app: FastAPI, 764 | app_client: TestClient, 765 | mock_smtp: MagicMock, 766 | params_success: Dict[str, str], 767 | faker: Faker, 768 | ) -> None: 769 | origin = faker.url() 770 | params = params_success 771 | 772 | response = app_client.post( 773 | "/api/mail/form", 774 | headers={"Origin": origin}, 775 | data=params, 776 | follow_redirects=False, 777 | ) 778 | assert response.status_code == HTTPStatus.FOUND 779 | assert response.headers["Location"] == origin 780 | 781 | assert mock_smtp.call_count == 1 782 | assert mock_smtp.return_value.starttls.call_count == 1 783 | assert mock_smtp.return_value.login.call_count == 1 784 | assert mock_smtp.return_value.send_message.call_count == 1 785 | assert mock_smtp.return_value.quit.call_count == 1 786 | 787 | 788 | def test_send_mail_form_none(app_client: TestClient) -> None: 789 | params: Dict[str, str] = {} 790 | 791 | response = app_client.post("/api/mail/form", data=params, follow_redirects=False) 792 | assert response.status_code == HTTPStatus.FOUND 793 | assert response.headers["Location"] == os.environ["ERROR_REDIRECT_URL"] 794 | 795 | 796 | def test_send_mail_form_none_redirect_origin( 797 | no_error_redirect_url: None, 798 | app_client: TestClient, 799 | faker: Faker, 800 | ) -> None: 801 | origin = faker.url() 802 | 803 | params: Dict[str, str] = {} 804 | 805 | response = app_client.post( 806 | "/api/mail/form", 807 | headers={"Origin": origin}, 808 | data=params, 809 | follow_redirects=False, 810 | ) 811 | assert response.status_code == HTTPStatus.FOUND 812 | assert response.headers["Location"] == origin 813 | 814 | 815 | def test_send_mail_form_recaptcha_invalid_secret( 816 | enable_recaptcha_invalid_secret: None, 817 | app_client: TestClient, 818 | mock_smtp: MagicMock, 819 | mock_recaptcha_verify_api: RequestsMock, 820 | params_success: Dict[str, str], 821 | ) -> None: 822 | params = params_success 823 | params["g-recaptcha-response"] = valid_recaptcha_response 824 | 825 | response = app_client.post("/api/mail/form", data=params, follow_redirects=False) 826 | assert response.status_code == HTTPStatus.FOUND 827 | assert response.headers["Location"] == os.environ["ERROR_REDIRECT_URL"] 828 | 829 | 830 | def test_send_mail_form_smtp_send_failed( 831 | app_client: TestClient, 832 | mock_smtp_send_error: MagicMock, 833 | params_success: Dict[str, str], 834 | ) -> None: 835 | params = params_success 836 | 837 | response = app_client.post("/api/mail/form", data=params, follow_redirects=False) 838 | assert response.status_code == HTTPStatus.FOUND 839 | assert response.headers["Location"] == os.environ["ERROR_REDIRECT_URL"] 840 | 841 | assert mock_smtp_send_error.call_count == 1 842 | assert mock_smtp_send_error.return_value.starttls.call_count == 1 843 | assert mock_smtp_send_error.return_value.login.call_count == 1 844 | assert mock_smtp_send_error.return_value.send_message.call_count == 1 845 | assert mock_smtp_send_error.return_value.quit.call_count == 0 846 | -------------------------------------------------------------------------------- /tests/test_home.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from http import HTTPStatus 4 | from fastapi import FastAPI 5 | from starlette.testclient import TestClient 6 | 7 | 8 | # ------------------------------------------------------------------------------ 9 | 10 | 11 | @pytest.fixture(scope="function") 12 | def enable_production(monkeypatch: pytest.MonkeyPatch) -> None: 13 | monkeypatch.setenv("APP_ENVIRONMENT", "production") 14 | 15 | 16 | @pytest.fixture(scope="function") 17 | def enable_force_https(monkeypatch: pytest.MonkeyPatch) -> None: 18 | monkeypatch.setenv("FORCE_HTTPS", "true") 19 | 20 | 21 | # ------------------------------------------------------------------------------ 22 | 23 | 24 | def test_get_homepage_success(app: FastAPI, app_client: TestClient) -> None: 25 | from mailer import __about__ 26 | 27 | response = app_client.get("/") 28 | assert response.status_code == HTTPStatus.OK 29 | 30 | data = response.text 31 | assert __about__.__title__ in data 32 | assert __about__.__version__ in data 33 | assert __about__.__description__ in data 34 | assert app.url_path_for("swagger_ui_html") in data 35 | assert app.url_path_for("redoc_html") in data 36 | 37 | 38 | def test_get_homepage_production_success( 39 | enable_production: None, app: FastAPI, app_client: TestClient 40 | ) -> None: 41 | from mailer import __about__ 42 | from starlette.routing import NoMatchFound 43 | 44 | response = app_client.get("/") 45 | assert response.status_code == HTTPStatus.OK 46 | 47 | data = response.text 48 | assert __about__.__title__ in data 49 | assert __about__.__version__ in data 50 | assert __about__.__description__ in data 51 | assert app.url_path_for("redoc_html") in data 52 | 53 | assert app.docs_url is None 54 | with pytest.raises(NoMatchFound): 55 | app.url_path_for("swagger_ui_html") 56 | 57 | 58 | def test_get_homepage_https_redirect( 59 | enable_force_https: None, app_client: TestClient 60 | ) -> None: 61 | response = app_client.get("/", follow_redirects=False) 62 | assert response.status_code == HTTPStatus.TEMPORARY_REDIRECT 63 | -------------------------------------------------------------------------------- /tests/test_mailer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from faker import Faker 4 | 5 | from mailer import mailer 6 | 7 | 8 | def test_send_pgp_message_encryption_failed(faker: Faker) -> None: 9 | from . import utils 10 | 11 | pgp_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 12 | 13 | m = mailer.Mailer( 14 | faker.email(), 15 | faker.email(), 16 | faker.name(), 17 | faker.hostname(), 18 | faker.port_number(), 19 | False, 20 | False, 21 | faker.user_name(), 22 | faker.password(), 23 | pgp_key, 24 | ) 25 | 26 | with pytest.raises(RuntimeError): 27 | m.send_email(faker.email(), faker.name(), faker.text(), faker.text(), None) 28 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from base64 import urlsafe_b64encode 4 | from faker import Faker 5 | from pydantic import ValidationError 6 | 7 | from mailer import settings 8 | 9 | 10 | def test_pgp_public_key_valid(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> None: 11 | from . import utils 12 | 13 | pgp_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 14 | pub_key = urlsafe_b64encode(str(pgp_key.pubkey).encode("utf-8")).decode("utf-8") 15 | monkeypatch.setenv("PGP_PUBLIC_KEY", pub_key) 16 | 17 | s = settings.Settings() 18 | assert str(s.pgp_public_key) == str(pgp_key.pubkey) 19 | 20 | 21 | def test_pgp_public_key_private(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> None: 22 | from . import utils 23 | 24 | pgp_key = utils.generate_pgp_key_pair(faker.name(), faker.email()) 25 | prv_key = urlsafe_b64encode(str(pgp_key).encode("utf-8")).decode("utf-8") 26 | monkeypatch.setenv("PGP_PUBLIC_KEY", prv_key) 27 | 28 | with pytest.raises(ValidationError): 29 | settings.Settings() 30 | 31 | 32 | def test_pgp_public_key_invalid(monkeypatch: pytest.MonkeyPatch, faker: Faker) -> None: 33 | from base64 import urlsafe_b64encode 34 | 35 | pgp_key = urlsafe_b64encode(faker.binary()).decode("utf-8") 36 | pub_key = urlsafe_b64encode(pgp_key.encode("utf-8")).decode("utf-8") 37 | monkeypatch.setenv("PGP_PUBLIC_KEY", pub_key) 38 | 39 | with pytest.raises(ValidationError): 40 | settings.Settings() 41 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import pgpy 2 | 3 | from base64 import b64decode 4 | from email import parser 5 | from typing import Optional 6 | from pgpy.constants import ( 7 | PubKeyAlgorithm, 8 | KeyFlags, 9 | HashAlgorithm, 10 | SymmetricKeyAlgorithm, 11 | CompressionAlgorithm, 12 | ) 13 | 14 | 15 | def generate_pgp_key_pair(name: str, email: str) -> pgpy.PGPKey: 16 | key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) 17 | uid = pgpy.PGPUID.new(name, email=email) 18 | key.add_uid( 19 | uid, 20 | usage={KeyFlags.Sign, KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, 21 | hashes=[ 22 | HashAlgorithm.SHA256, 23 | HashAlgorithm.SHA384, 24 | HashAlgorithm.SHA512, 25 | HashAlgorithm.SHA224, 26 | ], 27 | ciphers=[ 28 | SymmetricKeyAlgorithm.AES256, 29 | SymmetricKeyAlgorithm.AES192, 30 | SymmetricKeyAlgorithm.AES128, 31 | ], 32 | compression=[ 33 | CompressionAlgorithm.ZLIB, 34 | CompressionAlgorithm.BZ2, 35 | CompressionAlgorithm.ZIP, 36 | CompressionAlgorithm.Uncompressed, 37 | ], 38 | ) 39 | return key 40 | 41 | 42 | def encrypt_pgp_message(public_key: str, message: str) -> str: 43 | import pgpy 44 | 45 | pgp_key, _ = pgpy.PGPKey.from_blob(public_key) 46 | plain_message = pgpy.PGPMessage.new(message) 47 | pgp_message = pgp_key.encrypt(plain_message) 48 | 49 | return str(pgp_message) 50 | 51 | 52 | def decrypt_pgp_message(private_key: str, encrypted_message: str) -> str: 53 | import pgpy 54 | 55 | pgp_private_key, _ = pgpy.PGPKey.from_blob(private_key) 56 | pgp_message = pgpy.PGPMessage.from_blob(encrypted_message) 57 | plain_message = pgp_private_key.decrypt(pgp_message) 58 | 59 | return str(plain_message.message) 60 | 61 | 62 | def assert_pgp_email( 63 | email_str: str, 64 | email: str, 65 | name: str, 66 | subject: str, 67 | message: str, 68 | sender_email: str, 69 | to_email: str, 70 | to_name: str, 71 | private_key: pgpy.PGPKey, 72 | sender_public_key: Optional[pgpy.PGPKey], 73 | ) -> Optional[str]: 74 | p = parser.Parser() 75 | mail = p.parsestr(email_str) 76 | 77 | mail_headers = {k: v for k, v in mail.items()} 78 | assert ( 79 | 'multipart/encrypted; protocol="application/pgp-encrypted"; charset="UTF-8";' 80 | in mail_headers["Content-Type"] 81 | ) 82 | assert mail_headers["MIME-Version"] == "1.0" 83 | assert mail_headers["Date"] 84 | assert name in mail_headers["From"] 85 | assert sender_email in mail_headers["From"] 86 | assert to_email in mail_headers["To"] 87 | assert to_name in mail_headers["To"] 88 | assert name in mail_headers["Reply-To"] 89 | assert email in mail_headers["Reply-To"] 90 | assert mail_headers["Subject"] 91 | 92 | pgp_mime = mail.get_payload(0) 93 | pgp_mime_headers = {k: v for k, v in pgp_mime._headers} 94 | assert pgp_mime_headers["Content-Type"] == "application/pgp-encrypted" 95 | assert pgp_mime_headers["Content-Description"] == "PGP/MIME version identification" 96 | assert pgp_mime._payload == "Version: 1\n" 97 | 98 | pgp_enc_body = mail.get_payload(1) 99 | pgp_enc_body_headers = {k: v for k, v in pgp_enc_body._headers} 100 | assert ( 101 | pgp_enc_body_headers["Content-Type"] 102 | == 'application/octet-stream; name="encrypted.asc"' 103 | ) 104 | assert pgp_enc_body_headers["Content-Description"] == "OpenPGP encrypted message" 105 | assert ( 106 | pgp_enc_body_headers["Content-Disposition"] 107 | == 'inline; filename="encrypted.asc"' 108 | ) 109 | assert "-----BEGIN PGP MESSAGE-----" in pgp_enc_body._payload 110 | assert "-----END PGP MESSAGE-----" in pgp_enc_body._payload 111 | 112 | dec_message_str = decrypt_pgp_message(str(private_key), pgp_enc_body._payload) 113 | 114 | dec_email = p.parsestr(dec_message_str) 115 | dec_email_headers = {k: v for k, v in dec_email.items()} 116 | assert "multipart/mixed" in dec_email_headers["Content-Type"] 117 | 118 | dec_email_body = dec_email.get_payload(0) 119 | dec_email_body_headers = {k: v for k, v in dec_email_body._headers} 120 | assert dec_email_body_headers["Content-Type"] == 'text/plain; charset="utf-8"' 121 | assert dec_email_body_headers["MIME-Version"] == "1.0" 122 | assert dec_email_body_headers["Content-Transfer-Encoding"] == "base64" 123 | 124 | dec_email_body_payload = b64decode(dec_email_body._payload).decode("utf-8") 125 | assert dec_email_body_payload == message 126 | 127 | pub_key = None 128 | if dec_email.is_multipart() and sender_public_key: 129 | dec_email_attach = dec_email.get_payload(1) 130 | dec_email_attach_headers = {k: v for k, v in dec_email_attach._headers} 131 | assert ( 132 | dec_email_attach_headers["Content-Type"] 133 | == 'application/pgp-keys; name="publickey.asc"' 134 | ) 135 | assert ( 136 | dec_email_attach_headers["Content-Disposition"] 137 | == 'attachment; filename="publickey.asc"' 138 | ) 139 | assert dec_email_attach_headers["Content-Transfer-Encoding"] == "base64" 140 | 141 | pub_key = b64decode(dec_email_attach._payload).decode("utf-8") 142 | assert pub_key == str(sender_public_key) 143 | 144 | return pub_key 145 | 146 | 147 | def assert_plain_email( 148 | email_str: str, 149 | email: str, 150 | name: str, 151 | subject: str, 152 | message: str, 153 | sender_email: str, 154 | to_email: str, 155 | to_name: str, 156 | ) -> None: 157 | p = parser.Parser() 158 | mail = p.parsestr(email_str) 159 | 160 | mail_headers = {k: v for k, v in mail.items()} 161 | assert "multipart/mixed" in mail_headers["Content-Type"] 162 | assert mail_headers["MIME-Version"] == "1.0" 163 | assert mail_headers["Date"] 164 | assert name in mail_headers["From"] 165 | assert sender_email in mail_headers["From"] 166 | assert to_email in mail_headers["To"] 167 | assert to_name in mail_headers["To"] 168 | assert name in mail_headers["Reply-To"] 169 | assert email in mail_headers["Reply-To"] 170 | assert mail_headers["Subject"] 171 | assert mail.preamble == "This is a multi-part message in MIME format.\n" 172 | 173 | body = mail.get_payload(0) 174 | body_headers = {k: v for k, v in body._headers} 175 | assert body_headers["Content-Type"] == 'text/plain; charset="utf-8"' 176 | assert body_headers["MIME-Version"] == "1.0" 177 | assert body_headers["Content-Transfer-Encoding"] == "base64" 178 | 179 | body_payload = b64decode(body._payload).decode("utf-8") 180 | assert body_payload == message 181 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { "src": "mailer/__init__.py", "use": "@vercel/python" } 5 | ], 6 | "routes": [ 7 | { "src": "/(.*)", "dest": "mailer/__init__.py"} 8 | ] 9 | } 10 | --------------------------------------------------------------------------------