Skip to content

Add read receipt (open tracking) support for sent emails#3943

Open
jbecke wants to merge 2 commits into
mainfrom
claude/lucid-feynman-06go48
Open

Add read receipt (open tracking) support for sent emails#3943
jbecke wants to merge 2 commits into
mainfrom
claude/lucid-feynman-06go48

Conversation

@jbecke

@jbecke jbecke commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements end-to-end read receipt tracking for sent emails. When a user has read receipts enabled on an inbox, outgoing messages embed a unique tracking pixel. When recipients open the message, the pixel fetch records the open event, allowing senders to see when their messages were read.

Key Changes

Backend (Rust)

  • Open tracking pixel endpoint (email_service/src/api/tracking.rs): New unauthenticated /t/o/{token} endpoint that records message opens and returns a 1x1 transparent GIF
  • Message open tracking (email_db_client/src/messages/open_tracking.rs): Database operations to store tracking tokens on messages and record open events with first/last open timestamps and open count
  • Settings management (email_db_client/src/settings/): Updated to support read_receipts_enabled setting with partial update semantics (None fields preserve existing values)
  • Send pipeline (email_service/src/util/gmail/send.rs): New attach_open_tracking_pixel() function that:
    • Strips tracking pixels from quoted messages to prevent re-triggering old pixels
    • Generates unique token per message
    • Embeds tracking pixel URL in outgoing HTML when read receipts are enabled
  • Database schema (macro_db_migrator/migrations/): Added open_tracking_token, first_opened_at, last_opened_at, and open_count columns to email_messages table
  • Utility functions (email_utils/src/open_tracking.rs): Helpers for building pixel URLs, injecting pixels into HTML, and stripping Macro tracking pixels (handles cross-environment bodies)

Frontend (TypeScript/JavaScript)

  • Read receipts utilities (block-email/util/readReceipts.ts):
    • removeOwnTrackingPixels(): Strips Macro tracking pixels from sent message copies to prevent self-tracking
    • messageSeenAt(): Extracts last open time from message metadata
    • formatSeenLabel() and formatSeenTooltip(): Format read receipt information for UI display
  • Settings mutation (queries/email/link.ts): New useUpdateReadReceiptsMutation() hook for toggling read receipts across all user inboxes with optimistic updates and rollback on error
  • UI components:
    • Account.tsx: Added read receipts toggle switch in settings
    • CollapsedMessage.tsx and EmailMessageTopBar.tsx: Display "Seen" indicators with eye icon and timestamps for opened sent messages
    • EmailMessageBody.tsx: Removes tracking pixels from rendered sent message bodies
  • Email body preparation (prepareEmailBody.ts): Strips tracking pixels when quoting previous messages in replies
  • API client (service-email/client.ts): Added patchSettings() method for updating inbox settings
  • OpenAPI schema: Updated to include open_count, first_opened_at, and last_opened_at fields on messages, plus read_receipts_enabled setting

Testing

  • Comprehensive test suites for all new functionality:
    • Rust: Open tracking pixel URL generation, injection, stripping, and database operations
    • TypeScript: Pixel removal, message seen detection, and label formatting
    • Database fixtures for testing with realistic data

Implementation Details

  • Pixel format: 1x1 transparent GIF with minimal styling to avoid visual impact
  • Token matching: Tracks opens by UUID token embedded in pixel URL, only records opens for sent messages
  • Cross-environment support: Strips pixels from any email-service*.macro.com host to handle bodies synced across dev/prod environments
  • Partial updates: Settings use COALESCE logic to only update provided fields, preserving other settings
  • Best effort: Tracking failures are logged but don't block message sends
  • Privacy: Sent copies of messages have tracking pixels removed to prevent self-tracking

https://claude.ai/code/session_016hLXoTuPikV856TsiTKGeC

When an inbox has read receipts enabled (new per-inbox setting, on by
default), the scheduled-send worker embeds a unique tracking pixel in
the outgoing HTML right before the Gmail send. A new unauthenticated
GET /t/o/{token} endpoint on the email service serves a 1x1 transparent
GIF and records opens (first/last opened, open count) on the sender's
copy of the message. Stale Macro pixels are stripped from quoted reply
chains so replies never re-send or re-trigger earlier tracking.

Backend:
- email_messages gains open_tracking_token, first_opened_at,
  last_opened_at, open_count; email_settings gains read_receipts_enabled
- open data flows through the hex thread read path (DbMessageRow ->
  MessageRow -> Message -> ApiMessage) so GET /email/threads/{id}
  returns it
- settings PATCH is now partial: omitted fields keep their value
  instead of being reset to defaults

Frontend:
- "Seen Xh ago" indicator with open-count tooltip on opened sent
  messages, in both the expanded header and collapsed thread rows
- own tracking pixels are stripped when rendering or quoting your own
  sent mail, so viewing your own messages never counts as an open
- "Email read receipts" toggle in account settings, applied across all
  linked inboxes

https://claude.ai/code/session_016hLXoTuPikV856TsiTKGeC
@jbecke jbecke requested a review from a team as a code owner June 10, 2026 17:11
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fa440b08-9104-4131-867a-0a43cdf21008

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements email read receipt tracking across frontend and backend services. It extends message data models with open-tracking fields (first_opened_at, last_opened_at, open_count), adds per-inbox settings to enable/disable read receipts, injects transparent GIF tracking pixels into outgoing emails with unique tokens, and provides an unauthenticated API endpoint to record pixel fetches. The frontend displays "seen" indicators in message lists and headers, allows users to toggle read receipts globally in account settings, and strips tracking pixels from user-sent quoted replies to prevent self-tracking. Database migrations add the schema changes, and comprehensive tests verify both the database operations and HTML pixel manipulation.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'feat:'-style prefix, is under 72 characters (56 chars), and clearly summarizes the main change: adding read receipt/open tracking support for sent emails.
Description check ✅ Passed The description comprehensively relates to the changeset, providing detailed summaries of backend (Rust), frontend (TypeScript/JavaScript), and testing changes, all directly aligned with the PR's open tracking implementation across multiple files and systems.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@js/app/packages/block-email/util/readReceipts.test.ts`:
- Line 9: The TOKEN constant is a production-shaped UUID literal that triggers
secret scanners; update the TOKEN declaration in readReceipts.test.ts (symbol:
TOKEN) to use a clearly non-sensitive placeholder (e.g. "test-token" or
"dummy-token" or an all-zero UUID) and adjust any test/mocks that depend on its
exact format so tests continue to pass; ensure only the literal value changes
and keep the identifier TOKEN unchanged.

In `@js/app/packages/queries/email/link.ts`:
- Around line 91-95: The mutation currently builds its target list only from
cached emailKeys.links via queryClient.getQueryData, which causes a silent no-op
when the cache is empty; update mutationFn (the mutation function) to avoid
no-ops by fetching the authoritative links when the cache is missing — e.g.,
call queryClient.fetchQuery(emailKeys.links.queryKey) or call the list-links API
to obtain a ListLinksResponse and then build the links array, falling back to
whatever single-link identifier is available from mutation variables/context so
PATCH requests are always sent.

In `@rust/cloud-storage/email_db_client/src/messages/open_tracking.rs`:
- Around line 35-46: The UPDATE currently ignores whether a row was actually
modified; in set_message_open_tracking_token you must inspect the result of
.execute(pool).await (e.g., the returned ExecuteResult/rows_affected) and return
an error (or Err variant) when no rows were updated so callers don’t proceed as
if the token was persisted; update the function to check rows_affected for zero
and propagate a descriptive failure referencing message_id and link_id.

In `@rust/cloud-storage/email_service/src/api/tracking.rs`:
- Around line 37-38: The tracing span for open_pixel_handler is capturing the
request token because token from Path(token): Path<String> isn’t skipped; update
the attribute on async fn open_pixel_handler(State(ctx): State<ApiContext>,
Path(token): Path<String>) -> Response to skip the token as well (e.g., add
token to the skip list in #[tracing::instrument(...)]), so the raw open-tracking
token is not emitted into span fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bb0eb114-8d3d-47d7-ac67-86ee8f16a0c6

📥 Commits

Reviewing files that changed from the base of the PR and between da8ef88 and 7ffdc40.

⛔ Files ignored due to path filters (14)
  • js/app/packages/service-clients/service-email/generated/schemas/apiMessage.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/index.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/settings.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.ts is excluded by !**/generated/**
  • rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.json is excluded by !**/.sqlx/**
📒 Files selected for processing (35)
  • js/app/packages/app/component/settings/Account.tsx
  • js/app/packages/block-email/component/CollapsedMessage.tsx
  • js/app/packages/block-email/component/EmailMessageBody.tsx
  • js/app/packages/block-email/component/EmailMessageTopBar.tsx
  • js/app/packages/block-email/util/prepareEmailBody.ts
  • js/app/packages/block-email/util/readReceipts.test.ts
  • js/app/packages/block-email/util/readReceipts.ts
  • js/app/packages/queries/email/link.ts
  • js/app/packages/service-clients/service-email/client.ts
  • js/app/packages/service-clients/service-email/openapi.json
  • js/app/vitest.config.ts
  • rust/cloud-storage/email/src/domain/assembler.rs
  • rust/cloud-storage/email/src/domain/models/message.rs
  • rust/cloud-storage/email/src/inbound/axum/api_types/message.rs
  • rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs
  • rust/cloud-storage/email/src/outbound/email_pg_repo/thread.rs
  • rust/cloud-storage/email_db_client/fixtures/email_settings.sql
  • rust/cloud-storage/email_db_client/fixtures/message_open_tracking.sql
  • rust/cloud-storage/email_db_client/src/messages/mod.rs
  • rust/cloud-storage/email_db_client/src/messages/open_tracking.rs
  • rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs
  • rust/cloud-storage/email_db_client/src/settings/mod.rs
  • rust/cloud-storage/email_db_client/src/settings/test.rs
  • rust/cloud-storage/email_service/src/api/email/settings/patch.rs
  • rust/cloud-storage/email_service/src/api/mod.rs
  • rust/cloud-storage/email_service/src/api/tracking.rs
  • rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs
  • rust/cloud-storage/email_service/src/util/gmail/send.rs
  • rust/cloud-storage/email_utils/src/lib.rs
  • rust/cloud-storage/email_utils/src/open_tracking.rs
  • rust/cloud-storage/email_utils/src/open_tracking/test.rs
  • rust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sql
  • rust/cloud-storage/models_email/src/email/api/settings.rs
  • rust/cloud-storage/models_email/src/email/db/settings.rs
  • rust/cloud-storage/models_email/src/email/service/settings.rs

Comment thread js/app/packages/block-email/util/readReceipts.test.ts Outdated
Comment thread js/app/packages/queries/email/link.ts
Comment thread rust/cloud-storage/email_db_client/src/messages/open_tracking.rs Outdated
Comment thread rust/cloud-storage/email_service/src/api/tracking.rs Outdated

@evanhutnik evanhutnik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New email-service endpoints (like the tracking endpoint added in this PR) should be created in the email hex crate, not in email-service.

Otherwise looks fine assuming it works when tested

Move the open-tracking pixel endpoint into the `email` hex crate per
review: recording an open now flows through EmailRepo::record_message_open
-> EmailService -> a new inbound open_tracking_router, and email_service
just mounts that router unauthenticated at /t. The handler no longer lives
in email_service, and the duplicate record path was removed from
email_db_client (the send path's token-set helper stays there).

Also from review:
- set_message_open_tracking_token now errors if no row matched, so the
  send path never injects a pixel whose token wasn't persisted
- the pixel handler's tracing span no longer captures the raw token
- the read-receipts settings toggle fetches the links list when the cache
  is empty, so the change is never silently dropped
- replaced UUID-shaped test tokens that tripped secret scanners

https://claude.ai/code/session_016hLXoTuPikV856TsiTKGeC

jbecke commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@evanhutnik moved the tracking endpoint into the email hex crate as requested. Open recording now flows through EmailRepo::record_message_openEmailService::record_message_open → a new inbound::axum::open_tracking_router; email-service just mounts that router unauthenticated at /t and no longer owns any handler logic. The duplicate record path in email_db_client was removed (the send path's token-set helper stays there since the scheduled-send worker is in that layer).

Also addressed CodeRabbit's four points in the same push (1741420): set_message_open_tracking_token now errors when no row matches, the pixel handler's tracing span no longer captures the raw token, the settings toggle falls back to fetching the links list when the cache is empty, and the UUID-shaped test tokens were replaced. Hex repo tests cover the new record_message_open path; full email/email_service/email_db_client suites + clippy are green.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants