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.
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 |
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.
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.
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.
{"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.
{"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.
{"variant":"standard","reply":true,"mailbox":"anna@example.test"}
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.
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):
{"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.
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):
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, 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
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.
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.
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:
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.
{"kind":"logo","width":180,"animation":"shine"}
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
{"logo":{"source":"custom","value":"IMAGE_ID_FROM_UPLOAD"}}