You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add an "Unfollow this document" link to each document in the Change digest #449
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.
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
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.
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.
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.
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.
Header: the List-Unsubscribe header and its one-click POST stay as they are.
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.
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
A one-click Unfollow without sign-in. It needs a new token action with a document id and a new service-role SQL write. RFC 8058 forbids session context on the one-click POST.
Muting chat notices with Unfollow. Chat has its own notif_state control.
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
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.
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.
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:
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.digestis'Unsubscribe from digests'.resolveUnsubscribe(:54-76) signs one token for the footer link and theList-Unsubscribeheader.lib/email/pgmqConsumer.ts:235(the size fit) andsender.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) andfooterLinksText(plain text).packages/email-templates/src/digestWalk.ts:84: onestatusblock per document.packages/email-templates/templates/digest.eta:59-65paints the footer links on the right of each status bar, and:68paints them in thehomeblock. The digest uses thesheetframe (src/engine.ts:76-79), andtemplates/base.eta:32paints no footer row for that frame.packages/email-templates/src/templates.ts:105-118: the plain text prints the footer once, at the end.messagethatapply_unsubscribereturns (apps/hocuspocus.server/src/api/email.ts:420-425). SQL builds it fromaction_description(packages/supabase/scripts/07-5-email-notifications-pgmq.sql:939-941,:962).Pad (webapp)
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:56hides the row from the owner of a Private or Read-only document.apps/webapp/src/hooks/useHashOverlay.ts:37-48:parseOverlayHashknows two routes,notificationsandsettings. No hash opens Document settings today.components/TipTap/pad-title-section/PadTitle.tsx:66-73andMobilePadTitle.tsx:250-256consume the hash. Both use anelsearm, 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) setsworkspace_members.content_email_muted_at.notify_document_content_changereads 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, throughchannel_memberssettings.Design
${appUrl}/${slug}#follow. Build it asfollow_url?: stringinwithResolvedName(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, onDigestDocumentinpackages/email-templates/src/types.ts.walkDigestputs it on thestatusblock (digestWalk.ts:84) only whendoc.content_changesis present. Unfollow stops change notices only, so a chat-only document must not offer it.withResolvedNameruns.withSectionsdrops it when the window did not change (digestContentChanges.ts:167-169). The privacy re-read strips it later (filterDigestDocuments, called atapps/hocuspocus.server/src/lib/email/digestMessage.ts:65). Both keep every other field.statusblock carries it todigest.eta:59-65and to thestatuscase intemplates.ts:105-106.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 inhelpers.ts, besidefooterLinks, 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.digest.etapaints one closing line withit.footerHtmlunchanged. It holds "Manage preferences" and the stop-all link. Skip it when the walk is thehomeblock alone, because that block already paints it (:68).Unfollow this document: <url>on the line after each document's status line (templates.ts:105-106), also when that line is empty. The closingfooterLinksText(templates.ts:118) already prints once and stays.footerLinksandfooterLinksTextstay unchanged, so notification mail does not move.email:digest-grouping = aggregate), each document paints its own status bar and Unfollow link. The mail still has one closing line.followtoparseOverlayHash. InPadTitle, return beforeclearOverlayHash()when the overlay isfollow, so the hash survives until the toolbar reads it (PadTitle.tsx:68-72clears any overlay before it branches). InMobilePadTitle, make thesettingsarm explicit.HomePage.tsx:80already testsoverlay !== 'settings'.openOverlayHash(useHashOverlay.ts:64-74) on its two routes. Type its parameter'notifications' | 'settings', because its default arm writes#notificationsfor any other value.MobilePadTitleopensopenSheet('documentSettings')for#follow.#followin an effect inEditorToolbar, placed above the early return atEditorToolbar.tsx:97. Gate it on the same ready state (editor && !loading && !providerSyncing) and onuser, as the other hash readers do. Also wait whiledocumentSettingsOpenRequest.canRequest()is false. Then callclearOverlayHash()anddocumentSettingsOpenRequest.request(). TheListener(:363) is a child, so its effect has already run.PadTitlemounts before the toolbar (layouts/DesktopLayout.tsx:44-45), which is why it must not consume the hash. Read from code, not reproduced.List-Unsubscribeheader and its one-click POST stay as they are.UNSUBSCRIBE_TEXT.digest(sender.ts:45) to "Unsubscribe from all email notifications". The token action staysdigest, so the write staysemail_frequency = 'never'(07-5-email-notifications-pgmq.sql:939-940).'digest emails'to'all email notifications'at07-5-email-notifications-pgmq.sql:941. SQL owns this text for every action, so do not add a message table in Node.public.apply_unsubscribewhole, with its grants and comment (:1034-1039) and itssearch_path(:1057). Copy it from the edited script. Its last migration copy is inpackages/supabase/migrations/20260930120000_private_notification_preferences.sql.bun run --filter @docs.plus/supabase_back seedandbun 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.DocumentSettingsPanel.tsx:56, setshowFollowtocanFollow, 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
notif_statecontrol.Acceptance criteria
scripts/07-5-email-notifications-pgmq.sqland a paired migration, with the regeneratedseed.sqland types./<slug>#follow, signed in or out, sends noset_document_followrequest until the reader clicks the Follow toggle (browser Network tab).fitDigestDocumentsrenders the full HTML). The fit and the send build the same footer (pgmqConsumer.ts:235andsender.ts:145).white-space: nowrap(digest.eta:63).apps/hocuspocus.server/CLAUDE.md§Digest Email Links And Counts (also fix its staleresolveFooterLinksname; the helpers arefooterLinksandfooterLinksText),apps/webapp/CLAUDE.md§Overlay Hash Routes, and the digest layout preference inAGENTS.mddescribe the new footer.Verify
bun run --filter @docs.plus/email-templates test. The package script setsAPP_URL; a barebun testdoes not. Update the two digest snapshots (src/__tests__/engine.test.ts:188,:501). The footer test at:861-873uses an empty digest and checks only that the label appears, so it needs no change./<slug>#followon a document you follow.Open decisions
location.href(apps/webapp/src/components/auth/SignInForm.tsx:65), so#followshould survive. The email-link sign-in returns topathname + search(:114), so#followis 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.compile_digest_emailsdoes 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.