{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..af9dae9884
--- /dev/null
+++ b/js/app/packages/block-email/util/readReceipts.test.ts
@@ -0,0 +1,85 @@
+import type { ApiMessage } from '@service-email/generated/schemas';
+import { describe, expect, it } from 'vitest';
+import {
+ formatSeenLabel,
+ messageSeenAt,
+ removeOwnTrackingPixels,
+} from './readReceipts';
+
+// 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');
+}
+
+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..6b15cb4687 100644
--- a/js/app/packages/queries/email/link.ts
+++ b/js/app/packages/queries/email/link.ts
@@ -67,6 +67,97 @@ 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) => {
+ // 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 =
+ cachedLinks ?? (await throwOnErr(() => emailClient.getLinks())).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-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json b/rust/cloud-storage/.sqlx/query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json
new file mode 100644
index 0000000000..7ad89bd1ee
--- /dev/null
+++ b/rust/cloud-storage/.sqlx/query-38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f.json
@@ -0,0 +1,40 @@
+{
+ "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 as thread_db_id,\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_db_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 3,
+ "name": "open_count",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "38839e32c070448464f49f8f62e58406b9d676b5810939ccb61a8229ce914a8f"
+}
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-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..33009dae4b 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,8 +179,28 @@ 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.
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