mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
Add invite email (#209)
This commit is contained in:
parent
e1f7cd90ac
commit
72da582145
6 changed files with 237 additions and 23 deletions
BIN
packages/web/public/arrow.png
Normal file
BIN
packages/web/public/arrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 B |
|
|
@ -19,9 +19,12 @@ import { headers } from "next/headers"
|
|||
import { getStripe } from "@/lib/stripe"
|
||||
import { getUser } from "@/data/user";
|
||||
import { Session } from "next-auth";
|
||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL } from "@/lib/environment";
|
||||
import Stripe from "stripe";
|
||||
import { OnboardingSteps } from "./lib/constants";
|
||||
import { render } from "@react-email/components";
|
||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
|
|
@ -553,18 +556,71 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
|||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const email of emails) {
|
||||
await tx.invite.create({
|
||||
data: {
|
||||
recipientEmail: email,
|
||||
hostUserId: session.user.id,
|
||||
orgId,
|
||||
}
|
||||
});
|
||||
}
|
||||
await prisma.invite.createMany({
|
||||
data: emails.map((email) => ({
|
||||
recipientEmail: email,
|
||||
hostUserId: session.user.id,
|
||||
orgId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Send invites to recipients
|
||||
if (SMTP_CONNECTION_URL && EMAIL_FROM) {
|
||||
const origin = (await headers()).get('origin')!;
|
||||
await Promise.all(emails.map(async (email) => {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
recipientEmail_orgId: {
|
||||
recipientEmail: email,
|
||||
orgId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
org: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
const inviteLink = `${origin}/redeem?invite_id=${invite.id}`;
|
||||
const transport = createTransport(SMTP_CONNECTION_URL);
|
||||
const html = await render(InviteUserEmail({
|
||||
baseUrl: 'https://sourcebot.app',
|
||||
host: {
|
||||
name: session.user.name ?? undefined,
|
||||
email: session.user.email!,
|
||||
avatarUrl: session.user.image ?? undefined,
|
||||
},
|
||||
recipient: {
|
||||
name: recipient?.name ?? undefined,
|
||||
},
|
||||
orgName: invite.org.name,
|
||||
orgImageUrl: invite.org.imageUrl ?? undefined,
|
||||
inviteLink,
|
||||
}));
|
||||
|
||||
const result = await transport.sendMail({
|
||||
to: email,
|
||||
from: EMAIL_FROM,
|
||||
subject: `Join ${invite.org.name} on Sourcebot`,
|
||||
html,
|
||||
text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`,
|
||||
});
|
||||
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length > 0) {
|
||||
console.error(`Failed to send invite email to ${email}: ${failed}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import type { Provider } from "next-auth/providers";
|
|||
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
|
||||
import { createTransport } from 'nodemailer';
|
||||
import { render } from '@react-email/render';
|
||||
import MagicLinkEmail from './emails/magicLink';
|
||||
import MagicLinkEmail from './emails/magicLinkEmail';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
|
|
|
|||
20
packages/web/src/emails/emailFooter.tsx
Normal file
20
packages/web/src/emails/emailFooter.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
Hr,
|
||||
Link,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
export const EmailFooter = () => {
|
||||
return (
|
||||
<Section className="mt-[10px]">
|
||||
<Hr className="border border-solid border-[#eaeaea] mx-0 w-full" />
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
<Link href="https://sourcebot.dev" className="underline text-[#666666]" target="_blank">
|
||||
Sourcebot.dev,
|
||||
</Link>
|
||||
blazingly fast code search.
|
||||
</Text>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
145
packages/web/src/emails/inviteUserEmail.tsx
Normal file
145
packages/web/src/emails/inviteUserEmail.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
import { EmailFooter } from './emailFooter';
|
||||
interface InviteUserEmailProps {
|
||||
inviteLink: string;
|
||||
baseUrl: string;
|
||||
host: {
|
||||
email: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
},
|
||||
recipient: {
|
||||
name?: string;
|
||||
},
|
||||
orgName: string;
|
||||
orgImageUrl?: string;
|
||||
}
|
||||
|
||||
export const InviteUserEmail = ({
|
||||
baseUrl,
|
||||
host,
|
||||
recipient,
|
||||
orgName,
|
||||
orgImageUrl,
|
||||
inviteLink,
|
||||
}: InviteUserEmailProps) => {
|
||||
const previewText = `Join ${host.name ?? host.email} on Sourcebot`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Preview>{previewText}</Preview>
|
||||
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/sb_logo_light_large.png`}
|
||||
width="auto"
|
||||
height="60"
|
||||
alt="Sourcebot Logo"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Join <strong>{orgName}</strong> on <strong>Sourcebot</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
{`Hello${recipient.name ? ` ${recipient.name.split(' ')[0]}` : ''},`}
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
<InvitedByText email={host.email} name={host.name} /> has invited you to the <strong>{orgName}</strong> organization on{' '}
|
||||
<strong>Sourcebot</strong>.
|
||||
</Text>
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="right">
|
||||
<Img
|
||||
className="rounded-full"
|
||||
src={host.avatarUrl ? host.avatarUrl : `${baseUrl}/placeholder_avatar.png`}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</Column>
|
||||
<Column align="center">
|
||||
<Img
|
||||
src={`${baseUrl}/arrow.png`}
|
||||
width="12"
|
||||
height="9"
|
||||
alt="invited you to"
|
||||
/>
|
||||
</Column>
|
||||
<Column align="left">
|
||||
<Img
|
||||
className="rounded-full"
|
||||
src={orgImageUrl ? orgImageUrl : `${baseUrl}/placeholder_avatar.png`}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
href={inviteLink}
|
||||
>
|
||||
Join the organization
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
or copy and paste this URL into your browser:{' '}
|
||||
<Link href={inviteLink} className="text-blue-600 no-underline">
|
||||
{inviteLink}
|
||||
</Link>
|
||||
</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
|
||||
const emailElement = <Link href={`mailto:${email}`} className="text-blue-600 no-underline">{email}</Link>;
|
||||
|
||||
if (name) {
|
||||
const firstName = name.split(' ')[0];
|
||||
return <span><strong>{firstName}</strong> ({emailElement})</span>;
|
||||
}
|
||||
|
||||
return emailElement;
|
||||
}
|
||||
|
||||
InviteUserEmail.PreviewProps = {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
host: {
|
||||
name: 'Alan Turing',
|
||||
email: 'alan.turing@example.com',
|
||||
// avatarUrl: `http://localhost:3000/arrow.png`,
|
||||
},
|
||||
recipient: {
|
||||
// name: 'alanturing',
|
||||
},
|
||||
orgName: 'Enigma',
|
||||
orgImageUrl: `http://localhost:3000/arrow.png`,
|
||||
inviteLink: 'https://localhost:3000/redeem?invite_id=1234',
|
||||
} satisfies InviteUserEmailProps;
|
||||
|
||||
export default InviteUserEmail;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Hr,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
|
|
@ -9,7 +8,7 @@ import {
|
|||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { EmailFooter } from './emailFooter';
|
||||
|
||||
interface MagicLinkEmailProps {
|
||||
magicLink: string,
|
||||
|
|
@ -23,7 +22,7 @@ export const MagicLinkEmail = ({
|
|||
<Tailwind>
|
||||
<Preview>Log in to Sourcebot</Preview>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/sb_logo_light_large.png`}
|
||||
|
|
@ -34,7 +33,7 @@ export const MagicLinkEmail = ({
|
|||
/>
|
||||
</Section>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hey there,
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
You can log in to your Sourcebot account by clicking the link below.
|
||||
|
|
@ -53,13 +52,7 @@ export const MagicLinkEmail = ({
|
|||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
If you didn't try to login, you can safely ignore this email.
|
||||
</Text>
|
||||
<Hr className="border border-solid border-[#eaeaea] my-[10px] mx-0 w-full" />
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
<Link href="https://sourcebot.dev" className="underline text-[#666666]" target="_blank">
|
||||
Sourcebot.dev,
|
||||
</Link>
|
||||
blazingly fast code search.
|
||||
</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
Loading…
Reference in a new issue