Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions js/app/packages/app/component/settings/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -714,6 +727,22 @@ export function Account() {
</div>
</Show>

<Show when={ENABLE_EMAIL && emailActive()}>
<Row label="Email read receipts">
<Tooltip label="See when recipients open the emails you send">
<div>
<ToggleSwitch
checked={readReceiptsEnabled()}
disabled={updateReadReceiptsMutation.isPending}
onChange={(checked) =>
updateReadReceiptsMutation.mutate(checked)
}
/>
</div>
</Tooltip>
</Row>
</Show>

<Row label="GitHub">
<Show
when={!githubLinkStatus.loading}
Expand Down
20 changes: 19 additions & 1 deletion js/app/packages/block-email/component/CollapsedMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { UserIcon, type UserIconProps } from '@core/component/UserIcon';
import { useEmail } from '@core/context/user';
import Eye from '@phosphor/eye.svg';
import type { ApiMessage } from '@service-email/generated/schemas';
import { Tooltip } from '@ui';
import { cn } from '@ui/utils/classname';
import { createMemo } from 'solid-js';
import { createMemo, Show } from 'solid-js';
import { getSenderDisplayName, getSenderMacroId } from '../util/emailUser';
import {
formatSeenLabel,
formatSeenTooltip,
messageSeenAt,
} from '../util/readReceipts';
import { formatShortDate } from './EmailMessageTopBar';
import { EmailUserTooltip } from './EmailUserTooltip';

Expand Down Expand Up @@ -88,6 +95,17 @@ export function CollapsedMessage(props: CollapsedMessageProps) {
<div class="flex-1 min-w-0 text-sm text-ink-muted/60 truncate">
{snippet()}
</div>
<Show when={messageSeenAt(props.message)}>
{(seenAt) => (
<Tooltip
label={`${formatSeenLabel(seenAt())} · ${formatSeenTooltip(props.message)}`}
>
<div class="shrink-0 text-ink-extra-muted">
<Eye class="size-3.5" />
</div>
</Tooltip>
)}
</Show>
<div class="shrink-0 text-xs text-ink-extra-muted tabular-nums">
{props.message.internal_date_ts &&
formatShortDate(props.message.internal_date_ts)}
Expand Down
5 changes: 5 additions & 0 deletions js/app/packages/block-email/component/EmailMessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLAnchorElement>(
'a[style]'
Expand Down
16 changes: 16 additions & 0 deletions js/app/packages/block-email/component/EmailMessageTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -226,6 +232,16 @@ function HeaderTopRow(props: {
isLastMessage={props.isLastMessage}
hiddenActions={props.hiddenActions}
/>
<Show when={messageSeenAt(props.message)}>
{(seenAt) => (
<Tooltip label={formatSeenTooltip(props.message)}>
<div class="flex flex-row items-center gap-1 text-xs text-ink-extra-muted cursor-default">
<Eye class="size-3.5" />
<span>{formatSeenLabel(seenAt())}</span>
</div>
</Tooltip>
)}
</Show>
<Show when={props.message.internal_date_ts}>
<Tooltip label={formatFullDate(props.message.internal_date_ts!)}>
<div class="text-xs text-ink-extra-muted tabular-nums cursor-default">
Expand Down
15 changes: 14 additions & 1 deletion js/app/packages/block-email/util/prepareEmailBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down
85 changes: 85 additions & 0 deletions js/app/packages/block-email/util/readReceipts.test.ts
Original file line number Diff line number Diff line change
@@ -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(
`<p>hi</p>` +
`<img src="https://email-service.macro.com/t/o/${TOKEN}" width="1" height="1">` +
`<img src="https://email-service-dev.macro.com/t/o/${TOKEN}" width="1" height="1">`
);

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(
`<img src="https://example.com/logo.png">` +
`<img src="https://tracker.example.com/t/o/${TOKEN}">` +
`<img src="not a url">`
);

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'
);
});
});
77 changes: 77 additions & 0 deletions js/app/packages/block-email/util/readReceipts.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading
Loading