diff --git a/packages/web/public/arrow.png b/packages/web/public/arrow.png new file mode 100644 index 00000000..018f64d2 Binary files /dev/null and b/packages/web/public/arrow.png differ diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 9446b8a7..ab8d199a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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, diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 51fedee1..6a830b41 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -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'; diff --git a/packages/web/src/emails/emailFooter.tsx b/packages/web/src/emails/emailFooter.tsx new file mode 100644 index 00000000..82e16f4c --- /dev/null +++ b/packages/web/src/emails/emailFooter.tsx @@ -0,0 +1,20 @@ +import { + Hr, + Link, + Section, + Text, +} from '@react-email/components'; + +export const EmailFooter = () => { + return ( +
+
+ + + Sourcebot.dev, + +  blazingly fast code search. + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/emails/inviteUserEmail.tsx b/packages/web/src/emails/inviteUserEmail.tsx new file mode 100644 index 00000000..02aa85cf --- /dev/null +++ b/packages/web/src/emails/inviteUserEmail.tsx @@ -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 ( + + + + + {previewText} + +
+ Sourcebot Logo +
+ + Join {orgName} on Sourcebot + + + {`Hello${recipient.name ? ` ${recipient.name.split(' ')[0]}` : ''},`} + + + has invited you to the {orgName} organization on{' '} + Sourcebot. + +
+ + + + + + invited you to + + + + + +
+
+ +
+ + or copy and paste this URL into your browser:{' '} + + {inviteLink} + + + +
+ +
+ + ); +}; + +const InvitedByText = ({ email, name }: { email: string, name?: string }) => { + const emailElement = {email}; + + if (name) { + const firstName = name.split(' ')[0]; + return {firstName} ({emailElement}); + } + + 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; \ No newline at end of file diff --git a/packages/web/src/emails/magicLink.tsx b/packages/web/src/emails/magicLinkEmail.tsx similarity index 77% rename from packages/web/src/emails/magicLink.tsx rename to packages/web/src/emails/magicLinkEmail.tsx index b5e36c8f..2eb32f57 100644 --- a/packages/web/src/emails/magicLink.tsx +++ b/packages/web/src/emails/magicLinkEmail.tsx @@ -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 = ({ Log in to Sourcebot - +
- Hey there, + Hello, You can log in to your Sourcebot account by clicking the link below. @@ -53,13 +52,7 @@ export const MagicLinkEmail = ({ If you didn't try to login, you can safely ignore this email. -
- - - Sourcebot.dev, - -  blazingly fast code search. - +