From 1c49ea633891eba06aad556ce597ea376e351e8d Mon Sep 17 00:00:00 2001
From: David Gamez Diaz <1192523+davidgamez@users.noreply.github.com>
Date: Wed, 29 Jul 2026 16:37:25 -0400
Subject: [PATCH] stable implementation of scope all for no-end user
authenticated case
---
.../brevo_notification_sender.py | 37 +-
.../impl/subscriptions_api_impl.py | 56 ++-
.../test_brevo_notification_sender.py | 45 ++
.../test_subscriptions_api_impl.py | 47 +++
docs/UserServiceAPI.yaml | 89 +++-
docs/notifications-subscription-flows.md | 393 ++++++++++++++++++
docs/notifications.md | 35 +-
7 files changed, 663 insertions(+), 39 deletions(-)
create mode 100644 docs/notifications-subscription-flows.md
diff --git a/api/src/shared/notifications/brevo_notification_sender.py b/api/src/shared/notifications/brevo_notification_sender.py
index bc352dc42..27de67002 100644
--- a/api/src/shared/notifications/brevo_notification_sender.py
+++ b/api/src/shared/notifications/brevo_notification_sender.py
@@ -59,6 +59,7 @@
from datetime import datetime, timezone
from html import escape as _html_escape
from typing import Any, Dict, List, Optional
+from urllib.parse import quote
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
@@ -130,16 +131,29 @@ def _feed_page_url(stable_id: Optional[str]) -> Optional[str]:
return f"{_website_url()}/feeds/{stable_id}" if stable_id else None
+# Website route backing the one-click unsubscribe link. It reads the subscription
+# id from the ``id`` query param and calls ``DELETE /v1/subscriptions/{id}``
+# server-side (see docs/notifications-subscription-flows.md).
+_UNSUBSCRIBE_PATH = "/notifications/unsubscribe"
+
+
def _subscriptions_management_url() -> str:
- """Link to the subscriptions management page.
+ """Link to the account page where a signed-in user manages their subscriptions
+ (the "Manage subscriptions" footer link)."""
+ return f"{_website_url()}/account/notifications"
+
- Used for both the "Manage subscriptions" and "Unsubscribe" footer links —
- there is no one-click unsubscribe endpoint yet (see docs/notifications.md
- "Future Work"), so "Unsubscribe" currently points here too rather than to
- a dead link.
- TODO: implement a one-click unsubscribe endpoint and update the footer link to point there.
+def _unsubscribe_url(subscription_id: Optional[str]) -> Optional[str]:
+ """One-click unsubscribe link for a specific subscription, keyed on its id.
+
+ The id is the subscription's own UUID (``notification_subscription.id``) — the
+ capability the unsubscribe page passes to ``DELETE /v1/subscriptions/{id}``.
+ Returns None when no id is available, so the footer omits the "Unsubscribe"
+ link rather than pointing at an incomplete URL.
"""
- return f"{_website_url()}/account/notifications"
+ if not subscription_id:
+ return None
+ return f"{_website_url()}{_UNSUBSCRIBE_PATH}?id={quote(str(subscription_id), safe='')}"
# Brevo's transactional email API allows up to 1000 requests/second. Default
@@ -314,6 +328,7 @@ def build_params_feed_url_updated(events: List, subscription):
return {
"event_count": len(events),
"subscription_id": subscription.id,
+ "unsubscribe_url": _unsubscribe_url(subscription.id),
"events": [_event_row_dict(e) for e in events],
}
@@ -323,6 +338,7 @@ def build_params_admin_event_summary(events: List, subscription):
return {
"event_count": len(events),
"subscription_id": subscription.id,
+ "unsubscribe_url": _unsubscribe_url(subscription.id),
"summary": event_payload(summary_event) if summary_event else {},
}
@@ -345,8 +361,11 @@ def _subscription_footer_context(subscription) -> Dict[str, str]:
(e.g. a template rendered outside the subscription-driven send path)."""
if subscription is None:
return {}
- url = _subscriptions_management_url()
- return {"subscriptions_url": url, "unsubscribe_url": url}
+ context = {"subscriptions_url": _subscriptions_management_url()}
+ unsubscribe_url = _unsubscribe_url(subscription.id)
+ if unsubscribe_url:
+ context["unsubscribe_url"] = unsubscribe_url
+ return context
def build_single_html(event, subscription=None) -> str:
diff --git a/api/src/user_service/impl/subscriptions_api_impl.py b/api/src/user_service/impl/subscriptions_api_impl.py
index 83c010767..c87a2b775 100644
--- a/api/src/user_service/impl/subscriptions_api_impl.py
+++ b/api/src/user_service/impl/subscriptions_api_impl.py
@@ -14,6 +14,8 @@
# limitations under the License.
#
+from typing import Final
+
from fastapi import HTTPException
from shared.database.users_database import with_users_db_session
@@ -29,11 +31,15 @@
from user_service_gen.apis.subscriptions_api_base import BaseSubscriptionsApi
from user_service_gen.models.notification_subscription import NotificationSubscription
+# Values of the ?scope query parameter on DELETE /v1/subscriptions/{id}.
+SCOPE_ALL: Final = "all"
+
class SubscriptionsApiImpl(BaseSubscriptionsApi):
- """Public, unauthenticated subscription management.
+ """Subscription management keyed on the subscription ID, with no end-user auth.
- The subscription UUID is the access capability
+ The subscription UUID (``notification_subscription.id``) is the access
+ capability; these methods never read the request context.
"""
@with_users_db_session
@@ -44,31 +50,61 @@ def get_subscription(self, id: str, db_session=None) -> NotificationSubscription
return NotificationSubscriptionImpl.from_orm(sub)
@with_users_db_session
- def delete_subscription(self, id: str, db_session=None) -> None:
- """Removes a subscription by ID.
+ def delete_subscription(self, id: str, scope: str = None, db_session=None) -> None:
+ """Unsubscribe by subscription ID.
- The announcements subscription cannot be deleted; it is disabled instead.
+ ``scope='all'`` uses ``id`` only to resolve the owning user and
+ unsubscribes that user from every notification type they hold. Any other
+ value (the default ``'one'``) affects just the subscription identified by
+ ``id``.
+
+ In both cases the ``api.announcements`` subscription is never deleted, only
+ disabled (and its Brevo contact + opt-in flag are cleared); every other
+ type is hard-deleted.
"""
sub = db_session.get(NotificationSubscriptionOrm, id)
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found.")
+ if scope == SCOPE_ALL:
+ subscriptions = (
+ db_session.query(NotificationSubscriptionOrm)
+ .filter(NotificationSubscriptionOrm.user_id == sub.user_id)
+ .all()
+ )
+ # Keep the whole batch in one transaction: release_connection_before_brevo
+ # stays False so a Brevo failure on the announcements row rolls back every
+ # deletion in this call instead of leaving the user half-unsubscribed.
+ for subscription in subscriptions:
+ self._unsubscribe(db_session, subscription, release_connection_before_brevo=False)
+ else:
+ # Single unsubscribe: only reads happened above, so the announcements
+ # path can commit and release the pooled connection before the
+ # (possibly slow) Brevo call.
+ self._unsubscribe(db_session, sub, release_connection_before_brevo=True)
+
+ db_session.flush()
+
+ @staticmethod
+ def _unsubscribe(
+ db_session,
+ sub: NotificationSubscriptionOrm,
+ *,
+ release_connection_before_brevo: bool,
+ ) -> None:
+ """Disable-and-sync an ``api.announcements`` subscription, or hard-delete any other type."""
if sub.notification_type_id == ANNOUNCEMENTS_NOTIFICATION_TYPE_ID:
user = db_session.get(AppUser, sub.user_id)
if user is not None:
- # release_connection_before_brevo=True: only reads happened above,
- # so committing just returns the pooled connection before the
- # (possibly slow) Brevo call. Also clears the app_user opt-in flag.
set_announcements_optin(
db_session,
user,
subscribe=False,
subscription=sub,
- release_connection_before_brevo=True,
+ release_connection_before_brevo=release_connection_before_brevo,
)
else:
# Orphaned subscription (no owning user): just disable it.
sub.active = False
else:
db_session.delete(sub)
- db_session.flush()
diff --git a/api/tests/unittest/test_brevo_notification_sender.py b/api/tests/unittest/test_brevo_notification_sender.py
index e6899a359..009a0762f 100644
--- a/api/tests/unittest/test_brevo_notification_sender.py
+++ b/api/tests/unittest/test_brevo_notification_sender.py
@@ -41,6 +41,7 @@
target_feed,
_int_env,
_send,
+ _unsubscribe_url,
)
from shared.notifications.notification_constants import (
FeedUrlUpdateType,
@@ -202,6 +203,7 @@ def test_build_params_feed_url_updated():
params = build_params_feed_url_updated([event], _SUBSCRIPTION)
assert params["event_count"] == 1
assert params["subscription_id"] == "sub-1"
+ assert params["unsubscribe_url"] == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"
item = params["events"][0]
assert item["feed_stable_id"] == "mdb-1"
assert item["target_feed_stable_id"] == "mdb-2"
@@ -231,12 +233,32 @@ def test_build_params_admin_event_summary_with_and_without_events():
params = build_params_admin_event_summary([event], _SUBSCRIPTION)
assert params["event_count"] == 1
assert params["summary"] == {"emails_sent": 5}
+ assert params["unsubscribe_url"] == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"
empty = build_params_admin_event_summary([], _SUBSCRIPTION)
assert empty["event_count"] == 0
assert empty["summary"] == {}
+# ---------------------------------------------------------------------------
+# Unsubscribe link
+# ---------------------------------------------------------------------------
+
+
+def test_unsubscribe_url_builds_capability_link():
+ assert _unsubscribe_url("sub-1") == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"
+
+
+def test_unsubscribe_url_none_when_no_id():
+ assert _unsubscribe_url(None) is None
+ assert _unsubscribe_url("") is None
+
+
+def test_unsubscribe_url_encodes_id_and_respects_website_env(monkeypatch):
+ monkeypatch.setenv("MOBILITY_DATABASE_WEBSITE_URL", "https://example.test/")
+ assert _unsubscribe_url("a/b c") == "https://example.test/notifications/unsubscribe?id=a%2Fb%20c"
+
+
def test_build_params_by_notification_dispatch_and_unsupported():
feed_event = _event()
assert "events" in build_params_by_notification(NotificationTypeId.FEED_URL_UPDATED, [feed_event], _SUBSCRIPTION)
@@ -276,6 +298,29 @@ def test_build_single_html_redirected_and_replaced():
assert "https://old" in html
+def test_build_single_html_footer_has_manage_and_one_click_unsubscribe_links():
+ event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
+ html = build_single_html(event, _SUBSCRIPTION)
+ # "Manage subscriptions" points at the account page; "Unsubscribe" is the
+ # one-click capability link keyed on the subscription id.
+ assert 'href="https://mobilitydatabase.org/account/notifications"' in html
+ assert 'href="https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"' in html
+ assert "Unsubscribe" in html
+
+
+def test_build_single_html_without_subscription_has_no_footer_links():
+ event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
+ html = build_single_html(event)
+ assert "/notifications/unsubscribe" not in html
+ assert "/account/notifications" not in html
+
+
+def test_build_digest_html_footer_has_one_click_unsubscribe_link():
+ event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
+ html = build_digest_html([event], _SUBSCRIPTION)
+ assert 'href="https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"' in html
+
+
def test_build_single_html_url_replaced_shows_single_view_feed_button():
"""A plain URL update (one feed, not deprecated) must show only the single
"View Feed" CTA button — not the two-button deprecated/redirected layout."""
diff --git a/api/tests/unittest/user_service/test_subscriptions_api_impl.py b/api/tests/unittest/user_service/test_subscriptions_api_impl.py
index a860a7df4..88e4c95e6 100644
--- a/api/tests/unittest/user_service/test_subscriptions_api_impl.py
+++ b/api/tests/unittest/user_service/test_subscriptions_api_impl.py
@@ -115,6 +115,53 @@ def test_delete_missing_returns_404(self):
self.assertEqual(ctx.exception.status_code, 404)
self.mock_session.delete.assert_not_called()
+ def _wire_scope_all(self, subs, user):
+ """Point the initial get() at the first sub, AppUser lookups at ``user``, and the
+ 'all subscriptions for this user' query at ``subs``."""
+
+ def _get(model, key):
+ return subs[0] if model is NotificationSubscriptionOrm else user
+
+ self.mock_session.get.side_effect = _get
+ self.mock_session.query.return_value.filter.return_value.all.return_value = subs
+
+ def test_delete_all_deletes_normal_and_disables_announcement(self):
+ feed_sub = _make_sub(id="sub-feed", notification_type_id="feed.url_updated")
+ rt_sub = _make_sub(id="sub-rt", notification_type_id="gtfs_rt.feed_down")
+ ann_sub = _make_sub(id="sub-ann", notification_type_id="api.announcements")
+ self._wire_scope_all([feed_sub, rt_sub, ann_sub], _make_user())
+
+ with patch.object(helpers, "remove_contact_from_list") as rem, patch.object(
+ helpers, "get_announcements_list_id", return_value=42
+ ):
+ self.api.delete_subscription("sub-feed", scope="all", db_session=self.mock_session)
+
+ # Every non-announcement subscription is hard-deleted.
+ deleted = {c.args[0] for c in self.mock_session.delete.call_args_list}
+ self.assertEqual(deleted, {feed_sub, rt_sub})
+ # The announcement subscription is disabled (never deleted) and removed from Brevo.
+ self.assertNotIn(ann_sub, deleted)
+ self.assertFalse(ann_sub.active)
+ rem.assert_called_once_with("user@example.com", 42)
+
+ def test_delete_all_without_announcement_skips_brevo(self):
+ feed_sub = _make_sub(id="sub-feed", notification_type_id="feed.url_updated")
+ self._wire_scope_all([feed_sub], _make_user())
+
+ with patch.object(helpers, "remove_contact_from_list") as rem:
+ self.api.delete_subscription("sub-feed", scope="all", db_session=self.mock_session)
+
+ self.mock_session.delete.assert_called_once_with(feed_sub)
+ rem.assert_not_called()
+
+ def test_delete_all_missing_returns_404(self):
+ self.mock_session.get.return_value = None
+ with self.assertRaises(HTTPException) as ctx:
+ self.api.delete_subscription("missing", scope="all", db_session=self.mock_session)
+ self.assertEqual(ctx.exception.status_code, 404)
+ self.mock_session.query.assert_not_called()
+ self.mock_session.delete.assert_not_called()
+
def test_delete_announcement_brevo_failure_502(self):
import sib_api_v3_sdk
diff --git a/docs/UserServiceAPI.yaml b/docs/UserServiceAPI.yaml
index 3a60e9691..97fda9209 100644
--- a/docs/UserServiceAPI.yaml
+++ b/docs/UserServiceAPI.yaml
@@ -30,9 +30,19 @@ tags:
- name: "users"
description: "User profile management"
- name: "notifications"
- description: "Notification subscriptions"
+ description: "Notification catalog — the notification types a user can subscribe to."
- name: "subscriptions"
- description: "Public subscription management via subscription ID (e.g. email unsubscribe links)"
+ description: >-
+ Subscription management keyed on the subscription UUID, with **no end-user
+ authentication**. These endpoints perform no per-user identity check:
+ possession of the unguessable subscription `id` is the access capability,
+ which is what lets an email "unsubscribe" link work without the recipient
+ logging in. Note the transport is still authenticated — the call is made
+ server-side by an authorized IAP principal (a service account); the API is
+ not exposed to the open internet and every call must be authenticated.
+ Contrast with the per-user
+ `/v1/user/subscriptions*` endpoints (tag `users`), which additionally
+ require an end-user identity. See `docs/notifications-subscription-flows.md`.
paths:
/v1/user:
@@ -179,7 +189,7 @@ paths:
required: true
schema:
type: string
- description: Subscription ID.
+ description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID."
requestBody:
required: true
content:
@@ -216,7 +226,7 @@ paths:
required: true
schema:
type: string
- description: Subscription ID.
+ description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID."
responses:
"204":
description: Subscription deleted, or disabled for the announcements type.
@@ -229,9 +239,16 @@ paths:
/v1/subscriptions/{id}:
get:
- summary: Get a subscription by ID
+ summary: Get a subscription by ID (public)
description: |
Returns a single notification subscription identified by its ID.
+
+ **No end-user authentication** — the subscription UUID (`id`) is the
+ access capability. The call itself must still be made by an authorized IAP
+ principal (typically the website backend server-side) to render the
+ unsubscribe page, so the recipient can see which notification they are
+ about to unsubscribe from before confirming. Returns `404` for an
+ unknown/invalid ID.
operationId: getSubscription
tags:
- "subscriptions"
@@ -241,7 +258,7 @@ paths:
required: true
schema:
type: string
- description: Subscription ID.
+ description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID."
responses:
"200":
description: Subscription retrieved.
@@ -252,12 +269,36 @@ paths:
"404":
description: Subscription not found.
delete:
- summary: Delete or disable a subscription by ID
+ summary: Unsubscribe by subscription ID (public)
description: |
- Removes a notification subscription identified by its ID. The
- announcements subscription (`api.announcements`) cannot be deleted;
- calling this endpoint for it disables the subscription (sets it inactive)
- instead of removing it.
+ Unsubscribes using the subscription UUID (`id`). This backs the email
+ "unsubscribe" link — **no end-user authentication**, the UUID is the
+ capability. The call must still be made by an authorized IAP principal
+ (the website backend server-side); the anonymous recipient never calls
+ the API directly.
+
+ Behaviour depends on the notification type:
+ - **`api.announcements`** — never deleted; the subscription is *disabled*
+ (`active=false`), the contact is removed from the Brevo announcements
+ list, and `app_user.is_registered_to_receive_api_announcements` is
+ cleared. Kept idempotent so a repeated click still succeeds.
+ - **all other types** (e.g. `feed.url_updated`) — the subscription row is
+ hard-deleted; a second call therefore returns `404`.
+
+ Returns `404` for an unknown/invalid ID, and `502` if the email provider
+ (Brevo) cannot be reached while updating an announcements subscription.
+
+ The `id` embedded in an email is the subscription's own UUID
+ (`notification_subscription.id`) — mirrored onto the Brevo contact as the
+ `MDB_SUBSCRIPTION_ID` attribute — **not** the notification-type string
+ (`api.announcements`), which is shared by all recipients and is not a
+ valid subscription ID.
+
+ With `scope=all`, the subscription ID is used only to resolve the owning
+ user, and every subscription that user holds is unsubscribed (normal types
+ deleted; `api.announcements` disabled and removed from Brevo). With
+ `scope=one` (the default) only the single subscription identified by `id`
+ is affected.
operationId: deleteSubscription
tags:
- "subscriptions"
@@ -267,7 +308,15 @@ paths:
required: true
schema:
type: string
- description: Subscription ID.
+ description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID."
+ - name: scope
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [one, all]
+ default: one
+ description: "'one' (default) unsubscribes only the subscription identified by the path `id`. 'all' unsubscribes the owning user from every notification type, using the path `id` solely to identify that user."
responses:
"204":
description: Subscription deleted, or disabled for the announcements type.
@@ -355,13 +404,13 @@ components:
properties:
id:
type: string
- description: Unique identifier for the notification type (e.g. 'feed.published').
- example: "feed.published"
+ description: Unique identifier for the notification type (e.g. 'feed.url_updated', 'api.announcements').
+ example: "feed.url_updated"
description:
type: string
nullable: true
description: Human-readable description of the notification type.
- example: "Notifies when a new feed is published."
+ example: "Fired when a feed URL changes in-place or a feed is deprecated and redirected."
NotificationSubscription:
type: object
@@ -374,14 +423,18 @@ components:
properties:
id:
type: string
- description: Unique subscription ID (UUID v4).
+ description: >-
+ Unique subscription ID (UUID v4). This is the **capability token** used
+ by the public `/v1/subscriptions/{id}` endpoints and by email
+ unsubscribe links; for `api.announcements` it is mirrored onto the
+ Brevo contact as the `MDB_SUBSCRIPTION_ID` attribute.
user_id:
type: string
description: The ID of the subscribed user.
notification_id:
type: string
description: The notification type this subscription is for.
- example: "feed.published"
+ example: "feed.url_updated"
active:
type: boolean
description: Whether the subscription is currently active.
@@ -399,7 +452,7 @@ components:
notification_id:
type: string
description: The notification type to subscribe to.
- example: "feed.published"
+ example: "feed.url_updated"
UpdateNotificationSubscriptionRequest:
type: object
diff --git a/docs/notifications-subscription-flows.md b/docs/notifications-subscription-flows.md
new file mode 100644
index 000000000..af3154382
--- /dev/null
+++ b/docs/notifications-subscription-flows.md
@@ -0,0 +1,393 @@
+# Subscription & Unsubscribe Flows — End to End
+
+This document describes how a user subscribes to and unsubscribes from
+notifications across the website, the User Service API, the Users DB and Brevo.
+It covers the logged-in account-screen flow, the email-link unsubscribe flow
+(single subscription and all subscriptions), and the trust model that ties them
+together.
+
+For the delivery pipeline (dispatcher, Cloud Tasks, retries) see
+[notifications.md](./notifications.md). This document is about **who is
+subscribed to what** and how that state is changed — not how emails are sent.
+
+Throughout this document, "**the subscription ID**" always means
+`notification_subscription.id` — the subscription row's own primary key, a UUID
+string. It is distinct from the user ID (`app_user.id`) and from the notification
+type ID (`notification_type.id`, e.g. `feed.url_updated`).
+
+## Table of Contents
+
+1. [The two audiences](#the-two-audiences)
+2. [Authentication model](#authentication-model)
+3. [The core data model](#the-core-data-model)
+4. [The `api.announcements` special case — three representations](#the-apiannouncements-special-case--three-representations)
+5. [Endpoint inventory](#endpoint-inventory)
+6. [Flow 1 — Subscribe (account screen)](#flow-1--subscribe-account-screen)
+7. [Flow 2 — Unsubscribe / pause / resume (account screen)](#flow-2--unsubscribe--pause--resume-account-screen)
+8. [Flow 3 — Unsubscribe from an email link](#flow-3--unsubscribe-from-an-email-link)
+9. [Flow 4 — Unsubscribe from all](#flow-4--unsubscribe-from-all)
+10. [The capability-URL security model](#the-capability-url-security-model)
+11. [Integration points](#integration-points)
+
+---
+
+## The two audiences
+
+There are two entirely different entry points to subscription management, and
+they use two different sets of endpoints:
+
+| | **Logged-in user** (website account screen) | **No end-user login** (email unsubscribe link) |
+|---|---|---|
+| Who | A logged-in user managing their own notifications | Anyone who received an email and clicked "unsubscribe" — no account, no login |
+| End-user identity | Firebase / Bearer → `x-mdb-user-context` → `request_context.user_id` | None — the subscription ID in the URL scopes the action instead |
+| Who calls the API | The website backend, server-side, on the user's behalf | The website backend, server-side, anonymously |
+| Endpoints | `/v1/user/subscriptions*` (tag `users`) | `/v1/subscriptions/{id}` (tag `subscriptions`) |
+| Can do | list, create, pause / resume, delete — for **their own** subscriptions only | fetch one, delete one, delete all — by subscription ID |
+
+Keeping these separate is deliberate: the authenticated endpoints enforce
+per-user ownership; the capability endpoints treat the unguessable subscription
+ID as a bearer capability (the standard email-unsubscribe pattern).
+
+---
+
+## Authentication model
+
+"Unauthenticated" here refers to the **end-user**, not the API call. The
+Feeds / User Service API has **no open-internet endpoints**: every call must be
+authenticated as an **authorized IAP principal** (a service account granted IAP
+access). The website backend makes the calls server-side as such a principal;
+the browser never calls the API directly.
+
+- **Logged-in flows** — the backend calls the API with its IAP principal
+ credential **plus** an `x-mdb-user-context` JWT (HS256, `S2S_JWT_SECRET`)
+ identifying the end-user (`api/src/middleware/request_context.py`).
+- **Email-link unsubscribe flow** — the end-user is anonymous, so there is **no**
+ `x-mdb-user-context`. The backend still authenticates to IAP as an authorized
+ principal and forwards the subscription ID. The `subscriptions` endpoints
+ ignore user identity entirely and act on the subscription ID alone.
+
+Consequently the `subscriptions` endpoints stay behind IAP like every other
+endpoint, reached only by an authorized IAP principal; they are not exposed
+unauthenticated at the edge.
+
+---
+
+## The core data model
+
+Source of truth: `notification_subscription` in the **Users DB**
+(`api/src/shared/users_database_gen/sqlacodegen_models.py`).
+
+```
+notification_subscription
+ id TEXT PK ← the subscription ID: a UUID string
+ (generate_unique_id()). This is the value that
+ appears in email unsubscribe links.
+ user_id TEXT FK → app_user.id (ON DELETE CASCADE)
+ notification_type_id TEXT FK → notification_type.id e.g. 'feed.url_updated', 'api.announcements'
+ active BOOL default true ("paused" == active=false)
+ cadence TEXT default 'weekly' immediate | daily | weekly
+ digest BOOL default true
+ filter_params JSONB nullable
+ created_at / active_since timestamps
+```
+
+- One row per `(user, notification_type)`. Idempotency is enforced in code
+ (there is no DB unique constraint on `(user_id, notification_type_id)`).
+- **The subscription ID (`id`) is the capability token.** It is a UUID and is the
+ only value an anonymous caller presents to unsubscribe. It is mirrored into
+ Brevo (see next section) so an email can embed it.
+
+---
+
+## The `api.announcements` special case — three representations
+
+`api.announcements` is **not** delivered by the in-house dispatcher (the batch
+planner deliberately skips it — see
+[notifications.md](./notifications.md#dispatcher-tasks-cloud-tasks-fan-out)). It
+is delivered by **Brevo campaigns** to a Brevo contact list. As a result, an
+announcements opt-in is stored in **three places that must always agree**:
+
+```mermaid
+flowchart LR
+ subgraph UsersDB["Users DB"]
+ SUB["notification_subscription row
(type = api.announcements)
active = true/false"]
+ FLAG["app_user.is_registered_to_
receive_api_announcements
true/false"]
+ end
+ BREVO["Brevo contact list
member? + MDB_SUBSCRIPTION_ID attr"]
+
+ SUB <-. "kept in sync by
set_announcements_optin()" .-> FLAG
+ SUB <-. "add_/remove_contact_to_list()" .-> BREVO
+```
+
+The single function that reconciles all three is `set_announcements_optin()` in
+`api/src/user_service/impl/subscription_helpers.py`. It is idempotent:
+
+- **subscribe** → create / reactivate the row, `add_contact_to_list(...)` with the
+ contact attribute **`MDB_SUBSCRIPTION_ID` = the subscription ID**, set the flag
+ `true`. The whole change is atomic — if Brevo fails the row and flag roll back.
+- **unsubscribe** → `remove_contact_from_list(...)`, set `active = false`, set the
+ flag `false`. The announcements row is never hard-deleted, only disabled.
+
+The Brevo list ID is **not** in the DB — it comes from the
+`BREVO_API_ANNOUNCEMENTS_LIST_ID` env var (`get_announcements_list_id()`).
+
+The value an email must carry is the **subscription ID**
+(`notification_subscription.id`), surfaced on the Brevo contact as the
+`MDB_SUBSCRIPTION_ID` attribute — **not** the notification type string
+`api.announcements`, which is shared by every recipient and is not a valid
+subscription ID. Because `add_contact_to_list` writes `MDB_SUBSCRIPTION_ID` onto
+every announcements contact (and `migrate_firebase_users` backfills it), each
+Brevo contact carries its own subscription ID, and the campaign template builds
+the link with the merge tag, e.g.
+`…/notifications/unsubscribe?id={{ contact.MDB_SUBSCRIPTION_ID }}`.
+
+For all **other** notification types (`feed.url_updated`, …) there is no Brevo
+list and no flag — only the DB row matters, and delete is a real delete.
+
+---
+
+## Endpoint inventory
+
+All paths are under the User Service API (`docs/UserServiceAPI.yaml`).
+Implementations live in `api/src/user_service/impl/`. In every path, `{id}` is
+the **subscription ID** (`notification_subscription.id`, a UUID).
+
+### Authenticated — tag `users` (end-user identity required)
+
+| Method & path | operationId | Purpose |
+|---|---|---|
+| `GET /v1/notifications` | `getNotifications` | List available notification types |
+| `GET /v1/user/subscriptions` | `getUserSubscriptions` | List the caller's subscriptions |
+| `POST /v1/user/subscriptions` | `createUserSubscription` | Subscribe the caller to a type |
+| `PATCH /v1/user/subscriptions/{id}` | `updateUserSubscription` | Toggle `active` (pause / resume) |
+| `DELETE /v1/user/subscriptions/{id}` | `deleteUserSubscription` | Unsubscribe (delete, or disable for announcements) |
+
+### Capability endpoints — tag `subscriptions` (subscription ID is the credential)
+
+| Method & path | operationId | Purpose |
+|---|---|---|
+| `GET /v1/subscriptions/{id}` | `getSubscription` | Fetch one subscription by ID (so the page can show what is being unsubscribed) |
+| `DELETE /v1/subscriptions/{id}` | `deleteSubscription` | Unsubscribe one (delete, or disable for announcements) |
+| `DELETE /v1/subscriptions/{id}?scope=all` | `deleteSubscription` | Unsubscribe the owning user from every notification type |
+
+---
+
+## Flow 1 — Subscribe (account screen)
+
+The user toggles a notification on in their account settings. The website backend
+calls the authenticated API on the user's behalf.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant U as User (browser)
+ participant W as Website backend
(IAP principal)
+ participant API as User Service API
+ participant DB as Users DB
+ participant BR as Brevo
+
+ U->>W: Enable "Feed URL updated"
+ W->>API: POST /v1/user/subscriptions
x-mdb-user-context:
{ notification_id: "feed.url_updated" }
+ API->>API: request_context → user_id (401 if none, 403 if guest)
+ API->>DB: validate type exists (400 if unknown)
+ API->>DB: upsert row (create or set active=true)
+ Note over API,BR: feed.url_updated → no Brevo call
+ API-->>W: 201 NotificationSubscription
+ W-->>U: Toggle shows "on"
+```
+
+For **`api.announcements`** the same `POST` is used with
+`notification_id: "api.announcements"`, but the impl routes through
+`set_announcements_optin(subscribe=True)`, which additionally adds the contact to
+the Brevo list (with `MDB_SUBSCRIPTION_ID` set to the new subscription ID) and
+sets the `app_user` flag — all in one atomic transaction.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant W as Website backend
+ participant API as User Service API
+ participant DB as Users DB
+ participant BR as Brevo
+
+ W->>API: POST /v1/user/subscriptions { notification_id: "api.announcements" }
+ API->>DB: create/reactivate row (active=true)
+ API->>BR: add_contact_to_list(email, list_id, MDB_SUBSCRIPTION_ID=row.id)
+ alt Brevo fails
+ BR-->>API: error
+ API->>DB: ROLLBACK (row + flag reverted)
+ API-->>W: 502 "Failed to sync subscription with email provider."
+ else Brevo ok
+ API->>DB: set app_user flag = true, COMMIT
+ API-->>W: 201 NotificationSubscription
+ end
+```
+
+The `createUserSubscription` request body accepts `notification_id`; `cadence`,
+`digest` and `filter_params` take their column defaults.
+
+---
+
+## Flow 2 — Unsubscribe / pause / resume (account screen)
+
+The account screen offers three actions; all map onto existing endpoints:
+
+| UI action | Endpoint | Effect |
+|---|---|---|
+| Pause | `PATCH /v1/user/subscriptions/{id}` `{active:false}` | row `active=false`; for announcements also removes from Brevo and clears the flag |
+| Resume | `PATCH /v1/user/subscriptions/{id}` `{active:true}` | row `active=true`; for announcements also re-adds to Brevo and sets the flag |
+| Unsubscribe | `DELETE /v1/user/subscriptions/{id}` | hard-delete the row; for announcements disable-not-delete plus Brevo removal |
+
+Every authenticated call resolves `user_id` from the end-user context and
+verifies the subscription is **owned by the caller** (`_get_owned_subscription`
+→ 404 if not found *or* not owned), so one user can never touch another's
+subscriptions. `{id}` is the subscription ID.
+
+---
+
+## Flow 3 — Unsubscribe from an email link
+
+The email footer contains a link carrying the recipient's own subscription ID.
+The end-user needs no login; the API call is made server-side by the website
+backend as an authorized IAP principal, not by the browser directly.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant E as Email (Brevo)
+ participant U as User (browser · anonymous)
+ participant W as Website backend
(IAP principal)
+ participant IAP as Google IAP
+ participant API as User Service API
+ participant DB as Users DB
+ participant BR as Brevo
+
+ E->>U: Unsubscribe link
/notifications/unsubscribe?id={{MDB_SUBSCRIPTION_ID}}
+ U->>W: Open page with ?id=
+ Note over W,API: All API calls carry an authorized
IAP principal's token.
No x-mdb-user-context (anonymous).
+ W->>IAP: GET /v1/subscriptions/{id} (principal token)
+ IAP->>API: forwards (IAP verified)
+ alt not found / bad ID
+ API-->>W: 404
+ W-->>U: "This link is invalid or already used"
+ else found
+ API-->>W: 200 { notification_id, active, … }
+ W-->>U: "Unsubscribe from ?" (confirm)
+ U->>W: Confirm
+ W->>IAP: DELETE /v1/subscriptions/{id} (principal token)
+ IAP->>API: forwards (IAP verified)
+ alt type == api.announcements
+ API->>DB: set active=false (never deleted)
+ API->>BR: remove_contact_from_list(email, list_id)
+ API->>DB: clear app_user flag
+ else other type
+ API->>DB: DELETE row
+ end
+ API-->>W: 204
+ W-->>U: "You've been unsubscribed"
+ end
+```
+
+Error states:
+
+| Situation | How it surfaces |
+|---|---|
+| invalid subscription ID | `GET` / `DELETE` returns **404** (`db_session.get` → None) |
+| already unsubscribed | announcements: `DELETE` is idempotent (Brevo removal treats 400/404 as a no-op); other types: the row is gone → **404** on a second click. The page treats 404 as "already unsubscribed", not a hard error |
+| email provider unreachable | Brevo unreachable during announcements removal → **502** |
+
+The page does a `GET` first so it can name the notification type and require an
+explicit confirm click before issuing the `DELETE` — this avoids mail-client
+link prefetch triggering an unsubscribe on plain page load.
+
+---
+
+## Flow 4 — Unsubscribe from all
+
+`DELETE /v1/subscriptions/{id}` removes one subscription. To unsubscribe the
+recipient from everything, the same capability is used with `scope=all`:
+
+```
+DELETE /v1/subscriptions/{id}?scope=all
+```
+
+`{id}` is any one of the user's subscription IDs (the one embedded in the email).
+The handler resolves `user_id` from that row, then unsubscribes the user from
+every type they hold: normal types are hard-deleted; `api.announcements` is
+routed through `set_announcements_optin(subscribe=False)` so Brevo membership and
+the opt-in flag stay consistent. No end-user identity is needed — the
+subscription ID is the capability that identifies the user.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant W as Website backend
(IAP principal)
+ participant API as User Service API
(behind IAP)
+ participant DB as Users DB
+ participant BR as Brevo
+
+ W->>API: DELETE /v1/subscriptions/{id}?scope=all
(principal token, anonymous end-user)
+ API->>DB: sub = get(id) (404 if missing)
+ API->>DB: subs = all rows where user_id = sub.user_id
+ loop each subscription
+ alt api.announcements
+ API->>DB: active=false + clear flag
+ API->>BR: remove_contact_from_list
+ else other type
+ API->>DB: DELETE row
+ end
+ end
+ API-->>W: 204
+```
+
+The default (`scope=one`, or the parameter omitted) unsubscribes only the single
+subscription identified by `{id}`.
+
+---
+
+## The capability-URL security model
+
+Security is defense in depth across two layers — transport and capability:
+
+**Transport (who can reach the API at all).** The API is behind Google IAP;
+every call must present an authorized IAP principal's token. The caller is the
+website backend acting as such a principal; the anonymous browser never talks to
+the API directly, it talks to the website route, which makes the server-to-server
+call. Open-internet enumeration of `/v1/subscriptions/{id}` is therefore not
+possible — a caller must go through the website route, which the web layer can
+rate-limit and shape.
+
+**Capability (what a given call may do).** The `subscriptions` endpoints perform
+no end-user identity check — by design, documented in the impl
+(`SubscriptionsApiImpl`: *"the subscription UUID is the access capability"*):
+
+- The subscription ID is a random UUID, unguessable and not enumerable.
+ Possessing it authorizes exactly one action: unsubscribing that subscription,
+ or (with `scope=all`) that user's set. This is the model email providers use.
+- No PII is required, and the response exposes only the subscription's own type
+ and status.
+
+Prefetch protection and abuse / rate limiting are handled at the web layer: the
+`/notifications/unsubscribe` page requires an explicit confirm click before
+issuing the `DELETE`, so a mail-client link prefetch cannot unsubscribe on plain
+page load. The load balancer's `policy_rate_limiting` applies to the
+IAP-principal traffic as a backstop.
+
+---
+
+## Integration points
+
+The moving parts that connect the API to the rest of the system:
+
+- **Brevo templates carry the subscription ID.** The `api.announcements` campaign
+ email builds its unsubscribe link from `{{ contact.MDB_SUBSCRIPTION_ID }}`;
+ transactional `feed.url_updated` emails
+ (`api/src/shared/notifications/templates/*.j2`) embed the row's subscription ID
+ the same way. Both resolve to `notification_subscription.id`.
+- **The website `/notifications/unsubscribe` route** reads `id` from the query
+ string and calls `GET` then `DELETE /v1/subscriptions/{id}` (or `?scope=all`)
+ server-side as an authorized IAP principal.
+- **IAP authorization.** The website backend's IAP principal is authorized to
+ call the API — the same access it uses for `/v1/user/*`. `/v1/subscriptions`
+ routes to the same IAP-protected backend, so no separate edge route or browser
+ CORS policy is required.
diff --git a/docs/notifications.md b/docs/notifications.md
index 287b30b52..ee542ee79 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -9,7 +9,8 @@
5. [Retry Strategy](#retry-strategy)
6. [Email Delivery — Brevo](#email-delivery--brevo)
7. [Admin Event Summary](#admin-event-summary)
-8. [Future Work](#future-work)
+8. [Subscription Management (subscribe / unsubscribe)](#subscription-management-subscribe--unsubscribe)
+9. [Future Work](#future-work)
---
@@ -228,9 +229,39 @@ monitor redelivery sees the run already `completed` and is a no-op.
Admin users subscribe with `notification_type_id='admin.event_summary'` and
`cadence='daily'` to receive these as a daily digest.
+## Subscription Management (subscribe / unsubscribe)
+
+Everything above is about **delivery** (turning events into emails). *Who is
+subscribed to what*, and how users subscribe and unsubscribe, is documented
+end-to-end — the logged-in account-screen flow, the email-link unsubscribe flow
+(single and all), and the trust model — in a dedicated guide:
+
+**[Subscription & Unsubscribe Flows — End to End](./notifications-subscription-flows.md)**
+
+Quick orientation:
+
+- **Two endpoint sets.** Authenticated, per-user CRUD under
+ `/v1/user/subscriptions*` (tag `users`); and capability endpoints
+ `/v1/subscriptions/{id}` (tag `subscriptions`), where the subscription ID
+ (`notification_subscription.id`, a UUID) is the access capability used by email
+ unsubscribe links. "No end-user login" still means the call is made server-side
+ by an authorized IAP principal — the API has no open-internet endpoints; every
+ call must be authenticated. See
+ [`docs/UserServiceAPI.yaml`](./UserServiceAPI.yaml).
+- **`api.announcements` is special.** As noted above it is delivered via Brevo,
+ not this dispatcher. An opt-in is kept in sync across **three**
+ representations — the `notification_subscription` row, Brevo list membership,
+ and `app_user.is_registered_to_receive_api_announcements` — by
+ `set_announcements_optin()` in
+ `api/src/user_service/impl/subscription_helpers.py`. Announcements
+ subscriptions are **disabled, never deleted**.
+- **The Brevo/DB link.** Each announcements contact carries the contact attribute
+ `MDB_SUBSCRIPTION_ID` = its `notification_subscription.id`. That subscription ID
+ (not the type string `api.announcements`) is what an unsubscribe link embeds,
+ e.g. `…/notifications/unsubscribe?id={{ contact.MDB_SUBSCRIPTION_ID }}`.
+
## Future Work
- **`immediate` cadence**: Architecture is fully implemented. To activate, deploy a Cloud Scheduler job calling `notifications_dispatch_batch` with `cadence='immediate'` at the desired frequency (e.g. every 15 minutes). No code changes needed.
- **Additional notification types**: Add a new `notification_type` row, then call `_emit(notification_type_id, event_subtype, source, feeds=[...], payload={...})` in `notification_event_service.py` — no schema changes needed (feeds go in `notification_event_feed`, everything else in `payload`). The dispatcher, delivery, and retry infrastructure is reused automatically. For non-`feed.url_updated` types, add a Brevo subject/template mapping and a `build_params_*` / HTML renderer in `brevo_notification_sender.py`.
- **Operations API endpoint**: `GET /notifications/events` (paginated, filterable by type/date/source) for ops visibility into queued events. Belongs in the operations API, not the public API.
-- **Unsubscribe link**: Pass `subscription_id` in Brevo template params; build a one-click unsubscribe endpoint that sets `notification_subscription.active = false`.