Skip to content

Commit 72da582

Browse files
Add invite email (#209)
1 parent e1f7cd9 commit 72da582

File tree

6 files changed

+236
-22
lines changed

6 files changed

+236
-22
lines changed

packages/web/public/arrow.png

426 Bytes
Loading

packages/web/src/actions.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ import { headers } from "next/headers"
1919
import { getStripe } from "@/lib/stripe"
2020
import { getUser } from "@/data/user";
2121
import { Session } from "next-auth";
22-
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
22+
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL } from "@/lib/environment";
2323
import Stripe from "stripe";
2424
import { OnboardingSteps } from "./lib/constants";
25+
import { render } from "@react-email/components";
26+
import InviteUserEmail from "./emails/inviteUserEmail";
27+
import { createTransport } from "nodemailer";
2528

2629
const ajv = new Ajv({
2730
validateFormats: false,
@@ -553,18 +556,71 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
553556
} satisfies ServiceError;
554557
}
555558

556-
await prisma.$transaction(async (tx) => {
557-
for (const email of emails) {
558-
await tx.invite.create({
559-
data: {
560-
recipientEmail: email,
561-
hostUserId: session.user.id,
562-
orgId,
559+
await prisma.invite.createMany({
560+
data: emails.map((email) => ({
561+
recipientEmail: email,
562+
hostUserId: session.user.id,
563+
orgId,
564+
})),
565+
skipDuplicates: true,
566+
});
567+
568+
// Send invites to recipients
569+
if (SMTP_CONNECTION_URL && EMAIL_FROM) {
570+
const origin = (await headers()).get('origin')!;
571+
await Promise.all(emails.map(async (email) => {
572+
const invite = await prisma.invite.findUnique({
573+
where: {
574+
recipientEmail_orgId: {
575+
recipientEmail: email,
576+
orgId,
577+
},
578+
},
579+
include: {
580+
org: true,
563581
}
564582
});
565-
}
566-
});
567583

584+
if (!invite) {
585+
return;
586+
}
587+
588+
const recipient = await prisma.user.findUnique({
589+
where: {
590+
email,
591+
},
592+
});
593+
const inviteLink = `${origin}/redeem?invite_id=${invite.id}`;
594+
const transport = createTransport(SMTP_CONNECTION_URL);
595+
const html = await render(InviteUserEmail({
596+
baseUrl: 'https://sourcebot.app',
597+
host: {
598+
name: session.user.name ?? undefined,
599+
email: session.user.email!,
600+
avatarUrl: session.user.image ?? undefined,
601+
},
602+
recipient: {
603+
name: recipient?.name ?? undefined,
604+
},
605+
orgName: invite.org.name,
606+
orgImageUrl: invite.org.imageUrl ?? undefined,
607+
inviteLink,
608+
}));
609+
610+
const result = await transport.sendMail({
611+
to: email,
612+
from: EMAIL_FROM,
613+
subject: `Join ${invite.org.name} on Sourcebot`,
614+
html,
615+
text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`,
616+
});
617+
618+
const failed = result.rejected.concat(result.pending).filter(Boolean);
619+
if (failed.length > 0) {
620+
console.error(`Failed to send invite email to ${email}: ${failed}`);
621+
}
622+
}));
623+
}
568624

569625
return {
570626
success: true,

packages/web/src/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type { Provider } from "next-auth/providers";
2323
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
2424
import { createTransport } from 'nodemailer';
2525
import { render } from '@react-email/render';
26-
import MagicLinkEmail from './emails/magicLink';
26+
import MagicLinkEmail from './emails/magicLinkEmail';
2727

2828
export const runtime = 'nodejs';
2929

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
Hr,
3+
Link,
4+
Section,
5+
Text,
6+
} from '@react-email/components';
7+
8+
export const EmailFooter = () => {
9+
return (
10+
<Section className="mt-[10px]">
11+
<Hr className="border border-solid border-[#eaeaea] mx-0 w-full" />
12+
<Text className="text-[#666666] text-[12px] leading-[24px]">
13+
<Link href="https://sourcebot.dev" className="underline text-[#666666]" target="_blank">
14+
Sourcebot.dev,
15+
</Link>
16+
&nbsp;blazingly fast code search.
17+
</Text>
18+
</Section>
19+
)
20+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
Body,
3+
Button,
4+
Column,
5+
Container,
6+
Head,
7+
Heading,
8+
Html,
9+
Img,
10+
Link,
11+
Preview,
12+
Row,
13+
Section,
14+
Tailwind,
15+
Text,
16+
} from '@react-email/components';
17+
import { EmailFooter } from './emailFooter';
18+
interface InviteUserEmailProps {
19+
inviteLink: string;
20+
baseUrl: string;
21+
host: {
22+
email: string;
23+
name?: string;
24+
avatarUrl?: string;
25+
},
26+
recipient: {
27+
name?: string;
28+
},
29+
orgName: string;
30+
orgImageUrl?: string;
31+
}
32+
33+
export const InviteUserEmail = ({
34+
baseUrl,
35+
host,
36+
recipient,
37+
orgName,
38+
orgImageUrl,
39+
inviteLink,
40+
}: InviteUserEmailProps) => {
41+
const previewText = `Join ${host.name ?? host.email} on Sourcebot`;
42+
43+
return (
44+
<Html>
45+
<Head />
46+
<Tailwind>
47+
<Body className="bg-white my-auto mx-auto font-sans px-2">
48+
<Preview>{previewText}</Preview>
49+
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
50+
<Section className="mt-[32px]">
51+
<Img
52+
src={`${baseUrl}/sb_logo_light_large.png`}
53+
width="auto"
54+
height="60"
55+
alt="Sourcebot Logo"
56+
className="my-0 mx-auto"
57+
/>
58+
</Section>
59+
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
60+
Join <strong>{orgName}</strong> on <strong>Sourcebot</strong>
61+
</Heading>
62+
<Text className="text-black text-[14px] leading-[24px]">
63+
{`Hello${recipient.name ? ` ${recipient.name.split(' ')[0]}` : ''},`}
64+
</Text>
65+
<Text className="text-black text-[14px] leading-[24px]">
66+
<InvitedByText email={host.email} name={host.name} /> has invited you to the <strong>{orgName}</strong> organization on{' '}
67+
<strong>Sourcebot</strong>.
68+
</Text>
69+
<Section>
70+
<Row>
71+
<Column align="right">
72+
<Img
73+
className="rounded-full"
74+
src={host.avatarUrl ? host.avatarUrl : `${baseUrl}/placeholder_avatar.png`}
75+
width="64"
76+
height="64"
77+
/>
78+
</Column>
79+
<Column align="center">
80+
<Img
81+
src={`${baseUrl}/arrow.png`}
82+
width="12"
83+
height="9"
84+
alt="invited you to"
85+
/>
86+
</Column>
87+
<Column align="left">
88+
<Img
89+
className="rounded-full"
90+
src={orgImageUrl ? orgImageUrl : `${baseUrl}/placeholder_avatar.png`}
91+
width="64"
92+
height="64"
93+
/>
94+
</Column>
95+
</Row>
96+
</Section>
97+
<Section className="text-center mt-[32px] mb-[32px]">
98+
<Button
99+
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
100+
href={inviteLink}
101+
>
102+
Join the organization
103+
</Button>
104+
</Section>
105+
<Text className="text-black text-[14px] leading-[24px]">
106+
or copy and paste this URL into your browser:{' '}
107+
<Link href={inviteLink} className="text-blue-600 no-underline">
108+
{inviteLink}
109+
</Link>
110+
</Text>
111+
<EmailFooter />
112+
</Container>
113+
</Body>
114+
</Tailwind>
115+
</Html>
116+
);
117+
};
118+
119+
const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
120+
const emailElement = <Link href={`mailto:${email}`} className="text-blue-600 no-underline">{email}</Link>;
121+
122+
if (name) {
123+
const firstName = name.split(' ')[0];
124+
return <span><strong>{firstName}</strong> ({emailElement})</span>;
125+
}
126+
127+
return emailElement;
128+
}
129+
130+
InviteUserEmail.PreviewProps = {
131+
baseUrl: 'http://localhost:3000',
132+
host: {
133+
name: 'Alan Turing',
134+
email: 'alan.turing@example.com',
135+
// avatarUrl: `http://localhost:3000/arrow.png`,
136+
},
137+
recipient: {
138+
// name: 'alanturing',
139+
},
140+
orgName: 'Enigma',
141+
orgImageUrl: `http://localhost:3000/arrow.png`,
142+
inviteLink: 'https://localhost:3000/redeem?invite_id=1234',
143+
} satisfies InviteUserEmailProps;
144+
145+
export default InviteUserEmail;

packages/web/src/emails/magicLink.tsx renamed to packages/web/src/emails/magicLinkEmail.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import {
22
Body,
33
Container,
4-
Hr,
54
Img,
65
Link,
76
Preview,
87
Section,
98
Tailwind,
109
Text,
1110
} from '@react-email/components';
12-
11+
import { EmailFooter } from './emailFooter';
1312

1413
interface MagicLinkEmailProps {
1514
magicLink: string,
@@ -23,7 +22,7 @@ export const MagicLinkEmail = ({
2322
<Tailwind>
2423
<Preview>Log in to Sourcebot</Preview>
2524
<Body className="bg-white my-auto mx-auto font-sans px-2">
26-
<Container className="my-[40px] mx-auto p-[20px] max-w-[465px]">
25+
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
2726
<Section className="mt-[32px]">
2827
<Img
2928
src={`${baseUrl}/sb_logo_light_large.png`}
@@ -34,7 +33,7 @@ export const MagicLinkEmail = ({
3433
/>
3534
</Section>
3635
<Text className="text-black text-[14px] leading-[24px]">
37-
Hey there,
36+
Hello,
3837
</Text>
3938
<Text className="text-black text-[14px] leading-[24px]">
4039
You can log in to your Sourcebot account by clicking the link below.
@@ -53,13 +52,7 @@ export const MagicLinkEmail = ({
5352
<Text className="text-black text-[14px] leading-[24px]">
5453
If you didn&apos;t try to login, you can safely ignore this email.
5554
</Text>
56-
<Hr className="border border-solid border-[#eaeaea] my-[10px] mx-0 w-full" />
57-
<Text className="text-[#666666] text-[12px] leading-[24px]">
58-
<Link href="https://sourcebot.dev" className="underline text-[#666666]" target="_blank">
59-
Sourcebot.dev,
60-
</Link>
61-
&nbsp;blazingly fast code search.
62-
</Text>
55+
<EmailFooter />
6356
</Container>
6457
</Body>
6558
</Tailwind>

0 commit comments

Comments
 (0)