├── .gitignore ├── .prettierrc.json ├── README.md ├── app ├── auth │ └── callback │ │ └── route.ts ├── components │ ├── email-combobox.tsx │ ├── email-empty-view.tsx │ ├── email-list-column.tsx │ ├── folder-column.tsx │ ├── search.tsx │ └── toolbar.tsx ├── db │ ├── actions.ts │ ├── queries.ts │ └── utils.ts ├── f │ └── [name] │ │ ├── new │ │ ├── email-body.tsx │ │ └── page.tsx │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── icons │ ├── arrow-left.tsx │ ├── arrow-right.tsx │ ├── email.tsx │ ├── flag.tsx │ ├── folder.tsx │ ├── inbox.tsx │ ├── search.tsx │ ├── send.tsx │ ├── sent.tsx │ ├── trash.tsx │ └── user.tsx ├── layout.tsx ├── login │ └── page.tsx ├── scripts │ └── setup.js └── utils │ └── supabase │ ├── index.ts │ └── middleware.ts ├── middleware.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── github-mark-white.png ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .vscode -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Email Client 2 | 3 | This is a simple email client built with Next.js and Postgres. It's built to show off some of the features of the App Router, which enable you to build products that: 4 | 5 | - Navigate between routes in a column layout while maintaining scroll position (layouts support) 6 | - Submit forms without JavaScript enabled (progressive enhancement) 7 | - Navigate between routes extremely fast (prefetching and caching) 8 | - Retain your UI position on reload (URL state) 9 | 10 | The first version of the UI was built with [v0](https://v0.dev/t/RPsRRQilTDp). 11 | 12 | CleanShot 2023-11-04 at 21 09 49@2x 13 | 14 | ## Tech 15 | 16 | - [Next.js](https://nextjs.org/) 17 | - [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) 18 | - [Tailwind CSS](https://tailwindcss.com/) 19 | - [TypeScript](https://www.typescriptlang.org/) 20 | - [React Aria Components](https://react-spectrum.adobe.com/react-aria/index.html) 21 | 22 | ## Known Issues 23 | 24 | - [ ] Forward / reply / search aren't hooked up yet 25 | - [ ] Need to add a way to manage folders 26 | - [ ] Need to add a way to manage users 27 | - [ ] Fix to/from to pull sender/recipient everywhere 28 | - [ ] Error handling for form submissions 29 | - [x] Add search 30 | 31 | ## Setup 32 | 33 | In order to run this project locally, you'll need to create a Postgres database and add the connection string to your `.env.local` file. 34 | 35 | Further, you'll need to create the tables and insert some sample data. 36 | 37 | Follow these steps to get started: 38 | 39 | 1. Create a Postgres database 40 | 2. Navigate to the `.env.local` tab in the quickstart section Postgres dashboard 41 | 3. Copy the snippet and paste it into your `.env.local` file 42 | 4. Run `pnpm run setup` to create the tables and insert sample data 43 | 44 | ## Schema 45 | 46 | ``` 47 | create table users ( 48 | id serial primary key, 49 | first_name varchar(50), 50 | last_name varchar(50), 51 | email varchar(255) unique not null 52 | ); 53 | 54 | create table emails ( 55 | id serial primary key, 56 | sender_id integer references users(id) on delete cascade, 57 | recipient_id integer references users(id) on delete cascade, 58 | subject varchar(255), 59 | body text, 60 | sent_date timestamp default current_timestamp 61 | ); 62 | 63 | create table folders ( 64 | id serial primary key, 65 | name varchar(50) not null 66 | ); 67 | 68 | create table user_folders ( 69 | id serial primary key, 70 | user_id integer references users(id) on delete cascade, 71 | folder_id integer references folders(id) on delete cascade 72 | ); 73 | 74 | create table email_folders ( 75 | id serial primary key, 76 | email_id integer unique references emails(id) on delete cascade, 77 | folder_id integer references folders(id) on delete cascade 78 | ); 79 | ``` 80 | 81 | ## Sample Data 82 | 83 | ``` 84 | insert into users (first_name, last_name, email) 85 | values ('John', 'Doe', 'john.doe@example.com'), 86 | ('Jane', 'Doe', 'jane.doe@example.com'), 87 | ('Alice', 'Smith', 'alice.smith@example.com'), 88 | ('Bob', 'Johnson', 'bob.johnson@example.com'); 89 | 90 | insert into emails (sender_id, recipient_id, subject, body, sent_date) 91 | values (1, 2, 'Meeting Reminder', 'Don''t forget about our meeting tomorrow at 10am.', '2022-01-10 09:00:00'), 92 | (1, 3, 'Hello', 'Just wanted to say hello.', '2022-01-09 08:00:00'), 93 | (2, 1, 'Re: Meeting Reminder', 'I won''t be able to make it.', '2022-01-10 10:00:00'), 94 | (3, 1, 'Re: Hello', 'Hello to you too!', '2022-01-09 09:00:00'), 95 | (4, 1, 'Invitation', 'You are invited to my party.', '2022-01-11 07:00:00'), 96 | (1, 2, 'Work Project', 'Let''s discuss the new work project.', '2022-01-12 07:00:00'), 97 | (1, 4, 'Expenses Report', 'Please find the expenses report attached.', '2022-01-13 07:00:00'), 98 | (4, 1, 'Personal Note', 'Let''s catch up sometime.', '2022-01-14 07:00:00'); 99 | 100 | 101 | insert into folders (name) 102 | values ('Inbox'), 103 | ('Flagged'), 104 | ('Sent'), 105 | ('Work'), 106 | ('Expenses'), 107 | ('Personal'); 108 | 109 | insert into user_folders (user_id, folder_id) 110 | values (1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), 111 | (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (2, 6), 112 | (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6), 113 | (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (4, 6); 114 | 115 | insert into email_folders (email_id, folder_id) 116 | values (1, 1), 117 | (2, 1), 118 | (3, 3), 119 | (4, 1), 120 | (5, 1), 121 | (6, 4), 122 | (7, 5), 123 | (8, 6); 124 | ``` 125 | 126 | ## Database Relationships 127 | 128 | - Users can send and receive emails (users.id -> emails.sender_id and emails.recipient_id) 129 | - Users can have multiple folders (users.id -> user_folders.user_id) 130 | - Folders can contain multiple emails (folders.id -> email_folders.folder_id) 131 | - An email can be in multiple folders (emails.id -> email_folders.email_id) 132 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '../../utils/supabase'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export async function GET(request: Request) { 5 | const requestUrl = new URL(request.url); 6 | const code = requestUrl.searchParams.get('code'); 7 | 8 | if (code) { 9 | const supabase = createClient(); 10 | await supabase.auth.exchangeCodeForSession(code); 11 | } 12 | 13 | // URL to redirect to after sign in process completes 14 | return NextResponse.redirect(requestUrl.origin); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/email-combobox.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Button, 5 | ComboBox, 6 | Input, 7 | Item, 8 | Label, 9 | ListBox, 10 | Popover, 11 | } from 'react-aria-components'; 12 | import type { ItemProps } from 'react-aria-components'; 13 | import { formatEmailString } from '@/app/db/utils'; 14 | 15 | type UserEmail = { 16 | first_name: string; 17 | last_name: string; 18 | email: string; 19 | }; 20 | 21 | /** 22 | * Shoutout to the React Spectrum team 23 | * https://react-spectrum.adobe.com/react-aria/ComboBox.html 24 | */ 25 | export function EmailInputCombobox({ 26 | userEmails, 27 | }: { 28 | userEmails: UserEmail[]; 29 | }) { 30 | return ( 31 |
32 | 33 | 36 |
37 | 43 | 44 |
45 | 46 | 47 | 48 | {(e) => ( 49 | 50 | 51 | {formatEmailString(e, { includeFullEmail: true })} 52 | 53 | 54 | )} 55 | 56 | 57 |
58 |
59 | ); 60 | } 61 | 62 | function ListBoxItem(props: ItemProps & { children: React.ReactNode }) { 63 | return ( 64 | 68 | 69 | {props.children} 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/components/email-empty-view.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Toolbar, ToolbarSkeleton } from './toolbar'; 3 | 4 | export function EmailEmptyView() { 5 | return ( 6 |
7 | }> 8 | 9 | 10 |
11 | No Email Selected 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/email-list-column.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { formatEmailString } from '@/app/db/utils'; 3 | import { getEmailsForFolder } from '@/app/db/queries'; 4 | 5 | export async function EmailListColumn({ 6 | folderName, 7 | searchParams, 8 | }: { 9 | folderName: string; 10 | searchParams: { q?: string; id?: string }; 11 | }) { 12 | const emails = await getEmailsForFolder(folderName, searchParams.q); 13 | 14 | function createUrl(id: number) { 15 | const baseUrl = `/f/${folderName.toLowerCase()}`; 16 | const params = new URLSearchParams(searchParams); 17 | params.set('id', id.toString()); 18 | return `${baseUrl}?${params.toString()}`; 19 | } 20 | 21 | return ( 22 |
23 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/components/folder-column.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { getFoldersWithEmailCount } from '@/app/db/queries'; 3 | import { FlagIcon } from '@/app/icons/flag'; 4 | import { FolderIcon } from '@/app/icons/folder'; 5 | import { InboxIcon } from '@/app/icons/inbox'; 6 | import { SentIcon } from '@/app/icons/sent'; 7 | import { UserIcon } from '@/app/icons/user'; 8 | import { createClient } from '../utils/supabase'; 9 | import { revalidatePath } from 'next/cache'; 10 | import Image from 'next/image'; 11 | 12 | export async function FolderColumn() { 13 | const { specialFolders, otherFolders } = await getFoldersWithEmailCount(); 14 | const supabase = createClient(); 15 | 16 | const { 17 | data: { user }, 18 | } = await supabase.auth.getUser(); 19 | 20 | const logout = async () => { 21 | 'use server'; 22 | 23 | const supabase = createClient(); 24 | 25 | const { error } = await supabase.auth.signOut(); 26 | if (error) { 27 | console.log(error); 28 | } 29 | revalidatePath('/', 'layout'); 30 | }; 31 | 32 | return ( 33 |
34 |
35 | 59 |
60 | 73 |
74 |
75 | {user ? ( 76 | <> 77 | {`profile 84 |
85 | 86 | {user?.user_metadata.user_name} 87 | 88 |
89 | 92 |
93 |
94 | 95 | ) : ( 96 | <> 97 | 98 | 99 | Login 100 | 101 | 102 | )} 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /app/components/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { SearchIcon } from '@/app/icons/search'; 4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 5 | import { useDebouncedCallback } from 'use-debounce'; 6 | 7 | export function Search() { 8 | const searchParams = useSearchParams(); 9 | const { replace } = useRouter(); 10 | const pathname = usePathname(); 11 | 12 | const handleSearch = useDebouncedCallback((term) => { 13 | const params = new URLSearchParams(searchParams); 14 | if (term) { 15 | params.set('q', term); 16 | } else { 17 | params.delete('q'); 18 | } 19 | replace(`${pathname}?${params.toString()}`); 20 | }, 300); 21 | 22 | return ( 23 |
24 | 27 | { 31 | handleSearch(e.target.value); 32 | }} 33 | defaultValue={searchParams.get('q')?.toString()} 34 | /> 35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/components/toolbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useParams, useSearchParams } from 'next/navigation'; 4 | import { ArrowLeftIcon } from '@/app/icons/arrow-left'; 5 | import { ArrowRightIcon } from '@/app/icons/arrow-right'; 6 | import { EmailIcon } from '@/app/icons/email'; 7 | import { TrashIcon } from '@/app/icons/trash'; 8 | import Link from 'next/link'; 9 | import { deleteEmail } from '@/app/db/actions'; 10 | import { Search } from './search'; 11 | 12 | type Params = { 13 | name: string; 14 | }; 15 | 16 | export function Toolbar() { 17 | const params: Params = useParams(); 18 | const searchParams = useSearchParams(); 19 | const emailId = searchParams.get('id'); 20 | 21 | return ( 22 |
23 |
24 | 28 | 29 | 30 |
{ 33 | e.preventDefault(); 34 | 35 | if (emailId) { 36 | await deleteEmail(params.name, emailId); 37 | } 38 | }} 39 | > 40 | 46 |
47 | 50 | 53 |
54 | 57 |
58 | ); 59 | } 60 | 61 | export function ToolbarSkeleton() { 62 | return ( 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/db/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | import { z } from 'zod'; 5 | import { redirect } from 'next/navigation'; 6 | import { createClient } from '../utils/supabase'; 7 | 8 | const schema = z.object({ 9 | subject: z.string(), 10 | recipient_email: z.string().email(), 11 | body: z.string(), 12 | }); 13 | 14 | export async function sendEmail(formData: FormData) { 15 | const parsed = schema.parse({ 16 | subject: formData.get('subject'), 17 | recipient_email: formData.get('email'), 18 | body: formData.get('body'), 19 | }); 20 | 21 | const supabase = createClient(); 22 | 23 | const { data: newEmailId, error } = await supabase.rpc('send_email', parsed); 24 | 25 | if (error) { 26 | console.log(error); 27 | } 28 | 29 | revalidatePath('/', 'layout'); // Revalidate all data 30 | redirect(`/f/sent?id=${newEmailId}`); 31 | } 32 | 33 | export async function deleteEmail(folderName: string, emailId: string) { 34 | const supabase = createClient(); 35 | 36 | const { error } = await supabase 37 | .from('emails') 38 | .delete() 39 | .match({ id: emailId }); 40 | 41 | if (error) { 42 | console.log(error); 43 | } 44 | 45 | revalidatePath('/', 'layout'); // Revalidate all data 46 | redirect(`/f/${folderName}`); 47 | } 48 | -------------------------------------------------------------------------------- /app/db/queries.ts: -------------------------------------------------------------------------------- 1 | import { toTitleCase } from './utils'; 2 | import { createClient } from '../utils/supabase'; 3 | 4 | type Folder = { 5 | name: string; 6 | email_count: string; 7 | }; 8 | 9 | type UserEmail = { 10 | first_name: string; 11 | last_name: string; 12 | email: string; 13 | }; 14 | 15 | type EmailWithSenderAndRecipient = { 16 | id: number; 17 | sender_id: number; 18 | recipient_id: number; 19 | subject: string; 20 | body: string; 21 | sent_date: Date; 22 | sender: UserEmail; 23 | recipient: UserEmail; 24 | }; 25 | 26 | export async function getFoldersWithEmailCount() { 27 | const supabase = createClient(); 28 | 29 | const { data } = await supabase 30 | .from('folders_with_email_count') 31 | .select() 32 | .returns(); 33 | 34 | const specialFoldersOrder = ['Inbox', 'Flagged', 'Sent']; 35 | 36 | const specialFolders = (specialFoldersOrder 37 | .map((name) => data?.find((folder) => folder.name === name)) 38 | .filter(Boolean) ?? []) as Folder[]; 39 | 40 | const otherFolders = (data?.filter( 41 | (folder) => !specialFoldersOrder.includes(folder.name) 42 | ) ?? []) as Folder[]; 43 | 44 | return { specialFolders, otherFolders }; 45 | } 46 | 47 | export async function getEmailsForFolder(folderName: string, search?: string) { 48 | const originalFolderName = toTitleCase(decodeURIComponent(folderName)); 49 | 50 | const supabase = createClient(); 51 | 52 | if (search === undefined) { 53 | const { data } = await supabase 54 | .from('emails_with_folder_and_users') 55 | .select() 56 | .match({ folder_name: originalFolderName }) 57 | .order('sent_date', { ascending: false }) 58 | .returns(); 59 | 60 | return data ?? ([] as EmailWithSenderAndRecipient[]); 61 | } 62 | 63 | const orFilter = [ 64 | 'subject', 65 | 'body', 66 | 'recipient->>first_name', 67 | 'recipient->>last_name', 68 | 'recipient->>email', 69 | 'sender->>first_name', 70 | 'sender->>last_name', 71 | 'sender->>email', 72 | ] 73 | .map((filter) => `${filter}.ilike.%${search}%`) 74 | .join(','); 75 | 76 | const { data } = await supabase 77 | .from('emails_with_folder_and_users') 78 | .select() 79 | .match({ folder_name: originalFolderName }) 80 | .or(orFilter) 81 | .order('sent_date', { ascending: false }) 82 | .returns(); 83 | 84 | return data ?? ([] as EmailWithSenderAndRecipient[]); 85 | } 86 | 87 | export async function getEmailInFolder(folderName: string, emailId: string) { 88 | const originalFolderName = toTitleCase(decodeURIComponent(folderName)); 89 | 90 | const supabase = createClient(); 91 | 92 | const { data } = await supabase 93 | .from('emails_with_folder_and_users') 94 | .select() 95 | .match({ folder_name: originalFolderName, id: emailId }) 96 | .order('sent_date', { ascending: false }) 97 | .returns() 98 | .single(); 99 | 100 | return data as EmailWithSenderAndRecipient; 101 | } 102 | 103 | export async function getAllEmailAddresses() { 104 | const supabase = createClient(); 105 | 106 | const { data } = await supabase 107 | .from('users') 108 | .select('first_name, last_name, email'); 109 | 110 | return (data ?? []) as UserEmail[]; 111 | } 112 | -------------------------------------------------------------------------------- /app/db/utils.ts: -------------------------------------------------------------------------------- 1 | type UserEmail = { 2 | first_name: string; 3 | last_name: string; 4 | email: string; 5 | }; 6 | 7 | export function formatEmailString( 8 | userEmail: UserEmail, 9 | opts: { includeFullEmail: boolean } = { includeFullEmail: false } 10 | ) { 11 | if (userEmail.first_name && userEmail.last_name) { 12 | return `${userEmail.first_name} ${userEmail.last_name} ${ 13 | opts.includeFullEmail ? `<${userEmail.email}>` : '' 14 | }`; 15 | } 16 | return userEmail.email; 17 | } 18 | 19 | export function toTitleCase(str: string) { 20 | return str.replace(/\w\S*/g, function (txt: string) { 21 | return txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase(); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /app/f/[name]/new/email-body.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export function EmailBody() { 4 | const handleKeyDown = (e: React.KeyboardEvent) => { 5 | if ( 6 | (e.ctrlKey || e.metaKey) && 7 | (e.key === 'Enter' || e.key === 'NumpadEnter') 8 | ) { 9 | e.preventDefault(); 10 | e.currentTarget.form?.requestSubmit(); 11 | } 12 | }; 13 | 14 | return ( 15 |
16 |