` (shown as its label). Values for status and activity type are recorded
@@ -52,6 +53,7 @@ calendar text and are shown as recorded.
- **Subject**: "InTheKnow test notification" / "InTheKnow-testmelding".
- **Body**: states that this is a test and contains no changes, then gives a link to the notification settings
(`SettingsUrl`, FR-010).
+- **Footer**: the same as Changes, with the unsubscribe link (008).
- It has the unsubscribe headers.
## Public pages (`/n/…`)
diff --git a/specs/003-notification-channels/spec.md b/specs/003-notification-channels/spec.md
index 7db0266..555347d 100644
--- a/specs/003-notification-channels/spec.md
+++ b/specs/003-notification-channels/spec.md
@@ -19,7 +19,7 @@
- Q: When are changes sent, and how are low-priority changes handled? → A: One message per channel with everything that became due within a short collection window (default 5 minutes). Low-priority changes are included and marked.
- Q: Language? → A: Notifications are in English or Dutch, chosen per subscriber. The setting is available through the API now; the frontend will offer it later.
- Q: Consent? → A: Until accounts exist, an address must be confirmed before it receives anything but the confirmation email. The later account feature will not make an account valid before its address is confirmed, so an account's own address will count as confirmed.
-- Q: Unsubscribing? → A: Through the notification settings of the calendar in the later frontend, which requires login. Each email links, per calendar, to that calendar's notification settings page; there is no login-free unsubscribe page. The only login-free way out is the unsubscribe button of the mail client itself, which stops all emails to that address (kept because large mail providers expect it from senders of large volumes).
+- Q: Unsubscribing? → A: Through the notification settings of the calendar in the later frontend, which requires login. Each email links, per calendar, to that calendar's notification settings page; the login-free ways out are the unsubscribe button of the mail client itself, which stops all emails to that address (kept because large mail providers expect it from senders of large volumes), and, since 008, an unsubscribe link in the email footer that opens a one-button unsubscribe page for the same channel.
- Q: How does an email name a calendar, given that 001 stores no calendar name? → A: By the student's own label for that subscription, set through the API now and the frontend later. Without a label, the feed's own calendar name from its latest successful check is used, and the calendar's host when the feed has none.
- Q: In which time zone do emails show event and detection times? → A: In the subscriber's own time zone, used for all their notifications. It is set through the API now and in the account (frontend) later; the later account feature sets its default at account creation. Until then, a subscriber who has not set one gets the operator-configured default (Europe/Amsterdam).
- Q: How long are addresses of unconfirmed or unsubscribed channels kept? → A: A channel that has been *awaiting confirmation* or *unsubscribed* for 30 days is deleted automatically with its address. For "this wasn't me", only a one-way hash of the address is kept per subscriber, which blocks new confirmations to that address from that subscriber, also after the channel is removed.
@@ -252,7 +252,7 @@ After confirming their address, a student (or the operator, while setting up the
- **FR-028**: A subscriber MUST be able to add, list, pause, resume and remove their channels, request a new confirmation, send a test notification, turn each subscription's notifications on and off, set and clear each subscription's label, and view and set their language and time zone. Listing channels MUST show each channel's type, target, status, the reason when failing, and when it last received a message. Listing subscriptions MUST show whether notifications are on, the label, and the name used in emails.
- **FR-029**: Removing a channel MUST delete it with its target and its delivery records. Removing a subscriber's last subscription MUST NOT remove their channels, language or time zone.
- **FR-029a**: A channel that has been *awaiting confirmation* or *unsubscribed* for 30 days (counted from the later of entering that status and its latest confirmation email) MUST be deleted automatically, like a removal (FR-029). The "this wasn't me" block (FR-006) stays.
-- **FR-030**: Notification management MUST be available through the HTTP API from 001, under the same authentication. The confirmation and "this wasn't me" links and the mail client's unsubscribe button MUST be the only notification actions that work without it, and only for their own channel.
+- **FR-030**: Notification management MUST be available through the HTTP API from 001, under the same authentication. The confirmation and "this wasn't me" links, the mail client's unsubscribe button and the footer unsubscribe link (008) MUST be the only notification actions that work without it, and only for their own channel.
- **FR-030a**: The operator MUST be able to request, through the operator API of 001, the status of each channel type: whether it is configured, the outcome of its latest send attempt (success or failure, and the reason), the number of consecutive failed attempts and since when, and the number of messages waiting to be sent. It MUST NOT show addresses or other subscriber data. Every failed send attempt MUST be logged with its reason (within FR-031).
- **FR-031**: Logs MUST NOT contain full email addresses, confirmation or unsubscribe links or tokens, or email content.
- **FR-032**: Mail server credentials MUST be operator secrets, supplied like the other secrets of 001 and never stored in the repository.
@@ -278,7 +278,7 @@ After confirming their address, a student (or the operator, while setting up the
- **SC-001**: In an end-to-end test against a local mail capture, 100% of changes with outcome normal or low priority reach every active channel of their subscriber for calendars with notifications on, and 0 changes with outcome hidden, or of calendars with notifications off, are sent.
- **SC-002**: 0 emails other than the confirmation email are sent to an address that has not been confirmed.
-- **SC-003**: 100% of change emails link to the notification settings page of each calendar they list, 100% of non-confirmation emails support the mail client's unsubscribe button, and after it is used 0 further emails are sent to that channel. 0 links in emails change anything without login, except the confirmation and "this wasn't me" links in confirmation emails.
+- **SC-003**: 100% of change emails link to the notification settings page of each calendar they list, 100% of non-confirmation emails support the mail client's unsubscribe button, and after it is used 0 further emails are sent to that channel. 0 links in emails change anything without login, except the confirmation and "this wasn't me" links in confirmation emails and the footer unsubscribe link (008), each only through the button on the page it opens.
- **SC-004**: Across a test with repeated restarts while changes are pending, each change is delivered exactly once per channel.
- **SC-005**: After a mail server outage shorter than the retry window, 100% of pending changes are delivered after it recovers; 0 channels are marked failing because of the outage, and during the outage the operator status (FR-030a) shows email failing with its reason.
- **SC-006**: A change recorded on a calendar reaches a subscriber's inbox within 10 minutes with the default collection window, when the system and mail server are healthy and the send rate limit is not reached.
diff --git a/specs/007-branded-email-layout/contracts/email-html.md b/specs/007-branded-email-layout/contracts/email-html.md
index 0ac810d..fa8c1a3 100644
--- a/specs/007-branded-email-layout/contracts/email-html.md
+++ b/specs/007-branded-email-layout/contracts/email-html.md
@@ -34,9 +34,9 @@ inserted value is HTML-encoded. The plain-text part, subject, headers and recipi
| Kind | Body |
|---|---|
-| Changes | per calendar: `` name, settings text link; change rows separated by 1px #e3e3e3; if `NotListed > 0`, "and N more changes" in a #f7f7f7 box. Footer: `changes.footer`. |
+| Changes | per calendar: `` name, settings text link; change rows separated by 1px #e3e3e3; if `NotListed > 0`, "and N more changes" in a #f7f7f7 box. Footer: `changes.footer`, then a Muted (#5a5a5a) underlined link `page.unsubscribe.title` → `UnsubscribeUrl` (008). |
| Confirmation | intro paragraph; **button** `confirmation.confirm` → `ConfirmUrl`; text link `confirmation.decline` → `DeclineUrl`; `confirmation.expiry` in muted text. No footer. |
-| Test | `test.body`; text link `test.settings` → `SettingsUrl`. No footer. |
+| Test | `test.body`; text link `test.settings` → `SettingsUrl`. Footer: as Changes, with the unsubscribe link (008). |
| Account | body paragraph; if `Link`: **button** `account.{kind}.action`, then a muted line `account.linkFallback` + the full link. Footer: `account.footer`. |
## Change row
@@ -54,7 +54,8 @@ calendar names, are at most 200 bytes once encoded, the "…" included when shor
## Guarantees (checked by tests)
-- Text part, subject and headers byte-for-byte equal to the pre-feature snapshots (SC-004).
+- Text part, subject and headers byte-for-byte equal to the pre-feature snapshots (SC-004), except the Changes and Test
+ footer, which 008 changed (its snapshots were regenerated once).
- Every inserted value encoded (SC-005), for every kind.
- Every colour in the HTML is in the palette; every text pair ≥ 4.5:1 except the wordmark (SC-003).
- HTML of a changes email with `Notifier.Listed` long Dutch changes, one calendar each, with values that grow when
diff --git a/specs/008-email-unsubscribe-link/checklists/requirements.md b/specs/008-email-unsubscribe-link/checklists/requirements.md
new file mode 100644
index 0000000..7911226
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/checklists/requirements.md
@@ -0,0 +1,37 @@
+# Specification Quality Checklist: Unsubscribe link in the email footer
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-10-21
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- "HTML part" and "plain-text part" are named because both are views the recipient can see, not implementation
+ choices.
+- The spec amends 003 (clarification, FR-030, SC-003, contracts/messages.md) and 007 (contracts/email-html.md);
+ those documents are updated during implementation.
diff --git a/specs/008-email-unsubscribe-link/contracts/email-footer.md b/specs/008-email-unsubscribe-link/contracts/email-footer.md
new file mode 100644
index 0000000..f18a8b8
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/contracts/email-footer.md
@@ -0,0 +1,38 @@
+# Contract: Unsubscribe footer
+
+Amends the Changes and Test rows of [007 email-html.md](../../007-branded-email-layout/contracts/email-html.md) and
+the Changes/Test sections of [003 messages.md](../../003-notification-channels/contracts/messages.md).
+
+## Which emails
+
+| Kind | Footer | Unsubscribe link |
+|---|---|---|
+| Changes | `changes.footer` | yes |
+| Test | `changes.footer` (new) | yes |
+| Confirmation | none | no |
+| Account | `account.footer` | no |
+
+## Text part (after the body)
+
+```
+{changes.footer}
+
+{page.unsubscribe.title}: {UnsubscribeUrl}
+
+```
+
+## HTML part (below the card, in the existing 12px #5a5a5a footer block)
+
+```
+ {changes.footer, encoded}
+{page.unsubscribe.title, encoded}
+```
+
+## Guarantees (checked by tests)
+
+- The footer link's `href` equals the `List-Unsubscribe` header value without `<` `>` (FR-002), in both languages.
+- In both parts of Changes and Test, the only `/n/` URL is `UnsubscribeUrl`; no `/n/confirm` or `/n/decline` (SC-003).
+- Confirmation and Account emails contain no `/n/unsubscribe/` URL; their text snapshots are unchanged (FR-007, SC-005).
+- `GET /n/unsubscribe/{token}` leaves the channel `active`; only `POST` unsubscribes (FR-003; new assertion in `PublicPagesTests`, the code path is unchanged).
+- 007's "text part byte-for-byte equal to the snapshots" guarantee no longer holds for Changes and Test; their
+ snapshots are regenerated once by this feature.
diff --git a/specs/008-email-unsubscribe-link/data-model.md b/specs/008-email-unsubscribe-link/data-model.md
new file mode 100644
index 0000000..7d8f339
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/data-model.md
@@ -0,0 +1,13 @@
+# Data model: Unsubscribe link in the email footer
+
+No schema change and no new stored data.
+
+| Entity / value | Use in this feature | Change |
+|---|---|---|
+| `channel.unsubscribe_token` | already one per channel; builds the unsubscribe URL | none |
+| `channel.status` | `active` → `unsubscribed` only on POST to the page (003) | none |
+| `Message.UnsubscribeUrl` | now rendered in the footer of Changes and Test, besides the header | used in one more place |
+| `Texts` `changes.footer` (en, nl) | footer sentence, now mentions the link | text rewritten |
+| `Texts` `page.unsubscribe.title` (en, nl) | reused as the footer link label | none |
+
+Personal data: none added (Principle II).
diff --git a/specs/008-email-unsubscribe-link/plan.md b/specs/008-email-unsubscribe-link/plan.md
new file mode 100644
index 0000000..7b668df
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/plan.md
@@ -0,0 +1,107 @@
+# Implementation Plan: Unsubscribe link in the email footer
+
+**Branch**: `008-email-unsubscribe-link` (work currently on `main`) | **Date**: 2026-10-21 | **Spec**: [spec.md](./spec.md)
+
+**Input**: Feature specification from `/specs/008-email-unsubscribe-link/spec.md`
+
+## Summary
+
+Change and test emails get a footer link to the existing login-free unsubscribe page, the same URL as their
+`List-Unsubscribe` header (`Message.UnsubscribeUrl`). The page, its GET-shows/POST-acts split, the token and the
+channel states already exist (003); this feature only adds one footer line in `EmailChannel.Build`, rewrites the
+`changes.footer` text in English and Dutch, and updates the tests, snapshots and the 003/007 documents that said
+there was no such link.
+
+## Technical Context
+
+**Language/Version**: C# on .NET 10 (`global.json`)
+
+**Primary Dependencies**: BCL only; no new package
+
+**Storage**: N/A (no schema change; `channel.unsubscribe_token` is reused)
+
+**Testing**: xUnit v3 (`backend/InTheKnow.Tests`), text-part snapshots in `EmailSnapshots/`, Mailpit for a manual look
+
+**Target Platform**: the email apps from 007; the public `/n/unsubscribe/{token}` page in any browser
+
+**Project Type**: web application (backend change only)
+
+**Performance Goals**: N/A
+
+**Constraints**: the footer link uses `Muted` (#5a5a5a) on `Page` (#f0f0f0), a pair already checked ≥ 4.5:1 in
+`TextPairs`; GET on the page must stay side-effect free (FR-003, link scanners); no token or URL in logs (FR-008)
+
+**Scale/Scope**: 2 message kinds (Changes, Test), 2 languages, 1 changed text key
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+| Principle / rule | Status |
+|---|---|
+| I. Simplicity: smallest implementation, no speculative code | PASS: reuses the existing page, token and `UnsubscribeUrl`; one footer line in the renderer; link label reuses `page.unsubscribe.title` |
+| I. New dependency or layer justified | PASS: none added |
+| II. No new personal data | PASS: nothing stored; the token already exists per channel |
+| II. No secrets or deployment details in repo | PASS: tests and quickstart use `example.com`/`localhost` and Mailpit |
+| II. Trust boundary / state change | PASS: GET `/n/unsubscribe/{token}` only looks up the language; only POST unsubscribes (unchanged, `NotificationApi.cs`) |
+| II. Output encoded by default | PASS: label and URL go through the renderer's `E()` like every other link |
+| Tech: GPL-compatible, lockfiles | PASS: no new package |
+| Workflow: non-trivial logic has an automated check | PASS: per-kind footer/href assertions, unchanged account/confirmation snapshots, new PublicPagesTests assertion that GET on the unsubscribe page leaves the channel active |
+
+Post-design re-check: PASS (no dependency, layer, page, token or stored data added).
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/008-email-unsubscribe-link/
+├── plan.md
+├── research.md
+├── data-model.md
+├── quickstart.md
+├── contracts/email-footer.md
+└── tasks.md # /speckit-tasks
+```
+
+### Source Code (repository root)
+
+```text
+backend/InTheKnow/
+├── EmailChannel.cs # Build: Changes and Test add footer + unsubscribe link; Renderer: footer link line (Muted, underlined)
+└── Texts.cs # changes.footer rewritten (en, nl) to mention the link
+
+backend/InTheKnow.Tests/
+├── EmailChannelTests.cs # SettingsLinksAndNoActionLinks, TestEmail: footer + link; new href == List-Unsubscribe check
+├── PublicPagesTests.cs # + GET /n/unsubscribe/{token} leaves the channel active (SC-003, link scanners)
+└── EmailSnapshots/ # only changes.{en,nl}.txt and test.{en,nl}.txt change
+
+specs/003-notification-channels/spec.md # clarification "Unsubscribing?", FR-030, SC-003
+specs/003-notification-channels/contracts/messages.md # Changes footer + Links line; Test gets the footer
+specs/007-branded-email-layout/contracts/email-html.md # Changes/Test footer rows; text-part guarantee carve-out
+```
+
+**Structure Decision**: backend-only change inside `EmailChannel`; the public page, `Channels`, `Notifier`, the
+frontend and the database are untouched.
+
+## Ordering notes for tasks
+
+1. Footer sentence and link are separate ``s, sentence first, so `AssertFooterBelowCard` (regex on the first
+ footer `
`) keeps working unchanged.
+2. Do **not** reuse `Anchor()` for the footer link: it hardcodes `LinkColor`, and `(LinkColor, Page)` is not in
+ `TextPairs`, so the contrast test would pass while an unchecked pair ships. Use `Muted` + underline.
+3. Tests that break and must be rewritten, not deleted:
+ - `SettingsLinksAndNoActionLinks`: the comment "the only /n/ URL is the List-Unsubscribe header" and the
+ `"unsubscribe option to stop these notifications"` check. New rule (amended 003 SC-003): the only `/n/` URL in
+ either part is exactly `UnsubscribeUrl`; no `/n/confirm` or `/n/decline`.
+ - `TestEmail`: the URL list of the text part gains the unsubscribe URL; the "no footer" line flips to "footer
+ below the card".
+ - The Confirmation "no footer" assertion stays as it is (SC-005).
+4. Regenerate snapshots with `INTHEKNOW_UPDATE_SNAPSHOTS=1`; `git diff --stat backend/InTheKnow.Tests/EmailSnapshots/`
+ must show exactly the 4 changes/test files (proof of FR-007).
+5. Doc amendments last, from the spec's "Amendments to earlier specs" list.
+6. Leave the unrelated `.gitignore` change out of the feature's commits.
+
+## Complexity Tracking
+
+No violations.
diff --git a/specs/008-email-unsubscribe-link/quickstart.md b/specs/008-email-unsubscribe-link/quickstart.md
new file mode 100644
index 0000000..67da721
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/quickstart.md
@@ -0,0 +1,29 @@
+# Quickstart: validate the unsubscribe footer
+
+## Automated
+
+```bash
+cd backend && dotnet test
+```
+
+Expected: all green, including the footer/href assertions for Changes and Test ([contract](./contracts/email-footer.md)).
+
+Snapshots: after regenerating with `INTHEKNOW_UPDATE_SNAPSHOTS=1 dotnet test --filter TextPartMatchesSnapshot`,
+
+```bash
+git diff --stat backend/InTheKnow.Tests/EmailSnapshots/
+```
+
+must list exactly `changes.en.txt`, `changes.nl.txt`, `test.en.txt`, `test.nl.txt` (FR-007: no confirmation or
+account email changed).
+
+## Manual (Mailpit, from `compose.yaml`)
+
+1. Start the dev stack, sign in, add and confirm an email channel (Mailpit catches the confirmation).
+2. Send a test notification; in Mailpit check the HTML and Text tabs: footer sentence, then an underlined grey
+ "Unsubscribe" link / `Unsubscribe: http://localhost…/n/unsubscribe/…` line.
+3. Open the link: the one-button page shows; the channel is still *active* in the notifications settings (SC-003).
+4. Press the button: the page says done; the channel is *unsubscribed*. Press again via the back button: same
+ "done" page, no error.
+5. Record a calendar change: Mailpit receives nothing for that channel (SC-004).
+6. Switch the account language to Dutch and repeat step 2: footer text and "Afmelden" in Dutch.
diff --git a/specs/008-email-unsubscribe-link/research.md b/specs/008-email-unsubscribe-link/research.md
new file mode 100644
index 0000000..0499d4b
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/research.md
@@ -0,0 +1,52 @@
+# Research: Unsubscribe link in the email footer
+
+No open questions remained after reading the spec and the code; each item below records a decision.
+
+## R1. Which URL the footer links to
+
+- **Decision**: `Message.UnsubscribeUrl`, the value already put in `List-Unsubscribe` (`Notifier.Unsubscribe(token)`
+ → `{PublicBaseUrl}/n/unsubscribe/{token}`).
+- **Rationale**: FR-002 asks for the same page and channel; one value for both guarantees it.
+- **Alternatives**: a new footer-only token or page (rejected: FR-002 forbids it, and it adds storage).
+
+## R2. Link scanners (FR-003)
+
+- **Decision**: no change. `GET /n/unsubscribe/{token}` only calls `Channels.PageLanguageAsync` and renders a page with
+ a POST form; only `POST` calls `UnsubscribeAsync` (`NotificationApi.cs`).
+- **Rationale**: the split already exists for the mail client's button (003 research R8).
+- **Alternatives**: none needed. No test pins "GET leaves the channel active" for unsubscribe yet; one is added (SC-003).
+
+## R3. Already unsubscribed / removed channel
+
+- **Decision**: no change. A repeat POST skips `StopAsync` when the status is already `unsubscribed`
+ (`Channels.cs`), and an unknown token renders `NotFoundPage()`.
+
+## R4. Footer link style (FR-005)
+
+- **Decision**: `Muted` (#5a5a5a) text, `text-decoration:underline`, in the footer's 12px block, on its own `
`
+ after the footer sentence.
+- **Rationale**: the footer sits on `Page` (#f0f0f0); `(Muted, Page)` is already in `TextPairs`, so the existing
+ contrast test covers it. The existing `Anchor()` uses `LinkColor`, whose pair with `Page` is not declared.
+- **Alternatives**: `LinkColor` and add `(LinkColor, Page)` to `TextPairs` (rejected: FR-005 asks for grey footer
+ text); a button (rejected by the spec's assumptions).
+
+## R5. Text part
+
+- **Decision**: `{label}: {url}`, the format of `Renderer.Link`, after the footer sentence.
+- **Rationale**: the plain-text part shows the full address (FR-001) in the house format.
+
+## R6. Texts
+
+- **Decision**: rewrite `changes.footer` (en, nl) to mention the link and the mail client's option; reuse
+ `page.unsubscribe.title` ("Unsubscribe" / "Afmelden") as the link label. The test email uses `changes.footer` too,
+ since its reason is the same confirmed address.
+- **Proposed text**:
+ - en: "You receive this email because this address was confirmed for InTheKnow timetable notifications. To stop
+ these notifications to this address, use the link below or your mail client's unsubscribe option."
+ - nl: "Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de link
+ hieronder of de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen."
+- **Alternatives**: a new `changes.unsubscribe` label key (rejected: same words as the page title it leads to).
+
+## R7. Logs (FR-008)
+
+- **Decision**: no change. No new log call; the URL lives only in the message, as the header value already does.
diff --git a/specs/008-email-unsubscribe-link/spec.md b/specs/008-email-unsubscribe-link/spec.md
new file mode 100644
index 0000000..018e538
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/spec.md
@@ -0,0 +1,136 @@
+# Feature Specification: Unsubscribe link in the email footer
+
+**Feature Branch**: `008-email-unsubscribe-link`
+
+**Created**: 2026-10-21
+
+**Status**: Draft
+
+**Input**: User description: "Email: real unsubscribe link in the footer" (InTheKnow roadmap): "Add an actual
+unsubscribe link/button at the bottom of notification emails, instead of only the text 'Use your mail client's
+unsubscribe option to stop these notifications to this address.' So you can unsubscribe at the bottom of the e-mail
+or with your clients button."
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Unsubscribe from the bottom of a change email (Priority: P1)
+
+A student gets timetable change emails they no longer want at this address. Their mail app shows no unsubscribe
+button (many don't, or hide it), so they scroll to the bottom of the email, click "Unsubscribe", land on the
+existing one-button unsubscribe page, press the button, and get no further emails at that address.
+
+**Why this priority**: today the footer only tells them to find their mail client's option. When that option is
+missing, the only way out is logging in or marking the mail as spam, which hurts delivery for everyone.
+
+**Independent Test**: send a change email to an active channel, open it, follow the footer link, press the button
+on the page, then record new changes and verify nothing more is sent to that channel.
+
+**Acceptance Scenarios**:
+
+1. **Given** a change email, **When** it is opened in either the HTML or the plain-text view, **Then** the footer
+ contains a visible unsubscribe link in the recipient's language (English or Dutch).
+2. **Given** the footer link, **When** it is opened, **Then** the existing unsubscribe page is shown, and the
+ channel is still active.
+3. **Given** the unsubscribe page reached from the link, **When** the button is pressed, **Then** the channel
+ becomes *unsubscribed*, the page confirms it, and no further emails are sent to that channel.
+4. **Given** a change email, **When** the mail client's own unsubscribe button is used instead, **Then** it still
+ works exactly as before.
+
+---
+
+### User Story 2 - The same link in the test email (Priority: P2)
+
+A subscriber sends themselves a test notification. The test email already carries the mail client's unsubscribe
+option. It now also gets the same footer with the unsubscribe link, so every email that can be unsubscribed from
+says how at the bottom.
+
+**Why this priority**: test emails are rare and are requested by the subscriber themself, so they matter less than
+change emails. Treating them the same keeps the rule simple.
+
+**Independent Test**: send a test email, check that its footer has the link, and that the link leads to the
+unsubscribe page for that channel.
+
+**Acceptance Scenarios**:
+
+1. **Given** a test email, **When** it is opened, **Then** it shows a footer with the unsubscribe link, in both
+ the HTML and the plain-text view.
+
+---
+
+### Edge Cases
+
+- **Link scanners**: school and company mail systems open links in emails automatically to check them. Opening the
+ link alone MUST NOT unsubscribe anyone; only pressing the button on the page does.
+- **Already unsubscribed**: opening the link and pressing the button again shows the normal "done" result, with no
+ error and no extra effect.
+- **Channel removed or deleted** (by the subscriber, or automatically after 30 days unsubscribed): the link shows
+ the existing "not found" page.
+- **Forwarded email**: anyone who has the email can unsubscribe that one channel. This is the same exposure the mail
+ client's unsubscribe option already has, and it is accepted. It never affects other channels or the account.
+- **Language changed after sending**: the page follows the subscriber's current language, as it does today.
+- **Confirmation and account emails**: they get no unsubscribe link, as before. Account emails are about the
+ account, not optional news, and a confirmation email already has its own "this wasn't me" link.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: Every change email and every test email MUST have a footer with an unsubscribe link, in both the HTML
+ and the plain-text part. The plain-text part shows the full link address.
+- **FR-002**: The link MUST lead to the same unsubscribe page, for the same channel, as the mail client's unsubscribe
+ option in that email. No new page, link type or token is added.
+- **FR-003**: Opening the link MUST NOT change anything. The channel becomes *unsubscribed* only when the button on
+ the page is pressed.
+- **FR-004**: The footer text MUST say why the recipient gets the email and that they can stop these notifications
+ to this address with the link or with their mail client's unsubscribe option. The text exists in English and
+ Dutch, and the email uses the subscriber's language.
+- **FR-005**: In the HTML part, the link MUST match the existing footer style (small grey text), MUST look like a
+ link (underlined), and MUST meet the same contrast rule as the rest of the footer.
+- **FR-006**: The mail client's unsubscribe option MUST keep working as before on every email that has it.
+- **FR-007**: Confirmation emails and account emails MUST NOT get an unsubscribe link.
+- **FR-008**: Logs MUST NOT contain the unsubscribe link or its token (unchanged from 003 FR-031).
+
+### Amendments to earlier specs
+
+This feature changes recorded decisions of 003 and 007. These documents are updated during implementation so they
+agree with this spec:
+
+- **003 clarification "Unsubscribing?"**: there is now a login-free unsubscribe page that the email links to, next
+ to the mail client's button.
+- **003 FR-030**: the footer unsubscribe link is one of the notification actions that work without login, only for
+ its own channel.
+- **003 SC-003**: the footer unsubscribe link is the one other email link allowed to change something without login,
+ and only through the button on the page it opens.
+- **003 contracts/messages.md**: the change email footer mentions the link, and the test email gets the same
+ footer. The "Links" line lists the unsubscribe link as the one exception.
+- **007 contracts/email-html.md**: the footer may contain a link; the test email now has a footer. 007's rule that
+ the plain-text part stays unchanged does not apply to this footer change.
+
+### Key Entities
+
+- **Channel**: one notification address of a subscriber, with a status (*active*, *unsubscribed*, …) and its own
+ unsubscribe address. Unchanged by this feature; the footer link uses that same address.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 100% of change and test emails show the unsubscribe link in the footer of both the HTML and the
+ plain-text part, in the recipient's language.
+- **SC-002**: A recipient goes from opening the email to being unsubscribed in 2 clicks (the link, then the button)
+ without logging in.
+- **SC-003**: Opening the link without pressing the button leaves the channel active in 100% of cases.
+- **SC-004**: After unsubscribing through the link, 0 further emails are sent to that channel.
+- **SC-005**: 0 confirmation or account emails contain an unsubscribe link.
+
+## Assumptions
+
+- The existing one-button unsubscribe page is good enough as the landing page; its look stays as it is (007 keeps
+ the public pages minimal).
+- The footer link is a text link, not a large button, so it does not compete with the email's main content and
+ stays readable in every supported mail app. The roadmap's "link/button" is satisfied by the link plus the button
+ on the page.
+- Test emails get the link because they already carry the mail client's unsubscribe option (003 FR-010).
+- Unsubscribing stays per channel. Turning off one calendar stays on the settings links that change emails already
+ have, behind login.
+- Other channel types than email are out of scope; email is the only channel type today.
diff --git a/specs/008-email-unsubscribe-link/tasks.md b/specs/008-email-unsubscribe-link/tasks.md
new file mode 100644
index 0000000..a3ed668
--- /dev/null
+++ b/specs/008-email-unsubscribe-link/tasks.md
@@ -0,0 +1,198 @@
+---
+
+description: "Task list for 008 Unsubscribe link in the email footer"
+---
+
+# Tasks: Unsubscribe link in the email footer
+
+**Input**: Design documents from `/specs/008-email-unsubscribe-link/`
+
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/email-footer.md, quickstart.md
+
+**Tests**: Included. The constitution requires an automated check for non-trivial logic, and the plan lists the
+test changes (contracts/email-footer.md "Guarantees").
+
+**Organization**: Tasks are grouped by user story. Backend-only; paths are relative to the repository root.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (US1, US2)
+
+---
+
+## Phase 1: Setup
+
+None. No new project, package, schema or configuration (plan.md Technical Context).
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: the footer text and the renderer's footer-link line, used by both stories.
+
+- [X] T001 [P] Rewrite `changes.footer` in `backend/InTheKnow/Texts.cs` (en block line ~27, nl block line ~123) to the
+ research R6 text, verbatim:
+ - en: "You receive this email because this address was confirmed for InTheKnow timetable notifications. To stop
+ these notifications to this address, use the link below or your mail client's unsubscribe option."
+ - nl: "Je krijgt deze e-mail omdat dit adres is bevestigd voor roostermeldingen van InTheKnow. Gebruik de link
+ hieronder of de afmeldoptie van je mailprogramma om deze meldingen naar dit adres te stoppen."
+ Do not add a new label key; the link label reuses `page.unsubscribe.title` ("Unsubscribe" / "Afmelden").
+- [X] T002 [P] Add `public void FooterLink(string label, string url)` to `Renderer` in
+ `backend/InTheKnow/EmailChannel.cs`, next to `Footer(string s)`. Text part: `{label}: {url}\n\n` (same format as
+ `Renderer.Link`, research R5). HTML part, appended to the `footer` builder (not `html`), exactly per
+ contracts/email-footer.md:
+ `
{E(label)}
`.
+ Do **not** call `Anchor()`: it hardcodes `LinkColor`, and `(LinkColor, Page)` is not in `TextPairs`; `(Muted, Page)`
+ is (plan ordering note 2, FR-005). No logging of the URL (FR-008).
+
+**Checkpoint**: footer text and footer-link renderer exist; no email uses the link yet.
+
+---
+
+## Phase 3: User Story 1 - Unsubscribe from the bottom of a change email (Priority: P1) 🎯 MVP
+
+**Goal**: every change email has the footer sentence followed by an unsubscribe link to `Message.UnsubscribeUrl`,
+in both parts and both languages.
+
+**Independent Test**: render a Changes message; the footer link's `href` equals the `List-Unsubscribe` value
+without `<` `>`; following it with GET leaves the channel active, POST unsubscribes it (quickstart.md manual
+steps 3–5 for the change email).
+
+### Tests for User Story 1
+
+- [X] T003 [US1] Rewrite `SettingsLinksAndNoActionLinks` in `backend/InTheKnow.Tests/EmailChannelTests.cs` (do not
+ delete it): replace the comment "the only /n/ URL is the List-Unsubscribe header" with the amended 003 SC-003 rule
+ "the only /n/ URL in either part is exactly UnsubscribeUrl"; replace the
+ `"unsubscribe option to stop these notifications"` check with a check for `Texts.Get("en", "changes.footer")`
+ (HTML: encoded) and for the link; change the `Url().Matches` assertion so every URL is either a
+ `/calendars/[12]/notifications` settings URL or exactly the unsubscribe URL; assert neither part contains
+ `/n/confirm` or `/n/decline`. Keep `AssertFooterBelowCard(html, Texts.Get("en", "changes.footer"))` unchanged
+ (it reads the first footer ``, which stays the sentence).
+- [X] T004 [US1] Add a `[Theory]` `FooterLinkMatchesListUnsubscribe` with `[InlineData("en")]`/`[InlineData("nl")]`
+ for `MessageKind.Changes` in `backend/InTheKnow.Tests/EmailChannelTests.cs`: the HTML part contains
+ `{page.unsubscribe.title} `
+ after the footer sentence, and the text part contains `{page.unsubscribe.title}: {header without < >}` after
+ `changes.footer` (FR-001, FR-002, FR-004). Write it so US2 can add `MessageKind.Test` rows (T008).
+- [X] T005 [P] [US1] In `DeclineAndUnsubscribePages` in `backend/InTheKnow.Tests/PublicPagesTests.cs`, confirm the
+ existing assertion that GET on the unsubscribe URL leaves channel B `active` (line ~125); add `SC-003 (008)` and
+ `FR-003 (008)` to the test's leading comment. Only add a new assertion if that GET check is missing.
+
+### Implementation for User Story 1
+
+- [X] T006 [US1] In `EmailChannel.Build` (`backend/InTheKnow/EmailChannel.cs`), `case MessageKind.Changes:` add
+ `r.FooterLink(r.T("page.unsubscribe.title"), m.UnsubscribeUrl!);` directly after
+ `r.Footer(r.T("changes.footer"));` (sentence first, plan ordering note 1). Leave the `List-Unsubscribe` header code
+ unchanged (FR-006).
+- [X] T007 [US1] Regenerate snapshots for Changes:
+ `cd backend && INTHEKNOW_UPDATE_SNAPSHOTS=1 dotnet test --filter TextPartMatchesSnapshot`; check that
+ `backend/InTheKnow.Tests/EmailSnapshots/changes.en.txt` and `changes.nl.txt` end with the new footer sentence, a
+ blank line, and `Unsubscribe: …/n/unsubscribe/…` / `Afmelden: …`. Then `dotnet test` green.
+
+**Checkpoint**: change emails carry the footer link; US1 is shippable on its own.
+
+---
+
+## Phase 4: User Story 2 - The same link in the test email (Priority: P2)
+
+**Goal**: the test email gets the same footer sentence and unsubscribe link below the card.
+
+**Independent Test**: render a Test message in en and nl; footer below the card, link `href` equals the
+`List-Unsubscribe` value (quickstart.md manual step 2).
+
+### Tests for User Story 2
+
+- [X] T008 [US2] In `backend/InTheKnow.Tests/EmailChannelTests.cs`: add `MessageKind.Test` rows (en, nl) to
+ `FooterLinkMatchesListUnsubscribe` (T004), and rewrite `TestEmail`: the text-part URL list becomes
+ `[$"{Frontend}/notifications", $"{Public}/n/unsubscribe/UNSUB"]`; replace the
+ `DoesNotContain("max-width:600px;margin:0 auto;padding:16px 4px 0", html); // no footer` line with
+ `AssertFooterBelowCard(html, Texts.Get(language, "changes.footer"))`; keep the `DoesNotContain("mso-padding-alt")`
+ check. Keep the Confirmation "no footer" assertion elsewhere unchanged (FR-007, SC-005).
+
+### Implementation for User Story 2
+
+- [X] T009 [US2] In `EmailChannel.Build` (`backend/InTheKnow/EmailChannel.cs`), `case MessageKind.Test:` add, after
+ the settings link, `r.Footer(r.T("changes.footer"));` then
+ `r.FooterLink(r.T("page.unsubscribe.title"), m.UnsubscribeUrl!);` (contracts/email-footer.md "Which emails").
+- [X] T010 [US2] Regenerate snapshots as in T007, then run
+ `git diff --stat backend/InTheKnow.Tests/EmailSnapshots/`: it must list exactly `changes.en.txt`, `changes.nl.txt`,
+ `test.en.txt`, `test.nl.txt` (FR-007: no confirmation or account snapshot changed). `dotnet test` green, including
+ the `TextPairs` contrast test.
+
+**Checkpoint**: both change and test emails carry the footer link.
+
+---
+
+## Phase 5: Polish & Cross-Cutting Concerns
+
+**Purpose**: amend the 003/007 documents (spec "Amendments to earlier specs"), then validate.
+
+- [X] T011 [P] Amend `specs/003-notification-channels/spec.md`: clarification "Unsubscribing?" (a login-free
+ unsubscribe page the email links to, next to the mail client's button), FR-030 (the footer unsubscribe link works
+ without login, only for its own channel), SC-003 (the footer unsubscribe link is the one other email link allowed
+ to change something without login, only through the page's button). Reference 008.
+- [X] T012 [P] Amend `specs/003-notification-channels/contracts/messages.md`: Changes footer mentions the link and
+ shows the `{page.unsubscribe.title}: {UnsubscribeUrl}` line; Test gets the same footer; the "Links" line lists the
+ unsubscribe link as the one exception. Reference 008.
+- [X] T013 [P] Amend `specs/007-branded-email-layout/contracts/email-html.md`: Changes/Test footer rows (footer may
+ contain the Muted underlined link; Test now has a footer), and carve the "text part unchanged / byte-for-byte equal
+ to the snapshots" guarantee out for this footer change. Reference 008.
+- [X] T014 Run `cd backend && dotnet test` (all green) and the quickstart.md manual Mailpit steps 1–6, including the
+ Dutch check and the "press again via back button" repeat.
+- [X] T015 Commit the feature without the unrelated `.gitignore` change (plan ordering note 6).
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Foundational (Phase 2)**: T001 and T002 block both stories.
+- **US1 (Phase 3)**: after Phase 2. **US2 (Phase 4)**: after Phase 2; T008 extends the theory from T004, so do US1
+ first or create the theory in T008 if US2 is done alone.
+- **Polish (Phase 5)**: T011–T013 can be written any time; T014–T015 after both stories.
+
+### Within Each User Story
+
+- Tests (T003–T005, T008) first; they fail until T006/T009 land.
+- Snapshot regeneration (T007, T010) last in each story.
+- T003, T004, T006, T008, T009 touch the same two files (`EmailChannelTests.cs`, `EmailChannel.cs`): run them in
+ order, not in parallel.
+
+### Parallel Opportunities
+
+- T001 (`Texts.cs`) ‖ T002 (`EmailChannel.cs`).
+- T005 (`PublicPagesTests.cs`) ‖ T003/T004 (`EmailChannelTests.cs`).
+- T011 ‖ T012 ‖ T013 (three different docs).
+
+---
+
+## Parallel Example: User Story 1
+
+```bash
+Task: "Rewrite SettingsLinksAndNoActionLinks + add FooterLinkMatchesListUnsubscribe in backend/InTheKnow.Tests/EmailChannelTests.cs"
+Task: "Reference SC-003/FR-003 on the GET-leaves-active check in backend/InTheKnow.Tests/PublicPagesTests.cs"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Phase 2 (T001, T002).
+2. Phase 3 (T003–T007).
+3. **Validate**: `dotnet test` green; Mailpit change email shows the link; GET leaves active, POST unsubscribes.
+
+### Incremental Delivery
+
+1. Phase 2 + US1 → change emails have the link (MVP).
+2. US2 → test email gets the footer.
+3. Phase 5 → docs agree, quickstart passes, commit.
+
+---
+
+## Notes
+
+- No new page, token, key, dependency or log call (FR-002, FR-008, research R1/R7).
+- Account and Confirmation branches of `Build` are not touched (FR-007).
From d4cbad932bf605f1f1a1e0352a39afd9035fc75b Mon Sep 17 00:00:00 2001
From: Wjouter <34310151+Wjouter@users.noreply.github.com>
Date: Wed, 21 Oct 2026 14:08:49 +0200
Subject: [PATCH 2/2] chore: ignore .claude/launch.json
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index e8444ec..e06a335 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Claude Code: personal settings stay local, shared settings/skills get committed
.claude/settings.local.json
+.claude/launch.json
# Secrets / env
.env