Skip to content

Commit 0d9aa36

Browse files
feat(web): add /api/minidenticon endpoint for email avatar fallbacks
Replace placeholder avatars in email templates with dynamically generated minidenticon PNGs. The new endpoint converts minidenticon SVGs to PNGs via sharp, making them compatible with email clients that don't support data URIs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b3df558 commit 0d9aa36

File tree

7 files changed

+428
-278
lines changed

7 files changed

+428
-278
lines changed

packages/web/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
"devDependencies": {
197197
"@asteasolutions/zod-to-openapi": "7.3.4",
198198
"@eslint/eslintrc": "^3",
199-
"@react-email/preview-server": "5.2.8",
199+
"@react-email/preview-server": "5.2.10",
200200
"@react-grab/mcp": "^0.1.23",
201201
"@tanstack/eslint-plugin-query": "^5.74.7",
202202
"@testing-library/dom": "^10.4.1",
@@ -219,7 +219,7 @@
219219
"npm-run-all": "^4.1.5",
220220
"postcss": "^8",
221221
"raw-loader": "^4.0.2",
222-
"react-email": "^5.1.0",
222+
"react-email": "^5.2.10",
223223
"react-grab": "^0.1.23",
224224
"react-scan": "^0.5.3",
225225
"tailwindcss": "^3.4.1",

packages/web/src/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
723723
const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
724724
const transport = createTransport(smtpConnectionUrl);
725725
const html = await render(InviteUserEmail({
726+
baseUrl: env.AUTH_URL,
726727
host: {
727728
name: user.name ?? undefined,
728729
email: user.email!,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use server';
2+
3+
import { minidenticon } from 'minidenticons';
4+
import sharp from 'sharp';
5+
import { NextRequest } from 'next/server';
6+
import { apiHandler } from '@/lib/apiHandler';
7+
8+
// Generates a minidenticon avatar PNG from an email address.
9+
// Used as a fallback avatar in emails where data URIs aren't supported.
10+
export const GET = apiHandler(async (request: NextRequest) => {
11+
const email = request.nextUrl.searchParams.get('email');
12+
if (!email) {
13+
return new Response('Missing email parameter', { status: 400 });
14+
}
15+
16+
const svg = minidenticon(email, 50, 50);
17+
const png = await sharp(Buffer.from(svg))
18+
.flatten({ background: { r: 241, g: 245, b: 249 } })
19+
.resize(128, 128)
20+
.png()
21+
.toBuffer();
22+
23+
return new Response(new Uint8Array(png), {
24+
headers: {
25+
'Content-Type': 'image/png',
26+
'Cache-Control': 'public, max-age=31536000, immutable',
27+
},
28+
});
29+
}, { track: false });

packages/web/src/components/userAvatar.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { minidenticon } from 'minidenticons';
44
import { ComponentPropsWithoutRef, forwardRef, useMemo } from 'react';
5-
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5+
import { Avatar, AvatarImage } from '@/components/ui/avatar';
66
import { cn } from '@/lib/utils';
77

88
interface UserAvatarProps extends ComponentPropsWithoutRef<typeof Avatar> {
@@ -20,13 +20,8 @@ export const UserAvatar = forwardRef<HTMLSpanElement, UserAvatarProps>(
2020
}, [email]);
2121

2222
return (
23-
<Avatar ref={ref} className={cn(className)} {...rest}>
24-
<AvatarImage src={imageUrl ?? undefined} />
25-
<AvatarFallback className="bg-muted">
26-
{identiconUri ? (
27-
<img src={identiconUri} alt={email ?? 'avatar'} className="h-full w-full" />
28-
) : null}
29-
</AvatarFallback>
23+
<Avatar ref={ref} className={cn("bg-muted", className)} {...rest}>
24+
<AvatarImage src={imageUrl ?? identiconUri} />
3025
</Avatar>
3126
);
3227
}

packages/web/src/emails/inviteUserEmail.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { EmailFooter } from './emailFooter';
1818
import { SOURCEBOT_LOGO_LIGHT_LARGE_URL, SOURCEBOT_ARROW_IMAGE_URL, SOURCEBOT_PLACEHOLDER_AVATAR_URL } from './constants';
1919

2020
interface InviteUserEmailProps {
21+
baseUrl: string;
2122
inviteLink: string;
2223
host: {
2324
email: string;
@@ -32,6 +33,7 @@ interface InviteUserEmailProps {
3233
}
3334

3435
export const InviteUserEmail = ({
36+
baseUrl,
3537
host,
3638
recipient,
3739
orgName,
@@ -71,7 +73,7 @@ export const InviteUserEmail = ({
7173
<Column align="right">
7274
<Img
7375
className="rounded-full"
74-
src={host.avatarUrl ? host.avatarUrl : SOURCEBOT_PLACEHOLDER_AVATAR_URL}
76+
src={host.avatarUrl ?? `${baseUrl}/api/minidenticon?email=${encodeURIComponent(host.email)}`}
7577
width="64"
7678
height="64"
7779
/>
@@ -128,17 +130,16 @@ const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
128130
}
129131

130132
InviteUserEmail.PreviewProps = {
133+
baseUrl: 'http://localhost:3000',
131134
host: {
132135
name: 'Alan Turing',
133136
email: 'alan.turing@example.com',
134-
avatarUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL,
135137
},
136138
recipient: {
137139
// name: 'alanturing',
138140
},
139141
orgName: 'Enigma',
140-
orgImageUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL,
141-
inviteLink: 'https://sourcebot.example.com/redeem?invite_id=1234',
142+
inviteLink: 'http://localhost:3000/redeem?invite_id=1234',
142143
} satisfies InviteUserEmailProps;
143144

144145
export default InviteUserEmail;

packages/web/src/emails/joinRequestSubmittedEmail.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const JoinRequestSubmittedEmail = ({
6969
<Column align="right">
7070
<Img
7171
className="rounded-full"
72-
src={requestor.avatarUrl ? requestor.avatarUrl : SOURCEBOT_PLACEHOLDER_AVATAR_URL}
72+
src={requestor.avatarUrl ?? `${baseUrl}/api/minidenticon?email=${encodeURIComponent(requestor.email)}`}
7373
width="64"
7474
height="64"
7575
alt="Requestor avatar"
@@ -127,15 +127,13 @@ const RequestorInfo = ({ email, name }: { email: string, name?: string }) => {
127127
}
128128

129129
JoinRequestSubmittedEmail.PreviewProps = {
130-
baseUrl: 'https://sourcebot.example.com',
130+
baseUrl: 'http://localhost:3000',
131131
requestor: {
132132
name: 'Alan Turing',
133133
email: 'alan.turing@example.com',
134-
avatarUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL,
135134
},
136135
orgName: 'Enigma',
137136
orgDomain: '~',
138-
orgImageUrl: SOURCEBOT_PLACEHOLDER_AVATAR_URL,
139137
} satisfies JoinRequestSubmittedEmailProps;
140138

141139
export default JoinRequestSubmittedEmail;

0 commit comments

Comments
 (0)