Inbox
Gentelella's inbox is a fully interactive mail client built into the template — folders, reader pane, compose modal, J/K/R/S/# keyboard shortcuts, search. Self-contained: data, state, and UI all live in src/v4/inbox.js. The host page only provides an empty mount point.
Last updated May 22, 2026
The inbox at production/inbox.html is not a screenshot — it’s a working mail client with about 30 seed messages, real folder logic, search, keyboard shortcuts, compose/reply/forward modals, and per-folder unread counts that update live as you act.
The page itself is minimal:
<body data-shell="admin" data-page="inbox" data-breadcrumb="Home > Apps > Inbox">
<main class="main">
<div class="page-wrapper">
<div id="inbox-root"></div>
</div>
</main>
</body>
Everything else is rendered by src/v4/inbox.js, lazy-loaded only when #inbox-root exists on the page.
What’s wired
| Feature | How |
|---|---|
| 5 folders (Inbox, Sent, Drafts, Starred, Trash) + 4 labels | viewFilter() switches the visible message set |
| Click a message | Opens it in the reader pane, marks as read, updates folder counts |
| Star toggle | Click the star icon — moves the message in/out of Starred |
| Archive (trash) | Move to Trash. From Trash: Restore or Delete forever |
| Compose | Modal with To / Subject / Body fields — submits to Sent (or saves to Drafts if subject is blank) |
| Reply / Forward | Open compose pre-filled |
| Search | Filters by subject + body + sender, scoped to the active folder |
| Per-folder unread counts | Pill on each folder updates on every action |
| Keyboard shortcuts | J/K to navigate, R to reply, S to star, # to trash, C to compose |
State model
A single in-memory array of messages:
const messages = seed; // 30+ entries
// Each message:
{
id: 'msg-1', // unique
folder: 'inbox' | 'sent' | 'drafts' | 'starred' | 'trash',
unread: boolean,
starred: boolean,
label: 'work' | 'personal' | 'newsletter' | 'travel' | '',
from: 'Sarah K.', // display name
fromEmail: 'sarah@design.co',
subject: 'Re: Q1 design review',
preview: "I've added comments to the figma file…", // for the list view
body: 'Hey,\n\nI\'ve added comments…', // full body
time: '9:42 AM',
}
Mutations update the array in place, then call renderAll() to repaint. No state library, no immutability — the simplicity is the feature. Total state surface is small enough that the whole inbox repaints in under a frame on every action.
Keyboard shortcuts
The handlers are bound once on init and scoped to the inbox page via if (!document.getElementById('inbox-root')) return;. Inputs and textareas are also skipped (if (e.target.matches('input, textarea')) return;) so the shortcuts don’t fire while you’re typing in compose or search:
| Key | Action |
|---|---|
J / ↓ | Next message — auto-opens it in the reader |
K / ↑ | Previous message — auto-opens it in the reader |
R | Reply to the selected message (opens compose pre-filled). No-op on Drafts. |
S | Toggle star on the selected message |
# | Move to Trash (or delete forever if already in Trash) |
C | Compose new message |
Navigation auto-opens the message in the reader as you move — there’s no separate “Enter to open” step. Gmail-flavored but not Gmail-identical.
Folder logic
viewFilter() switches what the list view shows:
function viewFilter(msg) {
switch (currentView.kind) {
case 'folder':
// 'starred' is virtual — any starred message regardless of folder, but not Trash
if (currentView.value === 'starred') return msg.starred && msg.folder !== 'trash';
return msg.folder === currentView.value;
case 'label':
// Labels exclude Trash too
return msg.label === currentView.value && msg.folder !== 'trash';
}
}
Two things worth noting:
- Starred is a view, not a folder. A message starred while in Inbox stays in Inbox; selecting “Starred” in the sidebar just filters to show it.
- Trash is opaque to labels. Trashed messages don’t show in label views — when something’s trashed, it’s invisible everywhere except Trash itself.
Search
The search input filters the active folder/label, not globally:
function searchFilter(msg) {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return msg.subject.toLowerCase().includes(q)
|| msg.body.toLowerCase().includes(q)
|| msg.from.toLowerCase().includes(q);
}
Combined with viewFilter(), this means “search Inbox”, “search Sent”, “search Starred” — never “search everything across folders”. That’s the same behavior as Gmail’s per-label search. If you need cross-folder search, fork visibleMessages() to ignore viewFilter when there’s a search query.
Customizing the seed data
The seed messages live at the top of inbox.js:
const m = (p) => ({ id: id(), unread: false, starred: false, label: '', ...p });
const seed = [
m({ folder: 'inbox', unread: true, starred: true, label: 'work',
from: 'Sarah K.', fromEmail: 'sarah@design.co',
subject: 'Re: Q1 design review',
preview: "I've added comments to the figma file…",
body: "Hey,\n\nI've added comments…",
time: '9:42 AM' }),
// ...
];
m() is a constructor that supplies sensible defaults. To add your own messages, append to seed. To replace seed with API data, the template ships data-adapter.js with a list/update/create/remove API surface:
import { useApiMode, httpAdapter } from './data-adapter.js';
const adapter = useApiMode()
? httpAdapter('/api/messages', { listKey: 'messages' })
: seedAdapter(seed);
// All adapters expose the same surface:
const items = await adapter.list({ folder: 'inbox' });
await adapter.update(id, { unread: 0 });
await adapter.create({ /* new message */ });
await adapter.remove(id);
useApiMode() returns true when:
- The URL has
?api=1(handy for live demos that switch between “static preview” and “real backend”), or window.__GENTELELLA_API__ = trueis set before module load (good for build-time injection)
The default is false so the template ships demo-ready without any backend dependency. The inbox already wires the adapter — look at hydrateFromApi(root) in inbox.js for the actual call pattern.
Compose, Reply, Forward
All three reuse the same modal — they just pre-fill different fields:
- Compose — opens with empty To / Subject / Body
- Reply — pre-fills To with
fromEmail, Subject withRe: <subject>, Body with a quoted blockquote - Forward — pre-fills To empty, Subject with
Fwd: <subject>, Body with the full original message indented
Submitting the modal:
- If Subject is non-empty → creates a new message in
sent - If Subject is empty → creates the message in
drafts - Either way, the visible folder updates immediately if you’re looking at Sent/Drafts
The modal itself is generic — exported from src/v4/modal.js as showModal(opts). The inbox calls it with form contents; the same modal is used by Kanban, Settings, and Confirm dialogs across the template.
Per-message counts
Folder unread counts update on every mutation:
function unreadCountInFolder(folder) {
return messages.filter((m) => {
if (folder === 'starred') return m.starred && m.unread && m.folder !== 'trash';
return m.folder === folder && m.unread;
}).length;
}
Cheap because the message array is small. For a real app with thousands of messages, you’d want a backend-driven count and only recompute on folder-change events.
Responsive layout
The inbox uses a three-pane layout (sidebar / list / reader) on desktop. At smaller widths:
- Below 1024px: reader collapses, list takes full width
- Below 768px: sidebar collapses to a top bar, list/reader stack
The transitions are CSS-driven via _pages.scss. The updateBackLayout() function in inbox.js adjusts a few classes when the layout flips — mainly to handle the back button that appears in the reader header on mobile.
See also
- Kanban — the other interactive component on the template
- Architecture — the lazy-loading pattern that keeps the inbox out of pages that don’t need it
- Adding a page — how to add
inbox-stylepages of your own