Skip to content

Commit 937ed40

Browse files
committed
Block image trackers
1 parent 9f186b5 commit 937ed40

8 files changed

Lines changed: 187 additions & 80 deletions

File tree

backend/src/trpc-router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BOARD_INVITE_ROUTES } from '@/trpc-routes/board-invite.routes';
88
import { BOARD_MEMBER_ROUTES } from '@/trpc-routes/board-member.routes';
99
import { COMMENT_ROUTES } from '@/trpc-routes/comment.routes';
1010
import { EMAIL_DRAFT_ROUTES } from '@/trpc-routes/email-draft.routes';
11+
import { REPORT_ROUTES } from '@/trpc-routes/report.routes';
1112
import { SENDER_EMAIL_ADDRESS_ROUTES } from '@/trpc-routes/sender-email-address.routes';
1213
import { USER_ROUTES } from '@/trpc-routes/user.routes';
1314

@@ -22,6 +23,7 @@ const TRPC_ROUTES = {
2223
...COMMENT_ROUTES,
2324
...SENDER_EMAIL_ADDRESS_ROUTES,
2425
...EMAIL_DRAFT_ROUTES,
26+
...REPORT_ROUTES,
2527
};
2628

2729
export const trpcRouter = createTRPCRouter(TRPC_ROUTES);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { TRPCRouterRecord } from '@trpc/server';
2+
import { z } from 'zod';
3+
import { publicProcedure } from '@/trpc-config';
4+
import { Logger } from '@/utils/logger';
5+
6+
export const REPORT_ROUTES = {
7+
report: {
8+
suspiciousTracker: publicProcedure.input(z.object({ urls: z.array(z.url()) })).mutation(async ({ input }) => {
9+
if (input.urls.length === 0) return;
10+
Logger.info(`[Report] Suspicious trackers: ${input.urls.join(', ')}`);
11+
}),
12+
} satisfies TRPCRouterRecord,
13+
};

frontend/src/components/board-card/email-draft-card.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,11 @@ export const EmailDraftCard = ({
213213
const displayQuotedHtml = useMemo(
214214
() =>
215215
sanitizedDisplayHtml({
216-
bodyHtml: quotedHtml,
216+
html: quotedHtml,
217217
gmailAttachments: lastEmailMessage?.gmailAttachments || [],
218218
boardId,
219219
boardCardId,
220-
}),
220+
}).displayHtml,
221221
[quotedHtml, lastEmailMessage?.gmailAttachments, boardId, boardCardId],
222222
);
223223

frontend/src/components/board-card/email-message-card.tsx

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { ChevronDownIcon, Paperclip, Reply } from 'lucide-react';
1+
import { useMutation } from '@tanstack/react-query';
2+
import { ChevronDownIcon, Paperclip, Reply, ShieldAlert } from 'lucide-react';
23
import { useEffect, useRef, useState } from 'react';
34
import { ToggleQuotesButton } from '@/components/board-card/toggle-quotes-button';
45
import { Attachment } from '@/components/editor/attachment';
56
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
7+
import { Badge } from '@/components/ui/badge';
68
import { Button } from '@/components/ui/button';
79
import { Card } from '@/components/ui/card';
810
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
911
import { useEmailIframe } from '@/hooks/use-email-iframe';
12+
import { useRouteContext } from '@/hooks/use-route-context';
1013
import type { BoardMember } from '@/query-helpers/board';
1114
import type { EmailMessage } from '@/query-helpers/board-card';
1215
import { sanitizedDisplayHtml } from '@/utils/email';
@@ -18,10 +21,12 @@ const EmailMessageBody = ({
1821
emailMessage,
1922
boardId,
2023
boardCardId,
24+
setBlockedTrackers,
2125
}: {
2226
emailMessage: EmailMessage;
2327
boardId: string;
2428
boardCardId: string;
29+
setBlockedTrackers: (blockedTrackers: string[]) => void;
2530
}) => {
2631
const [displayMainHtml, setDisplayMainHtml] = useState('');
2732
const [blockquotesExpanded, setBlockquotesExpanded] = useState(false);
@@ -31,26 +36,38 @@ const EmailMessageBody = ({
3136

3237
const { mainHtml, mainText, quotedHtml, quotedText, styles } = emailMessage;
3338

39+
const { trpc } = useRouteContext();
40+
const reportSuspiciousTrackerMutation = useMutation(trpc.report.suspiciousTracker.mutationOptions());
41+
42+
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore mutation to avoid re-running
3443
useEffect(() => {
3544
if (mainHtml || quotedHtml) {
36-
const displayMain = sanitizedDisplayHtml({
37-
bodyHtml: mainHtml || '',
45+
const mainResult = sanitizedDisplayHtml({
46+
html: mainHtml || '',
3847
gmailAttachments: emailMessage.gmailAttachments,
3948
boardId,
4049
boardCardId,
4150
});
4251

43-
const displayQuoted = sanitizedDisplayHtml({
44-
bodyHtml: quotedHtml || '',
52+
const quotedResult = sanitizedDisplayHtml({
53+
html: quotedHtml || '',
4554
gmailAttachments: emailMessage.gmailAttachments,
4655
boardId,
4756
boardCardId,
4857
});
4958

50-
setDisplayMainHtml(displayMain);
51-
setDisplayQuotedHtml(displayQuoted);
59+
const allBlockedTrackers = [...mainResult.blockedTrackers, ...quotedResult.blockedTrackers];
60+
const allSuspiciousTrackers = [...mainResult.suspiciousTrackers, ...quotedResult.suspiciousTrackers];
61+
62+
setDisplayMainHtml(mainResult.displayHtml);
63+
setDisplayQuotedHtml(quotedResult.displayHtml);
64+
setBlockedTrackers(allBlockedTrackers);
65+
66+
if (allSuspiciousTrackers.length > 0 && !reportSuspiciousTrackerMutation.isPending) {
67+
reportSuspiciousTrackerMutation.mutate({ urls: allSuspiciousTrackers });
68+
}
5269
}
53-
}, [mainHtml, quotedHtml, emailMessage.gmailAttachments, boardId, boardCardId]);
70+
}, [mainHtml, quotedHtml, emailMessage.gmailAttachments, boardId, boardCardId, setBlockedTrackers]);
5471

5572
useEmailIframe(bodyIframeRef, { html: displayMainHtml, styles: styles ?? '' });
5673
useEmailIframe(backquotesIframeRef, { html: displayQuotedHtml, styles: styles ?? '', enabled: blockquotesExpanded });
@@ -117,7 +134,7 @@ export const EmailMessageCard = ({
117134
boardMembers: BoardMember[];
118135
onReply?: () => void;
119136
}) => {
120-
const [detailsOpen, setDetailsOpen] = useState(false);
137+
const [blockedTrackers, setBlockedTrackers] = useState<string[]>([]);
121138

122139
const participants = [
123140
emailMessage.from,
@@ -180,13 +197,39 @@ export const EmailMessageCard = ({
180197
<div className="text-xs text-muted-foreground">{`<${firstParticipant.email}>`}</div>
181198
</div>
182199
)}
183-
<div className="text-xs text-muted-foreground flex-shrink-0">
200+
<div className="text-xs text-muted-foreground flex-shrink-0 flex gap-2">
201+
{blockedTrackers.length > 0 && (
202+
<Popover>
203+
<PopoverTrigger asChild>
204+
<button type="button" className="focus:outline-none">
205+
<Badge
206+
variant="outline"
207+
size="sm"
208+
className="gap-1 cursor-pointer hover:bg-accent text-muted-foreground"
209+
>
210+
<ShieldAlert className="size-3" />
211+
{blockedTrackers.length} blocked
212+
</Badge>
213+
</button>
214+
</PopoverTrigger>
215+
<PopoverContent align="end" className="w-64">
216+
<div className="flex flex-col gap-2">
217+
<div className="font-medium text-muted-foreground text-xs">Blocked trackers</div>
218+
<ul className="list-disc list-inside text-xs">
219+
{blockedTrackers.map((tracker) => (
220+
<li key={tracker}>{tracker}</li>
221+
))}
222+
</ul>
223+
</div>
224+
</PopoverContent>
225+
</Popover>
226+
)}
184227
{formattedShortTime(new Date(emailMessage.externalCreatedAt))}
185228
</div>
186229
</div>
187230
<div className="flex items-center gap-1 min-w-0">
188231
<div className="text-xs text-muted-foreground truncate">{shortAddresses || 'To'}</div>
189-
<Popover open={detailsOpen} onOpenChange={setDetailsOpen}>
232+
<Popover>
190233
<PopoverTrigger asChild>
191234
<Button variant="ghost" size="icon" className="size-4.5 flex-shrink-0 rounded-full">
192235
<ChevronDownIcon className="size-4 text-muted-foreground pt-[2px]" />
@@ -244,7 +287,12 @@ export const EmailMessageCard = ({
244287
</div>
245288
</div>
246289
</div>
247-
<EmailMessageBody emailMessage={emailMessage} boardId={boardId} boardCardId={boardCardId} />
290+
<EmailMessageBody
291+
emailMessage={emailMessage}
292+
boardId={boardId}
293+
boardCardId={boardCardId}
294+
setBlockedTrackers={setBlockedTrackers}
295+
/>
248296
{emailMessage.gmailAttachments.length > 0 && (
249297
<div className="flex flex-col gap-2 mt-4 pt-4 border-t">
250298
<div className="flex items-center gap-1.5">

frontend/src/routes/auth.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
11
import { createFileRoute } from '@tanstack/react-router';
2-
import { ENV } from '@/utils/env';
32
import { API_ENDPOINTS } from '@/utils/urls';
43

54
// See frontend/vite.config.ts
65
const DEFAULT_HEADERS = {
7-
'Content-Security-Policy': [
8-
"default-src 'none'",
9-
"img-src 'self' https: data:",
10-
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
11-
"font-src 'self' https://fonts.gstatic.com",
12-
`connect-src 'self' ${ENV.SSR_API_ENDPOINT}`,
13-
"script-src 'self' 'unsafe-inline'",
14-
"frame-src 'self'",
15-
"frame-ancestors 'self'",
16-
"manifest-src 'self'",
17-
"base-uri 'self'",
18-
"form-action 'self'",
19-
].join('; '),
6+
'Content-Security-Policy': ["default-src 'none'"].join('; '),
207
'X-Frame-Options': 'SAMEORIGIN',
218
'Cache-Control': 'no-cache, no-store, must-revalidate, private',
229
Pragma: 'no-cache',

frontend/src/utils/email.test.ts

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ describe('sanitizedDisplayHtml', () => {
2323
},
2424
];
2525

26-
const result = sanitizedDisplayHtml({
27-
bodyHtml,
26+
const { displayHtml } = sanitizedDisplayHtml({
27+
html: bodyHtml,
2828
gmailAttachments,
2929
boardId: 'board1',
3030
boardCardId: 'card1',
3131
});
3232

33-
expect(result).toContain('gmailAttachmentId=att123');
34-
expect(result).toContain('boardId=board1');
35-
expect(result).toContain('boardCardId=card1');
36-
expect(result).not.toContain('cid:image.png');
33+
expect(displayHtml).toContain('gmailAttachmentId=att123');
34+
expect(displayHtml).toContain('boardId=board1');
35+
expect(displayHtml).toContain('boardCardId=card1');
36+
expect(displayHtml).not.toContain('cid:image.png');
3737
});
3838

3939
it('replaces cid: references with proxy URLs for inline images by contentId', () => {
@@ -47,15 +47,15 @@ describe('sanitizedDisplayHtml', () => {
4747
},
4848
];
4949

50-
const result = sanitizedDisplayHtml({
51-
bodyHtml,
50+
const { displayHtml } = sanitizedDisplayHtml({
51+
html: bodyHtml,
5252
gmailAttachments,
5353
boardId: 'board2',
5454
boardCardId: 'card2',
5555
});
5656

57-
expect(result).toContain('gmailAttachmentId=att456');
58-
expect(result).not.toContain('cid:abc123');
57+
expect(displayHtml).toContain('gmailAttachmentId=att456');
58+
expect(displayHtml).not.toContain('cid:abc123');
5959
});
6060

6161
it('handles multiple images with different cid references', () => {
@@ -81,17 +81,17 @@ describe('sanitizedDisplayHtml', () => {
8181
},
8282
];
8383

84-
const result = sanitizedDisplayHtml({
85-
bodyHtml,
84+
const { displayHtml } = sanitizedDisplayHtml({
85+
html: bodyHtml,
8686
gmailAttachments,
8787
boardId: 'board3',
8888
boardCardId: 'card3',
8989
});
9090

91-
expect(result).toContain('gmailAttachmentId=att1');
92-
expect(result).toContain('gmailAttachmentId=att2');
93-
expect(result).not.toContain('cid:image1.png');
94-
expect(result).not.toContain('cid:content-id-2');
91+
expect(displayHtml).toContain('gmailAttachmentId=att1');
92+
expect(displayHtml).toContain('gmailAttachmentId=att2');
93+
expect(displayHtml).not.toContain('cid:image1.png');
94+
expect(displayHtml).not.toContain('cid:content-id-2');
9595
});
9696

9797
it('does not replace non-image attachments', () => {
@@ -105,16 +105,16 @@ describe('sanitizedDisplayHtml', () => {
105105
},
106106
];
107107

108-
const result = sanitizedDisplayHtml({
109-
bodyHtml,
108+
const { displayHtml } = sanitizedDisplayHtml({
109+
html: bodyHtml,
110110
gmailAttachments,
111111
boardId: 'board4',
112112
boardCardId: 'card4',
113113
});
114114

115115
// Should not replace since it's not an image
116-
expect(result).toContain('cid:document.pdf');
117-
expect(result).not.toContain('gmailAttachmentId=att789');
116+
expect(displayHtml).toContain('cid:document.pdf');
117+
expect(displayHtml).not.toContain('gmailAttachmentId=att789');
118118
});
119119

120120
it('handles HTML without any images', () => {
@@ -128,28 +128,28 @@ describe('sanitizedDisplayHtml', () => {
128128
},
129129
];
130130

131-
const result = sanitizedDisplayHtml({
132-
bodyHtml,
131+
const { displayHtml } = sanitizedDisplayHtml({
132+
html: bodyHtml,
133133
gmailAttachments,
134134
boardId: 'board5',
135135
boardCardId: 'card5',
136136
});
137137

138-
expect(result).toBe('<div><p>Just text content</p></div>');
138+
expect(displayHtml).toBe('<div><p>Just text content</p></div>');
139139
});
140140

141141
it('preserves non-cid image sources', () => {
142142
const bodyHtml = '<div><img src="https://example.com/image.png" /><p>Content</p></div>';
143143
const gmailAttachments: Attachment[] = [];
144144

145-
const result = sanitizedDisplayHtml({
146-
bodyHtml,
145+
const { displayHtml } = sanitizedDisplayHtml({
146+
html: bodyHtml,
147147
gmailAttachments,
148148
boardId: 'board6',
149149
boardCardId: 'card6',
150150
});
151151

152-
expect(result).toContain('https://example.com/image.png');
152+
expect(displayHtml).toContain('https://example.com/image.png');
153153
});
154154

155155
it('sanitizes HTML before processing', () => {
@@ -163,49 +163,49 @@ describe('sanitizedDisplayHtml', () => {
163163
},
164164
];
165165

166-
const result = sanitizedDisplayHtml({
167-
bodyHtml,
166+
const { displayHtml } = sanitizedDisplayHtml({
167+
html: bodyHtml,
168168
gmailAttachments,
169169
boardId: 'board7',
170170
boardCardId: 'card7',
171171
});
172172

173-
expect(result).not.toContain('<script>');
174-
expect(result).not.toContain('alert');
175-
expect(result).toContain('gmailAttachmentId=att1');
173+
expect(displayHtml).not.toContain('<script>');
174+
expect(displayHtml).not.toContain('alert');
175+
expect(displayHtml).toContain('gmailAttachmentId=att1');
176176
});
177177

178178
it('removes dangerous event handlers via DOMPurify', () => {
179179
const bodyHtml = '<div onclick="evilCode()"><p onmouseover="steal()">Hover me</p></div>';
180180
const gmailAttachments: Attachment[] = [];
181181

182-
const result = sanitizedDisplayHtml({
183-
bodyHtml,
182+
const { displayHtml } = sanitizedDisplayHtml({
183+
html: bodyHtml,
184184
gmailAttachments,
185185
boardId: 'board8',
186186
boardCardId: 'card8',
187187
});
188188

189-
expect(result).not.toContain('onclick');
190-
expect(result).not.toContain('onmouseover');
191-
expect(result).not.toContain('evilCode');
192-
expect(result).not.toContain('steal');
193-
expect(result).toContain('Hover me');
189+
expect(displayHtml).not.toContain('onclick');
190+
expect(displayHtml).not.toContain('onmouseover');
191+
expect(displayHtml).not.toContain('evilCode');
192+
expect(displayHtml).not.toContain('steal');
193+
expect(displayHtml).toContain('Hover me');
194194
});
195195

196196
it('preserves allowed style attributes via DOMPurify', () => {
197197
const bodyHtml = '<div style="font-weight: bold; color: blue;"><p style="margin: 10px;">Styled content</p></div>';
198198
const gmailAttachments: Attachment[] = [];
199199

200-
const result = sanitizedDisplayHtml({
201-
bodyHtml,
200+
const { displayHtml } = sanitizedDisplayHtml({
201+
html: bodyHtml,
202202
gmailAttachments,
203203
boardId: 'board9',
204204
boardCardId: 'card9',
205205
});
206206

207-
expect(result).toContain('style="font-weight: bold; color: blue;"');
208-
expect(result).toContain('style="margin: 10px;"');
209-
expect(result).toContain('Styled content');
207+
expect(displayHtml).toContain('style="font-weight: bold; color: blue;"');
208+
expect(displayHtml).toContain('style="margin: 10px;"');
209+
expect(displayHtml).toContain('Styled content');
210210
});
211211
});

0 commit comments

Comments
 (0)