diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py
new file mode 100644
index 0000000..e86054a
--- /dev/null
+++ b/.github/scripts/bump_version.py
@@ -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())
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 5e133a7..203f23c 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -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
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f89423a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..748b1ad
--- /dev/null
+++ b/CHANGELOG.md
@@ -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`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..cee7264
--- /dev/null
+++ b/CLAUDE.md
@@ -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("")`. **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///`; 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.
diff --git a/MANIFEST.in b/MANIFEST.in
index edac6cf..38e203f 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,5 @@
include README.md
include LICENSE
-recursive-include verify_email/templates *
\ No newline at end of file
+include CHANGELOG.md
+recursive-include verify_email/templates *
+recursive-exclude tests *
diff --git a/README.md b/README.md
index 00ee391..7c74c92 100644
--- a/README.md
+++ b/README.md
@@ -1,394 +1,287 @@
+# Django-Verify-Email
+[](https://pypi.org/project/Django-Verify-Email/)
+[](https://pypi.org/project/Django-Verify-Email/)
+[](LICENSE)
-Email-Verification for Django
+Drop-in **two-step email verification** for Django sign-ups. On registration the
+app deactivates the new account, emails the user a signed, single-use
+verification link, and re-activates the account when that link is opened — all
+without you writing any verification views.
-Email verification for new signups or new users is a two-step verification process and adds a layer for security for valid users.
+- **Compatible with** Django 4.2, 5.0, 5.1, 5.2 on Python 3.8–3.12.
+- Works with any `AUTH_USER_MODEL` (uses `get_user_model()`).
+- Signed, expiring links built on Django's own token machinery.
+- Built-in "resend verification email" flow (by form or from an expired link).
+- Every page and email is template-overridable.
- verify_email is a django app that provides this functionality right of the bat without any complex implementation.
+> **Upgrading?** See [`CHANGELOG.md`](CHANGELOG.md). `3.1.0` is a
+> backwards-compatible release; public imports, URL names, template paths and
+> settings are unchanged.
-
+---
-## Version Update (2.0.0):
+## How it works
-
+1. You call `send_verification_email(request, form)` from your signup view.
+2. The app saves the user with `is_active = False` and emails a verification link.
+3. The user clicks the link; the app validates the signed token, sets
+ `is_active = True` and `last_login = now()`, and shows a success page (or
+ redirects straight to login).
+4. Used, tampered, or expired links are rejected and routed to the appropriate
+ page; expired links can offer the user a new one.
-> This version contains breaking changes and is not compatible with the previous version 1.0.9
-
-### What's in this update
-**Features:**
-* Added feature for **re-requesting email** in case the previous email was lost or deleted by mistake
-* Added a variable `REQUEST_NEW_EMAIL_TEMPLATE` where user can specify his custom template for requesting email again. More on this here.
-* Added a Django form for requesting email with a field `email`.
-
-Read about this feature here
-
-**Bug Fixes:**
-* Fixed a bug where the user was not able to request a new email using the previous link in case if the link expires.
-
- **Others**
- * Using exceptions instead of normal string errors
- * code cleanup
-
-
-
-## The app takes care of :
-* Settings user's is_active status to False.
-* Generate hashed token for each user.
-* Generate a verification link and send it to the user's email.
-* Recieve a request from the verification link and verify for its validity.
-* Activating the user's account.
-
-## What you have to implement is :
-* Three steps in Quick start below...
-
-Note : The app is designed to be used right of the bat, however, further customizations options are also provided in Advance section below.
+You do **not** write any verification view — the app ships its own URLs and views.
+---
## Installation
-NOTE: Don't forget to activate the virtual environment if you have one.
-
-```
+```bash
pip install Django-Verify-Email
```
-
-
Quick start
-
-
-The steps to getting started are very simple. Like any other app, this can be installed easily by adding "verify_email" in your installed apps like:
-
-Note: This documentation assumes that you already have a mail server configured for your project to send mails.
-
-if not, then your first step should be Step 0:
-
-### Step 0 :-
+### 1. Configure email (skip if your project already sends mail)
---- Bypass this step if you already have these things set up for your project. ---
-
-In your settings.py :
-```
-EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
-EMAIL_HOST = 'smtp.gmail.com'
+```python
+# settings.py
+EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
-EMAIL_HOST_USER = os.environ.get('EMAIL_ID')
-EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PW')
+EMAIL_HOST_USER = os.environ["EMAIL_ID"]
+EMAIL_HOST_PASSWORD = os.environ["EMAIL_PW"]
-DEFAULT_FROM_EMAIL = 'noreply'
+DEFAULT_FROM_EMAIL = "noreply "
```
-## Main steps...
+### 2. Add the app
-### Step 1 :-
-Add "verify_email" to your INSTALLED_APPS setting like this:
-```
- INSTALLED_APPS = [
- ...
- "verify_email.apps.VerifyEmailConfig",
- ]
+```python
+INSTALLED_APPS = [
+ # ...
+ "verify_email.apps.VerifyEmailConfig",
+]
```
-
-
Step 2 :-
-
-Include the "verify_email" URLconf in your project urls.py like this:
+### 3. Include the URLs
-```
-
+```python
+# project/urls.py
urlpatterns = [
- ...
- path('verification/', include('verify_email.urls')),
-
+ # ...
+ path("verification/", include("verify_email.urls")),
]
```
-
-
-
Step 3 :-
+### 4. Run migrations
-Apply migrations...
-
-
-```
+```bash
python manage.py migrate
```
-
+### 5. Send the verification email from your signup view
-
-### Step 4 :-
-For sending email from a signup form, in your views.py import:
-
-```
-...
+```python
from verify_email.email_handler import send_verification_email
-```
-Now in the function where you are validating the form:
-
-```
-...
def register_user(request):
- ...
-
+ form = MySignupForm(request.POST)
if form.is_valid():
-
inactive_user = send_verification_email(request, form)
+ # `inactive_user` is the saved user (is_active=False).
+ # Access submitted data via inactive_user.cleaned_data['email'], etc.
+ ...
```
-Attention : "send_verification_email()" takes two arguments, requests and form in order to set user's active status.
-
-The "inactive_user" that is returned by "send_verification_email()" contains a saved user object just like form.save() would do(with is_active status set as False), which you can further use to extract user information from cleaned_data dictionary, as shown below :
-
-```
-inactive_user.cleaned_data['email']
-
-# Output: test-user123@gmail.com
+`send_verification_email(request, form)` saves the user as inactive and sends the
+link — you don't call `form.save()` yourself. **If sending the email fails, the
+user is rolled back (deleted)** so the visitor can retry.
+
+> Your form must have an `email` field. If it's named differently, set
+> [`EMAIL_FIELD_NAME`](#configuration).
+
+That's it — verification is fully handled by the app from here.
+
+---
+
+## Configuration
+
+All settings are optional and read from your project's `settings.py`.
+
+| Setting | Default | Purpose |
+| --- | --- | --- |
+| `SUBJECT` | `"Email Verification Mail"` | Subject line of the verification email. |
+| `EMAIL_FIELD_NAME` | `"email"` | Name of the email field on your signup form. |
+| `HTML_MESSAGE_TEMPLATE` | `verify_email/email_verification_msg.html` | Template for the email body. Must render `{{ link }}`. |
+| `DEFAULT_FROM_EMAIL` | Django's default | From address for the email. |
+| `LOGIN_URL` | `"accounts_login"` | URL name the success page links to. Also used by Django. |
+| `VERIFICATION_SUCCESS_TEMPLATE` | `verify_email/email_verification_successful.html` | Success page. Set to `None` to skip it and redirect straight to `LOGIN_URL`. |
+| `VERIFICATION_SUCCESS_MSG` | *(sensible default)* | Message shown on success. |
+| `VERIFICATION_FAILED_TEMPLATE` | `verify_email/email_verification_failed.html` | Page shown for invalid/failed links. |
+| `LINK_EXPIRED_TEMPLATE` | `verify_email/link_expired.html` | Page shown when a link has expired. |
+| `VERIFICATION_FAILED_MSG` | *(sensible default)* | Message shown on failure. |
+| `REQUEST_NEW_EMAIL_TEMPLATE` | `verify_email/request_new_email.html` | Page hosting the "request a new link" form. |
+| `NEW_EMAIL_SENT_TEMPLATE` | `verify_email/new_email_sent.html` | Confirmation page after a new link is requested. |
+| `EXPIRE_AFTER` | `None` | Link lifetime. See [Link expiry](#link-expiry). |
+| `MAX_RETRIES` | `2` | How many times a user may request a new link. |
+| `HASHING_KEY` | project `SECRET_KEY` | Key used to sign links. |
+| `HASH_SALT` | `None` | Optional salt for the signer. |
+| `SEPARATOR` | `":"` | Separator used by the signer. Must not be in the URL-safe base64 alphabet. |
+
+---
+
+## Link expiry
+
+By default a link stays valid until it is used (subject to Django's
+`PASSWORD_RESET_TIMEOUT`, which the underlying token honours — 3 days by
+default). To set an explicit expiry, define `EXPIRE_AFTER`:
+
+```python
+EXPIRE_AFTER = "1d" # 1 day
+EXPIRE_AFTER = "2h" # 2 hours
+EXPIRE_AFTER = "30m" # 30 minutes (m = minutes, not months)
+EXPIRE_AFTER = 90 # bare integer = seconds
```
-The user is already being saved as inactive and you don't have to .save() it explicitly.
-
-If anything goes wrong in sending the verification link email, the user will not be saved, so that the user can try again.
-
-
-
-### At this point, you are good to go...
- Start the development server and signup with an email and you should be getting an email on the entered email with the default template for account activation. (You can provide your own HTML template. see Advance Section)
-
- Note : The app comes with default email templates which can be overriden. See Custom Email Templates
-
-# Verifying User's email :
-
-Nothing...
-
-That's right! , you don't have to implement any other code for validating users with their respective unique tokens and emails.
-
-The app takes care of everything in the background.
-
-* When the user clicks on the verification link, it comes to :
- ```
- path('verification/', include('verify_email')),
- ```
- which you defined in your project's urls.py in step 2 above.
-* This pattern is further extended in this app's urls.py where it accepts encoded email and encoded hashed tokens from the verification link.
-* It then checks for users by that email.
-* If the user exists, it then checks for a token if it is valid for that user or not.
-* If the token is valid, it activates the user's account by setting is_active attribute to True and last_login to timezone.now().
-* If the token is already been redeemed or modified, you'll be redirected to a "verification failed" page.
-
-#### This whole process from generating HMAC hashed token for each user to verify it for a unique user, is abstracted within the app 😃.
-
-
-
+Supported suffixes: `s` (seconds), `m` (minutes), `h` (hours), `d` (days). A
+bare integer is treated as seconds.
-
Advance
+---
-
-
Expiration of link and Resending emails :
-If you want your link to expire after a certain amount of time, you can use signed links, All you have to do is just set a variable in the settings.py file and BAMM! you got yourself a link that will expire after the specified time.
-It's that simple, just setting a variable.
-If you don't set this variable, the link will expire after being used at least once.
-
+## Resending verification emails
-The link, by default, does not expire until it has been used at least once, however, you can
-**change** this behavior by specifying the time as
-"EXPIRE_AFTER" in settings.py. The variable can be set as :
-* By default the time is considered in seconds, so if you set "EXPIRE_AFTER" as an integer, that will be considered in seconds.
-* You can specify time unit for large times, max unit is days.
-* **Its very simple** just suffix the "EXPIRE_AFTER" variable's value with a time unit from ["s", "m", "h", "d"]. (Keep in mind, the "m" here is minutes, not month)
+A user may request a new link up to `MAX_RETRIES` times (default `2`). After
+that they are shown a "maxed out" page.
-**Example**
+**From an expired link.** The expired-link page includes a button to request a
+new email — no extra setup needed.
-* If I have to make a link expire after **one-day**, then I'd do:
- * EXPIRE_AFTER = "1d" # Will expire after one day from link generation
-
-* If I have to make a link expire after **one-hour**, then I'd do:
- * EXPIRE_AFTER = "1h" # Will expire after one hour from link generation
-
-* If I have to make a link expire after **one-minute**, then I'd do:
- * EXPIRE_AFTER = "1m" # Will expire after 1 minute from link generation
-
-**Note:** By default, if you do not specify a unit, it'll be considered in seconds.
-
-
-
-
Re-Sending Email
-
-
-A user can request a new verification link **For a specific no. of times** in case the previous one has expired. By default, a user can request
-new link **two times** which, obviously can be modified by you.
-
-Set a "MAX_RETRIES" variable in settings.py specifying the no. of times a user is allowed to request a new link.
-
-After that no. is exceeded, the user will be automatically redirected to an error page showing that you have maxed out.
-
-
-
Re-Sending Email using previous link
-
-When the link expires, the user will be redirected to a page displaying that the link is expired and has a button to request a new email, now as long as the user hasn't exceeded max retries, the user can request a new email simply by clicking on that button.
-
-
-
Resend Email using Email Form
-
-
-In case when previous email/link is lost or deleted by the client, they can request a new email by specifying their email.
-
-The path for that is `https://yourdomain/verification/user/verify-email/request-new-link/`, at this path, there will be a form that will ask for the email of the registered user.
-
-The pathname is `request-new-link-from-email` which you can use to create a button on your front end and redirect traffic to the request email page.
-Something like:
+**From a form.** Link users to the request form via its URL name:
```html
-
+Resend verification email
```
-This will redirect you to full path `/verification/user/verify-email/request-new-link/`
-
-There are several checks done before sending an email again:
-* if the email is registered and the user's account is not been activated
-* the user hasn't exceeded max retry limit(set by you),
-
-Then a new email will be sent to the given email.
-
-The form template is supposed to be changed unless you are okay with the default template provided with the package.
-
-To set your own custom template for form, set a variable name `REQUEST_NEW_EMAIL_TEMPLATE` in settings.py with the path of template you want to use. Example:
-```py
-REQUEST_NEW_EMAIL_TEMPLATE = 'mytemplates/mycustomtemplate.html'
-```
-and then your template will be displayed at the path.
-
-**Making Form:** while making your custom template, keep in mind that the view will pass a variable named `form` to the provided template, this form will contain only 1 field `email`. Sample code that you can use while making your template is here:
+This serves a form with a single `email` field. To customise it, point
+`REQUEST_NEW_EMAIL_TEMPLATE` at your own template (the view passes a `form` in the
+context):
```html
-
```
-You can apply your styles or whatever you want. (this code is used in the default template)
-
-
-**NOTE:** This info is stored in the database so you have to apply migrations (step 3) to use this feature.
-
-
+> The resend form is **enumeration-safe**: it returns the same response whether
+> or not the address is registered, and whether or not the account is already
+> active. It will not reveal which emails exist. (See
+> [Security](#security-notes).)
-
Custom Email Templates :
+> This feature stores per-user counters in the database, so make sure you've run
+> migrations (step 4).
-The app is packed with default HTML templates to handle the web pages but if you want to provide your own template you can do it by setting an attribute in settings.py :
+---
-```
-HTML_MESSAGE_TEMPLATE = "path/to/html_template.html"
-
-VERIFICATION_SUCCESS_TEMPLATE = "path/to/success.html"
-
-VERIFICATION_FAILED_TEMPLATE = "path/to/failed.html"
+## Customising templates
-REQUEST_NEW_EMAIL_TEMPLATE = "path/to/email.html"
+Override any page or the email body by pointing the relevant setting at your own
+template (see the [Configuration](#configuration) table).
-LINK_EXPIRED_TEMPLATE = 'path/to/expired.html'
-
-NEW_EMAIL_SENT_TEMPLATE = 'path/to/new_email_sent.html'
-```
-```
-SUBJECT = 'subject of email'
+**Email body** (`HTML_MESSAGE_TEMPLATE`) — context: `{{ request }}`, `{{ link }}`.
+You **must** include `{{ link }}` or the email won't contain a working link:
-# default subject is: Email Verification Mail
+```html
+Verify your email
```
-
-
-## Inside Templates :
-
-### Custom HTML Message Template :
-
-Two variables are passed in context dict of "HTML_MESSAGE_TEMPLATE" :
-
-* ```{{request}}``` : Which is the same request passed in to send_verification_email.
-* ```{{link}}``` : Which contains verification link
-
-IMPORTANT : if you are using custom html message template for email that has to be sent to user, provide a {{link}} as a template tag to contain verification link.
-
-You Must Pass This In Your Template. Otherwise, the sent mail will not contain the verification link.
+**Success page** (`VERIFICATION_SUCCESS_TEMPLATE`) — context: `{{ msg }}`,
+`{{ link }}` (login URL), `{{ status }}`:
-For Ex :
-
-```my_custom_email_message.html : ```
-
-```
-
+```html
+{{ msg }}
+Login
```
-----> "link" is a variable, that contains a verification link, and is passed in an HTML message template during sending the email to the user.
-
-
-### Custom HTML Verification Success and Failed pages :
-
-
-Success :
-
-Two variables are passed in the context dictionary of "VERIFICATION_SUCCESS_TEMPLATE" :
-
-* ```{{mgs}}```: Which contains the message to be displayed on successful verification.
-* ```{{link}}```: Which contains a redirect link to the login page.
-
-In template :
-
+**Failed / expired pages** — context includes `{{ msg }}` and `{{ status }}`.
+
+### Redirecting to login after success
+
+- **Show a success page** that links to login: set `LOGIN_URL` to your login URL
+ name.
+- **Skip the success page** and go straight to login: set
+ `VERIFICATION_SUCCESS_TEMPLATE = None`.
+
+---
+
+## Security notes
+
+- Links are signed with Django's `TimestampSigner` using your `SECRET_KEY` (or a
+ custom `HASHING_KEY`). Tampered links produce a `BadSignature` and are rejected
+ outright — a modified link cannot be used to request a fresh one.
+- The verification token is Django's `default_token_generator` token, bound to
+ the user's password hash and `last_login`. Activating the account updates
+ `last_login`, which **invalidates the link**, making links effectively
+ single-use even without `EXPIRE_AFTER`.
+- The "request a new email" form is enumeration-safe (identical response for
+ unknown / pending / already-active accounts).
+- Already-active accounts are never re-activated, even if a still-valid token is
+ presented.
+- Internal error details are only shown to end users when the project runs with
+ `DEBUG = True`.
+
+### Operational hardening (recommended)
+
+- **Bound the resend window with `PASSWORD_RESET_TIMEOUT`.** By design, the
+ "request a new link" button on an *expired*-link page accepts the expired
+ token to mint a fresh link (that's the feature). The window in which an old
+ link can still be exchanged for a new one is therefore governed by Django's
+ `PASSWORD_RESET_TIMEOUT` (default **3 days**), independently of `EXPIRE_AFTER`,
+ and is capped per user by `MAX_RETRIES`. If you set a short `EXPIRE_AFTER`,
+ also lower `PASSWORD_RESET_TIMEOUT` to match your intended link lifetime.
+- **Add request throttling.** The package caps resends per user (`MAX_RETRIES`)
+ but does not rate-limit by IP, and the resend form is not constant-time (it
+ sends an email only for valid pending accounts, a subtle timing signal). For
+ internet-facing signups, put a throttle (e.g. `django-ratelimit`) in front of
+ the resend endpoints and consider sending email asynchronously.
+
+---
+
+## Public API
+
+```python
+from verify_email.email_handler import send_verification_email # primary entry point
+from verify_email.errors import VerifyEmailError # base of all package exceptions
```
-
- {{msg}} # __--> message variable
-
-
- # __--> Link of login page
- Login
-
-```
+URL names you can `reverse`/link to: `verify-email`,
+`request-new-link-from-token`, `request-new-link-from-email`.
-Failed :
+---
-Only "{{msg}}" is passed for failed msg in the template.
+## Development
+```bash
+# install dev dependencies
+pip install -e ".[dev]"
-In template :
+# run the test suite (config + settings come from pyproject.toml / tests/)
+pytest
+# or across all supported Python/Django versions
+tox
```
-
- {{msg}}
-
-```
-
-
-
-## Successful Verification :
-After verification is successful, you might want to redirect the user to the login page. You can do this in two ways :
-
-* 1 Redirect from success webpage.
- The user will be prompted to show a success page with a button on it to navigate to the Login page.
- ```
- LOGIN_URL = 'name of your login pattern'
- Note: This variable is also used by Django.
- ```
-* 2 Redirect directly to the login page without stopping at the success message page.
- The user will be directly sent to the login page, bypassing the success page.
- ```
- VERIFICATION_SUCCESS_TEMPLATE = None
- ```
-
+---
+## Contributing
-> There is always room for improvements and new ideas, feel free to raise PR or Issues
+Issues and pull requests are welcome at
+. There is always room for
+improvement — feel free to open an issue or PR.
+## License
+Released under the [MIT License](LICENSE).
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9085b7d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,72 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "Django-Verify-Email"
+version = "3.1.0"
+description = "A Django app for two-step email verification of new sign-ups."
+readme = "README.md"
+requires-python = ">=3.8"
+license = { text = "MIT" }
+authors = [{ name = "Nitin Sharma", email = "ns290670@gmail.com" }]
+keywords = ["django", "email", "verification", "signup", "authentication"]
+dependencies = ["Django>=4.2"]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Web Environment",
+ "Framework :: Django",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.0",
+ "Framework :: Django :: 5.1",
+ "Framework :: Django :: 5.2",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+
+[project.urls]
+Homepage = "https://github.com/foo290/Django-Verify-Email/"
+Source = "https://github.com/foo290/Django-Verify-Email/"
+Issues = "https://github.com/foo290/Django-Verify-Email/issues"
+
+[project.optional-dependencies]
+dev = ["pytest", "pytest-django", "tox", "build", "twine"]
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+include = ["verify_email*"]
+exclude = ["tests*"]
+
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "tests.settings"
+# Put the repo root on sys.path early (before pytest-django reads the settings
+# module) so `tests.settings` resolves under the `pytest` console script, not
+# just `python -m pytest`.
+pythonpath = ["."]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+
+[tool.black]
+line-length = 88
+target-version = ["py38"]
+
+[tool.ruff]
+line-length = 88
+target-version = "py38"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I"]
+# Line length is handled by the formatter (black); long string literals and
+# docstrings are allowed to exceed it.
+ignore = ["E501"]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 10aec78..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,20 +0,0 @@
-[metadata]
-name = Django-Verify-Email
-version = 3.0.3
-author = Nitin
-author_email = ns290670@gamil.com
-description = A Django app for email verification.
-url = https://github.com/foo290/Django-Verify-Email/
-packages =setuptools.find_packages()
-classifiers =
- Environment :: Web Environment
- Framework :: Django CMS :: 3.8
- Intended Audience :: Developers
- Programming Language :: Python :: 3.6
- License :: OSI Approved :: MIT License
- Operating System :: OS Independent
-python_requires = >=3.6
-
-[options]
-include_package_data = true
-packages = find:
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 8ecafc6..0000000
--- a/setup.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import setuptools
-
-with open("README.md", "r") as fh:
- long_description = fh.read()
-
-setuptools.setup(
- long_description=long_description,
- long_description_content_type="text/markdown",
-)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/settings.py b/tests/settings.py
new file mode 100644
index 0000000..0fb41e5
--- /dev/null
+++ b/tests/settings.py
@@ -0,0 +1,49 @@
+"""Minimal Django settings used to run the verify_email test suite."""
+
+SECRET_KEY = "test-secret-key-not-for-production"
+
+DEBUG = False
+
+INSTALLED_APPS = [
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.messages",
+ "verify_email.apps.VerifyEmailConfig",
+]
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": ":memory:",
+ }
+}
+
+ROOT_URLCONF = "tests.urls"
+
+MIDDLEWARE = [
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+]
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "APP_DIRS": True,
+ "DIRS": [],
+ "OPTIONS": {
+ "context_processors": [
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ }
+]
+
+EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
+DEFAULT_FROM_EMAIL = "noreply@example.com"
+
+LOGIN_URL = "login"
+
+USE_TZ = True
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/tests/test_verify_email.py b/tests/test_verify_email.py
new file mode 100644
index 0000000..8922c3c
--- /dev/null
+++ b/tests/test_verify_email.py
@@ -0,0 +1,267 @@
+import time
+
+from django import forms
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.test import RequestFactory, TestCase, override_settings
+from django.urls import reverse
+
+import verify_email
+from verify_email.confirm import UserActivationProcess
+from verify_email.email_handler import ActivationMailManager, send_verification_email
+from verify_email.errors import InvalidToken, UserAlreadyActive, VerifyEmailError
+from verify_email.token_manager import ActivationLinkManager, SafeURL, TokenManager
+
+User = get_user_model()
+
+
+class SignupForm(forms.ModelForm):
+ class Meta:
+ model = User
+ fields = ["username", "email"]
+
+
+class VerifyEmailTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser",
+ email="testuser@example.com",
+ password="testpass",
+ )
+ self.user.is_active = False
+ self.user.save()
+
+ # --- Sending / verifying happy paths -------------------------------------
+
+ def test_send_verification_email(self):
+ """A verification email is sent for an inactive user."""
+ response = ActivationMailManager.send_verification_link(self.user)
+ self.assertIsNotNone(response)
+ self.assertEqual(len(mail.outbox), 1)
+
+ def test_public_api_send_verification_email(self):
+ """
+ The documented public entry point ``send_verification_email(request, form)``
+ must exist, be importable from the package root, save the user as
+ inactive, and send exactly one email.
+ """
+ self.assertIs(verify_email.send_verification_email, send_verification_email)
+
+ request = RequestFactory().post("/signup/")
+ form = SignupForm(data={"username": "fresh", "email": "fresh@example.com"})
+ self.assertTrue(form.is_valid())
+
+ inactive_user = send_verification_email(request, form)
+
+ self.assertFalse(inactive_user.is_active)
+ self.assertEqual(inactive_user.email, "fresh@example.com")
+ self.assertEqual(len(mail.outbox), 1)
+
+ def test_verification_view_by_token_and_email(self):
+ user_token = TokenManager().generate_token_for_user(self.user)
+ user_email = SafeURL.perform_encoding(self.user.email)
+ response = self.client.get(
+ reverse("verify-email", args=[user_email, user_token])
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_verification_link(self):
+ user_token = TokenManager().generate_token_for_user(self.user)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+ resp = self.client.get(f"http://testserver{link}")
+ self.assertEqual(resp.status_code, 200)
+
+ def test_activation_activates_user_and_sets_last_login(self):
+ user_token = TokenManager().generate_token_for_user(self.user)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+ self.client.get(f"http://testserver{link}")
+
+ self.user.refresh_from_db()
+ self.assertTrue(self.user.is_active)
+ self.assertIsNotNone(self.user.last_login)
+
+ def test_used_link_cannot_be_replayed(self):
+ """Once a link activates the account, the same link no longer works."""
+ user_token = TokenManager().generate_token_for_user(self.user)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+
+ first = self.client.get(f"http://testserver{link}")
+ self.assertEqual(first.status_code, 200)
+
+ second = self.client.get(f"http://testserver{link}")
+ self.assertEqual(second.status_code, 401)
+
+ # --- Token expiry --------------------------------------------------------
+
+ def test_process(self):
+ user_token = TokenManager().generate_token_for_user(self.user)
+ time.sleep(1)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+ resp = self.client.get(f"http://testserver{link}")
+ self.assertEqual(resp.status_code, 200)
+
+ @override_settings(EXPIRE_AFTER="1s")
+ def test_timestamp_invalid_link(self):
+ """With EXPIRE_AFTER set, a stale link is rejected."""
+ user_token = TokenManager().generate_token_for_user(self.user)
+ time.sleep(3)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+ resp = self.client.get(f"http://testserver{link}")
+ self.assertEqual(resp.status_code, 401)
+
+ def test_timestamp_valid_link(self):
+ """Without EXPIRE_AFTER, the link stays valid (until used)."""
+ user_token = TokenManager().generate_token_for_user(self.user)
+ time.sleep(3)
+ link = ActivationLinkManager.generate_link(user_token, self.user.email)
+ resp = self.client.get(f"http://testserver{link}")
+ self.assertEqual(resp.status_code, 200)
+
+ # --- Token lookup with duplicate emails ----------------------------------
+
+ def test_get_user_by_token_with_duplicate_emails(self):
+ """
+ Django's default user model does not enforce unique emails. The token
+ must still resolve to the correct user even when another user shares the
+ same address (regression test for the early-return loop bug).
+ """
+ other = User.objects.create_user(
+ username="other", email=self.user.email, password="x"
+ )
+ other.is_active = False
+ other.save()
+
+ raw_token = TokenManager().generate_token_for_user(other, get_url_encoded=False)
+ resolved = TokenManager.get_user_by_token(self.user.email, raw_token)
+ self.assertEqual(resolved.pk, other.pk)
+
+ def test_get_user_by_token_invalid(self):
+ with self.assertRaises(InvalidToken):
+ TokenManager.get_user_by_token(self.user.email, "not-a-real-token")
+ # InvalidToken is part of the public error hierarchy.
+ self.assertTrue(issubclass(InvalidToken, VerifyEmailError))
+
+ def test_already_active_account_is_not_reactivated(self):
+ """
+ An already-active account presenting a still-valid token must be refused
+ (defense-in-depth), not silently re-activated.
+ """
+ # Active user whose token still validates because last_login is None.
+ active = User.objects.create_user(
+ username="already", email="already@example.com", password="x"
+ )
+ active.is_active = True
+ active.last_login = None
+ active.save()
+
+ token = TokenManager().generate_token_for_user(active)
+ email = SafeURL.perform_encoding(active.email)
+
+ with self.assertRaises(UserAlreadyActive):
+ UserActivationProcess.activate_user(email, token)
+
+ active.refresh_from_db()
+ self.assertIsNone(active.last_login) # untouched
+
+
+class RequestNewLinkEnumerationTests(TestCase):
+ """The public resend form must not reveal whether an email is registered."""
+
+ def setUp(self):
+ self.url = reverse("request-new-link-from-email")
+ self.pending = User.objects.create_user(
+ username="pending", email="pending@example.com", password="x"
+ )
+ self.pending.is_active = False
+ self.pending.save()
+
+ self.active = User.objects.create_user(
+ username="active", email="active@example.com", password="x"
+ )
+
+ def _post(self, email):
+ return self.client.post(self.url, data={"email": email})
+
+ def test_pending_user_gets_email(self):
+ resp = self._post("pending@example.com")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(len(mail.outbox), 1)
+
+ def test_unknown_email_same_response_no_mail(self):
+ resp = self._post("nobody@example.com")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(len(mail.outbox), 0)
+
+ def test_active_user_same_response_no_mail(self):
+ resp = self._post("active@example.com")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(len(mail.outbox), 0)
+
+ def test_responses_are_indistinguishable(self):
+ """Body for unknown vs. active vs. pending must be identical."""
+ bodies = {
+ self._post(email).content
+ for email in ("pending@example.com", "active@example.com", "x@example.com")
+ }
+ self.assertEqual(len(bodies), 1)
+
+
+class LinkCounterTests(TestCase):
+ """The LinkCounter is created lazily (no global signal) and caps resends."""
+
+ def setUp(self):
+ self.url = reverse("request-new-link-from-email")
+ self.user = User.objects.create_user(
+ username="pending", email="pending@example.com", password="x"
+ )
+ self.user.is_active = False
+ self.user.save()
+
+ def test_no_counter_created_at_signup(self):
+ """Creating a user must NOT create a LinkCounter (signal removed)."""
+ from verify_email.models import LinkCounter
+
+ self.assertFalse(LinkCounter.objects.filter(requester=self.user).exists())
+
+ def test_counter_created_lazily_on_first_resend(self):
+ from verify_email.models import LinkCounter
+
+ self._resend()
+ counter = LinkCounter.objects.get(requester=self.user)
+ # sent_count starts at 1 (initial email) and is incremented once.
+ self.assertEqual(counter.sent_count, 2)
+
+ @override_settings(MAX_RETRIES=2)
+ def test_resend_limit_is_enforced(self):
+ # cap = MAX_RETRIES + 1 = 3; counter starts at 1 -> 2 resends allowed.
+ self.assertEqual(self._resend().status_code, 200)
+ self.assertEqual(self._resend().status_code, 200)
+ self.assertEqual(len(mail.outbox), 2)
+
+ # Third resend is over the limit: still a generic 200, but no new email.
+ self.assertEqual(self._resend().status_code, 200)
+ self.assertEqual(len(mail.outbox), 2)
+
+ def _resend(self):
+ return self.client.post(self.url, data={"email": self.user.email})
+
+
+class TemplateTests(TestCase):
+ """Templates use a namespaced base; the old name stays as a compat shim."""
+
+ def test_request_new_email_form_page_renders(self):
+ resp = self.client.get(reverse("request-new-link-from-email"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "