Skip to content

R1.5 Preference categories and double opt-in - #19

Merged
alexeygrigorev merged 2 commits into
mainfrom
r1.5-preference-categories
Sep 8, 2026
Merged

R1.5 Preference categories and double opt-in#19
alexeygrigorev merged 2 commits into
mainfrom
r1.5-preference-categories

Conversation

@alexeygrigorev

Copy link
Copy Markdown
Member

Implements R1.5 (preference categories and double opt-in) from the community-base unification plan.

What changed

  1. Canonical category vocabulary in mailing/services/categories.py: newsletter, events, courses, product, transactional. A canonical name is stored as the CategoryPreference tag, so send-time suppression works through the same tag path as free-form category_tag values. The transactional category is always on.
  2. POST /api/subscriptions/subscribe and POST /api/subscriptions/unsubscribe accept an optional category. Subscribe enables the canonical preference, unsubscribe disables it (updated_reason records the reason or subscribe/unsubscribe). Unknown values are rejected with 400 {"category": "unknown"}; opting out of transactional is rejected with 400 {"category": "transactional_cannot_be_disabled"}. Reading: the endpoints keep their existing subscription semantics and the category update is applied on top; requests without category behave exactly as before.
  3. Double opt-in, stateless (no new model, no migration): POST /api/subscriptions/request-verification (scope + category not transactional + template_key owned by the client, fails closed with 404 {"template_key": "not_found"} when missing, inactive, or foreign) enqueues one verification message through that template. Context carries confirm_url (built from the new SUBSCRIPTION_CONFIRM_BASE_URL setting, defaulting to PUBLIC_BASE_URL), verification_token, and category. The token is signed and expiring (48h, django.core.signing.TimestampSigner, salted) and carries only ids plus the category name, so no raw email appears in URLs, tokens, or logs. POST /api/subscriptions/confirm validates signature, expiry, and that the token was issued for the authenticated client, enables the preference (creating it when needed), and returns the resulting preference state; it is idempotent. Any invalid token is a single 400 {"token": "invalid"}.
  4. Send-time check: a transactional send tagged category_tag: "transactional" is never suppressed by the category check (even for globally unsubscribed contacts); all other suppressed sends now include "suppressed": true in the 409 error payload next to the existing code/message/reason fields.
  5. OpenAPI: API_DOC_PATHS, OPENAPI_SPEC paths and schemas, route_path_map, and the staff endpoint reference updated together for the two new endpoints; category documented on the subscribe/unsubscribe request schemas.
  6. docs/api.md: canonical category table, category parameter semantics, the double opt-in flow, and the suppressed send response.
  7. Branch merged origin/main (R1.3) mid-flight; conflicts were two import blocks in mailing/services/transactional.py and mailing/views.py. The branch adds no migrations; the only migration in the merged tree is R1.3's 0026.

Verification

uv run --frozen ruff check . -> pass:

All checks passed!

uv run --frozen pytest -q (full suite, after merging R1.3) -> pass:

603 passed in 165.36s (0:02:45)

uv run --frozen python manage.py check -> System check identified no issues (0 silenced).
uv run --frozen python manage.py makemigrations --check --dry-run -> No changes detected.

New tests cover: subscribe/unsubscribe with and without category, backwards compatibility (no preference rows without category), unknown category rejected without partial mutation, transactional opt-out rejected, subscribe accepting category: "transactional", scope isolation (another client/audience preference untouched), canonical-category suppressed send reports suppressed: true, send with category_tag: "transactional" queued despite a globally unsubscribed contact, double opt-in happy path (request -> token -> confirm -> enabled, idempotent repeat), expired token rejected, tampered token rejected, confirm scoped to the issuing client, and request-verification failing closed for transactional/unknown categories and missing, unknown, foreign, inactive, or context-starved templates. The confirm URL in tests is asserted to contain no raw email.

Not run here, needs:

Sandbox verification from the issue, requiring this branch deployed to the sandbox:

  • subscribe with category=events
  • unsubscribe from events
  • send with category=events -> suppressed
  • send with category=transactional -> queued

Canonical category vocabulary (newsletter, events, courses, product,
transactional) stored as CategoryPreference tags. Subscribe and unsubscribe
accept an optional category to enable or disable the canonical preference;
transactional cannot be opted out. A transactional send tagged with the
transactional category is never suppressed, and suppressed category sends
report suppressed: true in the 409 payload. Double opt-in flows through
POST /api/subscriptions/request-verification and POST /api/subscriptions/confirm
using stateless signed expiring tokens; the verification message goes through a
client-owned transactional template with a confirm_url built from
SUBSCRIPTION_CONFIRM_BASE_URL. No new migrations.
…ories

# Conflicts:
#	mailing/services/transactional.py
#	mailing/views.py
@alexeygrigorev
alexeygrigorev merged commit 426a349 into main Sep 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant