Skip to content

Add an "Unfollow this document" link to each document in the Change digest #449

Description

@HMarzban

Summary

The Change digest footer offers only "Manage preferences" and "Unsubscribe from digests". A reader who wants less mail about one document has no direct way to say so. The maintainer asked for a per-document link:

"we have to change this "Unsubscribe from digests", this must change to unsubscribe from this document, not from the digest. so when user click on this, the document must be open and let user to unflow the document."

This issue adds that link. The link opens the document with its Follow setting in view, and the reader switches Follow off there. Opening the link changes nothing by itself. The 2026-10-07 maintainer ruling keeps one stop-all link at the end of the mail, under a label that says what it stops.

Related: #445 (the existing unsubscribe link acts on GET, so a mail link scanner can unsubscribe a reader).

Shares the digest payload and paint files with #448. Land that issue first, then add the link field on top.

Where

Mail (backend and templates)

  • apps/hocuspocus.server/src/lib/email/sender.ts:41-47: UNSUBSCRIBE_TEXT.digest is 'Unsubscribe from digests'. resolveUnsubscribe (:54-76) signs one token for the footer link and the List-Unsubscribe header.
  • The digest footer is resolved at two sites, which must stay equal: lib/email/pgmqConsumer.ts:235 (the size fit) and sender.ts:145 (the send). apps/hocuspocus.server/CLAUDE.md §Digest Email Links And Counts says the fit must measure the footer that is sent.
  • packages/email-templates/src/helpers.ts:74-103: EmailFooter, footerLinks (HTML) and footerLinksText (plain text).
  • packages/email-templates/src/digestWalk.ts:84: one status block per document. packages/email-templates/templates/digest.eta:59-65 paints the footer links on the right of each status bar, and :68 paints them in the home block. The digest uses the sheet frame (src/engine.ts:76-79), and templates/base.eta:32 paints no footer row for that frame.
  • packages/email-templates/src/templates.ts:105-118: the plain text prints the footer once, at the end.
  • The result page of an unsubscribe link prints the message that apply_unsubscribe returns (apps/hocuspocus.server/src/api/email.ts:420-425). SQL builds it from action_description (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:939-941, :962).

Pad (webapp)

  • The Follow control is the "Follow" row in apps/webapp/src/components/TipTap/toolbar/desktop/DocumentSettingsPanel.tsx:137-147. The same panel serves desktop (toolbar gear popover) and phone (TOC drawer → gear → openSheet('documentSettings')).
  • DocumentSettingsPanel.tsx:56 hides the row from the owner of a Private or Read-only document.
  • apps/webapp/src/hooks/useHashOverlay.ts:37-48: parseOverlayHash knows two routes, notifications and settings. No hash opens Document settings today.
  • components/TipTap/pad-title-section/PadTitle.tsx:66-73 and MobilePadTitle.tsx:250-256 consume the hash. Both use an else arm, so a new route value would open Settings until both are made explicit.

What Unfollow does

  • set_document_follow (packages/supabase/scripts/10-8-func-workspace_members.sql:264-293) sets workspace_members.content_email_muted_at.
  • Among the mail paths, only notify_document_content_change reads it (scripts/10-func-notifications.sql:632, :646). get_document_follow_state (10-8-func-workspace_members.sql:300-311) reads it for the Follow row. So Unfollow stops new change notices only. Mentions, replies and reactions for that document keep coming, through channel_members settings.

Design

  1. Label: "Unfollow this document". It matches the in-app "Follow" row. "Unsubscribe from this document" would promise more than Unfollow does, because chat notices keep coming.
  2. Link: ${appUrl}/${slug}#follow. Build it as follow_url?: string in withResolvedName (apps/hocuspocus.server/src/lib/email/digestContentChanges.ts:122-144). That step runs only when the document's metadata row exists, so a document with no human slug gets no link. Declare the field once, on DigestDocument in packages/email-templates/src/types.ts.
    • Gate it where it paints. walkDigest puts it on the status block (digestWalk.ts:84) only when doc.content_changes is present. Unfollow stops change notices only, so a chat-only document must not offer it.
    • Do not gate it earlier. The block can vanish after withResolvedName runs. withSections drops it when the window did not change (digestContentChanges.ts:167-169). The privacy re-read strips it later (filterDigestDocuments, called at apps/hocuspocus.server/src/lib/email/digestMessage.ts:65). Both keep every other field.
    • The status block carries it to digest.eta:59-65 and to the status case in templates.ts:105-106.
  3. Placement (ruling 1):
    • Status bar: each status cell (digest.eta:63) paints "Manage preferences" and, when set, "Unfollow this document". It no longer paints the stop-all link. Build that cell with one helper in helpers.ts, beside footerLinks, with the same link style and unsigned default. Add it to the template helper object only, not to the package index. renderDigestEmail (engine.ts:76-79) passes the resolved footer to the template, so the cell can call the helper.
    • Closing line: after its block loop, digest.eta paints one closing line with it.footerHtml unchanged. It holds "Manage preferences" and the stop-all link. Skip it when the walk is the home block alone, because that block already paints it (:68).
    • Plain text: print Unfollow this document: <url> on the line after each document's status line (templates.ts:105-106), also when that line is empty. The closing footerLinksText (templates.ts:118) already prints once and stays.
    • footerLinks and footerLinksText stay unchanged, so notification mail does not move.
    • In a combined digest (email:digest-grouping = aggregate), each document paints its own status bar and Unfollow link. The mail still has one closing line.
  4. Pad:
    • Add follow to parseOverlayHash. In PadTitle, return before clearOverlayHash() when the overlay is follow, so the hash survives until the toolbar reads it (PadTitle.tsx:68-72 clears any overlay before it branches). In MobilePadTitle, make the settings arm explicit. HomePage.tsx:80 already tests overlay !== 'settings'.
    • Keep openOverlayHash (useHashOverlay.ts:64-74) on its two routes. Type its parameter 'notifications' | 'settings', because its default arm writes #notifications for any other value.
    • Phone: MobilePadTitle opens openSheet('documentSettings') for #follow.
    • Desktop: consume #follow in an effect in EditorToolbar, placed above the early return at EditorToolbar.tsx:97. Gate it on the same ready state (editor && !loading && !providerSyncing) and on user, as the other hash readers do. Also wait while documentSettingsOpenRequest.canRequest() is false. Then call clearOverlayHash() and documentSettingsOpenRequest.request(). The Listener (:363) is a child, so its effect has already run. PadTitle mounts before the toolbar (layouts/DesktopLayout.tsx:44-45), which is why it must not consume the hash. Read from code, not reproduced.
    • The link never changes Follow by itself. Mail link scanners open every link, and some run JavaScript.
  5. Header: the List-Unsubscribe header and its one-click POST stay as they are.
  6. Stop-all label and result page (ruling 2): set UNSUBSCRIBE_TEXT.digest (sender.ts:45) to "Unsubscribe from all email notifications". The token action stays digest, so the write stays email_frequency = 'never' (07-5-email-notifications-pgmq.sql:939-940).
    • Change 'digest emails' to 'all email notifications' at 07-5-email-notifications-pgmq.sql:941. SQL owns this text for every action, so do not add a message table in Node.
    • Ship a new migration that recreates public.apply_unsubscribe whole, with its grants and comment (:1034-1039) and its search_path (:1057). Copy it from the edited script. Its last migration copy is in packages/supabase/migrations/20260930120000_private_notification_preferences.sql.
    • Then run bun run --filter @docs.plus/supabase_back seed and bun run --filter @docs.plus/supabase_back types (packages/supabase/CLAUDE.md). Only words change, so the SQL and the code may go up in either order.
  7. Owner Follow row (ruling 3): in DocumentSettingsPanel.tsx:56, set showFollow to canFollow, and delete the comment at :54-55. The owner-only fan-out branch already reads the owner's mute (10-func-notifications.sql:646).

Out of scope

Acceptance criteria

  • A one-document digest shows "Unfollow this document" in its status bar, in HTML and plain text.
  • A combined digest shows one such link per document.
  • A document whose block holds only chats shows no Unfollow link. Neither does a document whose block the privacy re-read stripped.
  • No status bar shows a stop-all link. The mail shows it once, after the last document, labelled "Unsubscribe from all email notifications", in HTML and plain text.
  • Opening that stop-all link shows "You have been unsubscribed from all email notifications." The SQL change ships in scripts/07-5-email-notifications-pgmq.sql and a paired migration, with the regenerated seed.sql and types.
  • The Unfollow link opens the document. On desktop the Document settings popover opens. On phone the Document settings sheet opens. In both, Follow is in view and its state is unchanged.
  • Loading /<slug>#follow, signed in or out, sends no set_document_follow request until the reader clicks the Follow toggle (browser Network tab).
  • The owner of a Private document, and of a Read-only document, sees the Follow row in Document settings.
  • The size fit measures the digest with the new links (fitDigestDocuments renders the full HTML). The fit and the send build the same footer (pgmqConsumer.ts:235 and sender.ts:145).
  • In Gmail web and the Gmail phone app, light and dark, each status bar shows its line and both links with no horizontal scroll. The links cell is white-space: nowrap (digest.eta:63).
  • apps/hocuspocus.server/CLAUDE.md §Digest Email Links And Counts (also fix its stale resolveFooterLinks name; the helpers are footerLinks and footerLinksText), apps/webapp/CLAUDE.md §Overlay Hash Routes, and the digest layout preference in AGENTS.md describe the new footer.

Verify

  • bun run --filter @docs.plus/email-templates test. The package script sets APP_URL; a bare bun test does not. Update the two digest snapshots (src/__tests__/engine.test.ts:188, :501). The footer test at :861-873 uses an empty digest and checks only that the label appears, so it needs no change.
  • In a browser, desktop and a phone user agent, signed in: open /<slug>#follow on a document you follow.
  • Signed out: open the same link and sign in. Check whether the panel opens after sign-in (open decision 1).

Open decisions

  1. A signed-out reader. Google sign-in returns to location.href (apps/webapp/src/components/auth/SignInForm.tsx:65), so #follow should survive. The email-link sign-in returns to pathname + search (:114), so #follow is lost. Both are read from code, not reproduced. Recommended: check both paths in a browser first. If the hash is lost, return to the document with Document settings closed, and accept that one extra click.
  2. Hide a change block that is already queued after an Unfollow? compile_digest_emails does not re-check the mute, so one more digest can still show the document. Recommended: no. That carrier was written while the reader still followed, and the next save writes none. If it is wanted, file it as its own issue.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions