Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python
"""Bump the patch component of the project version in pyproject.toml.

Prints the new version to stdout. Used by the PyPI publish workflow to advance
the version after each release, so the next merge to main publishes a new one.
A minor/major release is still done by editing the version in pyproject.toml by
hand; this script only ever increments the patch number.
"""

import pathlib
import re

PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml"
VERSION_RE = r'^(version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")'


def main() -> str:
text = PYPROJECT.read_text()
match = re.search(VERSION_RE, text, flags=re.MULTILINE)
if not match:
raise SystemExit('Could not find a version = "X.Y.Z" line in pyproject.toml')

major, minor, patch = (int(match.group(i)) for i in (2, 3, 4))
new_version = f"{major}.{minor}.{patch + 1}"

text = re.sub(
VERSION_RE,
rf"\g<1>{new_version}\g<5>",
text,
count=1,
flags=re.MULTILINE,
)
PYPROJECT.write_text(text)
return new_version


if __name__ == "__main__":
print(main())
82 changes: 61 additions & 21 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,70 @@
name: Publish to PyPI

name: Upload Python Package
# On every merge to main: run the tests, publish the version currently in
# pyproject.toml to PyPI, then auto-bump the patch version and commit it back so
# the next merge publishes a new version. The bump commit is tagged "[skip ci]"
# so it does not re-trigger this workflow.
#
# For a minor/major release, bump the version in pyproject.toml by hand in your
# PR; that version is published as-is and patch auto-bumping continues from there.

on:
push:
branches:
- main
branches: [main]
workflow_dispatch:

jobs:
deploy:
concurrency:
group: publish-pypi
cancel-in-progress: false

permissions:
contents: write

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install Django pytest pytest-django
- name: Run tests
run: pytest

publish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload --skip-existing dist/*
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install build tooling
run: |
python -m pip install --upgrade pip
python -m pip install build twine
- name: Build distributions
run: python -m build
- name: Check distributions
run: twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: twine upload --skip-existing dist/*
- name: Bump patch version for the next release
id: bump
run: echo "version=$(python .github/scripts/bump_version.py)" >> "$GITHUB_OUTPUT"
- name: Commit and push version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git commit -m "chore: bump version to ${{ steps.bump.outputs.version }} [skip ci]"
git push
43 changes: 43 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["4.2", "5.0", "5.1", "5.2"]
exclude:
# Django 5.x requires Python >= 3.10
- python-version: "3.8"
django-version: "5.0"
- python-version: "3.8"
django-version: "5.1"
- python-version: "3.8"
django-version: "5.2"
- python-version: "3.9"
django-version: "5.0"
- python-version: "3.9"
django-version: "5.1"
- python-version: "3.9"
django-version: "5.2"

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install "Django~=${{ matrix.django-version }}.0" pytest pytest-django
- name: Run tests
run: pytest
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Changelog

All notable changes to this project are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [3.1.0]

This is a backwards-compatible maintenance release. Public imports, URL names,
template paths and settings names are unchanged.

### Security
- **Account-enumeration fix.** The public "request a new verification email"
form now returns an identical response whether or not the submitted address
belongs to a real account, and regardless of whether that account is already
active. Previously it returned a `404 "User Not Found"`, an "Already Verified"
page, or a success page depending on the account's state, which let anyone
probe which emails were registered and their status.
- The verification view no longer surfaces internal error details unless the
project is running with `DEBUG = True`.
- **Already-active accounts are no longer re-activated.** The activation path now
explicitly refuses an already-active account (raising `UserAlreadyActive` and
showing an "already verified" page) instead of resetting `last_login`. The
token check already covered the normal case; this is defense-in-depth for
accounts activated out-of-band.
- Documented operational hardening in the README (bounding the resend window via
`PASSWORD_RESET_TIMEOUT`, and adding IP-based throttling) following a security
audit of the verification flow.

### Fixed
- **Restored the documented public API.** `send_verification_email(request, form)`
— the entry point shown in the README/quick-start — no longer existed after the
3.0 refactor; calling it raised `ImportError`. It is back as a thin wrapper over
`ActivationMailManager.send_verification_link` and is importable both from
`verify_email` and `verify_email.email_handler`. Covered by a regression test.
- The verification link is now built with `reverse('verify-email', ...)` instead
of a hard-coded `/verification/` path, so mounting `verify_email.urls` under any
prefix works correctly.
- `TokenManager.get_user_by_token` could raise `InvalidToken` after checking only
the first user when multiple accounts shared an email address. It now checks
every candidate before deciding the token is invalid.
- Removed unreachable/incorrect branches in `TokenManager._get_seconds` (a code
path returned a `WrongTimeInterval` instance instead of raising it).
- `GetFieldFromSettings.get` had a dead guard that meant `raise_exception` never
fired; it now correctly raises when a required setting resolves to `None`.

### Changed
- **No more global user signal.** Previous versions registered a `post_save`
handler on the project's user model that created a `LinkCounter` row for
*every* user (and re-saved it on every user save). This polluted the database
and added a query to every user save in the host project. The signal has been
removed; the counter is now created lazily the first time a user requests a
resend. **No migration or data change is required** — existing `LinkCounter`
rows are reused untouched, and resend-limit behaviour is unchanged.
- **Namespaced the base template.** The shared base template moved from the
un-namespaced `email_index.html` to `verify_email/base.html`, so it can no
longer collide with a host project's own `email_index.html`. The old
`email_index.html` remains as a deprecated forwarding shim, so any custom
template that still `{% extends "email_index.html" %}` keeps working.
- `LinkCounter.requester` now references `settings.AUTH_USER_MODEL` directly
instead of a module-level `get_user_model()` call (migration-neutral).
- Added a common base exception, `verify_email.errors.VerifyEmailError`; every
package exception now inherits from it, so integrators can catch the whole
family with a single `except`.
- Declared `Django>=4.2` as an install dependency and verified compatibility with
Django 4.2, 5.0, 5.1 and 5.2 on Python 3.8–3.12.

### Packaging / tooling
- Consolidated packaging metadata into `pyproject.toml` (PEP 621); removed
`setup.cfg`/`setup.py`. Fixed the author email and stale/incorrect classifiers.
- Added a test suite harness (`tests/settings.py`, `tests/urls.py`), a `tox`
matrix and a GitHub Actions test workflow across supported Python/Django
versions.
- Modernised the publish workflow to use `python -m build` + `twine check`.
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`Django-Verify-Email` is a reusable Django **app** (not a standalone project) published to PyPI as `Django-Verify-Email`. It handles two-step email verification for new signups: it sets a new user to `is_active=False`, emails them a signed verification link, and activates the account when the link is visited. Version lives in `setup.cfg` (`version = ...`); the current major is 3.x.

The package is installed into a host project that supplies `settings.py`, the user model, and a mail backend. A minimal test harness lives under `tests/` (see [Tests](#tests)).

## Public API

The intended public entry point is re-exported from the package root (`verify_email/__init__.py` → `email_handler`):

- `send_verification_email(request, form)` — the documented signup helper. It is a thin wrapper around `ActivationMailManager.send_verification_link(form=..., request=...)`. Saves the form's user as inactive and emails the link; returns the inactive user (with `.cleaned_data`). If sending fails, the user is **deleted** so signup can be retried. (This wrapper was missing in 3.0.x — the documented import raised `ImportError` — and was restored in 3.1.0; keep it and its regression test, `test_public_api_send_verification_email`.)

Verification and "resend link" flows are fully handled by the app's own views/URLs — host projects only `include('verify_email.urls')`; they do not write verification views.

## Architecture

The flow is layered; each layer has a single responsibility:

- **`email_handler.py` — `ActivationMailManager`** (frozen dataclass): orchestration layer. `send_verification_link` (inactivate + save user, build URL, render template, send) and `resend_verification_link` (decode prior link or look up by email, re-issue). Composes a `TokenManager` and `GetFieldFromSettings`.
- **`token_manager.py`** — all crypto/signing logic. `TokenManager` subclasses `django.core.signing.TimestampSigner` **and** `GeneralConfig` (multiple inheritance; `__post_init__` must call `GeneralConfig.__post_init__` then `TimestampSigner.__init__`). Key pieces:
- `SafeURL` — urlsafe base64 encode/decode of email and token for the URL.
- `ActivationLinkManager` — builds the link via `reverse('verify-email', ...)` (so the mount prefix is not hard-coded), and enforces resend limits via `can_request_new_link` / sent-count.
- Token = Django's `default_token_generator` token, optionally wrapped by the timestamp signer **only when `EXPIRE_AFTER` (`max_age`) is set**. This is the central branch: with no `max_age` the link "expires after one use"; with `max_age` set it expires by time and `SignatureExpired` triggers the resend path. `decrypt_token_and_get_user` is the verification workhorse and distinguishes `SignatureExpired` (offer new link) from `BadSignature` (tampered — refuse).
- **`confirm.py` — `UserActivationProcess.activate_user`** — verifies token via `TokenManager`, sets `is_active=True` and `last_login=now()`.
- **`views.py`** — `verify_and_activate_user` (GET only) and `request_new_link`. These map the many domain exceptions (`InvalidToken`, `MaxRetriesExceeded`, `UserAlreadyActive`, `UserNotFound`, `SignatureExpired`, `BadSignature`) to specific templates/HTTP statuses. This exception-to-template mapping is the bulk of view logic — preserve it when editing.
- **`app_configurations.py` — `GetFieldFromSettings`** — the single source of truth for every setting the app reads. All settings have defaults and are accessed through `.get("<key>")`. **Add any new configurable setting here**, not via direct `settings.` access elsewhere. Special case: `VERIFICATION_SUCCESS_TEMPLATE = None` skips the success page and redirects to `LOGIN_URL`.
- **`models.py` — `LinkCounter`** — one-to-one with the user (`settings.AUTH_USER_MODEL`), tracks `sent_count` for resend-limit enforcement (`MAX_RETRIES`). The counter is created **lazily** by `ActivationLinkManager._get_or_create_counter` on the first resend (`sent_count` starts at 1). There is no `signals.py` and no global `post_save` handler — earlier versions created a counter for every user in the project; that was removed. Existing rows are reused untouched, so no migration is needed.
- **`errors.py`** — domain exceptions raised by lower layers and caught in `views.py`.
- **`custom_types.py`** — `User` type alias.

URL structure (`urls.py`): verification link is `user/verify-email/<encoded_email>/<token>/`; resend has both a from-link form and a from-email form variant.

## Settings consumed

Read `GetFieldFromSettings.defaults_configs` for the authoritative list. Key ones: `EXPIRE_AFTER` (link lifetime; int = seconds, or suffix `s`/`m`/`h`/`d` — note `m` is minutes), `MAX_RETRIES`, `EMAIL_FIELD_NAME`, `HTML_MESSAGE_TEMPLATE`, `HASHING_KEY`/`HASH_SALT`/`SEPARATOR` (signer config), `SUBJECT`, and the `*_TEMPLATE` / `*_MSG` overrides. Host project must also configure a Django mail backend and `DEFAULT_FROM_EMAIL`.

## Tests

Tests live **outside** the shipped package, in the root `tests/` package (`tests/test_verify_email.py` plus the `tests/settings.py` / `tests/urls.py` harness), so nothing test-related is published to PyPI. They use `django.test.TestCase` and exercise the real send/verify flow against the configured user model and mail outbox. Run them with:

```
pytest # config (settings module, pythonpath, testpaths) comes from pyproject.toml
tox # full Python x Django matrix
```

`pyproject.toml` sets `pythonpath = ["."]` so the `tests` package resolves under the `pytest` console script (pytest-django reads `DJANGO_SETTINGS_MODULE = tests.settings` early, before pytest's own path insertion — without `pythonpath` it fails in CI with `No module named 'tests'`). `tests/urls.py` mounts `verify_email.urls` at `verification/` and defines a `login` URL (the success view reverses `LOGIN_URL`). Several tests use `time.sleep` to assert expiry behaviour, so the suite is intentionally slow.

## Build & release

Packaging metadata lives entirely in `pyproject.toml` (PEP 621; there is no longer a `setup.py`/`setup.cfg`). `Django>=4.2` is a declared dependency. Build with `python -m build`; validate with `twine check dist/*`. Releases publish to PyPI via `.github/workflows/python-publish.yml` (on push to `main`, on a published GitHub release, or manual dispatch). `.github/workflows/tests.yml` runs the test matrix on push/PR. Bump `version` in `pyproject.toml` for a release and add a `CHANGELOG.md` entry.

## Conventions

- Most core classes are `@dataclass`es that compose their collaborators via `field(default_factory=...)`. Follow this pattern rather than instantiating dependencies inline.
- All settings access goes through `GetFieldFromSettings`; lower layers raise typed exceptions from `errors.py` and let `views.py` decide the user-facing response.
4 changes: 3 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
include README.md
include LICENSE
recursive-include verify_email/templates *
include CHANGELOG.md
recursive-include verify_email/templates *
recursive-exclude tests *
Loading
Loading