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 { getStripe } from "@/lib/stripe"
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
import { Session } from "next-auth";
|
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 Stripe from "stripe";
|
||||||
import { OnboardingSteps } from "./lib/constants";
|
import { OnboardingSteps } from "./lib/constants";
|
||||||
|
import { render } from "@react-email/components";
|
||||||
|
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||||
|
import { createTransport } from "nodemailer";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -553,18 +556,71 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
} satisfies ServiceError;
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.invite.createMany({
|
||||||
for (const email of emails) {
|
data: emails.map((email) => ({
|
||||||
await tx.invite.create({
|
recipientEmail: email,
|
||||||
data: {
|
hostUserId: session.user.id,
|
||||||
recipientEmail: email,
|
orgId,
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import type { Provider } from "next-auth/providers";
|
||||||
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
|
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
|
||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import { render } from '@react-email/render';
|
import { render } from '@react-email/render';
|
||||||
import MagicLinkEmail from './emails/magicLink';
|
import MagicLinkEmail from './emails/magicLinkEmail';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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 {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Container,
|
Container,
|
||||||
Hr,
|
|
||||||
Img,
|
Img,
|
||||||
Link,
|
Link,
|
||||||
Preview,
|
Preview,
|
||||||
|
|
@ -9,7 +8,7 @@ import {
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
import { EmailFooter } from './emailFooter';
|
||||||
|
|
||||||
interface MagicLinkEmailProps {
|
interface MagicLinkEmailProps {
|
||||||
magicLink: string,
|
magicLink: string,
|
||||||
|
|
@ -23,7 +22,7 @@ export const MagicLinkEmail = ({
|
||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Preview>Log in to Sourcebot</Preview>
|
<Preview>Log in to Sourcebot</Preview>
|
||||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
<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]">
|
<Section className="mt-[32px]">
|
||||||
<Img
|
<Img
|
||||||
src={`${baseUrl}/sb_logo_light_large.png`}
|
src={`${baseUrl}/sb_logo_light_large.png`}
|
||||||
|
|
@ -34,7 +33,7 @@ export const MagicLinkEmail = ({
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
Hey there,
|
Hello,
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
You can log in to your Sourcebot account by clicking the link below.
|
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]">
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
If you didn't try to login, you can safely ignore this email.
|
If you didn't try to login, you can safely ignore this email.
|
||||||
</Text>
|
</Text>
|
||||||
<Hr className="border border-solid border-[#eaeaea] my-[10px] mx-0 w-full" />
|
<EmailFooter />
|
||||||
<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>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
Loading…
Reference in a new issue