A full-stack internal email platform for campus communication. Users can register, send and receive emails, manage conversations with threading, star important messages, search across mailboxes, send voice notes and file attachments, and upload profile avatars.
Built with React + TypeScript on the frontend and Express + SQLite on the backend.
Note: This project is under active development. See Known Limitations for areas planned for improvement.
- Features
- Tech Stack
- Folder Structure
- Prerequisites
- Getting Started
- Usage Guide
- API Reference
- Database Schema
- Authentication
- Environment & Configuration
- Production Build
- Known Limitations
- Contributing
- Authentication -- Register and log in with email and password (JWT-based)
- Inbox & Sent -- View received and sent emails separately
- Compose & Reply -- Send new emails to any registered user; reply to create threaded conversations
- Attachments -- Send up to 5 attachments per email with support for images, PDF, Word, Excel, PowerPoint, and TXT files
- Voice Notes -- Record and send voice notes up to 1 minute long, with a live countdown and a 10-second warning near the limit
- Email Threading -- View full conversation history in a single thread view
- Star & Unstar -- Mark important emails for quick access from the Starred folder
- Trash & Restore -- Soft-delete emails to trash; restore them at any time
- Search -- Full-text search across subject, body, and sender/recipient names with folder filtering and pagination
- Unread Count -- Live unread badge on the inbox (polls every 30 seconds)
- User Avatars -- Upload a profile picture (JPEG, PNG, GIF, or WebP, 5 MB max)
- Upload Limits & Cleanup -- Attachments are capped at 10 MB per file and 25 MB per email; expired temporary uploads are cleaned up automatically
- Responsive UI -- Mobile-friendly layout with sidebar navigation and hamburger menu
| Category | Technology |
|---|---|
| Framework | React 19 |
| Language | TypeScript 5.9 |
| Build Tool | Vite 7 |
| Styling | Tailwind CSS 4 |
| State (Auth) | Zustand |
| Server State | TanStack React Query |
| Forms | React Hook Form + Zod |
| Routing | React Router DOM 7 |
| HTTP Client | Axios |
| UI Components | Radix UI primitives, Lucide icons |
| Notifications | Sonner (toast) |
| Category | Technology |
|---|---|
| Runtime | Node.js |
| Framework | Express 5 |
| Database | SQLite via better-sqlite3 |
| Auth | JSON Web Tokens (jsonwebtoken) |
| Password Hash | bcryptjs |
| File Uploads | Multer |
campus-mail/
|-- client/ # React frontend
| |-- public/ # Static assets
| |-- src/
| | |-- components/
| | | |-- ui/ # Reusable UI primitives
| | | `-- Layout.tsx # App shell with sidebar, header, and navigation
| | |-- hooks/
| | | `-- useAuth.ts # Zustand auth store
| | |-- lib/
| | | |-- api/
| | | | `-- index.ts # Axios instance with auth token interceptor
| | | |-- query.ts # TanStack React Query client setup
| | | `-- utils.ts # Utility helpers
| | |-- pages/
| | | |-- Compose.tsx # New email / reply composition
| | | |-- Inbox.tsx # Received emails list
| | | |-- Login.tsx # Login form
| | | |-- Register.tsx # Registration form
| | | |-- Search.tsx # Search with filters and pagination
| | | |-- Sent.tsx # Sent emails list
| | | |-- Starred.tsx # Starred emails list
| | | |-- Trash.tsx # Deleted emails with restore option
| | | `-- ViewEmail.tsx # Single email view with thread
| | |-- App.tsx # Route definitions and auth guard
| | |-- main.tsx # Application entry point
| | `-- types.ts # Shared TypeScript interfaces
| |-- package.json
| `-- vite.config.ts
|-- server/ # Express backend
| |-- src/
| | |-- app.js # Express app setup
| | |-- config/ # Environment, paths, and upload configuration
| | |-- controllers/ # Request handlers
| | |-- db/ # SQLite schema, init, and migrations
| | |-- middlewares/ # Auth, errors, validation
| | |-- repositories/ # Database access layer
| | |-- routes/ # API route definitions
| | |-- services/ # Business logic
| | `-- validators/ # Request validation
| |-- uploads/
| | |-- attachments/ # Uploaded email attachments
| | |-- avatars/ # User-uploaded avatar images
| | `-- voice-notes/ # Uploaded voice notes
| |-- index.js # Server entry point
| |-- mail.db # SQLite database file
| `-- package.json
`-- README.md
- Node.js >= 18
- npm (comes with Node.js)
No external database server is needed. SQLite runs as an embedded file (server/mail.db) that is created automatically on first startup.
git clone <repository-url>
cd campus-mail# Install server dependencies
cd server
npm install
# Install client dependencies
cd ../client
npm installOpen two terminals:
Terminal 1 -- Backend (port 5000)
cd server
npm startTerminal 2 -- Frontend (port 5173)
cd client
npm run devNavigate to http://localhost:5173 in your browser. The Vite dev server proxies all /api requests to the backend at port 5000.
- Register -- Go to
/registerand create an account with your name, email, and a password (minimum 6 characters). - Log in -- Use your credentials at
/login. - Compose -- Click the "Compose" button in the sidebar. Select a recipient from the user directory, enter a subject and message body, then send.
- Add attachments -- Attach up to 5 files per email. Each file can be up to 10 MB, with a 25 MB total cap across the email. Supported types include images, PDF, Word, Excel, PowerPoint, and TXT.
- Record a voice note -- Record a voice note up to 1 minute long. The composer shows the remaining time live and highlights the last 10 seconds before auto-stop.
- Inbox -- View emails others have sent you. Unread messages appear bold. Click an email to read it (it gets marked as read automatically).
- Reply -- Inside an email, click "Reply" to continue the conversation. Replies are threaded together.
- Star -- Click the star icon on any email to bookmark it. View all starred emails from the Starred section in the sidebar.
- Delete -- Click the delete/trash icon to move an email to Trash. Emails are soft-deleted and can be restored.
- Restore -- Open Trash and click Restore on any email to move it back.
- Search -- Use the search bar in the header. Filter by folder (All Mail, Inbox, Sent) and page through results.
- Avatar -- Upload a profile picture from your user profile area.
Client routing is defined in client/src/App.tsx. All authenticated routes are wrapped in a PrivateRoute guard that redirects to /login when no token is present.
| Route | Page Component | Description |
|---|---|---|
/login |
Login | Public -- sign in |
/register |
Register | Public -- create account |
/inbox |
Inbox | Received emails |
/sent |
Sent | Sent emails |
/compose |
Compose | New email or reply |
/email/:id |
ViewEmail | Single email with thread |
/starred |
Starred | Starred emails |
/trash |
Trash | Deleted emails |
/search |
Search | Search with pagination |
API calls go through the Axios instance at client/src/lib/api/index.ts, which automatically attaches the JWT token from the Zustand auth store to every request header.
Server state is managed with TanStack React Query. Mutations invalidate relevant query keys so the UI stays in sync after actions like sending an email or starring a message.
Base URL: http://localhost:5000/api
All endpoints except Register and Login require an Authorization: Bearer <token> header.
| Method | Endpoint | Body | Description |
|---|---|---|---|
| POST | /register |
{ email, password, name } |
Create a new user account |
| POST | /login |
{ email, password } |
Authenticate and receive a JWT |
| Method | Endpoint | Body / Query | Description |
|---|---|---|---|
| POST | /emails |
{ to_email?, to_emails?, subject, body, reply_to_id?, voice_note_upload_id?, attachment_upload_ids? } |
Send a new email or reply with optional voice note and attachments |
| POST | /emails/voice-note-upload |
multipart/form-data (field: voice_note) |
Upload a temporary voice note (10 MB max, 1 minute max) |
| POST | /emails/attachment-upload |
multipart/form-data (field: attachment) |
Upload a temporary attachment (10 MB per file max) |
| GET | /emails/inbox |
-- | List received emails (excludes deleted) |
| GET | /emails/sent |
-- | List sent emails (excludes deleted) |
| GET | /emails/starred |
-- | List all starred emails |
| GET | /emails/trash |
-- | List deleted emails |
| GET | /emails/search |
`?q=&folder=all | inbox |
| GET | /emails/unread-count |
-- | Get count of unread inbox emails |
| GET | /emails/:id |
-- | Get a single email (auto-marks as read) |
| GET | /emails/:id/thread |
-- | Get full conversation thread |
| PATCH | /emails/:id/star |
-- | Toggle star status |
| PATCH | /emails/:id/read |
{ is_read: boolean } |
Set read/unread status (recipient only) |
| DELETE | /emails/:id |
-- | Soft-delete (move to trash) |
| PATCH | /emails/:id/restore |
-- | Restore email from trash |
| Method | Endpoint | Body | Description |
|---|---|---|---|
| GET | /users |
-- | List all users except the current user |
| GET | /users/me |
-- | Get the authenticated user's profile |
| PATCH | /users/avatar |
multipart/form-data (field: avatar) |
Upload a profile avatar (5 MB max) |
| DELETE | /users/avatar |
-- | Remove the current avatar |
Success -- JSON object with data (e.g., { token, user }, [ ...emails ], { message })
Error -- JSON with an error message and appropriate HTTP status:
{ "error": "Description of what went wrong" }The SQLite database (server/mail.db) contains the following main tables:
| Column | Type | Constraints |
|---|---|---|
| id | INTEGER | Primary key, auto-inc |
| TEXT | Unique, not null | |
| password | TEXT | Not null (bcrypt hash) |
| name | TEXT | Not null |
| avatar | TEXT | Nullable (filename) |
| created_at | DATETIME | Default: current time |
| Column | Type | Constraints |
|---|---|---|
| id | INTEGER | Primary key, auto-inc |
| from_user_id | INTEGER | FK -> users.id |
| to_user_id | INTEGER | FK -> users.id |
| subject | TEXT | Not null |
| body | TEXT | Not null |
| is_read | INTEGER | Default: 0 |
| reply_to_id | INTEGER | FK -> emails.id (nullable, for threading) |
| created_at | DATETIME | Default: current time |
Stores an optional voice note for an email.
| Column | Type | Constraints |
|---|---|---|
| id | INTEGER | Primary key, auto-inc |
| email_id | INTEGER | Unique, FK -> emails.id |
| file_name | TEXT | Not null |
| file_path | TEXT | Not null |
| mime_type | TEXT | Not null |
| size_bytes | INTEGER | Not null |
| duration_seconds | REAL | Nullable |
| created_at | DATETIME | Default: current time |
Stores one or more attachments for an email.
| Column | Type | Constraints |
|---|---|---|
| id | INTEGER | Primary key, auto-inc |
| email_id | INTEGER | FK -> emails.id |
| file_name | TEXT | Not null |
| file_path | TEXT | Not null |
| mime_type | TEXT | Not null |
| size_bytes | INTEGER | Not null |
| original_size_bytes | INTEGER | Nullable |
| created_at | DATETIME | Default: current time |
Temporary upload staging table for voice notes before an email is sent.
Temporary upload staging table for attachments before an email is sent.
Per-user flags for each email, enabling independent star/delete status per user.
| Column | Type | Constraints |
|---|---|---|
| user_id | INTEGER | PK (composite), FK -> users.id |
| email_id | INTEGER | PK (composite), FK -> emails.id |
| is_starred | INTEGER | Default: 0 |
| is_deleted | INTEGER | Default: 0 |
| deleted_at | DATETIME | Nullable |
Database features: WAL mode enabled for concurrent read performance, foreign keys enforced, indexes on frequently queried columns, and temporary upload tables for staged file handling before send.
The app uses JWT (JSON Web Token) authentication:
- User registers with name, email, and password.
- Password is hashed with bcryptjs (10 salt rounds) before storage.
- On login, credentials are verified and a JWT is issued.
- The client stores the token in
localStoragevia the Zustand auth store. - Every API request includes the token in the
Authorization: Bearer <token>header (handled automatically by the Axios interceptor). - The server's
authenticatemiddleware verifies the token and attaches the decoded user toreq.user.
During development, the Vite dev server proxies API requests to the backend:
/api/* -> http://localhost:5000
This is configured in client/vite.config.ts. No CORS issues occur in development because both client and API appear to be on the same origin from the browser's perspective.
| Service | Default Port |
|---|---|
| Backend | 5000 |
| Frontend | 5173 (Vite) |
| Upload Type | Limit |
|---|---|
| Avatar | 5 MB |
| Voice note | 10 MB |
| Attachment | 10 MB per file |
| Email attachments total | 25 MB per email |
Voice notes are limited to 1 minute. Attachments are limited to 5 files per email. Image attachments may be compressed client-side before upload to reduce storage usage.
The client uses @/ as an import alias for client/src/:
import { useAuth } from "@/hooks/useAuth";cd client
npm run buildThis runs TypeScript compilation followed by the Vite build, producing optimized assets in client/dist/.
cd server
npm startThe Express server serves the built client assets from ../client/dist and handles SPA routing with a catch-all route. Access the full application at http://localhost:5000.
These are areas identified for future improvement:
- No background job scheduler. Expired temporary uploads are cleaned up during relevant email and upload requests rather than by a dedicated scheduled job.
- Tokens do not expire. The JWT has no
expiresInoption set, and there is no refresh token mechanism. - No input sanitization on email body content (potential XSS if rendering raw HTML).
- No rate limiting on authentication or email endpoints.
- No tests. Neither the client nor server has automated tests.
- SQLite scaling. Works well for small-to-medium workloads but would need migration to PostgreSQL or MySQL for large-scale deployments.
- Local file storage is used for avatars, attachments, and voice notes. Production deployments would likely need object storage (S3, GCS, etc.).
- No email notifications. There are no push notifications or real-time updates beyond polling for unread count.
- Fork the repository.
- Create a feature branch:
git checkout -b feature/your-feature. - Make your changes and test them locally.
- Commit with a descriptive message:
git commit -m "feat: add your feature". - Push your branch:
git push origin feature/your-feature. - Open a pull request describing what you changed and why.
When contributing, keep in mind the known limitations above -- pull requests addressing any of those items are welcome.