Build mailbox extensions

Write code that reacts to mailbox events, publish it, and install it with its own permissions on your webhook or on Samva.

An extension is code you publish once and install into an organization. It receives mailbox events and acts through the mailbox API, for example to acknowledge every new message or to label threads. You choose where it runs:

RuntimeWhere the code runsHow events reach it
webhookYour server, at an HTTPS URL you give at install time.A signed webhook. You call the API with a key revealed once.
hostedSamva, from a bundle you publish. No server to run.Samva runs your handler for each event.

Write an extension

Install the authoring package @samva/mailbox, or scaffold a project with the CLI:

samva extensions init ./acknowledge --name acknowledge --runtime hosted

--runtime is hosted (the default) or webhook. The directory must be empty. The project contains src/extension.ts, and a webhook project adds src/server.ts.

src/extension.ts
import { defineExtension } from "@samva/mailbox";

export default defineExtension({
  manifest: {
    name: "acknowledge",
    displayName: "Acknowledge new mail",
    version: "1.0.0",
    runtime: "hosted",
    events: ["mailbox.message.received"],
    permissions: ["read", "send"],
  },
  on: {
    "mailbox.message.received": async (event, { samva, log }) => {
      const reply = await samva.messages.reply(event.data.mailboxId, event.data.message.id, {
        text: "Thanks, we got it.",
      });
      // "approvalRequired" waits for a person; "queued" retries until its `executeUntil` deadline.
      log(`Reply ${reply.state}`, event.data.message.id);
    },
  },
});

The manifest

FieldRules
name3 to 64 lowercase letters, digits, and hyphens. It identifies the extension in your organization.
displayName1 to 80 characters.
descriptionOptional, up to 500 characters.
versionSemver, such as 1.0.0. A published version is immutable.
runtimehosted or webhook.
eventsOne or more of the mailbox event types, each listed once.
permissionsThe mailbox permissions it may use: read, update, send, quarantine.read. read is required.

Every key in on must appear in events. An event with no handler is acknowledged and skipped.

What a handler receives

A handler gets the event (id, type, timestamp, data) and a context with:

  • samva, a mailbox client with messages.get, reply, replyAll, forward, and send, and threads.get, list, messages, and update.
  • log(...), which records a line in the run's log.
  • attempt, the delivery attempt number.

Sends made through samva carry an Idempotency-Key of the form <event id>:<method>:<n>, so a handler that runs again for the same event does not send twice. Other side effects need their own deduplication. Throwing from a handler fails the event, which Samva then retries.

If Samva temporarily cannot check the installation’s access, the event waits without starting the handler or consuming a handler attempt. A failed run whose mailbox API calls return a server error or cannot reach Samva also waits without exhausting its handler attempts. An invalid or revoked credential stops the installation and removes its waiting events. Outage retries slow down with a bounded delay, independently of handler attempts. If an event’s credential expires while it waits, Samva renews it without requiring another event. Renewal preserves its original permission, scope, and approval limits and applies any current restrictions.

Test locally

@samva/mailbox/test runs a handler without a network.

extension.test.ts
import { createTestClient, mailboxEvent, runExtension } from "@samva/mailbox/test";
import extension from "./src/extension";

const client = createTestClient();
const result = await runExtension(extension, mailboxEvent("mailbox.message.received"), { client });

console.log(result.ok, result.logs, result.calls);

To run your handlers against live events, use samva extensions dev. It tails the event stream and runs the extension for each event whose type is in the manifest. It calls the API with your own credential, so actions are attributed to you, not to an installation.

samva extensions dev . --mailbox mbx_...

Publish a version

Publishing and installing need a person signed in with samva login who can manage mailboxes. The CLI builds the project before it publishes. A hosted extension builds to one self-contained ES module, which must stay within the hosted limits.

samva extensions build . --out dist/extension.js
samva extensions publish .

publish creates the extension on its first version and prints the version it recorded. Publishing the same version with the same content again returns it; different content under the same version is a 409, so bump version.

List what you published with samva extensions list, an extension's versions with samva extensions versions <name>, and one version with its manifest with samva extensions version <name> <version>.

Install it

An installation applies a published version to your mailboxes. It is a principal of its own: it acts on the installer's grants, narrowed by what you give it here, and never exceeds them.

samva extensions install acknowledge \
  --mailbox mbx_... \
  --permission read --permission send \
  --send-mode approval
FlagMeaning
--versionA published version. The latest when omitted.
--mailboxRepeat to name mailboxes. Omit it to reach every mailbox the installer reaches.
--namespaceConfine the installation to one namespace.
--permissionRepeat to narrow the manifest's permissions. It can only remove permissions, never add them.
--send-modesend or approval. With approval, every send waits for a person.
--webhook-urlFor a webhook extension, the HTTPS URL that receives events.

An installation receives events only while it is active and its principal can read the event's mailbox right now. Quarantine events also need quarantine.read. An installation can never approve a send, release or discard held mail, or change grants and keys. If the installer loses the mailbox, the installation stops receiving its events.

List installations with samva extensions installations, read one with samva extensions installation <id>, and manage it with disable, enable, and uninstall. Disabling stops the installation at once and refuses its credential on every surface; events published while it was disabled are not delivered after you enable it. Uninstalling deletes the installation and its key, and the actions it took keep their attribution. Disabling or uninstalling also cancels definitely-unsent queued sends proposed by that installation, including human-approved sends. Accepted emails cannot be withdrawn. A held send records decision.source: "installation" when its installation requires approval; see Approve sends for receipts and progress.

When you re-enable a hosted installation, it can receive new events. A delayed revocation from an earlier disable cannot remove those events or invalidate their credentials.

Upgrade or roll back an installation

A person who can manage mailboxes can move an installation to another published version of the same extension. Sign in with samva login; an API key or an extension credential cannot upgrade an installation. Publishing a version does not change existing installations.

Preview the change first:

samva extensions upgrade exti_... --version 2.0.0 --dry-run --json

--version accepts a published semver or version id. Omitting it selects the extension's latest published version. The preview returns fromVersion, toVersion, and permissionDiff with added and removed grant strings. It returns the installation's current state, sets dryRun to true and versionChange to null, and changes nothing. A preview needs no permission acknowledgment.

Review every entry in permissionDiff.added. To apply the change, repeat --accept-permissions once for each exact string. For example, if the preview adds permission:send and event:mailbox.message.sent:

samva extensions upgrade exti_... --version 2.0.0 \
  --accept-permissions permission:send \
  --accept-permissions event:mailbox.message.sent \
  --yes

The acknowledgment list must match the added grants exactly. Omit --accept-permissions when there are no additions. --yes confirms the version change in automation; it does not acknowledge any added grant. In a terminal, omitting --yes asks for confirmation.

The installation keeps its id, credentials, webhook binding, mailbox and namespace scope, send mode, attribution, and enabled or disabled status. Removed permissions take effect immediately; removed event subscriptions stop new deliveries. For hosted extensions, new events use the selected version; events already waiting or running use the version they started with. Their credentials never gain the new version's added authority, and the installation's current narrower scope still applies.

To roll back, choose the previous published version with --version and follow the same preview and acknowledgment steps:

samva extensions upgrade exti_... --version 1.0.0 --dry-run --json
samva extensions upgrade exti_... --version 1.0.0 --yes

The second command succeeds only if the rollback adds no grants. Otherwise, pass each added grant from its preview explicitly.

Upgrade through the SDK or API

The SDK exposes extensionInstallations.upgrade, backed by POST /v1/extension-installations/{id}/upgrade. Both require a person credential. The request takes optional version, acceptPermissions (an array of exact added grant strings), and dryRun (a boolean). Use the same target for the preview and the applied change:

upgrade-extension.ts
const preview = await samva.extensionInstallations.upgrade({
  id: "exti_...",
  version: "2.0.0",
  dryRun: true,
});
console.log(preview.fromVersion, preview.toVersion, preview.permissionDiff);

// After reviewing the added grants, name each one explicitly.
const result = await samva.extensionInstallations.upgrade({
  id: "exti_...",
  version: "2.0.0",
  acceptPermissions: ["permission:send", "event:mailbox.message.sent"],
});
console.log(result.installation, result.versionChange);

The response contains installation, fromVersion and toVersion (each with id and version), permissionDiff, dryRun, and versionChange. An applied version change returns a versionChange record with id, organizationId, installationId, extensionId, fromVersion, toVersion, permissionDiff, changedBy, and changedAt. Selecting the current version with no grant changes returns versionChange: null.

Run on your server

A webhook installation returns an API key and a webhook secret once, when you install it. Store both. createWebhookHandler verifies each request's signature, runs your handler, and answers 200 on success or 500 so Samva retries.

src/server.ts
import { createWebhookHandler } from "@samva/mailbox/webhook";
import extension from "./extension";

Bun.serve({
  fetch: createWebhookHandler(extension, {
    apiKey: process.env.SAMVA_API_KEY!,
    webhookSecret: process.env.SAMVA_WEBHOOK_SECRET!,
  }),
});

Delivery, signing, and retries follow the webhook event catalog.

Run on Samva

A hosted extension needs no server and no credential. Samva runs your bundle for each event. The bundle has no network access: its only way out is samva, the mailbox client, which can do exactly what the installation's principal can do through the mailbox API.

List an installation's recent runs, then read one to see the lines your handler logged:

samva extensions runs exti_...
samva extensions run exti_... <run-id>

The list shows each run's event, attempt, outcome, and duration; the run itself adds its logs. A run's outcome is succeeded, failed, timed_out, budget_exhausted, or revoked.

Hosted limits

LimitValue
Bundle size1 MiB
CPU time per run1 second
Mailbox API calls per run50
Wall time per run30 seconds
Runs per installation per hour, retries included600
Attempts per event5
Log lines kept per run50
Characters kept per log line500
Runs kept in the run log100

An event that arrives after an installation has used its hourly runs is recorded as budget_exhausted and not run.

Manage extensions over the API

The CLI commands call the API, and the SDK exposes the same operations as the extensions and extensionInstallations namespaces.

ToCall
Publish a versionPOST /v1/extensions; it answers with the extension and the version.
List extensionsGET /v1/extensions, then GET /v1/extensions/{id}.
List versionsGET /v1/extensions/{id}/versions, one summary per version.
Read one versionGET /v1/extensions/{id}/versions/{versionId}, with the manifest.
Install a versionPOST /v1/extension-installations; the credentials of a webhook extension come back once.
Upgrade or roll backPOST /v1/extension-installations/{id}/upgrade; preview and acknowledge added grants.
List or read installationsGET /v1/extension-installations and GET /v1/extension-installations/{id}.
Disable, enable, or uninstallPOST /v1/extension-installations/{id}/disable, .../enable, and DELETE on the installation.
List runs or read oneGET /v1/extension-installations/{id}/runs lists runs without logs; .../runs/{runId} adds them.

The API reference lists the fields of each.

Next steps

Related documentation

On this page