Add read receipt (open tracking) support for sent emails#3943
Conversation
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
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis 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)
✏️ 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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (14)
js/app/packages/service-clients/service-email/generated/schemas/apiMessage.tsis excluded by!**/generated/**js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.tsis excluded by!**/generated/**js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.tsis excluded by!**/generated/**js/app/packages/service-clients/service-email/generated/schemas/index.tsis excluded by!**/generated/**js/app/packages/service-clients/service-email/generated/schemas/settings.tsis excluded by!**/generated/**js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.tsis excluded by!**/generated/**rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.jsonis excluded by!**/.sqlx/**rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.jsonis excluded by!**/.sqlx/**
📒 Files selected for processing (35)
js/app/packages/app/component/settings/Account.tsxjs/app/packages/block-email/component/CollapsedMessage.tsxjs/app/packages/block-email/component/EmailMessageBody.tsxjs/app/packages/block-email/component/EmailMessageTopBar.tsxjs/app/packages/block-email/util/prepareEmailBody.tsjs/app/packages/block-email/util/readReceipts.test.tsjs/app/packages/block-email/util/readReceipts.tsjs/app/packages/queries/email/link.tsjs/app/packages/service-clients/service-email/client.tsjs/app/packages/service-clients/service-email/openapi.jsonjs/app/vitest.config.tsrust/cloud-storage/email/src/domain/assembler.rsrust/cloud-storage/email/src/domain/models/message.rsrust/cloud-storage/email/src/inbound/axum/api_types/message.rsrust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rsrust/cloud-storage/email/src/outbound/email_pg_repo/thread.rsrust/cloud-storage/email_db_client/fixtures/email_settings.sqlrust/cloud-storage/email_db_client/fixtures/message_open_tracking.sqlrust/cloud-storage/email_db_client/src/messages/mod.rsrust/cloud-storage/email_db_client/src/messages/open_tracking.rsrust/cloud-storage/email_db_client/src/messages/open_tracking/test.rsrust/cloud-storage/email_db_client/src/settings/mod.rsrust/cloud-storage/email_db_client/src/settings/test.rsrust/cloud-storage/email_service/src/api/email/settings/patch.rsrust/cloud-storage/email_service/src/api/mod.rsrust/cloud-storage/email_service/src/api/tracking.rsrust/cloud-storage/email_service/src/pubsub/scheduled/process.rsrust/cloud-storage/email_service/src/util/gmail/send.rsrust/cloud-storage/email_utils/src/lib.rsrust/cloud-storage/email_utils/src/open_tracking.rsrust/cloud-storage/email_utils/src/open_tracking/test.rsrust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sqlrust/cloud-storage/models_email/src/email/api/settings.rsrust/cloud-storage/models_email/src/email/db/settings.rsrust/cloud-storage/models_email/src/email/service/settings.rs
evanhutnik
left a comment
There was a problem hiding this comment.
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
|
@evanhutnik moved the tracking endpoint into the Also addressed CodeRabbit's four points in the same push ( Generated by Claude Code |
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)
email_service/src/api/tracking.rs): New unauthenticated/t/o/{token}endpoint that records message opens and returns a 1x1 transparent GIFemail_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 countemail_db_client/src/settings/): Updated to supportread_receipts_enabledsetting with partial update semantics (None fields preserve existing values)email_service/src/util/gmail/send.rs): Newattach_open_tracking_pixel()function that:macro_db_migrator/migrations/): Addedopen_tracking_token,first_opened_at,last_opened_at, andopen_countcolumns toemail_messagestableemail_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)
block-email/util/readReceipts.ts):removeOwnTrackingPixels(): Strips Macro tracking pixels from sent message copies to prevent self-trackingmessageSeenAt(): Extracts last open time from message metadataformatSeenLabel()andformatSeenTooltip(): Format read receipt information for UI displayqueries/email/link.ts): NewuseUpdateReadReceiptsMutation()hook for toggling read receipts across all user inboxes with optimistic updates and rollback on errorAccount.tsx: Added read receipts toggle switch in settingsCollapsedMessage.tsxandEmailMessageTopBar.tsx: Display "Seen" indicators with eye icon and timestamps for opened sent messagesEmailMessageBody.tsx: Removes tracking pixels from rendered sent message bodiesprepareEmailBody.ts): Strips tracking pixels when quoting previous messages in repliesservice-email/client.ts): AddedpatchSettings()method for updating inbox settingsopen_count,first_opened_at, andlast_opened_atfields on messages, plusread_receipts_enabledsettingTesting
Implementation Details
email-service*.macro.comhost to handle bodies synced across dev/prod environmentshttps://claude.ai/code/session_016hLXoTuPikV856TsiTKGeC