Exchange is excluded from this API/CLI release. Any historical Exchange API or CLI example in this guide is unavailable; native portal behavior is outside this exclusion. See [current release scope](/developers/guides/release-scope?lang=en).

# Binadit MailSig and mailbox profiles

Binadit MailSig is free with Workspace. A company manages its signature once; the mail server inserts it in outgoing messages from any mail application, above quoted replies. Mailbox profiles supply the person's name, role, phone and other details. Administrators can fill the same optional fields for ordinary and shared mailboxes. These operations create no product order, paid seat or invoice line.

Use the portal's Workspace **Profiles** and **Signatures** screens, the customer API or the typed Rust CLI. The API always calls the same portal services. Clients never receive a signature-service token or contact the signature service directly.

## Administrator-managed appearance — service 0.3.0, policy of 2026-11-02

The Workspace administrator controls signature appearance and assigns the template or variant for each ordinary or shared mailbox. Employees may update only the profile values the administrator releases: `mobile`, `showMobile`, `photo`, `workdays`, `pronouns`, `language` and configured `custom.<key>` values. Other self-edit permissions are rejected. Employees do not choose an appearance or paste HTML; administrators use the managed template editor instead.

The old employee-choice and per-mailbox HTML controls are removed from API and CLI input and output. Unknown legacy properties return 422, including false, null or dry-run values. Old remote appearance edits do not change portal assignments. Fixed templates and variants remain available to authorized Workspace administration.

## Access and versions

All paths below start with `/api/v1/accounts/{account}/workspaces/{workspace}`. The company and workspace must belong to the current connection. Reading requires `workspace.mailboxes:read`; changes require `workspace.mailboxes:write`. Workspace restrictions also apply. A machine integration can automate the complete MailSig lifecycle. For testmail, supply `recipient_mailbox`: the integer ID of an active ordinary mailbox in this same Workspace. The portal resolves and rechecks its current address; foreign, deleted or suspended mailboxes are rejected. Personal connections may omit it to send to their current verified owner address, which retains its strong-authentication requirement. Testmail requires sensitive actions enabled in either mode. There is no arbitrary `to` address, subject or message body.

Read the resource first. Responses include `data.workspace_id` and `data.version`; local reads also return an `ETag`. For signature writes use the version from `GET /signatures/settings`. All signature sections share a revision, so another change invalidates that version. Profiles and profile settings have their own versions. Equal numeric IDs in personal and shared mailbox tables are different resources.

Every write requires `If-Match` and `Idempotency-Key`. `dry_run: true` or `X-Binadit-Dry-Run: true` checks the current input, authority and version without supplier calls, saved records or mail. It does not render, sanitize HTML, process an image or promise remote availability. `POST /signatures/preview` is a read-only rendering request: no new intent, key, version precondition or dry-run flag.

A repeated successful request with the same key and body returns its original receipt, even after its old version has changed. A changed body with the same key conflicts. On a timeout, `502`, held or failed operation, inspect the **original** operation; do not create a replacement key or automatically repeat a testmail/upload. The CLI preserves its original intent until the exact company/workspace/result is verified. No automatic mutation retry occurs.

## Endpoint inventory

| Method and path | Input or result |
| --- | --- |
| `GET/PUT /mailboxes/{mailbox}/profile` | Personal profile; `data.owner_kind=mailbox`, `owner_id`, `profile` |
| `GET/PUT /shared-mailboxes/{shared}/profile` | Shared profile; `owner_kind=shared_mailbox` |
| `GET/PUT /profile-settings` | `profile_settings`: country, departments, locations, custom fields, self-editable fields |
| `GET /profiles/export` | CSV: all ordinary/shared profile data |
| `POST /profiles/import/preview` | Read-only CSV review, no operation or writes |
| `POST /profiles/import` | Atomic confirmation of the original private review |
| `POST /profiles/bulk` | Atomic department/location update for reviewed owners |
| `GET/PUT /signatures/company` | Per-field company `{value,source}`; explicit account-IBAN opt-in |
| `GET/PUT /signatures/variants` | Administrator-managed new/reply template pairs and default flags |
| `GET /signatures/variables?lang=en` | Current service variable groups and block syntax (`en` or `nl`) |
| `GET/PUT /signatures/onboarding` | Setup state: `step` (1–4), `dismiss` boolean; read `required`, `step`, `dismissed` |
| `GET/PUT /signatures/settings` | Enable, mobile footers, footer and image policy |
| `GET/PUT /signatures/templates` | `{ "templates": [...] }`; replaces the complete ordered list |
| `GET/PUT /signatures/rules` | `{ "rules": [...] }`; replaces the complete ordered list |
| `GET/PUT /signatures/campaigns` | `{ "campaigns": [...] }`; replaces the complete ordered list |
| `GET /signatures/images` | This workspace's image metadata |
| `POST /signatures/images` | `content_base64`, `mime`, `kind`, optional processing options |
| `POST /signatures/banner` | Text, background, foreground, width and height |
| `DELETE /signatures/images/{image}` | Optional `{ "photo": true }`; returns `removedFromServer` |
| `POST /signatures/validate` | `{ "html": "..." }`; cleaned HTML, changes, warnings, errors; may import images |
| `POST /signatures/preview` | Exactly one `template: {html,text?}` or `variant: ID`; `reply?`, `mailbox?`/`sample?`, `footer?`; HTML/text, `fields`, `unknownFields`, `bytes` |
| `POST /signatures/testmail` | `{ "mailbox": "anna@example.test", "variant": "new" }`; `reply` is also supported; machines must add `recipient_mailbox: ID` |
| `GET /signatures/status` | Completeness, image DNS/TLS status, identity state and daily counters |
| `GET /signatures/gallery` | Built-in NL/EN designs |
| `POST /signatures/gallery/{design}/import` | `{ "language": "nl" }` or `en`; stores a new local template; optional `placement: default|extra` imports a full/reply pair and variant exactly as in the portal |

Signature responses use `data.<section>`: for example `settings`, `templates`, `preview`, `validate`, `testmail` or `gallery`. Mutations add `free: true` and the original `operation` receipt. Supplier credentials, private IDs and raw diagnostics are not returned. The complete live schemas and operation IDs are in [OpenAPI](/developers/openapi.json).

## Profile data

Keys follow the signature bundle: `titleBefore`, `firstName`, `infix`, `lastName`, `titleAfter`, `displayName`, `pronouns`, `jobTitle`, `jobTitleEn`, `department`, `location`, `phone`, `mobile`, `showMobile`, `workdays`, `photo`, `language`, `custom`, `template`, `variant`.

Names are plain text; whitespace is normalized without changing capitalization. Phone and mobile accept a string or `{ "display": "030 123 4567", "e164": "+31301234567" }`. Both representations must identify the same number. The default country is `NL`; valid numbers are stored as E.164 and formatted for display. An invalid number fails validation. `showMobile` controls whether the mobile number appears.

Administrators can set every field listed above for both ordinary and shared mailboxes, including employee-editable data and a workspace-owned photo. Signature completeness requires only `firstName` **or** `lastName` on an ordinary mailbox, and `displayName` on a shared mailbox. Every other field is optional; empty fields are omitted from the signature. Email and login come from the actual mailbox and cannot be changed through a profile. Department/location choices, images and template references remain workspace-scoped. Languages use a two-letter code, optionally a country suffix, such as `nl` or `en-GB`.

Profile responses include read-only `employeeChangedAt`, an object mapping the field or `custom.<key>` to its last employee-change ISO timestamp when an administrator has not superseded it. It contains no replay watermarks. Never copy it into PUT input: client-supplied provenance is rejected. Administrator changes win even when the field is released to employees.

Profile settings accept `defaultCountry`, `departments`, `locations`, `customFields` and `selfEditable`. Locations have `key`, `name`, optional `line1`, `postcode`, `city`, `country`, `phone`. At most ten custom fields use `key`, `label`, `type: text|choice|boolean`, and `options` for a choice field. `selfEditable` contains only `mobile`, `showMobile`, `photo`, `workdays`, `pronouns`, `language` and configured `custom.<key>` values. Appearance remains administrator-managed. Profile changes are audited; photo audit entries do not copy their raw content. The portal, API and CLI use the same atomic CSV review service described below.

## CSV review and bulk profile data

`GET /profiles/export` returns `data.csv` (maximum 2 MiB), including both owner kinds keyed by `type` and `email`. Export columns are the known profile-data columns and configured `custom.<key>` values. Appearance assignments, photos and read-only provenance are not CSV columns. Unknown/duplicate columns, unknown/duplicate addresses, wrong owner types and invalid values fail the whole import.

`POST /profiles/import/preview` accepts `{"csv":"..."}` (UTF-8, maximum 64 KiB and 500 rows). It reads current data and returns `changes`, a private encrypted `preview_token`, and its SHA256 as `version`/ETag. It creates no profile, audit entry or operation. Review every proposed row. Within ten minutes, `POST /profiles/import` accepts only `{"preview_token":"..."}` plus the reviewed ETag, original Idempotency-Key and current strong authorization. The review is bound to the original grant, company/workspace, settings and each owner version. Any invalid/stale row prevents every write. `dry_run:true` validates the complete review without saving. Reuse the exact original review/key to inspect an uncertain outcome; never automatically re-preview or retry a POST. Profiles contain private employee data: keep CSV/review files protected and do not commit them.

`POST /profiles/bulk` accepts `owners:[{kind:"user"|"shared",id,version}]` and nonempty `fields` containing only `department` and/or `location` (null clears). Obtain its workspace/settings ETag from `/profiles/export`; each owner also needs its own current profile version. The whole batch uses one atomic local transaction and the same strong verification, original key and dry-run controls.

Upload a photo through the existing signature image endpoint with `kind:"photo"`, then PUT its returned image ID into either profile's `photo`; PUT `photo:null` clears that profile. The two requests have independent current ETags and original keys. A completed upload is retained if assigning it later fails; inspect the original result rather than uploading again. To remove an unused photo from the service use the existing image delete action with `photo:true`.

```sh
binadit workspace mailbox profile export 7 --file profiles.csv --account 42
# Edit the protected CSV, then inspect the proposed changes before committing.
binadit workspace mailbox profile import 7 --input profiles.csv --review-file profile-review.json --account 42
binadit workspace mailbox profile import 7 --input profile-review.json --commit --confirm-workspace 7 --account 42 --yes
# The shared-mailbox aliases use the same reviewed workspace-wide CSV.
binadit workspace shared-mailbox profile export 7 --file shared-profiles.csv --account 42
binadit workspace mailbox profile bulk 7 --input department-change.json --version WORKSPACE_PROFILE_VERSION --account 42 --dry-run
```

## Templates, rules and images

A template has `id`, `name`, `html`, `text` or null, `language` (`nl` or `en`), and optional `source` (gallery ID or `custom`). A rule has `id`, `name`, one `when` condition, `new`, `reply`, `recipients: all|external` and `campaign: true|false`. `reply: null` uses the new-message template; `reply: "none"` suppresses it. Conditions are exactly one of `all: true`, `domains`, `departments`, `locations`, `mailboxes`, `sharedMailboxes`, or `custom: {key,present:true}` / `{key,equals:...}`. The first matching rule wins. IDs and referenced resources must exist in this workspace.

A campaign has `id`, `name`, `image`, descriptive `alt`, UTC RFC3339 `from`/`until` and `rules` IDs. It has no link. The banner generator accepts a text and two hex colours, for example `#0B5FFF` and `#FFFFFF`, plus dimensions.

Always review `validate` changes and errors before saving HTML. Only its returned HTML is saved; links lose their anchors but retain text, scripts are removed, and permitted external images are imported. Validation itself can therefore create images and uses the write-intent protocol. MailSig templates contain **no links**, tracking pixels or open tracking. Use plain-text website, email and phone fields.

Image upload accepts up to 5 MiB. JSON wraps the bytes in `content_base64` and includes `mime` and `kind: logo|photo|banner|other`. Processing options are `width`, `round`, six hex digits in `border`, `whitePlate`, and `crop: "x,y,w,h"`. Supported image inputs are processed by the service; WebP/SVG are converted for mail rather than embedded as WebP/SVG. Images are public at their URLs: never upload confidential material. A removed logo/banner may remain to keep old mail intact. Photos are removed immediately; inspect `removedFromServer`.

`settings.imageHost` is `auto` or `none`. The portal computes the resulting own-domain/shared mode from actual DNS and the configured server. It uses `sig.<customer-domain>` only after its CNAME is verified; otherwise shared image hosting is used when available. Do not supply arbitrary image hostnames. Sent-copy rewriting (`updateSentCopy`) remains false.

## Company details, variants and the HTML editor (MailSig 0.2)

`GET /signatures/company` returns thirteen fixed fields: `name`, `legalName`, `kvk`, `btw`, `iban`, `address`, `phone`, `email`, `website`, `tagline`, `brandColor`, `logo`, `locations`. Every field contains `value` and `source` (`account` or `custom`). `PUT` is a partial field map; omitted fields are preserved. An empty custom value deliberately hides a field and remains different from an account default. Reset a field with `{"source":"account"}`. Account-backed values follow subsequent company changes; the current resolved values are part of the signature ETag, so an account update invalidates a stale review.

The default general phone excludes mobile numbers. The default company email is an existing `info@`, `contact@`, `hallo@` or `hello@` on the first mail domain, never the account's personal contact address. IBAN stays empty unless explicitly customized or `useAccountIban:true` is supplied. Resetting IBAN, including `company reset --all`, removes that opt-in. `address` contains `line1`, `postcode`, `city`, `country`; `phone` is null or `{display,e164}`. `logo` refers to an owned uploaded logo. `locations` delegates to the existing profile settings, retains `source:custom`, and resetting it clears the list. Employees without a location use the company address. Company details can be reviewed in the portal's **Company details** tab and onboarding wizard.

In the portal, open **Company details → Brand and logo → Upload a new logo** (also available in setup step 1). Choose a PNG, JPG, WebP, GIF or SVG file up to 5 MiB, then click **Save** or **Continue**. The selected file becomes the company logo and is saved with your other company edits. The existing-logo selector lists previously uploaded logos; it is not the upload control. API/CLI image upload and company-logo assignment remain separate requests as documented below.

```json
{"name":{"source":"custom","value":"Example BV"},"tagline":{"source":"custom","value":"Clear advice"},"iban":{"source":"account"}}
```

`GET/PUT /signatures/variants` uses `{"variants":[...]}` for a complete replacement. Each item has `id`, `name`, optional `description`, `new` template ID, optional `reply` (null = same, `"none"` = none), optional `default`. Only one default is allowed; its templates become the first Everyone rule. References must exist in this workspace; a variant in use cannot be removed. A mailbox's fixed `template` takes precedence, then its `variant`, then the first matching rule. Campaigns and recipients still come from the rule. The Workspace administrator assigns `template` or `variant` on ordinary and shared profiles. Employee self-service cannot change either assignment or supply personal HTML.

```json
{"variants":[{"id":"standard","name":"Standard","description":"Full company details","new":"full","reply":"short","default":true}]}
```

`GET /signatures/variables?lang=en` returns the service's `syntax` and `groups`, including configured custom fields; `nl` is also accepted. The portal caches this metadata for ten minutes. Clients should not maintain a separate hardcoded variable catalog. Preview returns a flat string map `fields` (including empty values), `unknownFields` and UTF-8 byte counts `bytes.html`/`bytes.text`. These support variable insertion, block syntax and typo suggestions. Up to 2048 HTML bytes can appear while composing in webmail; larger signatures remain server-side. Preview accepts exactly one temporary template or a saved variant; it stays read-only and never initializes a remote bundle.

```json
{"variant":"standard","reply":true,"mailbox":"anna@example.test"}
```

```sh
binadit workspace signatures company show 7 --account 42
binadit workspace signatures company set 7 --input company.json --version "$SIG_VERSION" --account 42 --dry-run
binadit workspace signatures company set 7 --input company.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures company reset 7 --field name --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures company reset 7 --all --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures variant list 7 --account 42
binadit workspace signatures variant put 7 --input variants.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures variables 7 --language en --account 42
binadit workspace mailbox profile put 7 23 --input profile-variant.json --version "$PROFILE_VERSION" --account 42 --yes
```

Example `profile-variant.json`: `{"variant":"standard","template":null}`. Read the current version again after every successful change. All writes retain the same original request key until a strict matching receipt is confirmed; a malformed result or uncertain response never triggers an automatic retry.

## Typed CLI examples

Protect JSON/image input files with owner-only permissions. `--input -` reads JSON from stdin; image upload reads a protected binary `--file`. A current version is 64 lowercase hex characters; replace the example placeholders with the real `data.version`.

```sh
binadit workspace mailbox profile get 7 23 --account 42
binadit workspace mailbox profile put 7 23 --account 42 --input profile.json --version "$PROFILE_VERSION" --dry-run
binadit workspace mailbox profile put 7 23 --account 42 --input profile.json --version "$PROFILE_VERSION" --yes
binadit workspace shared-mailbox profile get 7 31 --account 42
binadit workspace profile-settings get 7 --account 42
binadit workspace profile-settings put 7 --account 42 --input profile-settings.json --version "$PROFILE_SETTINGS_VERSION" --yes

binadit workspace signatures settings get 7 --account 42
binadit workspace signatures gallery list 7 --account 42
# Save disabled settings once to initialize the bundle before a first preview.
binadit workspace signatures settings put 7 --input settings.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures gallery import 7 klassiek --language en --version "$SIG_VERSION" --account 42 --yes
# Read settings again for a fresh version after every successful change.
binadit workspace signatures template get 7 --account 42
binadit workspace signatures template put 7 --input templates.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures rule put 7 --input rules.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures campaign put 7 --input campaigns.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures validate 7 --input html.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures preview 7 --input preview.json --account 42
binadit workspace signatures image list 7 --account 42
binadit workspace signatures image upload 7 --file logo.png --mime image/png --input image-options.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures image banner 7 --input banner.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures image delete 7 1234567890abcdef --photo --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures enable 7 --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures testmail 7 --input testmail.json --version "$SIG_VERSION" --account 42 --yes
binadit workspace signatures status 7 --account 42
binadit workspace signatures disable 7 --version "$SIG_VERSION" --account 42 --yes
```

Example `profile.json`: `{"firstName":"Anna","lastName":"de Vries","jobTitle":"Consultant","phone":"030 123 4567","showMobile":false,"language":"en"}`.

Example `templates.json` (plain text, no links):

```json
{"templates":[{"id":"standard","name":"Standard","html":"<p>{{naam}}<br>{{functie}}<br>{{bedrijf}} · {{telefoon}}</p>","text":null,"language":"en","source":"custom"}]}
```

Example `rules.json`: `{"rules":[{"id":"everyone","name":"Everyone","when":{"all":true},"new":"standard","reply":null,"recipients":"all","campaign":false}]}`.

Example `preview.json`: `{"template":{"html":"<p>{{naam}}<br>{{functie}}</p>"},"mailbox":"anna@example.test","footer":true}`.

Example `image-options.json`: `{"kind":"logo","width":160,"whitePlate":true}`. Example `banner.json`: `{"text":"Office closed on Friday","background":"#0B5FFF","foreground":"#FFFFFF","width":600,"height":120}`. Example `testmail.json`: `{"mailbox":"anna@example.test","variant":"reply"}`.

## API code examples

This Python example reads the current company version and submits **one** original company-name request. Keep the key and exact body if the response is lost; this code deliberately has no mutation retry.

```python
import os, uuid, json, requests
url = "https://my.binadit.com/api/v1/accounts/42/workspaces/7/signatures/company"
headers = {"Authorization": "Bearer " + os.environ["BINADIT_TOKEN"]}
r = requests.get(url, headers=headers, timeout=30, allow_redirects=False)
r.raise_for_status()
# Read variants and the service variable catalog through the same scoped portal API.
for section in ["variants", "variables?lang=en"]:
    catalog = requests.get(url.removesuffix("company") + section, headers=headers, timeout=30, allow_redirects=False)
    catalog.raise_for_status()
    assert catalog.json()["data"]["workspace_id"] == 7
intent = {"key": str(uuid.uuid4()), "version": r.json()["data"]["version"], "body": {"name": {"source": "custom", "value": "Example BV"}}}
# Exclusive creation: an existing unresolved intent must be inspected, never overwritten.
with os.fdopen(os.open("mailsig-intent.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as f:
    json.dump(intent, f)
    f.flush()
    os.fsync(f.fileno())
headers.update({"If-Match": intent["version"], "Idempotency-Key": intent["key"]})
r = requests.put(url, headers=headers, json=intent["body"], timeout=30, allow_redirects=False)
r.raise_for_status()
result = r.json()
assert result["data"]["workspace_id"] == 7 and result["operation"]["account_id"] == 42
assert result["operation"]["action"] == "workspace.signatures.company.put"
assert result["operation"]["request_status"] == "succeeded"
print(result["operation"]["id"])
# Keep the intent file for review. Remove it manually only after the original outcome is verified.
```

Equivalent JavaScript (token comes from the environment, never a browser page or URL):

```javascript
import {open} from 'node:fs/promises';
import {randomUUID} from 'node:crypto';
const url = 'https://my.binadit.com/api/v1/accounts/42/workspaces/7/signatures/company';
const headers = {Authorization: `Bearer ${process.env.BINADIT_TOKEN}`};
const read = await fetch(url, {headers, redirect: 'error'});
if (!read.ok) throw new Error(`Read failed: ${read.status}`);
const {data} = await read.json();
for (const section of ["variants", "variables?lang=en"]) {
  const catalog = await fetch(url.replace(/company$/, section), {headers, redirect: "error"});
  if (!catalog.ok || (await catalog.json()).data?.workspace_id !== 7) throw new Error("Review workspace catalog");
}
const intent = {key: randomUUID(), version: data.version, body: {name: {source: 'custom', value: 'Example BV'}}};
const journal = await open('mailsig-intent.json', 'wx', 0o600);
try { await journal.writeFile(JSON.stringify(intent)); await journal.sync(); }
finally { await journal.close(); }
const result = await fetch(url, {method: 'PUT', redirect: 'error',
  headers: {...headers, 'Content-Type': 'application/json', 'If-Match': intent.version, 'Idempotency-Key': intent.key},
  body: JSON.stringify(intent.body)});
if (!result.ok) throw new Error(`Inspect the original intent: ${result.status}`);
const receipt = await result.json();
if (receipt.data?.workspace_id !== 7 || receipt.operation?.account_id !== 42 ||
    receipt.operation?.action !== 'workspace.signatures.company.put' ||
    receipt.operation?.request_status !== 'succeeded') throw new Error('Inspect original intent');
console.log(receipt.operation.id);
// Retain the journal for review; never overwrite or automatically retry an unresolved request.
```

PHP and C# reuse the [common transport helpers](/developers/guides/code-examples?lang=en), which reject redirects and never retry mutations. Save the PHP helper as `binadit.php` or the C# helper as `Binadit.cs`. These examples read settings, the mailbox profile, status, variants and variables, then submit only a **dry run** of a company-name change. Use an owner-only working directory (including a protected user-only ACL on Windows). The journal contains request metadata, never a token; keep it until the original result is reviewed. An actual update requires a new explicit review of the current company version and a separately persisted original request key.

```php
<?php
require 'binadit.php';
$token = BinaditExample::accessToken();
$base = '/api/v1/accounts/42/workspaces/7';
$settings = BinaditExample::api($token, 'GET', $base.'/signatures/settings');
$company = BinaditExample::api($token, 'GET', $base.'/signatures/company');
$profile = BinaditExample::api($token, 'GET', $base.'/mailboxes/23/profile');
$status = BinaditExample::api($token, 'GET', $base.'/signatures/status');
$variants = BinaditExample::api($token, 'GET', $base.'/signatures/variants');
$variables = BinaditExample::api($token, 'GET', $base.'/signatures/variables?lang=en');
if (($settings['data']['data']['workspace_id'] ?? null) !== 7
    || ($profile['data']['data']['owner_id'] ?? null) !== 23
    || ($status['data']['data']['workspace_id'] ?? null) !== 7) {
    throw new RuntimeException('Review the requested workspace and mailbox');
}
$key = bin2hex(random_bytes(16));
$intent = ['key' => $key, 'version' => $company['data']['data']['version'],
    'path' => $base.'/signatures/company', 'body' => ['name' => ['source' => 'custom', 'value' => 'Example BV'], 'dry_run' => true]];
$mask = umask(0077);
try { $file = fopen('mailsig-profile-intent.json', 'x'); }
finally { umask($mask); }
if ($file === false) { throw new RuntimeException('Inspect the existing original intent'); }
try {
    $json = json_encode($intent, JSON_THROW_ON_ERROR);
    if (fwrite($file, $json) !== strlen($json) || !fflush($file) || !fsync($file)) {
        throw new RuntimeException('Intent persistence failed; no request was submitted');
    }
} finally { fclose($file); }
$reply = BinaditExample::api($token, 'PUT', $intent['path'], $intent['body'], $intent['version'], $key);
if (($reply['data']['data']['dry_run'] ?? null) !== true) { throw new RuntimeException('Expected a dry-run result'); }
echo "Company validation finished; no change was saved.\n";
// Keep the journal. Do not automatically resend a request after a transport error.
```

```csharp
using System.Text.Json;
using System.Text.Json.Nodes;
using var api = new BinaditExample();
string token = await api.AccessToken();
const string root = "/api/v1/accounts/42/workspaces/7";
var settings = await api.Api(token, "GET", root + "/signatures/settings");
var company = await api.Api(token, "GET", root + "/signatures/company");
var profile = await api.Api(token, "GET", root + "/mailboxes/23/profile");
var status = await api.Api(token, "GET", root + "/signatures/status");
var variants = await api.Api(token, "GET", root + "/signatures/variants");
var variables = await api.Api(token, "GET", root + "/signatures/variables?lang=en");
if (settings.Data?["data"]?["workspace_id"]?.GetValue<long>() != 7 ||
    profile.Data?["data"]?["owner_id"]?.GetValue<long>() != 23 ||
    status.Data?["data"]?["workspace_id"]?.GetValue<long>() != 7)
    throw new InvalidOperationException("Review the requested workspace and mailbox");
string key = Guid.NewGuid().ToString();
string version = company.Data!["data"]!["version"]!.GetValue<string>();
var body = new JsonObject { ["name"] = new JsonObject { ["source"] = "custom", ["value"] = "Example BV" }, ["dry_run"] = true };
string path = root + "/signatures/company";
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
await using (var journal = new FileStream("mailsig-profile-intent.json", options))
{
    await JsonSerializer.SerializeAsync(journal, new { key, version, path, body });
    await journal.FlushAsync();
    journal.Flush(flushToDisk: true);
}
var reply = await api.Api(token, "PUT", path, body, version, key);
if (reply.Data?["data"]?["dry_run"]?.GetValue<bool>() != true)
    throw new InvalidOperationException("Expected a dry-run result");
Console.WriteLine("Company validation finished; no change was saved.");
// Retain the journal; a transport error must never trigger an automatic retry.
```

Rendered preview HTML is untrusted document content. Show it only in an iframe with `sandbox=""` (no scripts or same-origin) and a strict CSP; never inject it into the portal DOM. Compare wide/narrow and light/dark appearances; dark mode is an approximation.

## Status and limits

`identity: synced` means the signature is in the webmail/JMAP identity. `server` is a normal informational state: the signature exceeds Stalwart's 2048-byte identity limit and is inserted on sending, but is not visible while composing. `pending` and `error` mean synchronization has not finished. The portal coalesces updates to at most one bundle every ten seconds. A testmail acknowledgement is not inbox-delivery proof; the service limits it to ten per workspace per hour.

Missing own-domain image DNS is a recommendation, not a mail outage: shared hosting can continue. MailSig helps include company registration details; it does not certify legal compliance. Read status after synchronization and inspect warnings before enabling. Live SMTP/DKIM/client-rendering acceptance and deployment are separate from local API/CLI tests.

Example `settings.json`: `{"enabled":false,"imageHost":"auto"}`. The asynchronous bundle must be ready first; an early preview returns `409` and does not enable signatures itself.


## Fully unattended setup and maintenance

Use a service connection with `workspace.mailboxes:read` and `workspace.mailboxes:write`, limited to the intended Workspace. `operations:read` lets the job inspect its original receipts. No browser or repeated passkey is needed for the service connection. For a machine without a keychain, have your secret manager pipe the client secret into each CLI invocation with `--service-client-id ID --service-secret-stdin`; JSON inputs then use a protected file, not stdin. Credentials never belong in a repository, shell arguments or job logs. See the [CLI authentication manual](/developers/cli?lang=en).

The complete automation surface is 35 operations, including personal/shared profiles and CSV. Company details, custom fields, logo/photo upload, HTML templates, new/reply variants, ordered rules, campaigns, employee permissions, preview, validation, activation, status and deactivation are all available. To delete a template, variant, rule or campaign, PUT its complete current list without that item. Resolve dependent references first; an empty list clears only a collection without references. Wizard-only navigation is represented by `/signatures/onboarding`; it never enables signatures by itself.

For a **new, empty** signature configuration, this is the same gallery setup as the portal. Use an already authenticated service profile; each command can instead receive the per-command service options above. Set `W`, `ACCOUNT` and `PROFILE` explicitly. The recipient in `testmail.json` is the numeric ordinary-mailbox ID, not the shared-mailbox ID or an email address. Create files in a private working directory (`umask 077`), including `onboarding.json` = `{"step":4,"dismiss":true}` and `testmail.json` = `{"mailbox":"anna@example.test","variant":"new","recipient_mailbox":23}`. The mailbox address must really belong to this Workspace. `jq` is used only to read JSON:

```sh
set -eu
umask 077
# Existing service profile; no interactive browser login in the job.
cli() { binadit --profile "$PROFILE" --account "$ACCOUNT" --no-input --output json "$@"; }
version() { cli workspace signatures settings get "$W" | jq -er '.data.version'; }
# Verify templates/variants/rules are empty before this initial gallery import.
cli workspace signatures template get "$W" > templates-before.json
cli workspace signatures variant list "$W" > variants-before.json
cli workspace signatures rule get "$W" > rules-before.json
jq -e '.data.templates == []' templates-before.json >/dev/null
jq -e '.data.variants == []' variants-before.json >/dev/null
jq -e '.data.rules == []' rules-before.json >/dev/null
cli workspace signatures gallery import "$W" klassiek --language en --placement default --version "$(version)" --yes > gallery-result.json
# Optional: update company, images and personal/shared profiles with their own current versions.
cli workspace signatures onboarding put "$W" --input onboarding.json --version "$(version)" --yes > onboarding-result.json
cli workspace signatures enable "$W" --version "$(version)" --yes > activation-result.json
attempt=0
while :; do
  cli workspace signatures status "$W" > status.json
  if jq -e '.data.status.sync | .pending == false and (.revision | type == "number") and .pushedRevision == .revision' status.json >/dev/null; then break; fi
  attempt=$((attempt + 1))
  if [ "$attempt" -ge 24 ]; then echo 'Synchronization not confirmed; inspect status.json. No write was retried.' >&2; exit 1; fi
  sleep 5
done
# Check image DNS/TLS and mailbox identity/complete/missing before acceptance.
jq -e --arg mailbox 'anna@example.test' '{variant: .data.gallery.variant.id, mailbox: $mailbox, footer: true}' gallery-result.json > preview.json
cli workspace signatures preview "$W" --input preview.json > preview-result.json
cli workspace signatures testmail "$W" --input testmail.json --version "$(version)" --yes > testmail-result.json
```

The example polls synchronization for at most two minutes and builds `preview.json` from the actual returned variant ID. Adapt the real mailbox address in both JSON inputs. The status command itself does not wait. Keep every result and the CLI's original request journal. If a command fails, stop that run and inspect its original operation; do not rerun this setup block wholesale. A testmail acknowledgement confirms submission, not inbox delivery. Inspect the test mailbox separately for delivery/rendering acceptance. Ten testmails per Workspace per hour remains the service limit.

The API uses exactly the same inputs: `POST /signatures/gallery/klassiek/import` with `{"language":"en","placement":"default"}`, `PUT /signatures/onboarding` with `{"step":4,"dismiss":true}`, and `POST /signatures/testmail` with the body above. Each write has the latest signature `If-Match` and its own persisted `Idempotency-Key`. Read-only previews need neither header. The returned gallery object contains `source`, `placement` and `variant`; omitting `placement` preserves the older single-template response. Deactivation uses `PUT /signatures/settings` with `{"enabled":false}`; it does not delete the saved design or the Workspace.


## Free designs and logo effects

All gallery designs and logo effects are included free with Workspace. They create no product order, paid seat or invoice. The Workspace administrator controls the design; employee permissions and the no-links/no-tracking policy remain the same.

The gallery adds `atelier` (serif name and spacious typography), `horizon` (a fine brand-colour rule), `studio` (portrait beside the person’s details) and `compact-logo` (compact layout with a logo), each in English and Dutch. All designs that use a logo can display an animated logo; the signature text stays still. Existing designs stay available.

In the portal, open **Company details**, choose a new logo file and select **Logo animation (free)**. **Soft shine** and **Subtle zoom** are optional. The default **No added effect** leaves the upload unchanged. The image-bank upload offers the same options. Use PNG or JPG with no added effect for a still logo; an existing animated GIF retains its animation.

For API/CLI uploads, `animation` is `none` (default), `shine` or `breathe`. Effects support only `kind:logo` with PNG, JPEG or WebP source bytes, up to 4 million pixels and 4096 pixels per side. Display width is at most 320 px. Crop is supported and applied once before animation; round, whitePlate and border must be off. Animated output is bounded to 1 MiB. The portal processes the file locally and uploads the GIF through its existing MailSig connection; the client never contacts MailSig directly. `dry_run` checks the format, dimensions and compatible options without processing/uploading the effect.

The first and last frames show the complete logo. The effect lasts approximately one second and the final frame has a 30-second pause; the mail service may repeat the animation. Playback depends on the receiving mail app and its settings. A still first frame is therefore always usable. No JavaScript, hyperlink or tracking pixel is added to email.

Save this first JSON body as `logo-options.json`. The CLI’s `--mime` describes the original file (`image/png` in this example); the portal changes the upload to GIF after processing. Save the last JSON body as `company-logo.json`, replacing the image ID with the actual response. Each write uses a fresh version and its own original request key; existing uncertain-result recovery applies. The corresponding API request is the existing `POST /signatures/images` with `content_base64`, `mime` and these options, followed by the existing `PUT /signatures/company`.

```json
{"kind":"logo","width":180,"animation":"shine"}
```

```sh
binadit workspace signatures gallery list 7 --account 42
binadit workspace signatures gallery import 7 atelier --language en --placement default --version "$SIG_VERSION" --account 42 --yes --no-input
# Read the current signature version again before the next write.
binadit workspace signatures image upload 7 --file logo.png --mime image/png --input logo-options.json --version "$SIG_VERSION" --account 42 --yes --no-input
# Use the returned image ID in company[logo], then read a fresh version.
binadit workspace signatures company set 7 --input company-logo.json --version "$SIG_VERSION" --account 42 --yes --no-input
```

```json
{"logo":{"source":"custom","value":"IMAGE_ID_FROM_UPLOAD"}}
```
