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`.