From c8289bf2ac6c9c9644c7e1530a60e1a0a2d3bf6f Mon Sep 17 00:00:00 2001 From: Jaesik-kim Date: Sun, 31 May 2026 05:32:21 +0900 Subject: [PATCH] Add workspace mail scoping --- apps/web/app/api/google/threads/route.ts | 39 +- .../email-accounts/[emailAccountId]/route.ts | 19 + .../[workspaceId]/email-accounts/route.ts | 43 ++ .../[workspaceId]/members/[memberId]/route.ts | 45 ++ .../workspaces/[workspaceId]/members/route.ts | 70 +++ .../app/api/workspaces/[workspaceId]/route.ts | 43 ++ .../[invitationId]/accept/route.ts | 22 + apps/web/app/api/workspaces/route.ts | 55 ++ .../mail/components/account-switcher.tsx | 28 +- apps/web/components/mail/components/mail.tsx | 509 +++++++++++++++-- .../mail/components/workspace-sidebar.tsx | 63 +- apps/web/utils/auth.ts | 14 +- apps/web/utils/gmail/client.ts | 45 ++ apps/web/utils/store.ts | 4 + apps/web/utils/workspace-types.ts | 29 + apps/web/utils/workspace.ts | 539 ++++++++++++++++++ packages/resend/emails/workspace-invite.tsx | 67 +++ packages/resend/src/index.tsx | 26 + 18 files changed, 1571 insertions(+), 89 deletions(-) create mode 100644 apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceId]/members/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceId]/route.ts create mode 100644 apps/web/app/api/workspaces/invitations/[invitationId]/accept/route.ts create mode 100644 apps/web/app/api/workspaces/route.ts create mode 100644 apps/web/utils/workspace-types.ts create mode 100644 apps/web/utils/workspace.ts create mode 100644 packages/resend/emails/workspace-invite.tsx diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index 37d7642..f16abc6 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -3,7 +3,11 @@ import he from "he"; import { NextResponse } from "next/server"; import { parseMessages } from "@/utils/mail"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; +import { + getGmailAccessToken, + getGmailClient, + getGmailClientForEmailAccount, +} from "@/utils/gmail/client"; import { getPlan } from "@/utils/redis/plan"; import { INBOX_LABEL_ID, getGmailLabels } from "@/utils/label"; import { ThreadWithPayloadMessages } from "@/utils/types"; @@ -13,6 +17,7 @@ import { getThreadsBatch, parseGmailApiResponse } from "@/utils/gmail/thread"; import { withError } from "@/utils/middleware"; import { createGmailLabel } from "./archive/controller"; import { getEmailSummary } from "@/utils/langbase"; +import { getWorkspaceEmailAccountForUser } from "@/utils/workspace"; export const dynamic = "force-dynamic"; @@ -26,6 +31,8 @@ const threadsQuery = z.object({ isTeam: z.coerce.boolean().nullish(), isCalendar: z.coerce.boolean().nullish(), isSent: z.coerce.boolean().nullish(), + workspaceId: z.string().nullish(), + emailAccountId: z.string().nullish(), }); export type ThreadsQuery = z.infer; export type ThreadsResponse = Awaited>; @@ -33,15 +40,31 @@ export type ThreadsResponse = Awaited>; async function getThreads(query: ThreadsQuery) { const session = await auth(); const email = session?.user.email; - const emailDomain = email?.split("@")[1]; if (!email) throw new Error("Not authenticated"); - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - const accessToken = token?.token; + let gmail = getGmailClient(session); + let token = await getGmailAccessToken(session); + let accessToken = token?.token; + let mailboxEmail = email; + + if (query.workspaceId) { + const { emailAccount } = await getWorkspaceEmailAccountForUser({ + userId: session.user.id, + workspaceId: query.workspaceId, + emailAccountId: query.emailAccountId, + }); + + if (!emailAccount) return { threads: [] }; + + const scopedGmail = await getGmailClientForEmailAccount(emailAccount); + gmail = scopedGmail.gmail; + accessToken = scopedGmail.accessToken; + mailboxEmail = emailAccount.email; + } if (!accessToken) throw new Error("Missing access token"); + const emailDomain = mailboxEmail.split("@")[1]; const gmailLabels = await getGmailLabels(gmail); @@ -117,7 +140,7 @@ async function getThreads(query: ThreadsQuery) { messages: messagesAsGMailMessage, snippet: he.decode(thread.snippet || ""), plan: plan ? { ...plan, databaseRule: rule } : undefined, - category: await getCategory({ email, threadId: id }), + category: await getCategory({ email: mailboxEmail, threadId: id }), }; }) || [], ); @@ -134,6 +157,8 @@ export const GET = withError(async (request: Request) => { const isTeam = searchParams.get("isTeam"); const isCalendar = searchParams.get("isCalendar"); const isSent = searchParams.get("isSent"); + const workspaceId = searchParams.get("workspaceId"); + const emailAccountId = searchParams.get("emailAccountId"); const query = threadsQuery.parse({ limit, fromEmail, @@ -142,6 +167,8 @@ export const GET = withError(async (request: Request) => { isTeam, isCalendar, isSent, + workspaceId, + emailAccountId, }); const threads = await getThreads(query); diff --git a/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts new file mode 100644 index 0000000..029a805 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { removeWorkspaceEmailAccount } from "@/utils/workspace"; + +export const DELETE = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId || !params.emailAccountId) + throw new Error("Missing workspace email account"); + + return NextResponse.json( + await removeWorkspaceEmailAccount({ + userId: session.user.id, + workspaceId: params.workspaceId, + emailAccountId: params.emailAccountId, + }), + ); +}); diff --git a/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts new file mode 100644 index 0000000..96a68b1 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + addCurrentUserEmailAccount, + getWorkspaceMembership, +} from "@/utils/workspace"; + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const membership = await getWorkspaceMembership({ + userId: session.user.id, + workspaceId: params.workspaceId, + }); + + if (!membership) throw new Error("Workspace not found"); + + return NextResponse.json({ + emailAccounts: membership.organization.emailAccounts.map((account) => ({ + id: account.id, + name: account.name, + email: account.email, + })), + }); +}); + +export const POST = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const emailAccount = await addCurrentUserEmailAccount({ + userId: session.user.id, + workspaceId: params.workspaceId, + email: session.user.email, + name: session.user.name, + }); + + return NextResponse.json({ emailAccount }); +}); diff --git a/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts new file mode 100644 index 0000000..9fa9996 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts @@ -0,0 +1,45 @@ +import { MembershipRole } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + removeWorkspaceMember, + updateWorkspaceMemberRole, +} from "@/utils/workspace"; + +const updateMemberBody = z.object({ + role: z.nativeEnum(MembershipRole), +}); + +export const PATCH = withError(async (request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId || !params.memberId) + throw new Error("Missing workspace member"); + + const body = updateMemberBody.parse(await request.json()); + const member = await updateWorkspaceMemberRole({ + userId: session.user.id, + workspaceId: params.workspaceId, + memberId: params.memberId, + role: body.role, + }); + + return NextResponse.json({ member }); +}); + +export const DELETE = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId || !params.memberId) + throw new Error("Missing workspace member"); + + return NextResponse.json( + await removeWorkspaceMember({ + userId: session.user.id, + workspaceId: params.workspaceId, + memberId: params.memberId, + }), + ); +}); diff --git a/apps/web/app/api/workspaces/[workspaceId]/members/route.ts b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts new file mode 100644 index 0000000..acd1826 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts @@ -0,0 +1,70 @@ +import { MembershipRole } from "@prisma/client"; +import { sendWorkspaceInviteEmail } from "@inboxzero/resend"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { + getWorkspaceMembership, + inviteWorkspaceMember, +} from "@/utils/workspace"; + +const inviteMemberBody = z.object({ + email: z.string().trim().email(), + name: z.string().trim().max(80).optional(), + role: z.nativeEnum(MembershipRole).default(MembershipRole.USER), +}); + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const membership = await getWorkspaceMembership({ + userId: session.user.id, + workspaceId: params.workspaceId, + }); + + if (!membership) throw new Error("Workspace not found"); + + return NextResponse.json({ + members: membership.organization.membership, + }); +}); + +export const POST = withError(async (request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const body = inviteMemberBody.parse(await request.json()); + const workspace = await prisma.organization.findUniqueOrThrow({ + where: { id: params.workspaceId }, + select: { id: true, name: true }, + }); + + const invite = await inviteWorkspaceMember({ + workspaceId: workspace.id, + inviterUserId: session.user.id, + inviterEmail: session.user.email, + invitedEmail: body.email, + invitedName: body.name, + role: body.role, + }); + + await sendWorkspaceInviteEmail({ + to: body.email, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + invitedByName: session.user.name || session.user.email, + workspaceName: workspace.name, + inviteUrl: invite.invitedEmail + ? `${env.NEXT_PUBLIC_BASE_URL}/api/workspaces/invitations/${invite.id}/accept` + : `${env.NEXT_PUBLIC_BASE_URL}/mail`, + }, + }); + + return NextResponse.json({ member: invite }); +}); diff --git a/apps/web/app/api/workspaces/[workspaceId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/route.ts new file mode 100644 index 0000000..510d5e0 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + getWorkspaceMembership, + serializeWorkspace, + updateWorkspaceForUser, +} from "@/utils/workspace"; + +const updateWorkspaceBody = z.object({ + name: z.string().trim().min(1).max(80).optional(), + image: z.string().url().nullable().optional(), +}); + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const membership = await getWorkspaceMembership({ + userId: session.user.id, + workspaceId: params.workspaceId, + }); + + return NextResponse.json({ workspace: serializeWorkspace(membership) }); +}); + +export const PATCH = withError(async (request, { params }) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + if (!params.workspaceId) throw new Error("Missing workspace id"); + + const body = updateWorkspaceBody.parse(await request.json()); + const membership = await updateWorkspaceForUser({ + userId: session.user.id, + workspaceId: params.workspaceId, + name: body.name, + image: body.image, + }); + + return NextResponse.json({ workspace: serializeWorkspace(membership) }); +}); diff --git a/apps/web/app/api/workspaces/invitations/[invitationId]/accept/route.ts b/apps/web/app/api/workspaces/invitations/[invitationId]/accept/route.ts new file mode 100644 index 0000000..5899b46 --- /dev/null +++ b/apps/web/app/api/workspaces/invitations/[invitationId]/accept/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import { acceptWorkspaceInvitation } from "@/utils/workspace"; + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) { + return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/login`); + } + if (!params.invitationId) throw new Error("Missing invitation id"); + + await acceptWorkspaceInvitation({ + invitationId: params.invitationId, + userId: session.user.id, + email: session.user.email, + name: session.user.name, + }); + + return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/mail`); +}); diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..d047ca0 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,55 @@ +import { MembershipRole } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + createWorkspaceForUser, + ensurePersonalWorkspaceForUser, + getWorkspacesForUser, + serializeWorkspace, +} from "@/utils/workspace"; + +const createWorkspaceBody = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().url().nullish(), +}); + +async function getWorkspaces() { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + + await ensurePersonalWorkspaceForUser({ + userId: session.user.id, + email: session.user.email, + name: session.user.name, + image: session.user.image, + }); + + return { workspaces: await getWorkspacesForUser(session.user.id) }; +} + +export const GET = withError(async () => { + return NextResponse.json(await getWorkspaces()); +}); + +export const POST = withError(async (request: Request) => { + const session = await auth(); + if (!session?.user.email) throw new Error("Not authenticated"); + + const body = createWorkspaceBody.parse(await request.json()); + const membership = await createWorkspaceForUser({ + userId: session.user.id, + email: session.user.email, + userName: session.user.name, + name: body.name, + image: body.image || null, + }); + + return NextResponse.json({ + workspace: { + ...serializeWorkspace(membership), + role: MembershipRole.OWNER, + }, + }); +}); diff --git a/apps/web/components/mail/components/account-switcher.tsx b/apps/web/components/mail/components/account-switcher.tsx index 3a94fb6..08f9886 100644 --- a/apps/web/components/mail/components/account-switcher.tsx +++ b/apps/web/components/mail/components/account-switcher.tsx @@ -14,22 +14,26 @@ import { interface AccountSwitcherProps { isCollapsed: boolean; accounts: { + id?: string; label: string; email: string; icon: React.ReactNode; }[]; + selectedAccountId?: string | null; + onSelectAccount?: (accountId: string) => void; } export function AccountSwitcher({ isCollapsed, accounts, + selectedAccountId, + onSelectAccount, }: AccountSwitcherProps) { - const [selectedAccount, setSelectedAccount] = React.useState( - accounts[0].email, - ); + const fallbackValue = accounts[0]?.id || accounts[0]?.email || ""; + const selectedAccount = selectedAccountId || fallbackValue; return ( - span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0", @@ -39,18 +43,26 @@ export function AccountSwitcher({ aria-label="Select account" > - {accounts.find((account) => account.email === selectedAccount)?.icon} + { + accounts.find( + (account) => (account.id || account.email) === selectedAccount, + )?.icon + } { - accounts.find((account) => account.email === selectedAccount) - ?.label + accounts.find( + (account) => (account.id || account.email) === selectedAccount, + )?.label } {accounts.map((account) => ( - +
{account.icon} {account.email} diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 19cac9b..e382afc 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; import { - AlertCircle, Archive, ArchiveX, Building, @@ -11,12 +10,11 @@ import { Gauge, Inbox as InboxIcon, Loader2, - MessagesSquare, Newspaper, Pencil, Plus, Send, - ShoppingCart, + Settings2, Star, Trash2, Users2, @@ -32,21 +30,21 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { useAtom } from "jotai"; import useSWR from "swr"; import { - configAtom, openComposeAtom, openCreateWorkspaceOpenAtom, + selectedEmailAccountIdAtom, + selectedWorkspaceIdAtom, tabAtom, threadsAtom, } from "@/utils/store"; -import { ProfileDropdown } from "@/components/TopNav"; +import type { + WorkspaceMember, + WorkspaceSummary, +} from "@/utils/workspace-types"; import { Inbox } from "@/components/mail/components/inbox"; import { Newsletters } from "@/components/mail/components/newsletters"; import { MailStats } from "@/components/mail/components/mail-stats"; @@ -57,14 +55,16 @@ import { DialogContent, DialogFooter, DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SingleImageDropzone } from "@/components/ui/single-image-dropzone"; import { useEdgeStore } from "@/utils/edgestore"; import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +type Role = "OWNER" | "ADMIN" | "USER"; + interface MailProps { accounts: { label: string; @@ -80,13 +80,53 @@ export function Mail({ accounts, defaultLayout = [225, 440, 655], defaultCollapsed = false, - navCollapsedSize, }: MailProps) { + const [selectedWorkspaceId, setSelectedWorkspaceId] = useAtom( + selectedWorkspaceIdAtom, + ); + const [selectedEmailAccountId, setSelectedEmailAccountId] = useAtom( + selectedEmailAccountIdAtom, + ); + const { data: workspacesData, mutate: mutateWorkspaces } = useSWR<{ + workspaces: WorkspaceSummary[]; + }>("/api/workspaces", { + keepPreviousData: true, + }); + const workspaces = React.useMemo( + () => workspacesData?.workspaces ?? [], + [workspacesData], + ); + const selectedWorkspace = + workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? + null; + const selectedWorkspaceHasAccount = selectedWorkspace?.emailAccounts.some( + (account) => account.id === selectedEmailAccountId, + ); + const workspaceQuery = React.useMemo(() => { + const params = new URLSearchParams(); + if (selectedWorkspaceId) params.set("workspaceId", selectedWorkspaceId); + if (selectedWorkspaceId && selectedEmailAccountId) { + params.set("emailAccountId", selectedEmailAccountId); + } + return params; + }, [selectedEmailAccountId, selectedWorkspaceId]); + const threadsUrl = React.useCallback( + (filters: Record = {}) => { + const params = new URLSearchParams(workspaceQuery); + Object.entries(filters).forEach(([key, value]) => { + params.set(key, String(value)); + }); + const query = params.toString(); + return `/api/google/threads${query ? `?${query}` : ""}`; + }, + [workspaceQuery], + ); + const { data: threadsData, error: threadsError, isLoading: threadsLoading, - } = useSWR("/api/google/threads", { + } = useSWR(threadsUrl(), { keepPreviousData: true, }); @@ -94,7 +134,7 @@ export function Mail({ data: doneEmailsData, error: doneEmailsError, isLoading: doneEmailsLoading, - } = useSWR("/api/google/threads?isDone=true", { + } = useSWR(threadsUrl({ isDone: true }), { keepPreviousData: true, }); @@ -102,7 +142,7 @@ export function Mail({ data: teamEmailsData, error: teamEmailsError, isLoading: teamEmailsLoading, - } = useSWR("/api/google/threads?isTeam=true", { + } = useSWR(threadsUrl({ isTeam: true }), { keepPreviousData: true, }); @@ -110,7 +150,7 @@ export function Mail({ data: calendarEmailsData, error: calendarEmailsError, isLoading: calendarEmailsLoading, - } = useSWR("/api/google/threads?isCalendar=true", { + } = useSWR(threadsUrl({ isCalendar: true }), { keepPreviousData: true, }); @@ -118,28 +158,84 @@ export function Mail({ data: sentEmailsData, error: sentEmailsError, isLoading: sentEmailsLoading, - } = useSWR("/api/google/threads?isSent=true", { + } = useSWR(threadsUrl({ isSent: true }), { keepPreviousData: true, }); const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); + const [workspaceName, setWorkspaceName] = React.useState(""); + const [workspaceError, setWorkspaceError] = React.useState( + null, + ); + const [manageWorkspaceOpen, setManageWorkspaceOpen] = React.useState(false); + const [settingsName, setSettingsName] = React.useState(""); + const [settingsImage, setSettingsImage] = React.useState(""); + const [inviteEmail, setInviteEmail] = React.useState(""); + const [inviteName, setInviteName] = React.useState(""); + const [inviteRole, setInviteRole] = React.useState("USER"); + const [isSavingWorkspace, setIsSavingWorkspace] = React.useState(false); const [selectedTab, setSelectedTab] = useAtom(tabAtom); - const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); - const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); + const [, setComposeOpen] = useAtom(openComposeAtom); + const [, setStateThreadsData] = useAtom(threadsAtom); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useAtom( openCreateWorkspaceOpenAtom, ); const { edgestore } = useEdgeStore(); - const mail = useAtomValue(configAtom); + const workspaceAccounts = React.useMemo(() => { + if (!selectedWorkspace) return accounts; + + return selectedWorkspace.emailAccounts.map((account) => ({ + id: account.id, + label: account.name, + email: account.email, + icon: , + })); + }, [accounts, selectedWorkspace]); + + React.useEffect(() => { + if (!workspacesData) return; + if (!workspaces.length) { + setSelectedWorkspaceId(null); + return; + } + + if ( + !selectedWorkspaceId || + !workspaces.some((workspace) => workspace.id === selectedWorkspaceId) + ) { + setSelectedWorkspaceId(workspaces[0].id); + } + }, [selectedWorkspaceId, setSelectedWorkspaceId, workspaces, workspacesData]); React.useEffect(() => { - if (threadsData) { - setStateThreadsData(threadsData); + if (!selectedWorkspace) { + setSelectedEmailAccountId(null); + return; } + + const firstAccountId = selectedWorkspace.emailAccounts[0]?.id ?? null; + if (!selectedEmailAccountId || !selectedWorkspaceHasAccount) { + setSelectedEmailAccountId(firstAccountId); + } + }, [ + selectedEmailAccountId, + selectedWorkspace, + selectedWorkspaceHasAccount, + setSelectedEmailAccountId, + ]); + + React.useEffect(() => { + if (!selectedWorkspace) return; + setSettingsName(selectedWorkspace.name); + setSettingsImage(selectedWorkspace.image || ""); + }, [selectedWorkspace]); + + React.useEffect(() => { + if (threadsData) setStateThreadsData(threadsData); }, [threadsData, setStateThreadsData]); const handleOnCollapse = () => { @@ -149,12 +245,161 @@ export function Mail({ setIsCollapsed(!isCollapsed); }; + async function refreshWorkspaces() { + await mutateWorkspaces(); + } + + async function handleCreateWorkspace() { + setWorkspaceError(null); + if (!workspaceName.trim()) { + setWorkspaceError("Workspace name is required."); + return; + } + + try { + setIsUploading(true); + let image: string | undefined; + + if (file) { + const res = await edgestore.publicFiles.upload({ file }); + image = res.url; + } + + const response = await fetch("/api/workspaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: workspaceName, image }), + }); + + if (!response.ok) throw new Error("Could not create workspace"); + + const result = await response.json(); + await refreshWorkspaces(); + setSelectedWorkspaceId(result.workspace.id); + setSelectedEmailAccountId(result.workspace.emailAccounts[0]?.id ?? null); + setWorkspaceName(""); + setFile(undefined); + setCreateWorkspaceOpen(false); + } catch (error) { + setWorkspaceError( + error instanceof Error ? error.message : "Could not create workspace.", + ); + } finally { + setIsUploading(false); + } + } + + async function handleUpdateWorkspace() { + if (!selectedWorkspace) return; + setWorkspaceError(null); + setIsSavingWorkspace(true); + + try { + const response = await fetch(`/api/workspaces/${selectedWorkspace.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: settingsName, + image: settingsImage || null, + }), + }); + if (!response.ok) throw new Error("Could not update workspace"); + await refreshWorkspaces(); + } catch (error) { + setWorkspaceError( + error instanceof Error ? error.message : "Could not update workspace.", + ); + } finally { + setIsSavingWorkspace(false); + } + } + + async function handleAddCurrentAccount() { + if (!selectedWorkspace) return; + setWorkspaceError(null); + + try { + const response = await fetch( + `/api/workspaces/${selectedWorkspace.id}/email-accounts`, + { method: "POST" }, + ); + if (!response.ok) throw new Error("Could not add account"); + await refreshWorkspaces(); + } catch (error) { + setWorkspaceError( + error instanceof Error ? error.message : "Could not add account.", + ); + } + } + + async function handleInviteMember() { + if (!selectedWorkspace || !inviteEmail.trim()) return; + setWorkspaceError(null); + + try { + const response = await fetch( + `/api/workspaces/${selectedWorkspace.id}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: inviteEmail, + name: inviteName, + role: inviteRole, + }), + }, + ); + if (!response.ok) throw new Error("Could not invite member"); + await refreshWorkspaces(); + setInviteEmail(""); + setInviteName(""); + setInviteRole("USER"); + } catch (error) { + setWorkspaceError( + error instanceof Error ? error.message : "Could not invite member.", + ); + } + } + + async function handleUpdateMember(member: WorkspaceMember, role: Role) { + if (!selectedWorkspace) return; + await fetch( + `/api/workspaces/${selectedWorkspace.id}/members/${member.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }, + ); + await refreshWorkspaces(); + } + + async function handleRemoveMember(member: WorkspaceMember) { + if (!selectedWorkspace) return; + await fetch( + `/api/workspaces/${selectedWorkspace.id}/members/${member.id}`, + { method: "DELETE" }, + ); + await refreshWorkspaces(); + } + + async function handleRemoveEmailAccount(emailAccountId: string) { + if (!selectedWorkspace) return; + await fetch( + `/api/workspaces/${selectedWorkspace.id}/email-accounts/${emailAccountId}`, + { method: "DELETE" }, + ); + if (selectedEmailAccountId === emailAccountId) + setSelectedEmailAccountId(null); + await refreshWorkspaces(); + } + const returnTab = () => { switch (selectedTab) { case "Inbox": return ( - + setCreateWorkspaceOpen(!createWorkspaceOpen)} @@ -260,19 +509,20 @@ export function Mail({ { - setFile(file); - }} + onChange={setFile} />
- -
-
- - + setWorkspaceName(event.target.value)} + />
+ {workspaceError && ( +
{workspaceError}
+ )}
@@ -282,26 +532,7 @@ export function Mail({ > Cancel - + +
+
Accounts
+ {selectedWorkspace.emailAccounts.map((account) => ( +
+
+
+ {account.name} +
+
+ {account.email} +
+
+ +
+ ))} + +
+ +
+
Team
+
+ setInviteEmail(event.target.value)} + /> + setInviteName(event.target.value)} + /> + + +
+ {selectedWorkspace.members.map((member) => ( +
+
+
+ {member.user?.name || + member.invitedName || + member.user?.email || + member.invitedEmail} +
+
+ {member.user?.email || member.invitedEmail} +
+
+
+ + +
+
+ ))} +
+ {workspaceError && ( +
+ {workspaceError} +
+ )} +
+ )} + +
- + {workspaceAccounts.length ? ( + + ) : ( +
+ No accounts +
+ )} + {!isCollapsed && selectedWorkspace && ( + + )}
diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index a8d6cbc..0089a2b 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -7,6 +7,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { openCreateWorkspaceOpenAtom } from "@/utils/store"; +import type { WorkspaceSummary } from "@/utils/workspace-types"; import { useSetAtom } from "jotai"; import { Plus } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; @@ -21,24 +22,54 @@ const userNavigation = [ }, ]; -export function WorkspaceSidebar() { - const { data: session, status } = useSession(); +function getInitials(name: string) { + return name + .split(" ") + .filter(Boolean) + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); +} + +export function WorkspaceSidebar({ + workspaces, + selectedWorkspaceId, + onSelectWorkspace, +}: { + workspaces: WorkspaceSummary[]; + selectedWorkspaceId: string | null; + onSelectWorkspace: (workspaceId: string) => void; +}) { + const { data: session } = useSession(); const setCreateWorkspaceOpen = useSetAtom(openCreateWorkspaceOpenAtom); + return (
- - + {workspaces.map((workspace) => ( + + ))}
- {session && session.user && ( + {session?.user && (
- + - {session?.user.name ? session.user.name[0] : ""} + {session.user.name ? session.user.name[0] : ""} diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 13e98f0..837f32d 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -6,6 +6,7 @@ import GoogleProvider from "next-auth/providers/google"; import { createContact } from "@/utils/loops"; import prisma from "@/utils/prisma"; import { env } from "@/env.mjs"; +import { ensurePersonalWorkspaceForUser } from "@/utils/workspace"; const SCOPES = [ "https://www.googleapis.com/auth/userinfo.profile", @@ -114,9 +115,16 @@ export const getAuthOptions: (options?: { }, events: { signIn: async ({ isNewUser, user }) => { - if (isNewUser && user.email) { - await createContact(user.email); - } + if (!user.email || !user.id) return; + + await ensurePersonalWorkspaceForUser({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + }); + + if (isNewUser) await createContact(user.email); }, }, pages: { diff --git a/apps/web/utils/gmail/client.ts b/apps/web/utils/gmail/client.ts index 6edea9c..c90879d 100644 --- a/apps/web/utils/gmail/client.ts +++ b/apps/web/utils/gmail/client.ts @@ -1,6 +1,8 @@ import { google } from "googleapis"; +import type { EmailAccount } from "@prisma/client"; import { saveRefreshToken } from "@/utils/auth"; import { env } from "@/env.mjs"; +import prisma from "@/utils/prisma"; type ClientOptions = { accessToken?: string; @@ -67,3 +69,46 @@ export const getGmailClientWithRefresh = async ( return gmail; }; + +export const getGmailClientForEmailAccount = async ( + emailAccount: Pick< + EmailAccount, + "id" | "gmailAccessToken" | "gmailRefreshToken" | "gmailExpiresAt" + >, +) => { + const auth = getClient({ + accessToken: emailAccount.gmailAccessToken ?? undefined, + refreshToken: emailAccount.gmailRefreshToken ?? undefined, + }); + const gmail = google.gmail({ version: "v1", auth }); + + if ( + emailAccount.gmailAccessToken && + emailAccount.gmailExpiresAt && + emailAccount.gmailExpiresAt.getTime() > Date.now() + 10_000 + ) { + return { gmail, accessToken: emailAccount.gmailAccessToken }; + } + + if (!emailAccount.gmailRefreshToken) { + return { gmail, accessToken: emailAccount.gmailAccessToken ?? undefined }; + } + + const tokens = await auth.refreshAccessToken(); + const accessToken = + tokens.credentials.access_token ?? + emailAccount.gmailAccessToken ?? + undefined; + + await prisma.emailAccount.update({ + where: { id: emailAccount.id }, + data: { + gmailAccessToken: tokens.credentials.access_token, + gmailExpiresAt: tokens.credentials.expiry_date + ? new Date(tokens.credentials.expiry_date) + : emailAccount.gmailExpiresAt, + }, + }); + + return { gmail, accessToken }; +}; diff --git a/apps/web/utils/store.ts b/apps/web/utils/store.ts index 94e7522..1ec84b4 100644 --- a/apps/web/utils/store.ts +++ b/apps/web/utils/store.ts @@ -47,3 +47,7 @@ export const openComposeAtom = atom(false); export const threadsAtom = atom(null); export const openCreateWorkspaceOpenAtom = atom(false); + +export const selectedWorkspaceIdAtom = atom(null); + +export const selectedEmailAccountIdAtom = atom(null); diff --git a/apps/web/utils/workspace-types.ts b/apps/web/utils/workspace-types.ts new file mode 100644 index 0000000..db70f2f --- /dev/null +++ b/apps/web/utils/workspace-types.ts @@ -0,0 +1,29 @@ +import type { MembershipRole } from "@prisma/client"; + +export type WorkspaceEmailAccount = { + id: string; + name: string; + email: string; +}; + +export type WorkspaceMember = { + id: string; + role: MembershipRole; + invitedName: string | null; + invitedEmail: string | null; + user: { + id: string; + name: string | null; + email: string | null; + image: string | null; + } | null; +}; + +export type WorkspaceSummary = { + id: string; + name: string; + image: string | null; + role: MembershipRole; + emailAccounts: WorkspaceEmailAccount[]; + members: WorkspaceMember[]; +}; diff --git a/apps/web/utils/workspace.ts b/apps/web/utils/workspace.ts new file mode 100644 index 0000000..80ccb9b --- /dev/null +++ b/apps/web/utils/workspace.ts @@ -0,0 +1,539 @@ +import "server-only"; + +import { MembershipRole, type Account } from "@prisma/client"; +import prisma from "@/utils/prisma"; +import type { WorkspaceSummary } from "@/utils/workspace-types"; + +function getPersonalWorkspaceName(options: { + name?: string | null; + email: string; +}) { + if (options.name) return `${options.name}'s Workspace`; + return `${options.email.split("@")[0]}'s Workspace`; +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +function accountExpiryDate(account?: Pick | null) { + return account?.expires_at ? new Date(account.expires_at * 1000) : null; +} + +async function getGoogleAccountForUser(userId: string) { + return prisma.account.findFirst({ + where: { userId, provider: "google" }, + orderBy: { updatedAt: "desc" }, + }); +} + +export async function upsertWorkspaceEmailAccount(options: { + organizationId: string; + userId: string; + email: string; + name?: string | null; +}) { + const email = normalizeEmail(options.email); + const account = await getGoogleAccountForUser(options.userId); + const existing = await prisma.emailAccount.findFirst({ + where: { + organizationId: options.organizationId, + email, + }, + }); + + const data = { + name: options.name || email, + email, + gmailAccessToken: account?.access_token ?? null, + gmailRefreshToken: account?.refresh_token ?? null, + gmailExpiresAt: accountExpiryDate(account), + }; + + if (existing) { + return prisma.emailAccount.update({ + where: { id: existing.id }, + data, + }); + } + + return prisma.emailAccount.create({ + data: { + organizationId: options.organizationId, + ...data, + }, + }); +} + +export async function ensurePersonalWorkspaceForUser(options: { + userId: string; + email: string; + name?: string | null; + image?: string | null; +}) { + const email = normalizeEmail(options.email); + const existingMembership = await prisma.membership.findFirst({ + where: { + userId: options.userId, + organization: { + emailAccounts: { + some: { email }, + }, + }, + }, + include: { organization: true }, + }); + + if (existingMembership) { + await upsertWorkspaceEmailAccount({ + organizationId: existingMembership.organizationId, + userId: options.userId, + email, + name: options.name, + }); + return existingMembership.organization; + } + + const organization = await prisma.organization.create({ + data: { + name: getPersonalWorkspaceName({ name: options.name, email }), + image: options.image, + membership: { + create: { + userId: options.userId, + role: MembershipRole.OWNER, + }, + }, + }, + }); + + await upsertWorkspaceEmailAccount({ + organizationId: organization.id, + userId: options.userId, + email, + name: options.name, + }); + + return organization; +} + +export async function createWorkspaceForUser(options: { + userId: string; + email: string; + userName?: string | null; + name: string; + image?: string | null; +}) { + const organization = await prisma.organization.create({ + data: { + name: options.name, + image: options.image, + membership: { + create: { + userId: options.userId, + role: MembershipRole.OWNER, + }, + }, + }, + }); + + await upsertWorkspaceEmailAccount({ + organizationId: organization.id, + userId: options.userId, + email: options.email, + name: options.userName, + }); + + return getWorkspaceMembership({ + userId: options.userId, + workspaceId: organization.id, + }); +} + +export async function getWorkspaceMembership(options: { + userId: string; + workspaceId: string; +}) { + return prisma.membership.findFirst({ + where: { + userId: options.userId, + organizationId: options.workspaceId, + }, + include: { + organization: { + include: { + emailAccounts: { + orderBy: { createdAt: "asc" }, + }, + membership: { + orderBy: { id: "asc" }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + }, + }, + }); +} + +export function serializeWorkspace( + membership: Awaited>, +): WorkspaceSummary { + if (!membership) throw new Error("Workspace not found"); + + return { + id: membership.organization.id, + name: membership.organization.name, + image: membership.organization.image, + role: membership.role, + emailAccounts: membership.organization.emailAccounts.map((account) => ({ + id: account.id, + name: account.name, + email: account.email, + })), + members: membership.organization.membership.map((member) => ({ + id: member.id, + role: member.role, + invitedEmail: member.invitedEmail, + invitedName: member.invitedName, + user: member.user + ? { + id: member.user.id, + name: member.user.name, + email: member.user.email, + image: member.user.image, + } + : null, + })), + }; +} + +export async function getWorkspacesForUser(userId: string) { + const memberships = await prisma.membership.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + include: { + organization: { + include: { + emailAccounts: { + orderBy: { createdAt: "asc" }, + }, + membership: { + orderBy: { id: "asc" }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return memberships.map((membership) => serializeWorkspace(membership)); +} + +export async function assertWorkspaceRole(options: { + userId: string; + workspaceId: string; + roles?: MembershipRole[]; +}) { + const membership = await getWorkspaceMembership(options); + if (!membership) throw new Error("Workspace not found"); + + if (options.roles && !options.roles.includes(membership.role)) { + throw new Error("Missing workspace permission"); + } + + return membership; +} + +export async function updateWorkspaceForUser(options: { + userId: string; + workspaceId: string; + name?: string; + image?: string | null; +}) { + await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + await prisma.organization.update({ + where: { id: options.workspaceId }, + data: { + ...(options.name ? { name: options.name } : {}), + ...(options.image !== undefined ? { image: options.image } : {}), + }, + }); + + return getWorkspaceMembership({ + userId: options.userId, + workspaceId: options.workspaceId, + }); +} + +export async function inviteWorkspaceMember(options: { + workspaceId: string; + inviterUserId: string; + inviterEmail: string; + invitedEmail: string; + invitedName?: string | null; + role: MembershipRole; +}) { + await assertWorkspaceRole({ + userId: options.inviterUserId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + const invitedEmail = normalizeEmail(options.invitedEmail); + const inviterEmail = normalizeEmail(options.inviterEmail); + if (invitedEmail === inviterEmail) { + throw new Error("You are already a member of this workspace"); + } + + const existingUser = await prisma.user.findUnique({ + where: { email: invitedEmail }, + select: { id: true }, + }); + + if (existingUser) { + const existingMembership = await prisma.membership.findFirst({ + where: { + organizationId: options.workspaceId, + userId: existingUser.id, + }, + }); + + if (existingMembership) { + return prisma.membership.update({ + where: { id: existingMembership.id }, + data: { role: options.role }, + }); + } + } + + return prisma.membership.upsert({ + where: { + organizationId_invitedEmail: { + organizationId: options.workspaceId, + invitedEmail, + }, + }, + create: { + organizationId: options.workspaceId, + invitedEmail, + invitedName: options.invitedName || null, + role: options.role, + }, + update: { + invitedName: options.invitedName || null, + role: options.role, + }, + }); +} + +export async function acceptWorkspaceInvitation(options: { + invitationId: string; + userId: string; + email: string; + name?: string | null; +}) { + const email = normalizeEmail(options.email); + const invitation = await prisma.membership.findUnique({ + where: { id: options.invitationId }, + }); + + if (!invitation?.invitedEmail) throw new Error("Invitation not found"); + + if (normalizeEmail(invitation.invitedEmail) !== email) { + throw new Error("Invitation belongs to a different email address"); + } + + const existingMembership = await prisma.membership.findFirst({ + where: { + organizationId: invitation.organizationId, + userId: options.userId, + }, + }); + + if (existingMembership) { + await prisma.membership.delete({ where: { id: invitation.id } }); + await upsertWorkspaceEmailAccount({ + organizationId: existingMembership.organizationId, + userId: options.userId, + email, + name: options.name, + }); + return existingMembership; + } + + const membership = await prisma.membership.update({ + where: { id: invitation.id }, + data: { + userId: options.userId, + invitedEmail: null, + invitedName: null, + }, + }); + + await upsertWorkspaceEmailAccount({ + organizationId: membership.organizationId, + userId: options.userId, + email, + name: options.name, + }); + + return membership; +} + +export async function updateWorkspaceMemberRole(options: { + userId: string; + workspaceId: string; + memberId: string; + role: MembershipRole; +}) { + await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER], + }); + + const member = await prisma.membership.findFirstOrThrow({ + where: { + id: options.memberId, + organizationId: options.workspaceId, + }, + }); + + if ( + member.role === MembershipRole.OWNER && + options.role !== MembershipRole.OWNER + ) { + const owners = await prisma.membership.count({ + where: { + organizationId: options.workspaceId, + role: MembershipRole.OWNER, + }, + }); + if (owners <= 1) throw new Error("Workspace must keep at least one owner"); + } + + return prisma.membership.update({ + where: { id: member.id }, + data: { role: options.role }, + }); +} + +export async function removeWorkspaceMember(options: { + userId: string; + workspaceId: string; + memberId: string; +}) { + const actorMembership = await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + const member = await prisma.membership.findFirstOrThrow({ + where: { + id: options.memberId, + organizationId: options.workspaceId, + }, + }); + + if (member.role === MembershipRole.OWNER) { + if (actorMembership.role !== MembershipRole.OWNER) { + throw new Error("Only owners can remove other owners"); + } + + const owners = await prisma.membership.count({ + where: { + organizationId: options.workspaceId, + role: MembershipRole.OWNER, + }, + }); + if (owners <= 1) throw new Error("Workspace must keep at least one owner"); + } + + await prisma.membership.delete({ where: { id: member.id } }); + return { ok: true }; +} + +export async function addCurrentUserEmailAccount(options: { + userId: string; + workspaceId: string; + email: string; + name?: string | null; +}) { + await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + return upsertWorkspaceEmailAccount({ + organizationId: options.workspaceId, + userId: options.userId, + email: options.email, + name: options.name, + }); +} + +export async function removeWorkspaceEmailAccount(options: { + userId: string; + workspaceId: string; + emailAccountId: string; +}) { + await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + const account = await prisma.emailAccount.findFirstOrThrow({ + where: { + id: options.emailAccountId, + organizationId: options.workspaceId, + }, + }); + + await prisma.emailAccount.delete({ where: { id: account.id } }); + return { ok: true }; +} + +export async function getWorkspaceEmailAccountForUser(options: { + userId: string; + workspaceId: string; + emailAccountId?: string | null; +}) { + const membership = await assertWorkspaceRole({ + userId: options.userId, + workspaceId: options.workspaceId, + }); + + const emailAccount = options.emailAccountId + ? membership.organization.emailAccounts.find( + (account) => account.id === options.emailAccountId, + ) + : membership.organization.emailAccounts[0]; + + return { membership, emailAccount }; +} diff --git a/packages/resend/emails/workspace-invite.tsx b/packages/resend/emails/workspace-invite.tsx new file mode 100644 index 0000000..f58a86b --- /dev/null +++ b/packages/resend/emails/workspace-invite.tsx @@ -0,0 +1,67 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; + +export interface WorkspaceInviteEmailProps { + baseUrl: string; + invitedByName: string; + workspaceName: string; + inviteUrl: string; +} + +export default function WorkspaceInviteEmail(props: WorkspaceInviteEmailProps) { + const { + invitedByName = "A teammate", + workspaceName = "a Caley workspace", + inviteUrl = "https://www.caley.io", + } = props; + + return ( + + + + {invitedByName} invited you to {workspaceName} + + + + +
+ + Join {workspaceName} on Caley.io + + + {invitedByName} invited you to collaborate in {workspaceName}. + +
+ +
+ + If the button does not work, copy and paste this link into your + browser: {inviteUrl} + +
+
+ +
+ + ); +} + +const paragraph = { + fontSize: "16px", + lineHeight: "26px", +}; diff --git a/packages/resend/src/index.tsx b/packages/resend/src/index.tsx index 4f4b82a..a517aaa 100644 --- a/packages/resend/src/index.tsx +++ b/packages/resend/src/index.tsx @@ -2,6 +2,9 @@ import { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; import { nanoid } from "nanoid"; import StatsUpdateEmail, { StatsUpdateEmailProps } from "../emails/stats"; +import WorkspaceInviteEmail, { + WorkspaceInviteEmailProps, +} from "../emails/workspace-invite"; const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) @@ -71,3 +74,26 @@ export const sendStatsEmail = async ({ ], }); }; + +export const sendWorkspaceInviteEmail = async ({ + to, + test, + emailProps, +}: { + to: string; + test?: boolean; + emailProps: WorkspaceInviteEmailProps; +}) => { + return sendEmail({ + to, + subject: `Join ${emailProps.workspaceName} on Caley.io`, + react: , + test, + tags: [ + { + name: "category", + value: "workspace-invite", + }, + ], + }); +};