Skip to content

Decide content-change privacy in the worker and skip empty digests #204

Description

@HMarzban

Problem

A private document must reach its owner only. Supabase cannot decide that.

join_workspace (packages/supabase/scripts/10-functions.sql:833) adds any signed-in visitor to workspace_members and never looks at isPrivate, ownerId or deletedAt. It cannot: those three live in Prisma, and Supabase has no handle on them. So workspace_members is untrustworthy for privacy by construction.

Only the hocuspocus worker holds both database handles. If the worker does not gate, a document that turns private still emails every person who ever opened it.

Part of #169.

What to do

Blocked by Part D. git grep -n enrichDigestDocuments returns no match. Neither does content_change, p_only_user or email_content_changes. Part D supplies the content_changes block on a document entry. The send-time gate and the empty-digest skip both act on that block. Land Part D first, then this.

Two gates, both in the worker.

Gate one, at fan-out. Before calling the RPC, read the live Prisma metadata for the document and decide the audience:

  • deletedAt set — nobody. Return before the RPC call.
  • isPrivate with no ownerId — nobody.
  • isPrivate with an ownerId — that owner only, passed as p_only_user.
  • otherwise — the full audience, p_only_user null.

Put the call in the post-commit region of the save processor, after the 'Document stored successfully' log at apps/hocuspocus.server/src/lib/queue.ts:525. Copy the fire-and-forget shape of the setImmediate block at :530, but do not extend that block. :528 gates it on isFirstCreation && createdSlug, and a content change fires on every save. The doc:<id>:saved publish at :556 is a second example of the same shape.

The fan-out must never throw into the save job. A null Supabase client, a rejected RPC and a failed metadata read each log and continue.

Gate two, at send time. processDigestMessage (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:142) re-reads the metadata. A document can turn private between the carrier insert and the send. For a recipient who is not the owner, drop the content_changes block. Drop the whole document entry when a carrier seeded it.

Force-digest is a privacy invariant, not a rendering choice. In queue_email_notification (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:285), a content_change must always take the digest branch. The function writes its local queue_type into the email_type column (column list at :381, value at :386). So the assignment to make is queue_type := 'digest'. Only email_type = 'digest' rows are picked up by compile_digest_emails (:535, filter at :575).

The immediate path, processNotificationMessage (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:210), has no re-check and no enrichment. An immediate-typed carrier would email a line about a document that is now private.

Order matters. Put the force-digest assignment before the quiet-hours block. The frequency case is at packages/supabase/scripts/07-5-email-notifications-pgmq.sql:327-351. The quiet-hours gate is at :353-376. Quiet hours only tests queue_type = 'immediate' and then returns without inserting an email_queue row. If a content_change reached it, one quiet-hours window would drop the email. The sibling database issue suppresses re-fan-out for 24 hours, so one evening edit would silence a whole day. Name the test for this so it reads as a privacy pin.

Add the preference case in the same function (:309-318): when 'content_change' then should_queue := coalesce((prefs->>'email_content_changes')::boolean, true);. Default true. That is safe because the master email_enabled defaults to false (:305).

Consumer shape.

  • buildDigestDocuments (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:78): a content_change raw row seeds the document entry, then continues before the channel grouping. Otherwise the row falls into the channel map at :105-122 and renders as a chat line.
  • Empty-digest skip: drop a document entry that has neither real chat lines nor a content_changes block. If no document remains, skip the email entirely and mark the queue ids 'skipped'. Widen the local updateEmailStatus union (:61-65) — it accepts 'sent' | 'failed' today.
  • No database change is needed for 'skipped'. The email_queue status constraint already allows it (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:31), and update_email_status (:743) writes p_status straight through with no validation (:754-758). Still compare the production constraint against local first, because that constraint has drifted before.
  • Frequency normalisation: 'immediate' now reaches the digest path for the first time. The digest payload's frequency is the user's email_frequency preference, not the queue type — compile_digest_emails reads it at :569-571 and sends it at :667. processDigestMessage casts it as 'daily' | 'weekly' at apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:157, so an immediate value would render "immediate digest". Map immediate to daily before rendering.
  • Header copy: a digest built only from content changes counts zero notifications. The count is derived and rendered in four places. Fix all four: packages/email-templates/src/engine.ts:84, the HTML line at packages/email-templates/templates/digest.eta:7, the plaintext line at apps/hocuspocus.server/src/lib/email/templates.ts:120, and the subject line at apps/hocuspocus.server/src/lib/email/sender.ts:119. Each sums only channels[].notifications, so each would read "0 notifications".

Acceptance

  • A private document with an owner: only the owner gets a digest naming it. No other member gets a content_change row.
  • A private document with no owner: no carrier row and no email for anyone.
  • A tombstoned document: no carrier row and no email for anyone.
  • A document that turns private after the carrier row is inserted: a non-owner recipient's email drops that document entry. The owner still sees it.
  • A follower whose only pending row is a content_change for an unchanged document gets no email. The queue ids are marked 'skipped'.
  • A user with email_frequency = 'immediate' gets one email_queue row with email_type = 'digest'.
  • With quiet hours on and the clock inside the window, a content_change still produces a digest row.
  • No digest renders a chat line built from a content_change row.
  • A digest built only from content changes never shows "0 notifications", in the body, the plaintext and the subject.
  • A legacy digest payload with no content_changes renders byte-identical to today.
  • A null Supabase client, a rejected RPC and a failed metadata read each leave the save job green.

Notes

The fan-out matrix itself (member, editor, mute, 24-hour dedup) is proven in the sibling database issue. This issue proves the privacy decisions layered on top of it.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions