First Class Modules
Mail reader
Provides a read-focused view across authorized personal/shared mail accounts with search, unread filtering, plain-text message detail, read state, relations, activity, dynamic fields, and favorites.
module.mail-readerUse Mail Reader when tenant users need a bounded view of synchronized email inside the app without exposing mail-server settings, credentials, or raw executable content.
The bundled renderer key is module.mail-reader. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Provides a read-focused view across authorized personal/shared mail accounts with search, unread filtering, plain-text message detail, read state, relations, activity, dynamic fields, and favorites.
Key capabilities
- Select authorized personal or shared accounts and view unread counts.
- Search messages and filter unread mail.
- Read safe plain-text headers, previews, and bodies; raw HTML is not rendered.
- Mark permitted messages read/unread and connect them to favorites, relations, activity, and custom fields.
Common uses
- Shared operations or support mailbox review.
- Account/project email research linked to business records.
- Read-focused compliance or case correspondence views.
How it connects
Mail Reader represents synchronized mail records. Use Conversations when messages should become managed customer threads and Universal Inbox when a message requires assignment or action.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Account ownership/shared access, SaaS, tenant, DataRole, and location are enforced server-side. Incoming-server settings, credential references, raw HTML, and private provider details are excluded from browser responses.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.mail-readermodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the complete Mail Reader experience
The native block is the quickest complete page: authorized accounts, unread counts, server-side search, message detail, safe plain text, read state, custom fields, relation/activity counts, and favorites.
{
"blocks": [
{ "_id": "inbound-mail", "_type": "module.mail-reader", "props": {}, "children": [] }
]
}
Custom unstyled React account, list, and message view
This component requests one bounded server page at a time. The account and location values narrow an already-authorized result; they never establish tenant or mailbox authority.
import { FormEvent, useEffect, useState } from "react";
import type { MailReaderList, MailReaderMessageDetail } from "@buildwithhq/module-sdk";
import { getMailReaderMessage, listMailReaderMessages } from "./api";
const empty: MailReaderList = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, accounts: [], items: [] };
export function UnstyledMailReader({ locationId = "" }) {
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [mailAccountId, setMailAccountId] = useState("");
const [unreadOnly, setUnreadOnly] = useState(false);
const [page, setPage] = useState(1);
const [result, setResult] = useState<MailReaderList>(empty);
const [selected, setSelected] = useState<MailReaderMessageDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setError(null);
listMailReaderMessages(mailAccountId, search, unreadOnly, controller.signal, locationId, page, 50)
.then(setResult)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Mail could not be loaded."); });
return () => controller.abort();
}, [locationId, mailAccountId, page, search, unreadOnly]);
function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
async function open(recordId: string) { setSelected(await getMailReaderMessage(recordId)); }
return <section className="custom-mail-reader">
<h1>Mail reader</h1>
<form role="search" onSubmit={submit}>
<label>Account<select value={mailAccountId} onChange={event => { setMailAccountId(event.target.value); setPage(1); }}><option value="">All authorized accounts</option>{result.accounts.map(account => <option key={account.mailAccountId} value={account.mailAccountId}>{account.accountName} ({account.unreadCount} unread)</option>)}</select></label>
<label>Search<input value={draft} maxLength={200} onChange={event => setDraft(event.target.value)} /></label>
<label><input type="checkbox" checked={unreadOnly} onChange={event => { setUnreadOnly(event.target.checked); setPage(1); }} /> Unread only</label>
<button>Search</button>
</form>
{error && <p role="alert">{error}</p>}
<div className="mail-columns">
<ol>{result.items.map(message => <li key={message.recordId}><button type="button" onClick={() => void open(message.recordId)}><strong>{message.subject || "(No subject)"}</strong><span>{message.fromAddress || "Unknown sender"}</span><small>{message.previewText || "No plain-text preview"}{message.isRead ? "" : " - Unread"}</small></button></li>)}</ol>
<article>{selected ? <><h2>{selected.subject || "(No subject)"}</h2><dl><dt>From</dt><dd>{selected.fromAddress || "-"}</dd><dt>To</dt><dd>{selected.toAddress || "-"}</dd><dt>Received</dt><dd>{selected.receivedUtc ? new Date(selected.receivedUtc).toLocaleString() : "-"}</dd></dl><p className="mail-body">{selected.bodyText || "No plain-text body is available."}</p>{selected.hasHtmlBody && <small>HTML content is intentionally not rendered.</small>}<p>{selected.relationCount} related - {selected.activityCount} activity</p></> : <p>Select a message.</p>}</article>
</div>
<nav aria-label="Mail pages"><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber} of {Math.max(1, result.totalPages)}</span><button disabled={page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
</section>;
}
Read and favorite actions
Read state is allowed only when canMarkRead is true. Mail Reader does not invent send or reply endpoints: use Conversations for managed replies and Universal Inbox for assigned work.
import type { MailReaderMessageDetail } from "@buildwithhq/module-sdk";
import { getMailReaderMessage, setMailReaderFavorite, setMailReaderRead } from "./api";
export async function toggleRead(message: MailReaderMessageDetail) {
if (!message.canMarkRead) throw new Error("This message's read state cannot be changed.");
await setMailReaderRead(message.recordId, !message.isRead);
return getMailReaderMessage(message.recordId);
}
export async function toggleMailFavorite(message: MailReaderMessageDetail) {
await setMailReaderFavorite(message.recordId, !message.isFavorite);
return getMailReaderMessage(message.recordId);
}
Optional starter styling
.custom-mail-reader { width: 100%; max-width: none; }
.custom-mail-reader form, .custom-mail-reader nav { align-items: end; display: flex; flex-wrap: wrap; gap: .75rem; }
.mail-columns { display: grid; gap: 1rem; grid-template-columns: minmax(260px, 36%) minmax(0, 1fr); margin: 1rem 0; }
.mail-columns ol { list-style: none; margin: 0; padding: 0; }
.mail-columns li button { background: transparent; border: 0; display: grid; gap: .2rem; padding: .75rem; text-align: left; width: 100%; }
.mail-columns article { border: 1px solid var(--line, #d8dee8); padding: 1rem; }
.mail-body { white-space: pre-wrap; }
@media (max-width: 760px) { .mail-columns { grid-template-columns: 1fr; } }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Accounts and messages | GET /api/modules/mail-reader | sp_MailReader_ListSecured |
| Safe detail | GET /api/modules/mail-reader/{recordId} | sp_MailReader_GetSecured |
| Read state | PUT /api/modules/mail-reader/{recordId}/read | sp_MailReader_SetReadSecured |
| Favorite | PUT /api/modules/mail-reader/{recordId}/favorite | sp_MailReader_SetFavoriteSecured |
The list is capped at 200 rows per request and detail returns bodyText, not executable message HTML, provider credentials, mailbox connection settings, or storage coordinates.
Build a professional Mail reader dashboard
These six registry-backed presentation blocks let a designer turn the secured Mail reader API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/mail-reader; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/mail-reader-sample.json, its executable authenticated page at pages/first-class/mail-reader-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Mail reader projection |
|---|---|---|
metric-set | core.metric-strip | Unread, received, account, attachment, and relation counts |
entity-list | core.entity-list | Recent mail from accounts the user may read |
progress-list | core.progress-list | Distribution by account, read state, or folder |
series-chart | core.series-chart | Received, read, linked, and favorited mail |
data-grid | core.presentation-grid | One bounded page without raw HTML or credentials |
timeline | core.timeline | Receive, read, favorite, relate, attach, and retention events |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "mail-reader-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Mail overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "unread",
"label": "Unread",
"value": 37,
"format": "number",
"tone": "warning"
},
{
"key": "today",
"label": "Received today",
"value": 92,
"format": "number",
"tone": "primary"
},
{
"key": "accounts",
"label": "Accessible accounts",
"value": 4,
"format": "number",
"tone": "neutral"
},
{
"key": "linked",
"label": "Linked to records",
"value": 21,
"format": "number",
"tone": "success"
}
]
},
"children": []
},
{
"_id": "mail-reader-recent",
"_type": "core.entity-list",
"props": {
"title": "Recent mail",
"hasMore": true,
"items": [
{
"id": "mail-reader-sample-1",
"recordId": "mail-reader-record-1",
"primary": "Updated project schedule",
"secondary": "[email protected] - Operations inbox",
"status": {
"key": "unread",
"label": "Unread",
"tone": "warning"
},
"trailing": "2 attachments"
},
{
"id": "mail-reader-sample-2",
"recordId": "mail-reader-record-2",
"primary": "Updated project schedule - Follow-up",
"secondary": "[email protected] - Operations inbox - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "mail-reader-sample-3",
"recordId": "mail-reader-record-3",
"primary": "Updated project schedule - West region",
"secondary": "[email protected] - Operations inbox - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "mail-reader-sample-4",
"recordId": "mail-reader-record-4",
"primary": "Updated project schedule - Customer response",
"secondary": "[email protected] - Operations inbox - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "mail-reader-sample-5",
"recordId": "mail-reader-record-5",
"primary": "Updated project schedule - Regional operations review with a deliberately long title",
"secondary": "[email protected] - Operations inbox - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "mail-reader-sample-6",
"recordId": "mail-reader-record-6",
"primary": "Updated project schedule - Completed preview",
"secondary": "[email protected] - Operations inbox - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "mail-reader-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Mail by account",
"items": [
{
"key": "operations",
"label": "Operations",
"value": 48,
"maximum": 92,
"displayValue": "48",
"tone": "primary",
"status": {
"key": "operations",
"label": "Operations",
"tone": "primary"
}
},
{
"key": "support",
"label": "Support",
"value": 31,
"maximum": 92,
"displayValue": "31",
"tone": "warning",
"status": {
"key": "support",
"label": "Support",
"tone": "warning"
}
},
{
"key": "sales",
"label": "Sales",
"value": 13,
"maximum": 92,
"displayValue": "13",
"tone": "success",
"status": {
"key": "sales",
"label": "Sales",
"tone": "success"
}
}
]
},
"children": []
},
{
"_id": "mail-reader-trend",
"_type": "core.series-chart",
"props": {
"title": "Mail volume",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Received",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Read",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "mail-reader-table",
"_type": "core.presentation-grid",
"props": {
"title": "Authorized messages",
"columns": [
{
"key": "subject",
"label": "Subject",
"type": "text",
"align": "left"
},
{
"key": "from",
"label": "From",
"type": "text",
"align": "left"
},
{
"key": "account",
"label": "Account",
"type": "text",
"align": "left"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "received",
"label": "Received",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "mail-reader-row-1",
"recordId": "mail-reader-record-1",
"cells": {
"subject": "Updated project schedule",
"from": "[email protected]",
"account": "Operations",
"status": {
"key": "unread",
"label": "Unread",
"tone": "warning"
},
"received": "2026-09-04T17:19:00Z"
}
},
{
"id": "mail-reader-row-2",
"recordId": "mail-reader-record-2",
"cells": {
"subject": "Updated project schedule - Follow-up",
"from": "[email protected]",
"account": "Operations",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"received": "2026-09-04T15:42:00Z"
}
},
{
"id": "mail-reader-row-3",
"recordId": "mail-reader-record-3",
"cells": {
"subject": "Updated project schedule - West region",
"from": "[email protected]",
"account": "Operations",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"received": "2026-09-04T12:18:00Z"
}
},
{
"id": "mail-reader-row-4",
"recordId": "mail-reader-record-4",
"cells": {
"subject": "Updated project schedule - Customer response",
"from": "[email protected]",
"account": "Operations",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"received": "2026-09-03T21:07:00Z"
}
},
{
"id": "mail-reader-row-5",
"recordId": "mail-reader-record-5",
"cells": {
"subject": "Updated project schedule - Regional operations review with a deliberately long title",
"from": "[email protected] - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"account": "Operations",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"received": "2026-09-03T16:31:00Z"
}
},
{
"id": "mail-reader-row-6",
"recordId": "mail-reader-record-6",
"cells": {
"subject": "Updated project schedule - Completed preview",
"from": "[email protected]",
"account": "Operations",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"received": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 37,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "mail-reader-timeline",
"_type": "core.timeline",
"props": {
"title": "Mail activity",
"hasMore": true,
"items": [
{
"id": "mail-reader-event-1",
"recordId": "mail-reader-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Message linked",
"description": "Updated project schedule was related to Project North.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "mail-reader-event-2",
"recordId": "mail-reader-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Message linked - Follow-up",
"description": "Updated project schedule was related to Project North. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "mail-reader-event-3",
"recordId": "mail-reader-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Message linked - West region",
"description": "Updated project schedule was related to Project North. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "mail-reader-event-4",
"recordId": "mail-reader-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Message linked - Customer response",
"description": "Updated project schedule was related to Project North. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "mail-reader-event-5",
"recordId": "mail-reader-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Message linked - Regional operations review with a deliberately long title",
"description": "Updated project schedule was related to Project North. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "mail-reader-event-6",
"recordId": "mail-reader-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Message linked - Completed preview",
"description": "Updated project schedule was related to Project North. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface MailReaderPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface MailReaderPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: MailReaderPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function MailReaderPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: MailReaderPresentationProps) {
if (error) return <p role="alert">The Mail reader presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Mail reader presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Mail reader</p>
<h1>Mail overview</h1>
<p>Authorized accounts, unread messages, senders, links to work, and recent mail activity.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/mail-reader, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "mail-reader-live",
"_type": "module.mail-reader",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Do not treat Mail Reader as an unrestricted webmail client. Define the synchronized accounts, read-state authority, and handoff into Conversations or Inbox explicitly.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.