Skip to content

Research whether the recipient's distinct ID can be inferred from the email address #534

Closed

Description

@Silthus

Question

Can PostHog infer the recipient's distinct ID on the server from the to address, so that distinctId becomes optional on posthog.emails.send?

Michael (2026-10-21), from architecture prototype #526: passing distinctId on every send is friction. The SDK should take it as an optional override, and only if that matches a regular posthog-node pattern. When it is absent, our side looks up the person by email address. #532 advised against inferring from the email property, but it did not weigh an existing precedent: support tickets already resolve a person by email (products/conversations/backend/person_lookup.py, _get_persons_by_email). Decide whether the lookup holds, and make it a requirement if it does.

Answer with file:line evidence on upstream/master, and on posthog-js read by ref:

  1. The SDK pattern. How posthog-node treats an optional distinctId today: capture, flags, withContext, and the Express middleware that reads the request's tracing headers. What happens when the ID is missing and there is no context? Is "optional, inferred on the server" an existing pattern anywhere in the SDKs? The request context holds the requester, but an email belongs to its recipient (invites, mentions, digests). Should emails.send ignore withContext, and how do we keep that from surprising developers who rely on context for capture?

  2. The lookup. The existing primitives (_get_persons_by_email, the persons API ?email= filter): what each reads, the materialized email column and its index, the cost per lookup, and whether batching or caching applies. Which properties key it matches (email, $email, case), and how it fits the repo's person-data rules (docs/internal/person-data-access.md: properties from ClickHouse, personhog for identity only). Where it should run: Django on the request path, or the Node worker after the 202. How does it behave when a campaign sends many emails at once? Give the design load in terms of the code's own limits. Do not include production numbers.

  3. Correctness. Each case where the lookup misses or picks the wrong person:

    • a person created moments before the send (a welcome email right after identify);
    • several persons with the same address;
    • no person with that address;
    • an address that changed.

    Which of the person's distinct IDs to use. How conversations guards its lookup (products/conversations/backend/events.py around the email fallback), and which guards carry over. What a miss costs: engagement on a separate email-address person, as today.

  4. When to resolve. At send time, or when an engagement event is captured (opens and clicks arrive minutes later). How each choice interacts with the scoped capture policy, the signed tracking header, $email_message_id and the status record decided in Shape the architecture of Resend parity #526.

  5. Requirements. The smallest requirement list for the spec (#531), placed in the PR order of grilling #525, and what Shape the architecture of Resend parity #526 must show. List each owed production-shape proof as a gap: production read access arrives on 2026-11-02.

Write the report on branch research/email-recipient-lookup. Resolve with the answers and the branch pointer. Michael approves the exact text of any comment before it is posted: return the resolution draft to the parent instead of posting.

How to work this ticket

  • Map: Map: send code-managed email templates with one PostHog call #461 (map tracker Silthus/posthog, pass -R Silthus/posthog to gh issue). Code repo: PostHog/posthog; this checkout's origin is the public fork Silthus/posthog, upstream is PostHog/posthog. Read PostHog/posthog master as upstream/master (fetch it first); the fork's origin/master is stale. Read posthog-js in ~/dev/posthog-js by ref only (origin/main, origin/feat/node-emails-send); never switch branches there.
  • Public repo: the fork, its issues, branches and commits are public. Never write usage or revenue numbers, customer names or data, internal metrics, Slack quotes, or security-sensitive reasoning there.
  • Read-only for product code: change nothing outside your report. Commit the report on the named research branch and push it to origin.
  • Casting: you are the cast for this ticket. Any nested worker follows the user's model selection policy: Survey work on Sonnet 5.5 high, Deep research on GPT-6.1 Sol high; name model and effort on every dispatch.
  • Wall-time cap: 60 minutes.
  • Leave nothing running: run every wait in the foreground; end with no background command, watcher, monitor, subagent, or child task live.

Activity

  1. Silthus commented on Oct 21, 2026

    @Silthus
    OwnerAuthor

    Note

    🤖 GPT-6.1 Sol responding on behalf of Michael

    Yes, but only as best-effort enrichment, not guaranteed recipient identification. Keep distinctId optional, never inherit requester context, and let an explicit recipient ID win. Missing, ambiguous, or unavailable lookup results retain today's address fallback; recent identification and changed addresses still require an explicit ID for reliable attribution.

    • 🤖 The SDK pattern Capture resolves locally, while context-aware flags return empty results without identity. Email already ignores context; document and test that recipient-specific exception.
    • 🤖 The lookup Use bounded, team-scoped HogQL search on case-insensitive properties.email, then personhog for identity. Prefer Django before enqueueing, conditional on latency/index proofs; reuse existing campaign person anchors.
    • 🤖 Correctness Do not copy conversations' first-person selection unchanged. Require one eligible identified match; otherwise fall back to to. Neither uniqueness nor the selected associated distinct ID proves the caller's intended account.
    • 🤖 When to resolve Freeze identity, capture policy, and message ID with the accepted send. Never relookup email on engagement; status visibility must not determine attribution.
    • 🤖 Requirements Put lookup/snapshot behavior in the body route, context documentation/tests in Node PR 1, and replay stability in idempotency. Shape the architecture of Resend parity #526 must demonstrate success, fallback, requester-versus-recipient, and late-callback paths. If production proofs fail, ship optional explicit ID with address fallback without promising inference.

    Branch: research/email-recipient-lookup

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions