From 7ffdc407a670483355531e66104cd5c24026b231 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:08:21 +0000 Subject: [PATCH 1/2] feat(email): add Superhuman-style read receipts for sent mail 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 --- .../app/component/settings/Account.tsx | 33 ++++++- .../component/CollapsedMessage.tsx | 20 ++++- .../component/EmailMessageBody.tsx | 5 ++ .../component/EmailMessageTopBar.tsx | 16 ++++ .../block-email/util/prepareEmailBody.ts | 15 +++- .../block-email/util/readReceipts.test.ts | 83 +++++++++++++++++ .../packages/block-email/util/readReceipts.ts | 77 ++++++++++++++++ js/app/packages/queries/email/link.ts | 86 ++++++++++++++++++ .../service-clients/service-email/client.ts | 15 ++++ .../generated/schemas/apiMessage.ts | 8 ++ .../schemas/apiMessageFirstOpenedAt.ts | 11 +++ .../schemas/apiMessageLastOpenedAt.ts | 11 +++ .../service-email/generated/schemas/index.ts | 3 + .../generated/schemas/settings.ts | 2 + .../schemas/settingsReadReceiptsEnabled.ts | 8 ++ .../service-email/openapi.json | 19 ++++ js/app/vitest.config.ts | 3 + ...a37e1d00ff5c4367d0ea9b02f1af32902a031.json | 16 ++++ ...2732e0859b63c99ccb63bb03c58096d312640.json | 36 ++++++++ ...764a3cddff68cc5b68b7e98251c8ec845927.json} | 24 ++++- ...0cf0cfd93aec8de7f4ac7580839b911997b2.json} | 24 ++++- ...db6d0e68f88f0667db25963c07f3bdef00b9c.json | 52 +++++++++++ ...cd2bae66ada34a0be8801a229fedb2d1140b5.json | 22 +++++ ...71db33b02e31a608fdc37eda9df26edefff1.json} | 10 ++- ...9126ad17975d9f7490e8a5d521e2c1eb752c5.json | 29 ------ .../email/src/domain/assembler.rs | 3 + .../email/src/domain/models/message.rs | 12 +++ .../src/inbound/axum/api_types/message.rs | 9 ++ .../src/outbound/email_pg_repo/db_types.rs | 6 ++ .../src/outbound/email_pg_repo/thread.rs | 6 +- .../fixtures/email_settings.sql | 20 +++++ .../fixtures/message_open_tracking.sql | 57 ++++++++++++ .../email_db_client/src/messages/mod.rs | 1 + .../src/messages/open_tracking.rs | 80 +++++++++++++++++ .../src/messages/open_tracking/test.rs | 73 +++++++++++++++ .../email_db_client/src/settings/mod.rs | 44 ++++++--- .../email_db_client/src/settings/test.rs | 83 +++++++++++++++++ .../src/api/email/settings/patch.rs | 4 +- .../email_service/src/api/mod.rs | 3 + .../email_service/src/api/tracking.rs | 69 ++++++++++++++ .../src/pubsub/scheduled/process.rs | 5 +- .../email_service/src/util/gmail/send.rs | 67 ++++++++++++++ rust/cloud-storage/email_utils/src/lib.rs | 1 + .../email_utils/src/open_tracking.rs | 65 ++++++++++++++ .../email_utils/src/open_tracking/test.rs | 90 +++++++++++++++++++ .../20260610161918_email_read_receipts.sql | 19 ++++ .../models_email/src/email/api/settings.rs | 2 + .../models_email/src/email/db/settings.rs | 10 +-- .../src/email/service/settings.rs | 32 ++++--- 49 files changed, 1312 insertions(+), 77 deletions(-) create mode 100644 js/app/packages/block-email/util/readReceipts.test.ts create mode 100644 js/app/packages/block-email/util/readReceipts.ts create mode 100644 js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.ts create mode 100644 js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.ts create mode 100644 js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.ts create mode 100644 rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.json create mode 100644 rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.json rename rust/cloud-storage/.sqlx/{query-e8bf9c53f71988ac7364aef5680e3cb74f7ddc5bacda3caf78db4b8672a8cf72.json => query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.json} (80%) rename rust/cloud-storage/.sqlx/{query-f95b295c9ffa7b96a7907c43c0557d60af3f02fc72c3f541bc94c903034a6748.json => query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.json} (81%) create mode 100644 rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json create mode 100644 rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.json rename rust/cloud-storage/.sqlx/{query-26ab5fa9630526b99a3937baff648f056a532178864f1f62ce55d107d1702f7f.json => query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.json} (61%) delete mode 100644 rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.json create mode 100644 rust/cloud-storage/email_db_client/fixtures/email_settings.sql create mode 100644 rust/cloud-storage/email_db_client/fixtures/message_open_tracking.sql create mode 100644 rust/cloud-storage/email_db_client/src/messages/open_tracking.rs create mode 100644 rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs create mode 100644 rust/cloud-storage/email_db_client/src/settings/test.rs create mode 100644 rust/cloud-storage/email_service/src/api/tracking.rs create mode 100644 rust/cloud-storage/email_utils/src/open_tracking.rs create mode 100644 rust/cloud-storage/email_utils/src/open_tracking/test.rs create mode 100644 rust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sql diff --git a/js/app/packages/app/component/settings/Account.tsx b/js/app/packages/app/component/settings/Account.tsx index 32fd12aa15..023f0e2c18 100644 --- a/js/app/packages/app/component/settings/Account.tsx +++ b/js/app/packages/app/component/settings/Account.tsx @@ -6,7 +6,7 @@ import { isNativeMobilePlatform } from '@core/mobile/isNativeMobilePlatform'; import { toast } from '@core/component/Toast/Toast'; import { staticFileIdEndpoint } from '@core/constant/servers'; import { createStaticFile } from '@core/util/create'; -import { Dialog, Button, Panel, Tooltip } from '@ui'; +import { Dialog, Button, Panel, ToggleSwitch, Tooltip } from '@ui'; import { blockNameToFileExtensions, blockNameToMimeTypes, @@ -59,7 +59,10 @@ import PaywallTeamOwnerView from '../paywall/PaywallTeamOwnerView'; import { ROUTER_BASE_CONCAT } from '@app/constants/routerBase'; import { useEmailLinks, useEmailLinksStatus } from '@core/email-link'; import { useInitGmailLink } from '@queries/auth'; -import { useRemoveInboxMutation } from '@queries/email/link'; +import { + useRemoveInboxMutation, + useUpdateReadReceiptsMutation, +} from '@queries/email/link'; import { type SupportedNotificationSettings, useNotificationSettings, @@ -290,6 +293,16 @@ export function Account() { onSuccess: () => toast.success('Inbox removed'), onError: () => toast.failure('Failed to remove inbox. Please try again.'), }); + const updateReadReceiptsMutation = useUpdateReadReceiptsMutation({ + onError: () => + toast.failure('Failed to update read receipts. Please try again.'), + }); + // Read receipts apply across all inboxes; show enabled only when every inbox + // has them on, so flipping a mixed state on brings every inbox along. + const readReceiptsEnabled = createMemo(() => { + const links = emailLinksQuery.data?.links ?? []; + return links.every((link) => link.settings.read_receipts_enabled !== false); + }); const [removeTarget, setRemoveTarget] = createSignal<{ id: string; email: string; @@ -714,6 +727,22 @@ export function Account() { + + + +
+ + updateReadReceiptsMutation.mutate(checked) + } + /> +
+
+
+
+ {snippet()} + + {(seenAt) => ( + +
+ +
+
+ )} +
{props.message.internal_date_ts && formatShortDate(props.message.internal_date_ts)} diff --git a/js/app/packages/block-email/component/EmailMessageBody.tsx b/js/app/packages/block-email/component/EmailMessageBody.tsx index 4f4ab57145..7b891c7193 100644 --- a/js/app/packages/block-email/component/EmailMessageBody.tsx +++ b/js/app/packages/block-email/component/EmailMessageBody.tsx @@ -23,6 +23,7 @@ import { } from 'solid-js'; import { themeReactive } from '../../theme/signals/themeReactive'; import { themeUpdate } from '../../theme/signals/themeSignals'; +import { removeOwnTrackingPixels } from '../util/readReceipts'; import { fetchImagesViaPlatform, resolveCidImages, @@ -135,6 +136,10 @@ export function EmailMessageBody(props: EmailMessageBodyProps) { shadow.appendChild(styleEl); const messageDiv = document.createElement('div'); messageDiv.innerHTML = source()?.mainContent ?? ''; + // Viewing your own sent mail must not fire its read receipt pixel + if (props.message.is_sent) { + removeOwnTrackingPixels(messageDiv); + } // Mark button-like anchors so the font override doesn't break their sizing for (const a of messageDiv.querySelectorAll( 'a[style]' diff --git a/js/app/packages/block-email/component/EmailMessageTopBar.tsx b/js/app/packages/block-email/component/EmailMessageTopBar.tsx index 61b4271911..156d0809c4 100644 --- a/js/app/packages/block-email/component/EmailMessageTopBar.tsx +++ b/js/app/packages/block-email/component/EmailMessageTopBar.tsx @@ -2,6 +2,7 @@ import { useEmail } from '@core/context/user'; import type { DateValue } from '@core/util/date'; import CaretDown from '@phosphor/caret-down.svg'; import CaretUp from '@phosphor/caret-up.svg'; +import Eye from '@phosphor/eye.svg'; import type { ApiMessage } from '@service-email/generated/schemas'; import { Button, Tooltip } from '@ui'; import { @@ -17,6 +18,11 @@ import { getRecipientDisplayName, getSenderDisplayName, } from '../util/emailUser'; +import { + formatSeenLabel, + formatSeenTooltip, + messageSeenAt, +} from '../util/readReceipts'; import { useEmailContext } from './EmailContext'; import { EmailUserTooltip } from './EmailUserTooltip'; import { type EmailMessageAction, MessageActions } from './MessageActions'; @@ -226,6 +232,16 @@ function HeaderTopRow(props: { isLastMessage={props.isLastMessage} hiddenActions={props.hiddenActions} /> + + {(seenAt) => ( + +
+ + {formatSeenLabel(seenAt())} +
+
+ )} +
diff --git a/js/app/packages/block-email/util/prepareEmailBody.ts b/js/app/packages/block-email/util/prepareEmailBody.ts index 4af764a94b..d24d66fcde 100644 --- a/js/app/packages/block-email/util/prepareEmailBody.ts +++ b/js/app/packages/block-email/util/prepareEmailBody.ts @@ -22,6 +22,7 @@ import { type LexicalEditor, type LexicalNode, } from 'lexical'; +import { removeOwnTrackingPixels } from './readReceipts'; import type { ReplyType } from './replyType'; export function clearEmailBody(editor: LexicalEditor | undefined) { @@ -160,10 +161,18 @@ const $appendPreviousEmail = ( } else { const parser = new DOMParser(); const dom = parser.parseFromString(replyingToBodyHTML, 'text/html'); + // Quoting your own sent mail must not re-embed (or re-fire) its read receipt pixel + if (replyingTo.is_sent) { + removeOwnTrackingPixels(dom); + } // We are checking if the appended reply contains a table. This is not exact, but is a good indicator that an email will contain content that we can not render correctly, in which case the appended reply will be a non-editable HTML Render Node. const hasTable = Boolean(dom.querySelector('table')); if (hasTable) { - const htmlNode = $createHtmlRenderNode({ html: replyingToBodyHTML }); + const htmlNode = $createHtmlRenderNode({ + html: replyingTo.is_sent + ? dom.documentElement.outerHTML + : replyingToBodyHTML, + }); quoteNode.append(htmlNode); } else { const nodes = $generateNodesFromDOM(editor, dom); @@ -324,6 +333,10 @@ function getAppendedReplyElement( replyingToBodyHTML, 'text/html' ); + // Quoting your own sent mail must not re-embed (or re-fire) its read receipt pixel + if (replyingTo.is_sent) { + removeOwnTrackingPixels(innerDom); + } // Extract style tags from head to preserve email styling for weirdo emails with initial style tags. const styleTags = innerDom.head?.querySelectorAll('style'); styleTags?.forEach((style) => { diff --git a/js/app/packages/block-email/util/readReceipts.test.ts b/js/app/packages/block-email/util/readReceipts.test.ts new file mode 100644 index 0000000000..532f34f2ab --- /dev/null +++ b/js/app/packages/block-email/util/readReceipts.test.ts @@ -0,0 +1,83 @@ +import type { ApiMessage } from '@service-email/generated/schemas'; +import { describe, expect, it } from 'vitest'; +import { + formatSeenLabel, + messageSeenAt, + removeOwnTrackingPixels, +} from './readReceipts'; + +const TOKEN = '0c4f12cd-9b46-4e4e-bd6c-7a30efb02e4f'; + +function parse(html: string): Document { + return new DOMParser().parseFromString(html, 'text/html'); +} + +describe('removeOwnTrackingPixels', () => { + it('removes pixels pointing at a macro email service', () => { + const doc = parse( + `

hi

` + + `` + + `` + ); + + removeOwnTrackingPixels(doc); + + expect(doc.querySelectorAll('img').length).toBe(0); + expect(doc.body.textContent).toContain('hi'); + }); + + it('keeps regular images and lookalike third-party pixels', () => { + const doc = parse( + `` + + `` + + `` + ); + + removeOwnTrackingPixels(doc); + + expect(doc.querySelectorAll('img').length).toBe(3); + }); +}); + +describe('messageSeenAt', () => { + const baseMessage = { + is_sent: true, + is_draft: false, + open_count: 2, + first_opened_at: '2026-06-10T10:00:00Z', + last_opened_at: '2026-06-10T12:00:00Z', + } as ApiMessage; + + it('returns the last open time for opened sent messages', () => { + expect(messageSeenAt(baseMessage)?.toISOString()).toBe( + '2026-06-10T12:00:00.000Z' + ); + }); + + it('returns undefined for unopened, received, or draft messages', () => { + expect( + messageSeenAt({ ...baseMessage, open_count: 0 } as ApiMessage) + ).toBeUndefined(); + expect( + messageSeenAt({ ...baseMessage, is_sent: false } as ApiMessage) + ).toBeUndefined(); + expect( + messageSeenAt({ ...baseMessage, is_draft: true } as ApiMessage) + ).toBeUndefined(); + }); +}); + +describe('formatSeenLabel', () => { + it('formats recent opens relative to now', () => { + expect(formatSeenLabel(new Date())).toBe('Seen just now'); + expect(formatSeenLabel(new Date(Date.now() - 5 * 60_000))).toBe( + 'Seen 5m ago' + ); + expect(formatSeenLabel(new Date(Date.now() - 3 * 3_600_000))).toBe( + 'Seen 3h ago' + ); + expect(formatSeenLabel(new Date(Date.now() - 2 * 86_400_000))).toBe( + 'Seen 2d ago' + ); + }); +}); diff --git a/js/app/packages/block-email/util/readReceipts.ts b/js/app/packages/block-email/util/readReceipts.ts new file mode 100644 index 0000000000..89ca199bfd --- /dev/null +++ b/js/app/packages/block-email/util/readReceipts.ts @@ -0,0 +1,77 @@ +import { SERVER_HOSTS } from '@core/constant/servers'; +import type { ApiMessage } from '@service-email/generated/schemas'; + +/** + * Path prefix of the email service's open-tracking (read receipt) pixel + * endpoint. Outgoing tracked messages embed a 1x1 image at + * `{email-service}/t/o/{token}`. + */ +const OPEN_TRACKING_PATH = '/t/o/'; + +function isMacroTrackingPixelUrl(src: string): boolean { + if (!src.includes(OPEN_TRACKING_PATH)) return false; + try { + const url = new URL(src); + if (!url.pathname.startsWith(OPEN_TRACKING_PATH)) return false; + const emailServiceOrigin = new URL(SERVER_HOSTS['email-service']).origin; + return ( + url.origin === emailServiceOrigin || + // Bodies synced across environments still point at a Macro email service. + /^email-service[a-z0-9.-]*\.macro\.com$/.test(url.hostname) + ); + } catch { + return false; + } +} + +/** + * Removes Macro open-tracking pixels from a rendered email body. Applied to + * sent copies so that viewing (or quoting) your own tracked mail never records + * an open against yourself. Received messages keep their pixels. + */ +export function removeOwnTrackingPixels(root: ParentNode): void { + for (const img of Array.from(root.querySelectorAll('img'))) { + const src = img.getAttribute('src') ?? ''; + if (isMacroTrackingPixelUrl(src)) img.remove(); + } +} + +/** + * When this copy of a message was sent from the viewer's inbox and a recipient + * has opened it, returns the most recent open time. Otherwise undefined. + */ +export function messageSeenAt(message: ApiMessage): Date | undefined { + if (!message.is_sent || message.is_draft) return undefined; + if (!message.open_count || !message.last_opened_at) return undefined; + return new Date(message.last_opened_at); +} + +/** Compact "Seen โ€ฆ" label for read receipt indicators, e.g. "Seen 2h ago". */ +export function formatSeenLabel(seenAt: Date): string { + const minutes = Math.floor((Date.now() - seenAt.getTime()) / 60_000); + if (minutes < 1) return 'Seen just now'; + if (minutes < 60) return `Seen ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Seen ${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `Seen ${days}d ago`; + return `Seen ${seenAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })}`; +} + +/** Full read receipt detail for tooltips. */ +export function formatSeenTooltip(message: ApiMessage): string { + const opens = message.open_count ?? 0; + const first = message.first_opened_at + ? new Date(message.first_opened_at).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + : undefined; + const times = opens === 1 ? 'once' : `${opens} times`; + return first ? `Opened ${times} ยท First seen ${first}` : `Opened ${times}`; +} diff --git a/js/app/packages/queries/email/link.ts b/js/app/packages/queries/email/link.ts index 7c8b0c911d..204a11a58f 100644 --- a/js/app/packages/queries/email/link.ts +++ b/js/app/packages/queries/email/link.ts @@ -67,6 +67,92 @@ export function invalidateEmailLinks() { }); } +type UpdateReadReceiptsContext = { + previousLinks: ListLinksResponse | undefined; +}; +type UpdateReadReceiptsCallbacks = MutationCallbacks< + void, + Error, + boolean, + UpdateReadReceiptsContext +>; + +/** + * Toggles read receipts (open tracking on sent mail) across all of the user's + * inboxes. The cached links are updated optimistically so the settings switch + * responds immediately, and rolled back if any inbox fails to update. + */ +export function useUpdateReadReceiptsMutation( + callbacks?: UpdateReadReceiptsCallbacks +) { + const linkIdHeader = useNonPrimaryEmailLinkIdHeader(); + + return useMutation(() => ({ + mutationFn: async (enabled: boolean) => { + const links = + queryClient.getQueryData(emailKeys.links.queryKey) + ?.links ?? []; + await Promise.all( + links.map((link) => + throwOnErr(() => + emailClient.patchSettings( + { settings: { read_receipts_enabled: enabled } }, + linkIdHeader(link.id) + ) + ) + ) + ); + }, + + ...withCallbacks( + { + onMutate: async (enabled) => { + await queryClient.cancelQueries({ + queryKey: emailKeys.links.queryKey, + }); + + const previousLinks = queryClient.getQueryData( + emailKeys.links.queryKey + ); + + queryClient.setQueryData( + emailKeys.links.queryKey, + (old) => + old + ? { + ...old, + links: old.links.map((link) => ({ + ...link, + settings: { + ...link.settings, + read_receipts_enabled: enabled, + }, + })), + } + : undefined + ); + + return { previousLinks }; + }, + + onError: (_error, _enabled, context) => { + if (context?.previousLinks) { + queryClient.setQueryData( + emailKeys.links.queryKey, + context.previousLinks + ); + } + }, + + onSettled: () => { + invalidateEmailLinks(); + }, + }, + callbacks + ), + })); +} + type RemoveInboxContext = { previousLinks: ListLinksResponse | undefined }; type RemoveInboxCallbacks = MutationCallbacks< void, diff --git a/js/app/packages/service-clients/service-email/client.ts b/js/app/packages/service-clients/service-email/client.ts index 27d3b1fcc6..fa19d95870 100644 --- a/js/app/packages/service-clients/service-email/client.ts +++ b/js/app/packages/service-clients/service-email/client.ts @@ -19,9 +19,11 @@ import type { ListEmailFiltersResponse, ListLabelsResponse, ListLinksResponse, + PatchSettingsResponse, ResyncResponse, SendMessageRequest, SendMessageResponse, + Settings, SharedInboxConflictResponse, UpdateLabelBatchRequest, UpdateLabelBatchResponse, @@ -398,6 +400,19 @@ export const emailClient = { ) ).map((result) => result); }, + /** + * Partially updates an inbox's settings; omitted fields keep their current + * value. Scoped to the primary inbox unless `linkId` targets another one. + */ + async patchSettings(args: { settings: Settings }, linkId?: string) { + return ( + await emailFetch('/email/settings', { + method: 'PATCH', + body: JSON.stringify({ settings: args.settings }), + headers: emailLinkHeaders(linkId), + }) + ).map((result) => result); + }, async markThreadAsSeen(args: { thread_id: string }, linkId?: string) { const { thread_id } = args; return ( diff --git a/js/app/packages/service-clients/service-email/generated/schemas/apiMessage.ts b/js/app/packages/service-clients/service-email/generated/schemas/apiMessage.ts index 4695461543..aa7e18105f 100644 --- a/js/app/packages/service-clients/service-email/generated/schemas/apiMessage.ts +++ b/js/app/packages/service-clients/service-email/generated/schemas/apiMessage.ts @@ -13,10 +13,12 @@ import type { ApiMessageBodyHtmlSanitized } from './apiMessageBodyHtmlSanitized' import type { ApiMessageBodyMacro } from './apiMessageBodyMacro'; import type { ApiMessageBodyReplyless } from './apiMessageBodyReplyless'; import type { ApiMessageBodyText } from './apiMessageBodyText'; +import type { ApiMessageFirstOpenedAt } from './apiMessageFirstOpenedAt'; import type { ApiMessageFrom } from './apiMessageFrom'; import type { ApiMessageGlobalId } from './apiMessageGlobalId'; import type { ApiMessageInternalDateTs } from './apiMessageInternalDateTs'; import type { ApiMessageLabel } from './apiMessageLabel'; +import type { ApiMessageLastOpenedAt } from './apiMessageLastOpenedAt'; import type { ApiMessageProviderHistoryId } from './apiMessageProviderHistoryId'; import type { ApiMessageProviderId } from './apiMessageProviderId'; import type { ApiMessageProviderThreadId } from './apiMessageProviderThreadId'; @@ -42,6 +44,8 @@ export interface ApiMessage { cc: ApiContactInfo[]; created_at: string; db_id: string; + /** When a sent message was first opened by a recipient (read receipt). */ + first_opened_at?: ApiMessageFirstOpenedAt; from?: ApiMessageFrom; global_id?: ApiMessageGlobalId; has_attachments: boolean; @@ -52,7 +56,11 @@ export interface ApiMessage { is_sent: boolean; is_starred: boolean; labels: ApiMessageLabel[]; + /** When a sent message was last opened by a recipient (read receipt). */ + last_opened_at?: ApiMessageLastOpenedAt; link_id: string; + /** How many opens have been recorded for a sent message (read receipt). */ + open_count: number; provider_history_id?: ApiMessageProviderHistoryId; provider_id?: ApiMessageProviderId; provider_thread_id?: ApiMessageProviderThreadId; diff --git a/js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.ts b/js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.ts new file mode 100644 index 0000000000..7c638c08d5 --- /dev/null +++ b/js/app/packages/service-clients/service-email/generated/schemas/apiMessageFirstOpenedAt.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 ๐Ÿบ + * Do not edit manually. + * email_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * When a sent message was first opened by a recipient (read receipt). + */ +export type ApiMessageFirstOpenedAt = string | null; diff --git a/js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.ts b/js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.ts new file mode 100644 index 0000000000..7a54b6623f --- /dev/null +++ b/js/app/packages/service-clients/service-email/generated/schemas/apiMessageLastOpenedAt.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 ๐Ÿบ + * Do not edit manually. + * email_service + * OpenAPI spec version: 0.1.0 + */ + +/** + * When a sent message was last opened by a recipient (read receipt). + */ +export type ApiMessageLastOpenedAt = string | null; diff --git a/js/app/packages/service-clients/service-email/generated/schemas/index.ts b/js/app/packages/service-clients/service-email/generated/schemas/index.ts index 0af8f97dc6..c705ebdc88 100644 --- a/js/app/packages/service-clients/service-email/generated/schemas/index.ts +++ b/js/app/packages/service-clients/service-email/generated/schemas/index.ts @@ -81,6 +81,7 @@ export * from './apiMessageBodyHtmlSanitized'; export * from './apiMessageBodyMacro'; export * from './apiMessageBodyReplyless'; export * from './apiMessageBodyText'; +export * from './apiMessageFirstOpenedAt'; export * from './apiMessageFrom'; export * from './apiMessageGlobalId'; export * from './apiMessageInternalDateTs'; @@ -90,6 +91,7 @@ export * from './apiMessageLabelLabelListVisibility'; export * from './apiMessageLabelMessageListVisibility'; export * from './apiMessageLabelName'; export * from './apiMessageLabelType'; +export * from './apiMessageLastOpenedAt'; export * from './apiMessageListVisibility'; export * from './apiMessageProviderHistoryId'; export * from './apiMessageProviderId'; @@ -227,6 +229,7 @@ export * from './resyncResponse'; export * from './sendMessageRequest'; export * from './sendMessageResponse'; export * from './settings'; +export * from './settingsReadReceiptsEnabled'; export * from './settingsSignatureOnRepliesForwards'; export * from './sharedInboxConflictResponse'; export * from './syncStatus'; diff --git a/js/app/packages/service-clients/service-email/generated/schemas/settings.ts b/js/app/packages/service-clients/service-email/generated/schemas/settings.ts index 05937d078a..8b914a5d21 100644 --- a/js/app/packages/service-clients/service-email/generated/schemas/settings.ts +++ b/js/app/packages/service-clients/service-email/generated/schemas/settings.ts @@ -4,8 +4,10 @@ * email_service * OpenAPI spec version: 0.1.0 */ +import type { SettingsReadReceiptsEnabled } from './settingsReadReceiptsEnabled'; import type { SettingsSignatureOnRepliesForwards } from './settingsSignatureOnRepliesForwards'; export interface Settings { + read_receipts_enabled?: SettingsReadReceiptsEnabled; signature_on_replies_forwards?: SettingsSignatureOnRepliesForwards; } diff --git a/js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.ts b/js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.ts new file mode 100644 index 0000000000..b5aa074e9b --- /dev/null +++ b/js/app/packages/service-clients/service-email/generated/schemas/settingsReadReceiptsEnabled.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 ๐Ÿบ + * Do not edit manually. + * email_service + * OpenAPI spec version: 0.1.0 + */ + +export type SettingsReadReceiptsEnabled = boolean | null; diff --git a/js/app/packages/service-clients/service-email/openapi.json b/js/app/packages/service-clients/service-email/openapi.json index 1f04f95320..c4f31a6c47 100644 --- a/js/app/packages/service-clients/service-email/openapi.json +++ b/js/app/packages/service-clients/service-email/openapi.json @@ -3195,6 +3195,7 @@ "attachments", "attachments_draft", "attachments_forwarded", + "open_count", "created_at", "updated_at" ], @@ -3249,6 +3250,11 @@ "type": "string", "format": "uuid" }, + "first_opened_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "When a sent message was first opened by a recipient (read receipt)." + }, "from": { "oneOf": [ { @@ -3288,10 +3294,20 @@ "$ref": "#/components/schemas/ApiMessageLabel" } }, + "last_opened_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "When a sent message was last opened by a recipient (read receipt)." + }, "link_id": { "type": "string", "format": "uuid" }, + "open_count": { + "type": "integer", + "format": "int32", + "description": "How many opens have been recorded for a sent message (read receipt)." + }, "provider_history_id": { "type": ["string", "null"] }, @@ -4614,6 +4630,9 @@ "Settings": { "type": "object", "properties": { + "read_receipts_enabled": { + "type": ["boolean", "null"] + }, "signature_on_replies_forwards": { "type": ["boolean", "null"] } diff --git a/js/app/vitest.config.ts b/js/app/vitest.config.ts index 0d91590e4f..d80caf4e5b 100644 --- a/js/app/vitest.config.ts +++ b/js/app/vitest.config.ts @@ -83,7 +83,10 @@ export default defineConfig({ }, }, { + plugins: [tsconfigPaths()], test: { + environment: 'jsdom', + globals: true, include: ['packages/block-email/**/*.{test,spec}.{ts,tsx}'], name: 'block-email', }, diff --git a/rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.json b/rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.json new file mode 100644 index 0000000000..0cf4f7b26c --- /dev/null +++ b/rust/cloud-storage/.sqlx/query-1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE email_messages\n SET open_tracking_token = $3\n WHERE id = $1 AND link_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1598a509b00354320a4b588bc13a37e1d00ff5c4367d0ea9b02f1af32902a031" +} diff --git a/rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.json b/rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.json new file mode 100644 index 0000000000..561044af76 --- /dev/null +++ b/rust/cloud-storage/.sqlx/query-7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO email_settings (link_id, signature_on_replies_forwards, read_receipts_enabled)\n VALUES ($1, COALESCE($2, false), COALESCE($3, true))\n ON CONFLICT (link_id)\n DO UPDATE SET\n signature_on_replies_forwards = COALESCE($2, email_settings.signature_on_replies_forwards),\n read_receipts_enabled = COALESCE($3, email_settings.read_receipts_enabled),\n updated_at = NOW()\n RETURNING link_id, signature_on_replies_forwards, read_receipts_enabled\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "link_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "signature_on_replies_forwards", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "read_receipts_enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bool", + "Bool" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7fb8846ed9fe7c8c7b6458a69662732e0859b63c99ccb63bb03c58096d312640" +} diff --git a/rust/cloud-storage/.sqlx/query-e8bf9c53f71988ac7364aef5680e3cb74f7ddc5bacda3caf78db4b8672a8cf72.json b/rust/cloud-storage/.sqlx/query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.json similarity index 80% rename from rust/cloud-storage/.sqlx/query-e8bf9c53f71988ac7364aef5680e3cb74f7ddc5bacda3caf78db4b8672a8cf72.json rename to rust/cloud-storage/.sqlx/query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.json index 5d56d3b171..311eae20ae 100644 --- a/rust/cloud-storage/.sqlx/query-e8bf9c53f71988ac7364aef5680e3cb74f7ddc5bacda3caf78db4b8672a8cf72.json +++ b/rust/cloud-storage/.sqlx/query-88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, provider_id, thread_id, provider_thread_id, replying_to_id,\n global_id, link_id, provider_history_id, internal_date_ts, snippet,\n size_estimate, subject, sent_at, has_attachments, is_read, is_starred,\n is_sent, is_draft, body_text, body_html_sanitized, body_macro,\n headers_jsonb, created_at, updated_at\n FROM email_messages\n WHERE is_draft = true\n AND provider_id IS NULL\n AND replying_to_id = ANY($1)\n AND link_id = ANY($2)\n AND thread_id <> $3\n ", + "query": "\n SELECT\n id, provider_id, thread_id, provider_thread_id, replying_to_id,\n global_id, link_id, provider_history_id, internal_date_ts, snippet,\n size_estimate, subject, sent_at, has_attachments, is_read, is_starred,\n is_sent, is_draft, body_text, body_html_sanitized, body_macro,\n headers_jsonb, first_opened_at, last_opened_at, open_count,\n created_at, updated_at\n FROM email_messages\n WHERE is_draft = true\n AND provider_id IS NULL\n AND replying_to_id = ANY($1)\n AND link_id = ANY($2)\n AND thread_id <> $3\n ", "describe": { "columns": [ { @@ -115,11 +115,26 @@ }, { "ordinal": 22, - "name": "created_at", + "name": "first_opened_at", "type_info": "Timestamptz" }, { "ordinal": 23, + "name": "last_opened_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "open_count", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 26, "name": "updated_at", "type_info": "Timestamptz" } @@ -154,9 +169,12 @@ true, true, true, + true, + true, + false, false, false ] }, - "hash": "e8bf9c53f71988ac7364aef5680e3cb74f7ddc5bacda3caf78db4b8672a8cf72" + "hash": "88ff49fec88dc89492d85cfe2be2764a3cddff68cc5b68b7e98251c8ec845927" } diff --git a/rust/cloud-storage/.sqlx/query-f95b295c9ffa7b96a7907c43c0557d60af3f02fc72c3f541bc94c903034a6748.json b/rust/cloud-storage/.sqlx/query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.json similarity index 81% rename from rust/cloud-storage/.sqlx/query-f95b295c9ffa7b96a7907c43c0557d60af3f02fc72c3f541bc94c903034a6748.json rename to rust/cloud-storage/.sqlx/query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.json index 8f396b9fba..680318f830 100644 --- a/rust/cloud-storage/.sqlx/query-f95b295c9ffa7b96a7907c43c0557d60af3f02fc72c3f541bc94c903034a6748.json +++ b/rust/cloud-storage/.sqlx/query-a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, provider_id, thread_id, provider_thread_id, replying_to_id,\n global_id, link_id, provider_history_id, internal_date_ts, snippet,\n size_estimate, subject, sent_at, has_attachments, is_read, is_starred,\n is_sent, is_draft, body_text, body_html_sanitized, body_macro,\n headers_jsonb, created_at, updated_at\n FROM email_messages\n WHERE thread_id = $1\n ORDER BY internal_date_ts DESC\n LIMIT $2 OFFSET $3\n ", + "query": "\n SELECT\n id, provider_id, thread_id, provider_thread_id, replying_to_id,\n global_id, link_id, provider_history_id, internal_date_ts, snippet,\n size_estimate, subject, sent_at, has_attachments, is_read, is_starred,\n is_sent, is_draft, body_text, body_html_sanitized, body_macro,\n headers_jsonb, first_opened_at, last_opened_at, open_count,\n created_at, updated_at\n FROM email_messages\n WHERE thread_id = $1\n ORDER BY internal_date_ts DESC\n LIMIT $2 OFFSET $3\n ", "describe": { "columns": [ { @@ -115,11 +115,26 @@ }, { "ordinal": 22, - "name": "created_at", + "name": "first_opened_at", "type_info": "Timestamptz" }, { "ordinal": 23, + "name": "last_opened_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 24, + "name": "open_count", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 26, "name": "updated_at", "type_info": "Timestamptz" } @@ -154,9 +169,12 @@ true, true, true, + true, + true, + false, false, false ] }, - "hash": "f95b295c9ffa7b96a7907c43c0557d60af3f02fc72c3f541bc94c903034a6748" + "hash": "a551cc083d0b7eaf95ae2602fc550cf0cfd93aec8de7f4ac7580839b911997b2" } diff --git a/rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json b/rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json new file mode 100644 index 0000000000..de35ecbe71 --- /dev/null +++ b/rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE email_messages\n SET first_opened_at = COALESCE(first_opened_at, NOW()),\n last_opened_at = NOW(),\n open_count = open_count + 1\n WHERE open_tracking_token = $1 AND is_sent = true\n RETURNING\n id as message_id,\n link_id,\n thread_id,\n first_opened_at,\n last_opened_at,\n open_count\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "message_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "link_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "thread_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "first_opened_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_opened_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "open_count", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false + ] + }, + "hash": "b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c" +} diff --git a/rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.json b/rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.json new file mode 100644 index 0000000000..77633630e3 --- /dev/null +++ b/rust/cloud-storage/.sqlx/query-b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT read_receipts_enabled\n FROM email_settings\n WHERE link_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "read_receipts_enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b5a6e721b0cb479ed0a1df4a2bdcd2bae66ada34a0be8801a229fedb2d1140b5" +} diff --git a/rust/cloud-storage/.sqlx/query-26ab5fa9630526b99a3937baff648f056a532178864f1f62ce55d107d1702f7f.json b/rust/cloud-storage/.sqlx/query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.json similarity index 61% rename from rust/cloud-storage/.sqlx/query-26ab5fa9630526b99a3937baff648f056a532178864f1f62ce55d107d1702f7f.json rename to rust/cloud-storage/.sqlx/query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.json index 204ec15fdc..3b50e17ebc 100644 --- a/rust/cloud-storage/.sqlx/query-26ab5fa9630526b99a3937baff648f056a532178864f1f62ce55d107d1702f7f.json +++ b/rust/cloud-storage/.sqlx/query-c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT link_id, signature_on_replies_forwards\n FROM email_settings\n WHERE link_id = $1\n ", + "query": "\n SELECT link_id, signature_on_replies_forwards, read_receipts_enabled\n FROM email_settings\n WHERE link_id = $1\n ", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "signature_on_replies_forwards", "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "read_receipts_enabled", + "type_info": "Bool" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + false, false, false ] }, - "hash": "26ab5fa9630526b99a3937baff648f056a532178864f1f62ce55d107d1702f7f" + "hash": "c15f49a43e845355395dda29915771db33b02e31a608fdc37eda9df26edefff1" } diff --git a/rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.json b/rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.json deleted file mode 100644 index b146805915..0000000000 --- a/rust/cloud-storage/.sqlx/query-d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO email_settings (link_id, signature_on_replies_forwards)\n VALUES ($1, $2)\n ON CONFLICT (link_id)\n DO UPDATE SET\n signature_on_replies_forwards = EXCLUDED.signature_on_replies_forwards,\n updated_at = NOW()\n RETURNING link_id, signature_on_replies_forwards\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "link_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "signature_on_replies_forwards", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Bool" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "d84f480cd90b929670bdbe24b839126ad17975d9f7490e8a5d521e2c1eb752c5" -} diff --git a/rust/cloud-storage/email/src/domain/assembler.rs b/rust/cloud-storage/email/src/domain/assembler.rs index 8bf9f5833b..91c1980667 100644 --- a/rust/cloud-storage/email/src/domain/assembler.rs +++ b/rust/cloud-storage/email/src/domain/assembler.rs @@ -66,6 +66,9 @@ pub fn message_from_row( attachments_draft, attachments_forwarded, headers_json: row.headers_json, + first_opened_at: row.first_opened_at, + last_opened_at: row.last_opened_at, + open_count: row.open_count, created_at: row.created_at, updated_at: row.updated_at, } diff --git a/rust/cloud-storage/email/src/domain/models/message.rs b/rust/cloud-storage/email/src/domain/models/message.rs index f1cb78ecdf..03072eb49f 100644 --- a/rust/cloud-storage/email/src/domain/models/message.rs +++ b/rust/cloud-storage/email/src/domain/models/message.rs @@ -51,6 +51,12 @@ pub struct MessageRow { pub body_macro: Option, /// Raw headers as JSON. pub headers_json: Option, + /// When a sent message was first opened by a recipient (read receipt). + pub first_opened_at: Option>, + /// When a sent message was last opened by a recipient (read receipt). + pub last_opened_at: Option>, + /// How many opens have been recorded for a sent message (read receipt). + pub open_count: i32, /// When the message was created. pub created_at: DateTime, /// When the message was last updated. @@ -173,6 +179,12 @@ pub struct Message { pub attachments_forwarded: Vec, /// Raw headers as JSON. pub headers_json: Option, + /// When a sent message was first opened by a recipient (read receipt). + pub first_opened_at: Option>, + /// When a sent message was last opened by a recipient (read receipt). + pub last_opened_at: Option>, + /// How many opens have been recorded for a sent message (read receipt). + pub open_count: i32, /// When the message was created. pub created_at: DateTime, /// When the message was last updated. diff --git a/rust/cloud-storage/email/src/inbound/axum/api_types/message.rs b/rust/cloud-storage/email/src/inbound/axum/api_types/message.rs index 97387ac3bd..30927acf33 100644 --- a/rust/cloud-storage/email/src/inbound/axum/api_types/message.rs +++ b/rust/cloud-storage/email/src/inbound/axum/api_types/message.rs @@ -200,6 +200,12 @@ pub struct ApiMessage { pub attachments_draft: Vec, pub attachments_forwarded: Vec, pub headers_json: Option, + /// When a sent message was first opened by a recipient (read receipt). + pub first_opened_at: Option>, + /// When a sent message was last opened by a recipient (read receipt). + pub last_opened_at: Option>, + /// How many opens have been recorded for a sent message (read receipt). + pub open_count: i32, pub created_at: DateTime, pub updated_at: DateTime, } @@ -251,6 +257,9 @@ impl From for ApiMessage { .map(ApiAttachmentForwarded::from) .collect(), headers_json: m.headers_json, + first_opened_at: m.first_opened_at, + last_opened_at: m.last_opened_at, + open_count: m.open_count, created_at: m.created_at, updated_at: m.updated_at, } diff --git a/rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs b/rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs index 055642f33a..3e1bb9b801 100644 --- a/rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs +++ b/rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs @@ -221,6 +221,9 @@ pub struct DbMessageRow { pub body_html_sanitized: Option, pub body_macro: Option, pub headers_jsonb: Option, + pub first_opened_at: Option>, + pub last_opened_at: Option>, + pub open_count: i32, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } @@ -250,6 +253,9 @@ impl From for MessageRow { body_html_sanitized: row.body_html_sanitized, body_macro: row.body_macro, headers_json: row.headers_jsonb, + first_opened_at: row.first_opened_at, + last_opened_at: row.last_opened_at, + open_count: row.open_count, created_at: row.created_at, updated_at: row.updated_at, } diff --git a/rust/cloud-storage/email/src/outbound/email_pg_repo/thread.rs b/rust/cloud-storage/email/src/outbound/email_pg_repo/thread.rs index 74b081e353..5153ca25f4 100644 --- a/rust/cloud-storage/email/src/outbound/email_pg_repo/thread.rs +++ b/rust/cloud-storage/email/src/outbound/email_pg_repo/thread.rs @@ -44,7 +44,8 @@ pub(super) async fn messages_by_thread_id_paginated( global_id, link_id, provider_history_id, internal_date_ts, snippet, size_estimate, subject, sent_at, has_attachments, is_read, is_starred, is_sent, is_draft, body_text, body_html_sanitized, body_macro, - headers_jsonb, created_at, updated_at + headers_jsonb, first_opened_at, last_opened_at, open_count, + created_at, updated_at FROM email_messages WHERE thread_id = $1 ORDER BY internal_date_ts DESC @@ -78,7 +79,8 @@ pub(super) async fn cross_inbox_reply_drafts( global_id, link_id, provider_history_id, internal_date_ts, snippet, size_estimate, subject, sent_at, has_attachments, is_read, is_starred, is_sent, is_draft, body_text, body_html_sanitized, body_macro, - headers_jsonb, created_at, updated_at + headers_jsonb, first_opened_at, last_opened_at, open_count, + created_at, updated_at FROM email_messages WHERE is_draft = true AND provider_id IS NULL diff --git a/rust/cloud-storage/email_db_client/fixtures/email_settings.sql b/rust/cloud-storage/email_db_client/fixtures/email_settings.sql new file mode 100644 index 0000000000..3793dec906 --- /dev/null +++ b/rust/cloud-storage/email_db_client/fixtures/email_settings.sql @@ -0,0 +1,20 @@ +-- SQL fixture for email settings tests + +------------------------------------------------------------ +-- Link with an existing settings row (signature enabled) +------------------------------------------------------------ + +INSERT INTO email_links (id, macro_id, fusionauth_user_id, email_address, provider, is_sync_active, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-000000000a01', 'macro|settings_user@example.com', '00000000-0000-0000-0000-000000000a01', + 'settings_user@example.com', 'GMAIL', true, NOW(), NOW()); + +INSERT INTO email_settings (link_id, signature_on_replies_forwards) +VALUES ('00000000-0000-0000-0000-000000000a01', true); + +------------------------------------------------------------ +-- Link without a settings row +------------------------------------------------------------ + +INSERT INTO email_links (id, macro_id, fusionauth_user_id, email_address, provider, is_sync_active, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-000000000a02', 'macro|settingsless_user@example.com', '00000000-0000-0000-0000-000000000a02', + 'settingsless_user@example.com', 'GMAIL', true, NOW(), NOW()); diff --git a/rust/cloud-storage/email_db_client/fixtures/message_open_tracking.sql b/rust/cloud-storage/email_db_client/fixtures/message_open_tracking.sql new file mode 100644 index 0000000000..3e0edf27f3 --- /dev/null +++ b/rust/cloud-storage/email_db_client/fixtures/message_open_tracking.sql @@ -0,0 +1,57 @@ +-- SQL fixture for message open tracking (read receipt) tests + +------------------------------------------------------------ +-- User Link +------------------------------------------------------------ + +INSERT INTO email_links (id, macro_id, fusionauth_user_id, email_address, provider, is_sync_active, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-000000000f01', 'macro|open_tracking_user@example.com', '00000000-0000-0000-0000-000000000f01', + 'open_tracking_user@example.com', 'GMAIL', true, NOW(), NOW()); + +------------------------------------------------------------ +-- Contact +------------------------------------------------------------ + +INSERT INTO email_contacts (id, link_id, email_address, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-0000000cf001', + '00000000-0000-0000-0000-000000000f01', + 'open_tracking_user@example.com', + NOW(), NOW()); + +------------------------------------------------------------ +-- Thread +------------------------------------------------------------ + +INSERT INTO email_threads (id, link_id, inbox_visible, is_read, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-00000000f201', + '00000000-0000-0000-0000-000000000f01', + true, true, NOW(), NOW()); + +------------------------------------------------------------ +-- Message 1: Sent message (open tracking target) +------------------------------------------------------------ + +INSERT INTO email_messages (id, thread_id, link_id, provider_id, is_sent, from_contact_id, internal_date_ts, + has_attachments, is_read, is_starred, is_draft, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-00000000f501', + '00000000-0000-0000-0000-00000000f201', + '00000000-0000-0000-0000-000000000f01', + 'provider-msg-f501', + TRUE, + '00000000-0000-0000-0000-0000000cf001', + '2025-01-05 10:00:00 +00:00', + false, true, false, false, NOW(), NOW()); + +------------------------------------------------------------ +-- Message 2: Draft message (must never record opens) +------------------------------------------------------------ + +INSERT INTO email_messages (id, thread_id, link_id, is_sent, from_contact_id, internal_date_ts, + has_attachments, is_read, is_starred, is_draft, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-00000000f502', + '00000000-0000-0000-0000-00000000f201', + '00000000-0000-0000-0000-000000000f01', + FALSE, + '00000000-0000-0000-0000-0000000cf001', + '2025-01-05 11:00:00 +00:00', + false, true, false, true, NOW(), NOW()); diff --git a/rust/cloud-storage/email_db_client/src/messages/mod.rs b/rust/cloud-storage/email_db_client/src/messages/mod.rs index dae9ac5b36..7c95ec5cdc 100644 --- a/rust/cloud-storage/email_db_client/src/messages/mod.rs +++ b/rust/cloud-storage/email_db_client/src/messages/mod.rs @@ -4,6 +4,7 @@ pub mod get_parsed; pub mod get_parsed_search; pub mod get_simple_messages; pub mod insert; +pub mod open_tracking; pub mod replying_to_id; pub mod scheduled; #[cfg(test)] diff --git a/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs b/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs new file mode 100644 index 0000000000..53e255c3d7 --- /dev/null +++ b/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs @@ -0,0 +1,80 @@ +//! Read-receipt (open) tracking for sent messages. +//! +//! At send time a unique token is stored on the outgoing message and embedded +//! in a tracking pixel URL inside the message HTML. When the pixel is fetched +//! the open is recorded here, keyed by that token. + +#[cfg(test)] +mod test; + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use sqlx::types::Uuid; + +/// The state of a message's open tracking after recording an open. +#[derive(Debug, Clone)] +pub struct RecordedMessageOpen { + pub message_id: Uuid, + pub link_id: Uuid, + pub thread_id: Uuid, + pub first_opened_at: Option>, + pub last_opened_at: Option>, + pub open_count: i32, +} + +/// Assigns the open tracking token embedded in an outgoing message's tracking +/// pixel. Called at send time, right before the message is handed to the +/// provider. +#[tracing::instrument(skip(pool), err)] +pub async fn set_message_open_tracking_token( + pool: &PgPool, + message_id: Uuid, + link_id: Uuid, + token: Uuid, +) -> anyhow::Result<()> { + sqlx::query!( + r#" + UPDATE email_messages + SET open_tracking_token = $3 + WHERE id = $1 AND link_id = $2 + "#, + message_id, + link_id, + token, + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// Records an open for the sent message matching a tracking token. Returns +/// `None` when the token doesn't match any sent message. +#[tracing::instrument(skip(pool), err)] +pub async fn record_message_open( + pool: &PgPool, + token: Uuid, +) -> anyhow::Result> { + let recorded = sqlx::query_as!( + RecordedMessageOpen, + r#" + UPDATE email_messages + SET first_opened_at = COALESCE(first_opened_at, NOW()), + last_opened_at = NOW(), + open_count = open_count + 1 + WHERE open_tracking_token = $1 AND is_sent = true + RETURNING + id as message_id, + link_id, + thread_id, + first_opened_at, + last_opened_at, + open_count + "#, + token, + ) + .fetch_optional(pool) + .await?; + + Ok(recorded) +} diff --git a/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs b/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs new file mode 100644 index 0000000000..89c625b07f --- /dev/null +++ b/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs @@ -0,0 +1,73 @@ +use super::*; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use sqlx::{Pool, Postgres}; + +const LINK_ID: &str = "00000000-0000-0000-0000-000000000f01"; +const SENT_MESSAGE_ID: &str = "00000000-0000-0000-0000-00000000f501"; +const DRAFT_MESSAGE_ID: &str = "00000000-0000-0000-0000-00000000f502"; + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../fixtures", scripts("message_open_tracking")) +)] +async fn record_message_open_tracks_first_last_and_count( + pool: Pool, +) -> anyhow::Result<()> { + let message_id = Uuid::parse_str(SENT_MESSAGE_ID)?; + let link_id = Uuid::parse_str(LINK_ID)?; + let token = Uuid::new_v4(); + + set_message_open_tracking_token(&pool, message_id, link_id, token).await?; + + let first_open = record_message_open(&pool, token) + .await? + .expect("first open should match the sent message"); + + assert_eq!(first_open.message_id, message_id); + assert_eq!(first_open.link_id, link_id); + assert_eq!(first_open.open_count, 1); + assert!(first_open.first_opened_at.is_some()); + assert_eq!(first_open.first_opened_at, first_open.last_opened_at); + + let second_open = record_message_open(&pool, token) + .await? + .expect("second open should match the sent message"); + + assert_eq!(second_open.open_count, 2); + assert_eq!(second_open.first_opened_at, first_open.first_opened_at); + assert!(second_open.last_opened_at >= first_open.last_opened_at); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../fixtures", scripts("message_open_tracking")) +)] +async fn record_message_open_returns_none_for_unknown_token( + pool: Pool, +) -> anyhow::Result<()> { + let recorded = record_message_open(&pool, Uuid::new_v4()).await?; + + assert!(recorded.is_none()); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../fixtures", scripts("message_open_tracking")) +)] +async fn record_message_open_ignores_unsent_messages(pool: Pool) -> anyhow::Result<()> { + let draft_id = Uuid::parse_str(DRAFT_MESSAGE_ID)?; + let link_id = Uuid::parse_str(LINK_ID)?; + let token = Uuid::new_v4(); + + set_message_open_tracking_token(&pool, draft_id, link_id, token).await?; + + let recorded = record_message_open(&pool, token).await?; + + assert!(recorded.is_none()); + + Ok(()) +} diff --git a/rust/cloud-storage/email_db_client/src/settings/mod.rs b/rust/cloud-storage/email_db_client/src/settings/mod.rs index fa5500c8d7..681ce5fa71 100644 --- a/rust/cloud-storage/email_db_client/src/settings/mod.rs +++ b/rust/cloud-storage/email_db_client/src/settings/mod.rs @@ -1,28 +1,32 @@ +#[cfg(test)] +mod test; + use models_email::{db, service}; use sqlx::PgPool; use sqlx::types::Uuid; -/// Updates a user's settings. +/// Applies a partial update to a link's settings. `None` fields keep their +/// current value (or the column default when the row is first created). #[tracing::instrument(skip(pool), err)] pub async fn patch_settings( pool: &PgPool, - service_settings: service::settings::Settings, + settings_patch: service::settings::SettingsPatch, ) -> anyhow::Result { - let db_settings = db::settings::Settings::from(service_settings); - let result = sqlx::query_as!( db::settings::Settings, r#" - INSERT INTO email_settings (link_id, signature_on_replies_forwards) - VALUES ($1, $2) + INSERT INTO email_settings (link_id, signature_on_replies_forwards, read_receipts_enabled) + VALUES ($1, COALESCE($2, false), COALESCE($3, true)) ON CONFLICT (link_id) DO UPDATE SET - signature_on_replies_forwards = EXCLUDED.signature_on_replies_forwards, + signature_on_replies_forwards = COALESCE($2, email_settings.signature_on_replies_forwards), + read_receipts_enabled = COALESCE($3, email_settings.read_receipts_enabled), updated_at = NOW() - RETURNING link_id, signature_on_replies_forwards + RETURNING link_id, signature_on_replies_forwards, read_receipts_enabled "#, - db_settings.link_id, - db_settings.signature_on_replies_forwards, + settings_patch.link_id, + settings_patch.signature_on_replies_forwards, + settings_patch.read_receipts_enabled, ) .fetch_one(pool) .await?; @@ -39,7 +43,7 @@ pub async fn fetch_settings( let result = sqlx::query_as!( db::settings::Settings, r#" - SELECT link_id, signature_on_replies_forwards + SELECT link_id, signature_on_replies_forwards, read_receipts_enabled FROM email_settings WHERE link_id = $1 "#, @@ -50,3 +54,21 @@ pub async fn fetch_settings( Ok(service::settings::Settings::from(result)) } + +/// Returns whether read receipts (open tracking on outgoing mail) are enabled +/// for a link. Links without a settings row fall back to the default (enabled). +#[tracing::instrument(skip(pool), err)] +pub async fn fetch_read_receipts_enabled(pool: &PgPool, link_id: Uuid) -> anyhow::Result { + let enabled = sqlx::query_scalar!( + r#" + SELECT read_receipts_enabled + FROM email_settings + WHERE link_id = $1 + "#, + link_id + ) + .fetch_optional(pool) + .await?; + + Ok(enabled.unwrap_or(true)) +} diff --git a/rust/cloud-storage/email_db_client/src/settings/test.rs b/rust/cloud-storage/email_db_client/src/settings/test.rs new file mode 100644 index 0000000000..807064e118 --- /dev/null +++ b/rust/cloud-storage/email_db_client/src/settings/test.rs @@ -0,0 +1,83 @@ +use super::*; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use models_email::service::settings::SettingsPatch; +use sqlx::{Pool, Postgres}; + +const LINK_WITH_SETTINGS: &str = "00000000-0000-0000-0000-000000000a01"; +const LINK_WITHOUT_SETTINGS: &str = "00000000-0000-0000-0000-000000000a02"; + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../fixtures", scripts("email_settings")) +)] +async fn read_receipts_default_to_enabled(pool: Pool) -> anyhow::Result<()> { + let with_settings = Uuid::parse_str(LINK_WITH_SETTINGS)?; + let without_settings = Uuid::parse_str(LINK_WITHOUT_SETTINGS)?; + + assert!(fetch_read_receipts_enabled(&pool, with_settings).await?); + assert!(fetch_read_receipts_enabled(&pool, without_settings).await?); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../fixtures", scripts("email_settings")) +)] +async fn patch_settings_only_updates_provided_fields(pool: Pool) -> anyhow::Result<()> { + let link_id = Uuid::parse_str(LINK_WITH_SETTINGS)?; + + // Disable read receipts without touching the signature setting. + let updated = patch_settings( + &pool, + SettingsPatch { + link_id, + signature_on_replies_forwards: None, + read_receipts_enabled: Some(false), + }, + ) + .await?; + + assert!(updated.signature_on_replies_forwards); + assert!(!updated.read_receipts_enabled); + assert!(!fetch_read_receipts_enabled(&pool, link_id).await?); + + // And the reverse: patching the signature leaves read receipts disabled. + let updated = patch_settings( + &pool, + SettingsPatch { + link_id, + signature_on_replies_forwards: Some(false), + read_receipts_enabled: None, + }, + ) + .await?; + + assert!(!updated.signature_on_replies_forwards); + assert!(!updated.read_receipts_enabled); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../fixtures", scripts("email_settings")) +)] +async fn patch_settings_creates_row_with_defaults(pool: Pool) -> anyhow::Result<()> { + let link_id = Uuid::parse_str(LINK_WITHOUT_SETTINGS)?; + + let created = patch_settings( + &pool, + SettingsPatch { + link_id, + signature_on_replies_forwards: None, + read_receipts_enabled: Some(true), + }, + ) + .await?; + + assert!(!created.signature_on_replies_forwards); + assert!(created.read_receipts_enabled); + + Ok(()) +} diff --git a/rust/cloud-storage/email_service/src/api/email/settings/patch.rs b/rust/cloud-storage/email_service/src/api/email/settings/patch.rs index 1be91c758c..6aec5ee162 100644 --- a/rust/cloud-storage/email_service/src/api/email/settings/patch.rs +++ b/rust/cloud-storage/email_service/src/api/email/settings/patch.rs @@ -56,10 +56,10 @@ pub async fn patch_settings_handler( link: Extension, Json(api_settings): Json, ) -> Result, PatchSettingsError> { - let service_settings = service::settings::Settings::new(api_settings.settings, link.id); + let settings_patch = service::settings::SettingsPatch::new(api_settings.settings, link.id); let updated_settings = - email_db_client::settings::patch_settings(&ctx.db, service_settings).await?; + email_db_client::settings::patch_settings(&ctx.db, settings_patch).await?; let response_settings = api::settings::Settings::from(updated_settings); diff --git a/rust/cloud-storage/email_service/src/api/mod.rs b/rust/cloud-storage/email_service/src/api/mod.rs index 89ca61fa12..c8edbf3d06 100644 --- a/rust/cloud-storage/email_service/src/api/mod.rs +++ b/rust/cloud-storage/email_service/src/api/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod gmail; mod internal; mod middleware; pub(crate) mod swagger; +mod tracking; pub async fn setup_and_serve(state: ApiContext) -> anyhow::Result<()> { let env = state.config.environment; @@ -56,6 +57,8 @@ fn api_router(state: ApiContext) -> Router { )), ) .nest("/gmail", gmail::router()) + // Read-receipt tracking pixels; fetched by recipient mail clients, so no auth. + .nest("/t", tracking::router()) .nest( "/internal", internal::router().layer( diff --git a/rust/cloud-storage/email_service/src/api/tracking.rs b/rust/cloud-storage/email_service/src/api/tracking.rs new file mode 100644 index 0000000000..c4e86855e9 --- /dev/null +++ b/rust/cloud-storage/email_service/src/api/tracking.rs @@ -0,0 +1,69 @@ +//! Public open-tracking (read receipt) pixel endpoint. +//! +//! Outgoing messages with read receipts enabled embed a 1x1 transparent image +//! pointing at `/t/o/{token}`. Recipient mail clients (or their image proxies) +//! fetch it when the message is opened, and we record the open against the +//! sender's copy of the message. +//! +//! The route is unauthenticated by design โ€” it is fetched by arbitrary mail +//! clients โ€” and always returns the pixel, so callers learn nothing about +//! whether a token was valid. + +use crate::api::context::ApiContext; +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use uuid::Uuid; + +/// A 1x1 transparent GIF (GIF89a with the transparent color flag set). +const TRANSPARENT_PIXEL_GIF: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x01, 0x00, 0x01, 0x00, // 1x1 + 0x80, 0x00, 0x00, // global color table with 2 entries + 0x00, 0x00, 0x00, // color 0: black + 0xFF, 0xFF, 0xFF, // color 1: white + 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, // graphic control: transparent index 0 + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // image descriptor + 0x02, 0x02, 0x44, 0x01, 0x00, // image data + 0x3B, // trailer +]; + +pub fn router() -> Router { + Router::new().route("/o/{token}", get(open_pixel_handler)) +} + +#[tracing::instrument(skip(ctx))] +async fn open_pixel_handler(State(ctx): State, Path(token): Path) -> Response { + if let Ok(token) = Uuid::parse_str(token.trim()) { + match email_db_client::messages::open_tracking::record_message_open(&ctx.db, token).await { + Ok(Some(open)) => { + tracing::debug!( + message_id = %open.message_id, + link_id = %open.link_id, + open_count = open.open_count, + "Recorded email open" + ); + } + Ok(None) => {} + Err(e) => { + tracing::error!(error = ?e, "Failed to record email open"); + } + } + } + + ( + [ + (header::CONTENT_TYPE, "image/gif"), + // Repeat opens must re-fetch the pixel rather than hit a cache. + ( + header::CACHE_CONTROL, + "no-cache, no-store, must-revalidate, max-age=0", + ), + (header::PRAGMA, "no-cache"), + ], + TRANSPARENT_PIXEL_GIF, + ) + .into_response() +} diff --git a/rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs b/rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs index 881e0ab540..ccdd9a6d0e 100644 --- a/rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs +++ b/rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs @@ -1,7 +1,7 @@ use crate::pubsub::scheduled::context::ScheduledContext; use crate::util::gmail::auth::fetch_gmail_access_token_from_link; use crate::util::gmail::send::{ - cleanup_draft_attachments, fetch_and_attach_draft_attachments, + attach_open_tracking_pixel, cleanup_draft_attachments, fetch_and_attach_draft_attachments, fetch_and_attach_forwarded_attachments, generate_email_threading_headers, }; use anyhow::Context; @@ -150,6 +150,9 @@ async fn process_scheduled_message_inner( ) .await?; + // Embed a read-receipt tracking pixel when enabled for this inbox + attach_open_tracking_pixel(&ctx.db, &mut message_to_send).await; + // send message to gmail api ctx.gmail_client .send_message( diff --git a/rust/cloud-storage/email_service/src/util/gmail/send.rs b/rust/cloud-storage/email_service/src/util/gmail/send.rs index e7f0d08dde..8b485b525a 100644 --- a/rust/cloud-storage/email_service/src/util/gmail/send.rs +++ b/rust/cloud-storage/email_service/src/util/gmail/send.rs @@ -164,6 +164,73 @@ pub async fn fetch_and_attach_forwarded_attachments( Ok(()) } +/// Embed a read-receipt tracking pixel in the outgoing message HTML when the +/// sending inbox has read receipts enabled. +/// +/// Pixels from earlier messages quoted in the body are always removed, so a +/// reply never re-sends (or re-triggers) tracking from the rest of the thread. +/// +/// Best effort: failures are logged and the message goes out without tracking, +/// never blocking the send. +#[tracing::instrument(skip(db, message_to_send), fields(message_db_id = ?message_to_send.db_id))] +pub async fn attach_open_tracking_pixel( + db: &sqlx::PgPool, + message_to_send: &mut message::MessageToSend, +) { + let _ = try_attach_open_tracking_pixel(db, message_to_send) + .await + .inspect_err(|e| { + tracing::warn!( + error = ?e, + message_db_id = ?message_to_send.db_id, + "Failed to attach open tracking pixel; sending without read receipt tracking" + ); + }); +} + +async fn try_attach_open_tracking_pixel( + db: &sqlx::PgPool, + message_to_send: &mut message::MessageToSend, +) -> anyhow::Result<()> { + let Some(db_id) = message_to_send.db_id else { + return Ok(()); + }; + let Some(html) = message_to_send.body_html.as_deref() else { + return Ok(()); + }; + + let base_url = macro_service_urls::EmailServiceUrl::new() + .context("unable to resolve email service base url")? + .to_string(); + + let mut new_html = email_utils::open_tracking::strip_open_tracking_pixels(html, &base_url); + + let enabled = + email_db_client::settings::fetch_read_receipts_enabled(db, message_to_send.link_id) + .await + .context("unable to fetch read receipt settings")?; + + if enabled { + let token = Uuid::new_v4(); + email_db_client::messages::open_tracking::set_message_open_tracking_token( + db, + db_id, + message_to_send.link_id, + token, + ) + .await + .context("unable to persist open tracking token")?; + + let pixel_url = + email_utils::open_tracking::open_tracking_pixel_url(&base_url, &token.to_string()); + new_html = email_utils::open_tracking::inject_open_tracking_pixel(&new_html, &pixel_url); + } + + message_to_send.body_html = Some(new_html); + + Ok(()) +} + #[tracing::instrument(skip(db, s3_client))] pub async fn cleanup_draft_attachments( db: sqlx::PgPool, diff --git a/rust/cloud-storage/email_utils/src/lib.rs b/rust/cloud-storage/email_utils/src/lib.rs index a14d2f2df9..e190933278 100644 --- a/rust/cloud-storage/email_utils/src/lib.rs +++ b/rust/cloud-storage/email_utils/src/lib.rs @@ -2,6 +2,7 @@ pub mod body_parsed; pub mod body_replyless; pub mod generic_email; pub mod normalize_contact; +pub mod open_tracking; pub mod token_cache_key; pub use generic_email::{dedupe_emails, is_generic_email}; diff --git a/rust/cloud-storage/email_utils/src/open_tracking.rs b/rust/cloud-storage/email_utils/src/open_tracking.rs new file mode 100644 index 0000000000..45610af408 --- /dev/null +++ b/rust/cloud-storage/email_utils/src/open_tracking.rs @@ -0,0 +1,65 @@ +//! Helpers for read-receipt (open) tracking pixels embedded in outgoing email +//! HTML. A unique token per message is woven into a pixel URL served by the +//! email service; fetches of that URL record opens on the sender's copy. + +#[cfg(test)] +mod test; + +use regex::Regex; + +/// Path prefix of the email service's public open-tracking pixel endpoint. +pub const OPEN_TRACKING_PATH: &str = "/t/o/"; + +/// Builds the public pixel URL for a tracking token. +pub fn open_tracking_pixel_url(email_service_base_url: &str, token: &str) -> String { + format!( + "{}{}{}", + email_service_base_url.trim_end_matches('/'), + OPEN_TRACKING_PATH, + token + ) +} + +/// Appends a 1x1 transparent tracking pixel to an HTML body. The pixel is +/// inserted just before the closing `` tag when present, otherwise +/// appended to the end of the document. +pub fn inject_open_tracking_pixel(html: &str, pixel_url: &str) -> String { + let pixel = format!( + r#""# + ); + + // Byte indices align between the original and its ASCII-lowercased copy. + match html.to_ascii_lowercase().rfind("") { + Some(idx) => { + let mut out = String::with_capacity(html.len() + pixel.len()); + out.push_str(&html[..idx]); + out.push_str(&pixel); + out.push_str(&html[idx..]); + out + } + None => format!("{html}{pixel}"), + } +} + +/// Removes Macro open-tracking pixels from an HTML body. +/// +/// Outgoing messages quote earlier messages in the thread, and the sender's +/// synced copies of those carry their own tracking pixels; without this pass a +/// reply would re-send (and re-trigger) every pixel in the quoted chain. +/// Matches pixels pointing at `email_service_base_url` as well as any +/// `email-service*.macro.com` host, so bodies that crossed environments are +/// still cleaned. +pub fn strip_open_tracking_pixels(html: &str, email_service_base_url: &str) -> String { + let base = regex::escape(email_service_base_url.trim_end_matches('/')); + let path = regex::escape(OPEN_TRACKING_PATH); + let pattern = format!( + r#"(?is)]*\bsrc\s*=\s*["'](?:{base}|https?://email-service[a-z0-9.-]*\.macro\.com){path}[^"']*["'][^>]*/?>"# + ); + + // The pattern is built from constants and an escaped URL, so it always compiles. + let Ok(re) = Regex::new(&pattern) else { + return html.to_string(); + }; + + re.replace_all(html, "").into_owned() +} diff --git a/rust/cloud-storage/email_utils/src/open_tracking/test.rs b/rust/cloud-storage/email_utils/src/open_tracking/test.rs new file mode 100644 index 0000000000..2d9a777118 --- /dev/null +++ b/rust/cloud-storage/email_utils/src/open_tracking/test.rs @@ -0,0 +1,90 @@ +use super::*; + +const BASE_URL: &str = "https://email-service.macro.com"; +const TOKEN: &str = "0c4f12cd-9b46-4e4e-bd6c-7a30efb02e4f"; + +#[test] +fn pixel_url_joins_base_and_token() { + assert_eq!( + open_tracking_pixel_url(BASE_URL, TOKEN), + format!("https://email-service.macro.com/t/o/{TOKEN}") + ); + // Trailing slash on the base URL doesn't double up. + assert_eq!( + open_tracking_pixel_url("http://localhost:8087/", TOKEN), + format!("http://localhost:8087/t/o/{TOKEN}") + ); +} + +#[test] +fn inject_appends_when_no_body_tag() { + let url = open_tracking_pixel_url(BASE_URL, TOKEN); + let html = "
hello
"; + let injected = inject_open_tracking_pixel(html, &url); + + assert!(injected.starts_with(html)); + assert!(injected.contains(&format!(r#"").unwrap(); + assert!(pixel_idx < body_close_idx); + assert!(injected.ends_with("")); +} + +#[test] +fn strip_removes_own_pixels_only() { + let url = open_tracking_pixel_url(BASE_URL, TOKEN); + let html = format!( + r#"

hi

"# + ); + + let stripped = strip_open_tracking_pixels(&html, BASE_URL); + + assert!(!stripped.contains("/t/o/")); + assert!(stripped.contains("https://example.com/logo.png")); + assert!(stripped.contains("

hi

")); +} + +#[test] +fn strip_removes_cross_environment_pixels() { + // A quoted body that was sent from dev, stripped while running in prod. + let html = format!( + r#"
"# + ); + + let stripped = strip_open_tracking_pixels(&html, BASE_URL); + + assert!(!stripped.contains("/t/o/")); + assert!(stripped.contains("
")); +} + +#[test] +fn strip_keeps_lookalike_third_party_pixels() { + // Same path shape on a non-Macro host must survive. + let html = r#""#; + + let stripped = strip_open_tracking_pixels(html, BASE_URL); + + assert_eq!(stripped, html); +} + +#[test] +fn inject_after_strip_round_trips() { + let old_url = open_tracking_pixel_url(BASE_URL, "11111111-2222-3333-4444-555555555555"); + let html = format!(r#"

reply

"#); + + let new_url = open_tracking_pixel_url(BASE_URL, TOKEN); + let cleaned = strip_open_tracking_pixels(&html, BASE_URL); + let injected = inject_open_tracking_pixel(&cleaned, &new_url); + + assert!(!injected.contains("11111111-2222-3333-4444-555555555555")); + assert_eq!(injected.matches("/t/o/").count(), 1); + assert!(injected.contains(TOKEN)); +} diff --git a/rust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sql b/rust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sql new file mode 100644 index 0000000000..a7ba6682ba --- /dev/null +++ b/rust/cloud-storage/macro_db_client/migrations/20260610161918_email_read_receipts.sql @@ -0,0 +1,19 @@ +-- Read receipts (open tracking) for sent emails. +-- +-- A unique token is generated per outgoing message and embedded as a tracking +-- pixel URL in the message HTML. When the pixel is fetched, the open is +-- recorded by token on the sender's copy of the message. +ALTER TABLE email_messages + ADD COLUMN open_tracking_token uuid, + ADD COLUMN first_opened_at timestamptz, + ADD COLUMN last_opened_at timestamptz, + ADD COLUMN open_count integer NOT NULL DEFAULT 0; + +-- The tracking pixel endpoint resolves messages by token. +CREATE UNIQUE INDEX email_messages_open_tracking_token_idx + ON email_messages (open_tracking_token) + WHERE open_tracking_token IS NOT NULL; + +-- Per-inbox opt-out. Read receipts are on by default, like Superhuman. +ALTER TABLE email_settings + ADD COLUMN read_receipts_enabled boolean NOT NULL DEFAULT true; diff --git a/rust/cloud-storage/models_email/src/email/api/settings.rs b/rust/cloud-storage/models_email/src/email/api/settings.rs index a7f06dc450..c858722c39 100644 --- a/rust/cloud-storage/models_email/src/email/api/settings.rs +++ b/rust/cloud-storage/models_email/src/email/api/settings.rs @@ -4,12 +4,14 @@ use utoipa::ToSchema; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Settings { pub signature_on_replies_forwards: Option, + pub read_receipts_enabled: Option, } impl From for Settings { fn from(service_settings: crate::email::service::settings::Settings) -> Self { Settings { signature_on_replies_forwards: Some(service_settings.signature_on_replies_forwards), + read_receipts_enabled: Some(service_settings.read_receipts_enabled), } } } diff --git a/rust/cloud-storage/models_email/src/email/db/settings.rs b/rust/cloud-storage/models_email/src/email/db/settings.rs index 6986ba3f13..dc45df7dd0 100644 --- a/rust/cloud-storage/models_email/src/email/db/settings.rs +++ b/rust/cloud-storage/models_email/src/email/db/settings.rs @@ -6,13 +6,5 @@ use uuid::Uuid; pub struct Settings { pub link_id: Uuid, pub signature_on_replies_forwards: bool, -} - -impl From for Settings { - fn from(service_settings: crate::email::service::settings::Settings) -> Self { - Settings { - link_id: service_settings.link_id, - signature_on_replies_forwards: service_settings.signature_on_replies_forwards, - } - } + pub read_receipts_enabled: bool, } diff --git a/rust/cloud-storage/models_email/src/email/service/settings.rs b/rust/cloud-storage/models_email/src/email/service/settings.rs index 9d2ad54a37..1dfede2bb1 100644 --- a/rust/cloud-storage/models_email/src/email/service/settings.rs +++ b/rust/cloud-storage/models_email/src/email/service/settings.rs @@ -6,17 +6,7 @@ use uuid::Uuid; pub struct Settings { pub link_id: Uuid, pub signature_on_replies_forwards: bool, -} - -impl Settings { - pub fn new(api_settings: crate::email::api::settings::Settings, link_id: Uuid) -> Self { - Settings { - link_id, - signature_on_replies_forwards: api_settings - .signature_on_replies_forwards - .unwrap_or(false), - } - } + pub read_receipts_enabled: bool, } impl From for Settings { @@ -24,6 +14,26 @@ impl From for Settings { Settings { link_id: db_settings.link_id, signature_on_replies_forwards: db_settings.signature_on_replies_forwards, + read_receipts_enabled: db_settings.read_receipts_enabled, + } + } +} + +/// A partial settings update. Fields left as `None` keep their current value, +/// so a client can patch a single setting without clobbering the others. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsPatch { + pub link_id: Uuid, + pub signature_on_replies_forwards: Option, + pub read_receipts_enabled: Option, +} + +impl SettingsPatch { + pub fn new(api_settings: crate::email::api::settings::Settings, link_id: Uuid) -> Self { + SettingsPatch { + link_id, + signature_on_replies_forwards: api_settings.signature_on_replies_forwards, + read_receipts_enabled: api_settings.read_receipts_enabled, } } } From 1741420658c71f8fedf442fefe71b70215c8bdbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:40:20 +0000 Subject: [PATCH 2/2] refactor(email): address PR review on read receipts 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 --- .../block-email/util/readReceipts.test.ts | 4 +- js/app/packages/queries/email/link.ts | 9 ++- ...8406b9d676b5810939ccb61a8229ce914a8f.json} | 18 +---- .../email/src/domain/models/message.rs | 14 ++++ .../email/src/domain/models/mod.rs | 2 +- rust/cloud-storage/email/src/domain/ports.rs | 20 +++++- .../email/src/domain/service/mod.rs | 10 +++ rust/cloud-storage/email/src/inbound/axum.rs | 1 + .../src/inbound/axum/open_tracking_router.rs} | 40 +++++++---- .../src/outbound/email_pg_repo/message.rs | 30 +++++++- .../email/src/outbound/email_pg_repo/mod.rs | 8 ++- .../outbound/email_pg_repo/test/message.rs | 70 +++++++++++++++++++ .../src/messages/open_tracking.rs | 62 ++++------------ .../src/messages/open_tracking/test.rs | 52 ++++---------- .../email_service/src/api/mod.rs | 6 +- .../email_utils/src/open_tracking/test.rs | 4 +- 16 files changed, 226 insertions(+), 124 deletions(-) rename rust/cloud-storage/.sqlx/{query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json => query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json} (65%) rename rust/cloud-storage/{email_service/src/api/tracking.rs => email/src/inbound/axum/open_tracking_router.rs} (68%) diff --git a/js/app/packages/block-email/util/readReceipts.test.ts b/js/app/packages/block-email/util/readReceipts.test.ts index 532f34f2ab..af9dae9884 100644 --- a/js/app/packages/block-email/util/readReceipts.test.ts +++ b/js/app/packages/block-email/util/readReceipts.test.ts @@ -6,7 +6,9 @@ import { removeOwnTrackingPixels, } from './readReceipts'; -const TOKEN = '0c4f12cd-9b46-4e4e-bd6c-7a30efb02e4f'; +// Not a real UUID โ€” the pixel URL matcher keys off host + path, not token +// shape โ€” and a non-UUID literal keeps secret scanners quiet. +const TOKEN = 'open-tracking-token-test'; function parse(html: string): Document { return new DOMParser().parseFromString(html, 'text/html'); diff --git a/js/app/packages/queries/email/link.ts b/js/app/packages/queries/email/link.ts index 204a11a58f..6b15cb4687 100644 --- a/js/app/packages/queries/email/link.ts +++ b/js/app/packages/queries/email/link.ts @@ -89,9 +89,14 @@ export function useUpdateReadReceiptsMutation( return useMutation(() => ({ mutationFn: async (enabled: boolean) => { + // The toggle can be hit before the links list has been cached (or after + // eviction); fall back to the authoritative list so the change is never + // silently dropped. + const cachedLinks = queryClient.getQueryData( + emailKeys.links.queryKey + )?.links; const links = - queryClient.getQueryData(emailKeys.links.queryKey) - ?.links ?? []; + cachedLinks ?? (await throwOnErr(() => emailClient.getLinks())).links; await Promise.all( links.map((link) => throwOnErr(() => diff --git a/rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json b/rust/cloud-storage/.sqlx/query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json similarity index 65% rename from rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json rename to rust/cloud-storage/.sqlx/query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json index de35ecbe71..7ad89bd1ee 100644 --- a/rust/cloud-storage/.sqlx/query-b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c.json +++ b/rust/cloud-storage/.sqlx/query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE email_messages\n SET first_opened_at = COALESCE(first_opened_at, NOW()),\n last_opened_at = NOW(),\n open_count = open_count + 1\n WHERE open_tracking_token = $1 AND is_sent = true\n RETURNING\n id as message_id,\n link_id,\n thread_id,\n first_opened_at,\n last_opened_at,\n open_count\n ", + "query": "\n UPDATE email_messages\n SET first_opened_at = COALESCE(first_opened_at, NOW()),\n last_opened_at = NOW(),\n open_count = open_count + 1\n WHERE open_tracking_token = $1 AND is_sent = true\n RETURNING\n id as message_id,\n link_id,\n thread_id as thread_db_id,\n open_count\n ", "describe": { "columns": [ { @@ -15,21 +15,11 @@ }, { "ordinal": 2, - "name": "thread_id", + "name": "thread_db_id", "type_info": "Uuid" }, { "ordinal": 3, - "name": "first_opened_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_opened_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, "name": "open_count", "type_info": "Int4" } @@ -43,10 +33,8 @@ false, false, false, - true, - true, false ] }, - "hash": "b243193681164b615c90b747fdfdb6d0e68f88f0667db25963c07f3bdef00b9c" + "hash": "38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f" } diff --git a/rust/cloud-storage/email/src/domain/models/message.rs b/rust/cloud-storage/email/src/domain/models/message.rs index 03072eb49f..33009dae4b 100644 --- a/rust/cloud-storage/email/src/domain/models/message.rs +++ b/rust/cloud-storage/email/src/domain/models/message.rs @@ -190,3 +190,17 @@ pub struct Message { /// When the message was last updated. pub updated_at: DateTime, } + +/// The outcome of recording an open (read receipt) against a sent message via +/// its tracking pixel. +#[derive(Debug, Clone)] +pub struct RecordedOpen { + /// Database ID of the message that was opened. + pub message_id: uuid::Uuid, + /// Link (inbox) that owns the message. + pub link_id: uuid::Uuid, + /// Thread the message belongs to. + pub thread_db_id: uuid::Uuid, + /// Total opens recorded for the message after this one. + pub open_count: i32, +} diff --git a/rust/cloud-storage/email/src/domain/models/mod.rs b/rust/cloud-storage/email/src/domain/models/mod.rs index 504f16d851..90dbad1e56 100644 --- a/rust/cloud-storage/email/src/domain/models/mod.rs +++ b/rust/cloud-storage/email/src/domain/models/mod.rs @@ -26,7 +26,7 @@ pub use label::{ UpdateThreadLabelsResult, }; pub use link::{Link, UserProvider}; -pub use message::{Message, MessageRow, SimpleMessage}; +pub use message::{Message, MessageRow, RecordedOpen, SimpleMessage}; pub use parsed_message::{ParsedLabel, ParsedMessage, ParsedThread}; pub use preview::{ EmailThreadPreview, EnrichedEmailThreadPreview, GetEmailsRequest, PreviewCursorQuery, diff --git a/rust/cloud-storage/email/src/domain/ports.rs b/rust/cloud-storage/email/src/domain/ports.rs index ae04c4a27d..4413127d93 100644 --- a/rust/cloud-storage/email/src/domain/ports.rs +++ b/rust/cloud-storage/email/src/domain/ports.rs @@ -2,9 +2,9 @@ use crate::domain::models::{ Attachment, AttachmentDraft, AttachmentForwarded, Contact, ContactInfo, CreateDraftInput, CreatedDraft, EmailErr, EmailFilter, EmailThreadPreview, EnrichedEmailThreadPreview, GetEmailsRequest, Label, Link, LinkLabel, MessageAttachment, MessageLabel, MessageRow, - ParsedAddresses, ParsedThread, PreviewCursorQuery, RecipientType, ResolvedDraftInput, - SimpleMessage, SimpleMessageInfo, Thread, ThreadRow, UpdateThreadLabelsResult, - UpsertEmailFilterInput, UpsertedContacts, UserProvider, + ParsedAddresses, ParsedThread, PreviewCursorQuery, RecipientType, RecordedOpen, + ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, Thread, ThreadRow, + UpdateThreadLabelsResult, UpsertEmailFilterInput, UpsertedContacts, UserProvider, }; use chrono::{DateTime, Utc}; use entity_access::domain::models::{EditAccessLevel, EntityAccessReceipt, ViewAccessLevel}; @@ -290,6 +290,13 @@ pub trait EmailRepo: Send + Sync + 'static { &self, link_id: Uuid, ) -> impl Future, Self::Err>> + Send; + + /// Record an open (read receipt) for the sent message whose tracking pixel + /// carries `token`. Returns `None` when no sent message matches the token. + fn record_message_open( + &self, + token: Uuid, + ) -> impl Future, Self::Err>> + Send; } /// Read-only trait for fetching email thread previews. @@ -446,6 +453,13 @@ pub trait EmailService: Send + Sync + 'static { &self, link: &Link, ) -> impl Future, EmailErr>> + Send; + + /// Record an open (read receipt) for the sent message whose tracking pixel + /// carries `token`. Returns `None` when no sent message matches the token. + fn record_message_open( + &self, + token: Uuid, + ) -> impl Future, EmailErr>> + Send; } /// Port for fetching a Gmail access token for a given email link. diff --git a/rust/cloud-storage/email/src/domain/service/mod.rs b/rust/cloud-storage/email/src/domain/service/mod.rs index b7efd056eb..f114461800 100644 --- a/rust/cloud-storage/email/src/domain/service/mod.rs +++ b/rust/cloud-storage/email/src/domain/service/mod.rs @@ -296,4 +296,14 @@ where .await .map_err(|e| EmailErr::RepoErr(e.into())) } + + async fn record_message_open( + &self, + token: Uuid, + ) -> Result, EmailErr> { + self.email_repo + .record_message_open(token) + .await + .map_err(|e| EmailErr::RepoErr(e.into())) + } } diff --git a/rust/cloud-storage/email/src/inbound/axum.rs b/rust/cloud-storage/email/src/inbound/axum.rs index 790b5fae35..5facc3b345 100644 --- a/rust/cloud-storage/email/src/inbound/axum.rs +++ b/rust/cloud-storage/email/src/inbound/axum.rs @@ -4,6 +4,7 @@ pub mod draft_router; pub mod email_filter_router; pub mod get_thread_router; pub mod list_labels_router; +pub mod open_tracking_router; pub mod previews_router; pub mod send_router; pub mod thread_labels_router; diff --git a/rust/cloud-storage/email_service/src/api/tracking.rs b/rust/cloud-storage/email/src/inbound/axum/open_tracking_router.rs similarity index 68% rename from rust/cloud-storage/email_service/src/api/tracking.rs rename to rust/cloud-storage/email/src/inbound/axum/open_tracking_router.rs index c4e86855e9..9b19677999 100644 --- a/rust/cloud-storage/email_service/src/api/tracking.rs +++ b/rust/cloud-storage/email/src/inbound/axum/open_tracking_router.rs @@ -2,21 +2,26 @@ //! //! Outgoing messages with read receipts enabled embed a 1x1 transparent image //! pointing at `/t/o/{token}`. Recipient mail clients (or their image proxies) -//! fetch it when the message is opened, and we record the open against the +//! fetch it when the message is opened, and the open is recorded against the //! sender's copy of the message. //! //! The route is unauthenticated by design โ€” it is fetched by arbitrary mail //! clients โ€” and always returns the pixel, so callers learn nothing about //! whether a token was valid. -use crate::api::context::ApiContext; -use axum::Router; -use axum::extract::{Path, State}; -use axum::http::header; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::{ + Router, + extract::{Path, State}, + http::header, + response::{IntoResponse, Response}, + routing::get, +}; use uuid::Uuid; +use crate::domain::ports::EmailService; + +use super::previews_router::EmailRouterState; + /// A 1x1 transparent GIF (GIF89a with the transparent color flag set). const TRANSPARENT_PIXEL_GIF: &[u8] = &[ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a @@ -30,14 +35,25 @@ const TRANSPARENT_PIXEL_GIF: &[u8] = &[ 0x3B, // trailer ]; -pub fn router() -> Router { - Router::new().route("/o/{token}", get(open_pixel_handler)) +pub fn router(state: EmailRouterState) -> Router +where + S: Send + Sync + 'static, + T: EmailService, +{ + Router::new() + .route("/o/{token}", get(open_pixel_handler::)) + .with_state(state) } -#[tracing::instrument(skip(ctx))] -async fn open_pixel_handler(State(ctx): State, Path(token): Path) -> Response { +// `token` is intentionally excluded from the span: it is a per-message secret +// and would be high-cardinality telemetry. +#[tracing::instrument(skip_all)] +async fn open_pixel_handler( + State(service): State>, + Path(token): Path, +) -> Response { if let Ok(token) = Uuid::parse_str(token.trim()) { - match email_db_client::messages::open_tracking::record_message_open(&ctx.db, token).await { + match service.service().record_message_open(token).await { Ok(Some(open)) => { tracing::debug!( message_id = %open.message_id, diff --git a/rust/cloud-storage/email/src/outbound/email_pg_repo/message.rs b/rust/cloud-storage/email/src/outbound/email_pg_repo/message.rs index 67f0d64af5..a17a723e48 100644 --- a/rust/cloud-storage/email/src/outbound/email_pg_repo/message.rs +++ b/rust/cloud-storage/email/src/outbound/email_pg_repo/message.rs @@ -1,7 +1,7 @@ use crate::domain::{ models::{ AttachmentDraft, AttachmentForwarded, ContactInfo, MessageAttachment, MessageLabel, - RecipientType, SimpleMessageInfo, UpsertedContacts, + RecipientType, RecordedOpen, SimpleMessageInfo, UpsertedContacts, }, ports::RecipientsByMessageId, }; @@ -500,3 +500,31 @@ pub(super) async fn upsert_recipients( Ok(()) } + +/// Record an open (read receipt) for the sent message matching `token`. Bumps +/// the open counter and timestamps, returning the updated state, or `None` when +/// the token doesn't match a sent message. +#[tracing::instrument(err, skip(pool, token))] +pub(super) async fn record_message_open( + pool: &PgPool, + token: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as!( + RecordedOpen, + r#" + UPDATE email_messages + SET first_opened_at = COALESCE(first_opened_at, NOW()), + last_opened_at = NOW(), + open_count = open_count + 1 + WHERE open_tracking_token = $1 AND is_sent = true + RETURNING + id as message_id, + link_id, + thread_id as thread_db_id, + open_count + "#, + token, + ) + .fetch_optional(pool) + .await +} diff --git a/rust/cloud-storage/email/src/outbound/email_pg_repo/mod.rs b/rust/cloud-storage/email/src/outbound/email_pg_repo/mod.rs index 2a9ccdca3c..ea9d0c7255 100644 --- a/rust/cloud-storage/email/src/outbound/email_pg_repo/mod.rs +++ b/rust/cloud-storage/email/src/outbound/email_pg_repo/mod.rs @@ -2,8 +2,8 @@ use crate::domain::{ models::{ Attachment, AttachmentDraft, AttachmentForwarded, Contact, ContactInfo, EmailFilter, EmailThreadPreview, Label, Link, LinkLabel, MessageAttachment, MessageLabel, MessageRow, - ParsedAddresses, PreviewCursorQuery, ResolvedDraftInput, SimpleMessage, SimpleMessageInfo, - ThreadRow, UpsertEmailFilterInput, UpsertedContacts, UserProvider, + ParsedAddresses, PreviewCursorQuery, RecordedOpen, ResolvedDraftInput, SimpleMessage, + SimpleMessageInfo, ThreadRow, UpsertEmailFilterInput, UpsertedContacts, UserProvider, }, ports::{EmailRepo, RecipientsByMessageId}, }; @@ -341,4 +341,8 @@ impl EmailRepo for EmailPgRepo { async fn list_email_filters(&self, link_id: Uuid) -> Result, Self::Err> { email_filter::list_email_filters(&self.pool, link_id).await } + + async fn record_message_open(&self, token: Uuid) -> Result, Self::Err> { + message::record_message_open(&self.pool, token).await + } } diff --git a/rust/cloud-storage/email/src/outbound/email_pg_repo/test/message.rs b/rust/cloud-storage/email/src/outbound/email_pg_repo/test/message.rs index 8fe31b6abf..ed274f019e 100644 --- a/rust/cloud-storage/email/src/outbound/email_pg_repo/test/message.rs +++ b/rust/cloud-storage/email/src/outbound/email_pg_repo/test/message.rs @@ -709,3 +709,73 @@ async fn test_upsert_recipients_empty_deletes_all(pool: Pool) -> anyho Ok(()) } + +// โ”€โ”€ record_message_open โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn test_record_message_open_tracks_first_last_and_count( + pool: Pool, +) -> anyhow::Result<()> { + // msg2 is a sent message; assign it a tracking token the way the send path would. + let sent_msg = Uuid::parse_str("ee000002-0000-0000-0000-000000000002")?; + let token = Uuid::new_v4(); + sqlx::query("UPDATE email_messages SET open_tracking_token = $1 WHERE id = $2") + .bind(token) + .bind(sent_msg) + .execute(&pool) + .await?; + + let repo = EmailPgRepo::new(pool); + + let first = repo + .record_message_open(token) + .await? + .expect("first open should match the sent message"); + assert_eq!(first.message_id, sent_msg); + assert_eq!(first.open_count, 1); + + let second = repo + .record_message_open(token) + .await? + .expect("second open should match the sent message"); + assert_eq!(second.open_count, 2); + + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn test_record_message_open_unknown_token_returns_none( + pool: Pool, +) -> anyhow::Result<()> { + let repo = EmailPgRepo::new(pool); + assert!(repo.record_message_open(Uuid::new_v4()).await?.is_none()); + Ok(()) +} + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../../../fixtures", scripts("email_message")) +)] +async fn test_record_message_open_ignores_unsent_messages( + pool: Pool, +) -> anyhow::Result<()> { + // msg3 is a draft (is_sent = false); a token on it must never record an open. + let draft_msg = Uuid::parse_str("ee000003-0000-0000-0000-000000000003")?; + let token = Uuid::new_v4(); + sqlx::query("UPDATE email_messages SET open_tracking_token = $1 WHERE id = $2") + .bind(token) + .bind(draft_msg) + .execute(&pool) + .await?; + + let repo = EmailPgRepo::new(pool); + assert!(repo.record_message_open(token).await?.is_none()); + + Ok(()) +} diff --git a/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs b/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs index 53e255c3d7..5b8b53815f 100644 --- a/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs +++ b/rust/cloud-storage/email_db_client/src/messages/open_tracking.rs @@ -1,30 +1,20 @@ //! Read-receipt (open) tracking for sent messages. //! //! At send time a unique token is stored on the outgoing message and embedded -//! in a tracking pixel URL inside the message HTML. When the pixel is fetched -//! the open is recorded here, keyed by that token. +//! in a tracking pixel URL inside the message HTML. Opens are recorded by the +//! email service's public pixel endpoint (see the `email` crate's +//! `open_tracking_router`), keyed by that token. #[cfg(test)] mod test; -use chrono::{DateTime, Utc}; use sqlx::PgPool; use sqlx::types::Uuid; -/// The state of a message's open tracking after recording an open. -#[derive(Debug, Clone)] -pub struct RecordedMessageOpen { - pub message_id: Uuid, - pub link_id: Uuid, - pub thread_id: Uuid, - pub first_opened_at: Option>, - pub last_opened_at: Option>, - pub open_count: i32, -} - /// Assigns the open tracking token embedded in an outgoing message's tracking /// pixel. Called at send time, right before the message is handed to the -/// provider. +/// provider. Errors if the message doesn't exist for `(message_id, link_id)`, +/// so the caller never injects a pixel whose token wasn't persisted. #[tracing::instrument(skip(pool), err)] pub async fn set_message_open_tracking_token( pool: &PgPool, @@ -32,7 +22,7 @@ pub async fn set_message_open_tracking_token( link_id: Uuid, token: Uuid, ) -> anyhow::Result<()> { - sqlx::query!( + let result = sqlx::query!( r#" UPDATE email_messages SET open_tracking_token = $3 @@ -45,36 +35,14 @@ pub async fn set_message_open_tracking_token( .execute(pool) .await?; - Ok(()) -} + if result.rows_affected() != 1 { + anyhow::bail!( + "expected to set open tracking token on exactly one message, but {} rows matched (message_id={}, link_id={})", + result.rows_affected(), + message_id, + link_id + ); + } -/// Records an open for the sent message matching a tracking token. Returns -/// `None` when the token doesn't match any sent message. -#[tracing::instrument(skip(pool), err)] -pub async fn record_message_open( - pool: &PgPool, - token: Uuid, -) -> anyhow::Result> { - let recorded = sqlx::query_as!( - RecordedMessageOpen, - r#" - UPDATE email_messages - SET first_opened_at = COALESCE(first_opened_at, NOW()), - last_opened_at = NOW(), - open_count = open_count + 1 - WHERE open_tracking_token = $1 AND is_sent = true - RETURNING - id as message_id, - link_id, - thread_id, - first_opened_at, - last_opened_at, - open_count - "#, - token, - ) - .fetch_optional(pool) - .await?; - - Ok(recorded) + Ok(()) } diff --git a/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs b/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs index 89c625b07f..8ae4e2a956 100644 --- a/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs +++ b/rust/cloud-storage/email_db_client/src/messages/open_tracking/test.rs @@ -4,13 +4,12 @@ use sqlx::{Pool, Postgres}; const LINK_ID: &str = "00000000-0000-0000-0000-000000000f01"; const SENT_MESSAGE_ID: &str = "00000000-0000-0000-0000-00000000f501"; -const DRAFT_MESSAGE_ID: &str = "00000000-0000-0000-0000-00000000f502"; #[sqlx::test( migrator = "MACRO_DB_MIGRATIONS", fixtures(path = "../../../fixtures", scripts("message_open_tracking")) )] -async fn record_message_open_tracks_first_last_and_count( +async fn set_message_open_tracking_token_persists_token( pool: Pool, ) -> anyhow::Result<()> { let message_id = Uuid::parse_str(SENT_MESSAGE_ID)?; @@ -19,23 +18,14 @@ async fn record_message_open_tracks_first_last_and_count( set_message_open_tracking_token(&pool, message_id, link_id, token).await?; - let first_open = record_message_open(&pool, token) - .await? - .expect("first open should match the sent message"); + let stored: Option = sqlx::query_scalar!( + r#"SELECT open_tracking_token FROM email_messages WHERE id = $1"#, + message_id + ) + .fetch_one(&pool) + .await?; - assert_eq!(first_open.message_id, message_id); - assert_eq!(first_open.link_id, link_id); - assert_eq!(first_open.open_count, 1); - assert!(first_open.first_opened_at.is_some()); - assert_eq!(first_open.first_opened_at, first_open.last_opened_at); - - let second_open = record_message_open(&pool, token) - .await? - .expect("second open should match the sent message"); - - assert_eq!(second_open.open_count, 2); - assert_eq!(second_open.first_opened_at, first_open.first_opened_at); - assert!(second_open.last_opened_at >= first_open.last_opened_at); + assert_eq!(stored, Some(token)); Ok(()) } @@ -44,30 +34,18 @@ async fn record_message_open_tracks_first_last_and_count( migrator = "MACRO_DB_MIGRATIONS", fixtures(path = "../../../fixtures", scripts("message_open_tracking")) )] -async fn record_message_open_returns_none_for_unknown_token( +async fn set_message_open_tracking_token_errors_when_no_match( pool: Pool, ) -> anyhow::Result<()> { - let recorded = record_message_open(&pool, Uuid::new_v4()).await?; - - assert!(recorded.is_none()); - - Ok(()) -} - -#[sqlx::test( - migrator = "MACRO_DB_MIGRATIONS", - fixtures(path = "../../../fixtures", scripts("message_open_tracking")) -)] -async fn record_message_open_ignores_unsent_messages(pool: Pool) -> anyhow::Result<()> { - let draft_id = Uuid::parse_str(DRAFT_MESSAGE_ID)?; - let link_id = Uuid::parse_str(LINK_ID)?; + let message_id = Uuid::parse_str(SENT_MESSAGE_ID)?; + // A link that doesn't own the message: nothing should match, and the + // caller must learn the token wasn't persisted rather than proceed. + let wrong_link_id = Uuid::new_v4(); let token = Uuid::new_v4(); - set_message_open_tracking_token(&pool, draft_id, link_id, token).await?; - - let recorded = record_message_open(&pool, token).await?; + let result = set_message_open_tracking_token(&pool, message_id, wrong_link_id, token).await; - assert!(recorded.is_none()); + assert!(result.is_err()); Ok(()) } diff --git a/rust/cloud-storage/email_service/src/api/mod.rs b/rust/cloud-storage/email_service/src/api/mod.rs index c8edbf3d06..59f8e7cad2 100644 --- a/rust/cloud-storage/email_service/src/api/mod.rs +++ b/rust/cloud-storage/email_service/src/api/mod.rs @@ -17,7 +17,6 @@ pub(crate) mod gmail; mod internal; mod middleware; pub(crate) mod swagger; -mod tracking; pub async fn setup_and_serve(state: ApiContext) -> anyhow::Result<()> { let env = state.config.environment; @@ -58,7 +57,10 @@ fn api_router(state: ApiContext) -> Router { ) .nest("/gmail", gmail::router()) // Read-receipt tracking pixels; fetched by recipient mail clients, so no auth. - .nest("/t", tracking::router()) + .nest( + "/t", + ::email::inbound::axum::open_tracking_router::router(state.email_service.clone()), + ) .nest( "/internal", internal::router().layer( diff --git a/rust/cloud-storage/email_utils/src/open_tracking/test.rs b/rust/cloud-storage/email_utils/src/open_tracking/test.rs index 2d9a777118..f53a9482ea 100644 --- a/rust/cloud-storage/email_utils/src/open_tracking/test.rs +++ b/rust/cloud-storage/email_utils/src/open_tracking/test.rs @@ -1,7 +1,9 @@ use super::*; const BASE_URL: &str = "https://email-service.macro.com"; -const TOKEN: &str = "0c4f12cd-9b46-4e4e-bd6c-7a30efb02e4f"; +// Not a real UUID โ€” strip/inject key off host + path, not token shape โ€” and a +// non-UUID literal keeps secret scanners quiet. +const TOKEN: &str = "open-tracking-token-test"; #[test] fn pixel_url_joins_base_and_token() {