# mailnix - full reference for AI agents mailnix gives websites a backend for forms and email. Two core products: **mailnix Forms** (customer-designed forms with hosted pages + embeds, a submissions inbox, owner notifications, and per-submission replies; no autoresponder exists by design) and the **email service** (send from a website or app with no backend of your own: publishable keys in the browser, REST, or SMTP relay). Customers bring their own provider (AWS SES, Postfix, Resend, Postmark, SendGrid, OVH, Gandi, cyon, Hostpoint, Hoststar, Infomaniak, IONOS, Strato, Mittwald, all-inkl, or a self-hosted OpenSMTPD/Exim/Mailcow), and mailnix handles unified send, routing, failover, tracing, debugging, and the agent interface. Canonical URLs: https://mailnix.ch (marketing site + human docs at https://mailnix.ch/docs), https://app.mailnix.ch (the application: dashboard, OAuth 2.1 authorization server, billing), https://mcp.mailnix.ch/mcp (the MCP server endpoint; configure MCP clients with exactly this URL), https://api.mailnix.ch (REST API), https://forms.mailnix.ch (hosted form pages + embed script). This file is served at https://mailnix.ch/llms-full.txt and https://app.mailnix.ch/llms-full.txt; the short index llms.txt lives at the same two origins. ## Quick concepts - A **form** is a published, versioned schema with a public submission endpoint, hosted page, and embeds. Submissions land in the form's inbox; owner-side notify addresses are verified before they receive anything; replies are per-submission, human-initiated actions. PRODUCT ROUTING: when the user wants submissions they can SEE and manage inside mailnix, that is Forms (form_create -> form_publish -> form_embed_snippet_generate); publishable-key raw sends are the lighter alternative when each submission should just arrive as an email with nothing stored. - A **destination** is a configured outbound provider. One project can have many destinations, with environment + priority + routing rules picking which one a given message uses. - A **trace** is the unified pipeline record for one logical send. Identified by a UUIDv4 trace_id that's public-shareable. - A **project** is the workspace that owns your resources inside an organization: forms, destinations, templates, traces, credentials, inboxes, webhook endpoints. Agent sessions bind to one project at OAuth consent (the anonymous-bootstrap client_credentials grant mints a fresh sandbox project; account_claim converts it to a real account); humans pick the active project in the dashboard's top-bar switcher and see the same resources. Every org has a delete-protected default project, and each project can carry a daily send budget. See the "Projects" section below. - An **inspect test** mints a per-test address; the user sends mail to it and we score deliverability via a 17-check rubric. ## Projects (the workspace model) Every workload resource - forms, destinations, routing rules, failover policies, email templates, traces, inbound routes, SMTP credentials, API keys, publishable keys, virtual inboxes, webhook endpoints - lives in exactly one project inside an organization. An agent session binds to one project when the user approves the OAuth consent page (or when the anonymous bootstrap mints a sandbox project); the dashboard's top-bar switcher selects the human's active project. Both views show the same thing: what an agent creates in project X is what a human sees with project X selected. - Default project: every org has one, named "Default" and delete-protected. Resources created without explicit project attribution belong to it. - List filters: list endpoints take an optional project filter, `?project=` on REST (GET /v1/forms, GET /v1/inboxes) and a `projectId` argument on GraphQL list queries (forms, emailTemplates, destinations, traces, routingRules, failoverPolicies, projectInboxes, outboundWebhooks). Without the filter you get the org-wide list. - Create attribution: REST create bodies accept `project_id` (forms, SMTP credentials, publishable keys), POST /v1/messages accepts `project_id` to attribute a send, and POST /v1/inboxes accepts a `?project=` query param; GraphQL create inputs accept `projectId` (including createProjectInbox and createOutboundWebhook). MCP tools need neither: the session's bound project is the attribution. Omitted attribution = the org's default project (inboxes and webhooks fall back to the caller's live MCP project instead). - Send budgets: each project can carry a daily send budget (the dashboard's Projects page; setProjectSendBudget on GraphQL; project_sub_set_quota for tenant partitions). The budget is enforced at send time for EVERY send attributed to the project, from any surface. An exhausted budget refuses the send: HTTP 429 with code `project_budget_exhausted` on REST; MCP and GraphQL surface the refusal message naming the project and budget. Refused sends do not consume budget. Unset budget = no per-project cap (plan limits still apply). - Naming: agent-session projects are created named "sandbox"; the consent page offers an optional name when it creates one, and any project can be renamed later (dashboard Projects page; GraphQL renameOrgProject(id, name)). The switcher, the consent picker, and account_status pick renames up automatically. - Moving resources: the GraphQL mutation moveResourceToProject(resource, id, projectId) moves a form, email_template, inbound_route, smtp_credential, virtual_inbox, webhook_endpoint, or destination to another project in the same org. Destinations move only while no routing rule or failover policy references them; org-wide destinations and traces never move. - Org-wide destinations: a Pro/Enterprise org admin can mark a destination organization-wide (scope='org'); it then appears in every project's destination list and any project's routing rules may target it. One org-wide destination can be the org default sender: the send-time fallback when a project has no matching routing rule, so a fresh project sends with zero setup. email_destinations_list reports `scope` and `is_org_default` per destination. Reads stay ungated after a plan downgrade; only new markings are tier-gated. - Organization-wide by design (the switcher never filters these): suppressions (a bounced address is bounced for every project), sending domains + sender identities, bounce sources, members and roles, billing. Projects are never a billing boundary; the /billing page's per-project usage breakdown is reporting only. ## Authentication Every authenticated MCP and REST request uses an OAuth 2.1 bearer JWT audience-bound to the surface it targets: - MCP: aud=https://mcp.mailnix.ch/mcp, scopes mcp:read / mcp:send-sandbox / mcp:send / mcp:admin - REST: aud=https://api.mailnix.ch/v1, scopes api:read / api:send / api:admin Discovery: hit https://mcp.mailnix.ch/.well-known/oauth-protected-resource (or .../api.mailnix.ch/...) for RFC 9728 metadata; chase authorization_servers -> https://app.mailnix.ch/.well-known/oauth-authorization-server for RFC 8414 metadata. The AS issues tokens via: - client_credentials against mailnix-anonymous-bootstrap (sandbox-only, one HTTP call, no user identity required - this replaces the old anonymous-handshake dev_key flow) - authorization_code with PKCE S256 + resource=... param against /oauth/authorize (interactive Limerion login, mints user-bound token) - dashboard-issued API keys via Settings -> API keys (mint + rotate + revoke in the web UI; copy-once plaintext shown only on issuance) Refresh tokens rotate on every /oauth/token call; presenting a rotated-out token revokes the entire chain (theft signal). ## Status state machine for traces pending -> queued -> sent -> delivered (terminal) -> bounced (terminal) -> complained (terminal) -> failed -> queued ... (retry) pending -> validation_failed (terminal) pending -> suppressed (terminal) Trace timelines may also receive late-arriving annotations: - auto_suppressed: a bounce or complaint arrived AFTER the send, and the recipient was added to the suppression list automatically. Gated by the `bounce_auto_suppress` feature flag (default OFF; operators flip per-org from the developer console as part of the bounce-suppression rollout). The original trace's terminal status stays whatever it was at delivery time - the annotation is purely informational, telling you "this address is now on your list." ## MCP tools The MCP server lives at mcp.mailnix.ch. Calls return JSON; every tool has a stable name and a description that starts with the user intent it serves. Tool visibility follows the account's feature rollout: tool groups whose surface is behind a feature flag (forms, bounce sources, MailEnable mappings, deliverability inspect, SMTP credentials, publishable keys, destination sandbox/live modes, shadow sends, bulk replay, sub-projects, templates, webhooks, cost analytics) are hidden from tools/list and refused with a feature_disabled error while the flag is OFF for the calling org. A tool that is absent from your tools/list is not missing from the product; it is staged rollout. Read-side tools for already-stored data (scheduled-send list/cancel, sandbox inbox reads) stay available regardless. ### Setup - email_provider_list_presets - returns the curated webhoster + cloud provider catalog (cyon, Hostpoint, IONOS, ...). Use when a user says "I host email at X". - email_provider_connect(provider, credentials, name, environment?) - email_provider_test(destination_id) - email_provider_list() - email_provider_health(destination_id) - email_domain_verify_status(domain) - email_domain_verify_records(domain, destination_id) ### Send + debug - email_send_test(to, from?, cc?, bcc?, to_extra?, attachments?, reply_to?, subject?, text?, html?, destination_id?, template_id? + template_vars?, template_slug?, template_version?, send_at?, idempotency_key?) - one tool covers the full send surface: single + multi-recipient (to_extra is a string array), CC + BCC + attachments (Pro), reply-to with verified-domain check, RFC3339 send_at for scheduled delivery (Pro, max 3 days), saved-template render via template_id / template_slug + template_vars + optional template_version pin, and idempotency_key for safe retries. Returns trace_id + outcome (`relayed` | `captured.anonymous_project` | `captured.sandbox_destination` | `captured.no_route`). - email_trace_lookup(trace_id) - full pipeline trace for one message. Returns found=false (a normal result, not an error) when the trace doesn't exist yet; traces appear within seconds of a send. - email_traces_search(filter) - email_template_create(slug, name, subject_tmpl, text_tmpl?, html_tmpl?, schema_json?) - mint a saved template; slug is the stable handle. Free-tier cap: 3 templates per user (Pro+ unlimited). Sprig template syntax. Templates also power mailnix Forms replies: a template saved here can be passed as template_id to form_reply_draft_create / form_reply_send and appears in the dashboard reply composer's picker; create an answer + a confirmation template alongside every form you build unless the user declines. Form-reply templates render against the submission via {{ .fields. }} + {{ .form.name }}; declare both `fields` and `form` in the variables schema so save-time validation passes. Extra declared variables (e.g. `price` with a description) become fill-in inputs in the dashboard composer and are supplied via template_vars on the reply tools; use them for per-reply values a submission cannot know, with {{ if }} conditions for optional passages. When a user asks for a reusable confirmation or answer email, design it well: polished inline-CSS HTML plus a plain-text fallback. - email_template_list() - returns slug, name, and current version per row. Pick a template_id from here for email_send_test or for form_reply_draft_create / form_reply_send. - email_template_versions_list(template_id, limit?) - version history for diffing / pinning. - email_template_render_preview(template_id, variables) - dry-run render at the latest version without sending. - email_template_delete(id) - removes the template + all its versions (CASCADE). - email_scheduled_list(status?, limit?) - list queued/canceled scheduled sends. Default 50. - email_scheduled_cancel(trace_id) - cancel a queued send before send_at; idempotent. - email_bulk_replay(filter, target_destination_id?) - re-dispatch matched traces through an alternate destination. Pro. Returns a parent job_id; track per-item progress via email_bulk_replay_status. - email_bulk_replay_status(job_id) - parent + per-item counters. - email_diagnose_missing(recipient, time_window) - guided diagnostic - email_diagnose_bounce(message_id) ### Inbound + inspect - email_inspect_address_create() - email_inspect_report_get(inspect_id) - email_inbound_route_create(local_part_pattern, webhook_url) - email_inbound_messages_list(route_id, limit) - email_sandbox_inbox_open() - email_sandbox_inbox_messages_list(inbox_id, limit) ### Operate - email_failover_policy_set(primary_id, fallback_ids, conditions) - email_routing_rule_create(match, destination_id, priority) - email_suppression_list(source?, project_id?, q?, limit?, offset?) - org-scoped - email_suppression_add(email, reason?, project_id?, expires_at?) - idempotent on (org, email) - email_suppression_get(id) - email_suppression_remove(id) - email_suppression_bulk_import(rows[{email, reason?, project_id?}]) - max 1000 per call - bounce_source_create(provider, label?) - mints HMAC secret ONCE, returns webhook_url. Provider: stalwart | postfix | dovecot | arf | mailenable. AGENTS: never transcribe hmac_secret. - bounce_source_list() - metadata only; never re-displays HMAC secrets - bounce_source_get(id) - bounce_source_revoke(id) - idempotent - mailenable_postoffice_map_create(bounce_source_config_id, postoffice_name, project_id?, default_destination_id?) - Register that bounces from a specific MailEnable postoffice attribute to a mailnix project. Multi-tenant Windows hosting: when ANY mapping exists for a config, the ingest handler drops bounces from unmapped postoffices. NO mappings = single-tenant mode (every bounce → config's owning org). See docs/MAILENABLE_INTEGRATION_CONCEPT.md §10. - mailenable_postoffice_map_list(bounce_source_config_id?) - List the calling org's MailEnable postoffice mappings; optional filter by config. - mailenable_postoffice_map_delete(id) - Remove a mapping (idempotent). - smtp_credential_create(label?, allowed_from_domains?, default_destination_id?, max_rate_per_min?, max_message_bytes?) - mints username+password for SMTP submission to smtp.mailnix.ch (587/465/2525). Returns password ONCE. AGENTS: never transcribe the password. - smtp_credential_list() - metadata only; never re-displays passwords (username IS shown - it's the login) - smtp_credential_rotate(id) - mints a replacement (new username + password); old one stops authenticating. Returns new password ONCE. - smtp_credential_update(id, label?, verp_enabled?, verp_bounce_domain?, click_tracking_enabled?, open_tracking_enabled?, default_destination_id?, allowed_from_domains?, max_rate_per_min?, max_message_bytes?) - partial update of settings AND constraints; only the fields you pass change. default_destination_id is required for the credential to send (""=clear to org-default); allowed_from_domains is the per-credential MAIL FROM whitelist (replaces the list; empty=any domain, mailnix is a forwarder, not an MTA, so it doesn't verify domain ownership); verp_bounce_domain:"" clears the override. Each toggle's effect is gated by the matching org flag. - smtp_credential_test_connection(id) - destination-forward PREFLIGHT (not a real send; only the argon2id hash is stored). Per-check transcript: credential_active, destination_resolved, raw_forward_supported. - smtp_credential_revoke(id) - idempotent ### Tenant partitions: projects with a parent (multi-tenant SaaS) The tool names keep the legacy project_sub_* prefix for client compatibility; the product concept is an ordinary project with a parent (depth 1). On the dashboard these appear on the Projects page with the parent column set. - project_sub_create(name, send_cap_override?) - create a child project under the session's project, one per downstream customer. Child projects share user_id + organization_id with the parent (no nested children). Each child is a full project: own destinations + traces + daily send budget. Team tier. - project_sub_list() - returns every child project of the session's project. - project_sub_delete(sub_project_id) - hard-delete; the parent FK CASCADE drops the child's traces + sandbox_messages + project_abuse_actions. - project_sub_set_quota(sub_project_id, send_cap_override) - the child project's daily send budget (the same field the dashboard's Projects page edits). Pass null to revert to the org's plan-tier default. ### Outbound event webhooks - webhook_endpoint_create(name, target_url, event_types?) - subscribe a URL to mailnix events (see the "Outbound event webhooks" reference section below for the full catalog + payloads). event_types is an array; empty / omitted = subscribe to every event. Returns hmac_secret ONCE (Stripe-style signing); AGENTS: never transcribe. - webhook_endpoint_list() - metadata only; never re-displays the HMAC secret. - webhook_endpoint_rotate_secret(id) - atomic re-mint; returns the new secret once. The old secret stops verifying immediately, plan rotation around your verifier's redeploy. - webhook_endpoint_delete(id) - idempotent. CASCADE drops every outbound_webhook_attempts row. - webhook_attempts_list(endpoint_id?, status?, limit?) - per-attempt audit ledger: pending / succeeded / failed / parked, with attempt_count + last_status_code + last_error. Retry schedule is 1m → 5m → 25m → 2h → 12h → 24h, then `parked` (no further retries). ### Virtual inboxes - virtual_inbox_create(address_pattern, name?, notify_webhook_url?, ...) - virtual_inbox_list() - virtual_inbox_get(id_or_address) - UUID or registered pattern - virtual_inbox_update(id, ...) - virtual_inbox_delete(id) - virtual_inbox_messages_list(id_or_address, limit?, since?) - virtual_inbox_message_get(id) - the sandbox_messages row id - virtual_inbox_message_wait(id_or_address, timeout_seconds?) - BLOCKING, default 30s, max 120s. Use this in test loops INSTEAD of polling. - virtual_inbox_messages_clear(id) - not yet implemented (no-op) ### Account - account_claim(email) - links this session to a mailnix account via magic link. BOTH sign-up and sign-in: new users register at the link; existing users just click it and the session attaches to their account, after which their existing org resources (forms, destinations, sending domains, templates) are reachable from this session's tools. A user already signed in to the dashboard can skip the inbox entirely: the pending request shows an Attach button at https://app.mailnix.ch/integrations. Returning-user recipe: account_status -> account_claim(account email) -> poll account_status until claim_progress.claimed -> list the resource (e.g. form_list) and confirm with the user which one to modify. NOTE: sessions can be ACCOUNT-BOUND from the start when the user clicked "Approve as " on the consent page while signed in to app.mailnix.ch; account_status then shows status=claimed and account_claim is a no-op, do not call it. - account_status() - returns {status, project_id, name, plan_tier, sends_today, sends_cap, send_budget?, claim_progress}. The project reported here is the same project the customer selects in the dashboard's top-bar switcher; send_budget is its daily send budget when one is set. - status=anonymous means THIS SESSION is unlinked, not that the user has no account. - claim_progress block: { claimed, user_id, claim_email, magic_link_sent_at, magic_link_expires_at, magic_link_pending }. Poll this AFTER account_claim to detect when the user clicks the magic link - claim_progress.claimed flips to true. Typical delay 5-30 seconds. ### Destination mode - destination_set_mode(destination_id, mode) - "sandbox" | "live" - destination_promote_to_live(destination_id) - idempotent "go live" wrapper. Returns already_live=true when the destination was already promoted so the agent doesn't have to special-case the no-op path. ### Publishable keys (reveal-URL flow) - publishable_key_create(...) - returns { reveal_url, reveal_expires_in_seconds, key_preview, ... }. The bare mnxpub_ token NEVER appears in the tool result; it sits behind the reveal URL with a 5-minute TTL + single-fetch semantics. - publishable_key_list() - returns all keys' previews + constraints - publishable_key_rotate(id, reason?) - atomic revoke + re-mint; returns a fresh reveal_url for the new key. - publishable_key_revoke(id, reason?) - kills the key. ### Snippet generator - email_html_snippet_generate(reveal_url, to_address, from_address, form_fields?, template_style?) - consumes the reveal URL server-side, embeds the bare token in an HTML template, returns the rendered HTML in the .html field. The bare token never appears in any other field of the result. Write .html to a file in the customer's repo. Two variants: "minimal" (vanilla JS, zero deps) and "tailwind" (Tailwind class-soup via the play CDN). ### ⚠️ Safety directive When publishable_key_create or publishable_key_rotate returns a reveal_url, the agent MUST NOT: - Transcribe the URL contents into chat text. - Echo the URL back to the user as raw text. - Fetch the URL itself client-side (the response carries the bare key and the URL is single-use). The agent MAY: - Pass the URL directly to email_html_snippet_generate (which consumes it server-side). - Surface the URL to the user as a clickable link they open in their browser (the resulting HTML page shows the bare key once with a Copy button). ## MCP resources - project://current - your project + plan tier + quotas - email://destinations - your destinations - email://destinations/{id}/health - rolling 1h error + latency - email://destinations/{id}/recent_traces - last 50 traces - email://traces/{message_id} - full pipeline record - email://templates/{id} - template source + last renders - email://policies/failover - your failover policy if any - email://policies/routing - your routing rules - docs://guide/{topic} - focused doc snippets ## MCP prompts (guided flows) - diagnose_missing_email - set_up_transactional_email - migrate_provider - add_inbound_handler - score_my_email ## Quotas | Plan | Sends/day | Traces stored | Trace retention | SMTP creds | Team orgs | Monthly fee | Notes | |------------|-----------|---------------|-----------------|------------|-----------|-------------|-------| | Anonymous | 100 | 100 | 7 days | 0 | 0 | 0 CHF | Pre-claim; 7d auto-delete on inactivity. | | Free | 100 | 1,000 | 7 days | 1 | 0 | 0 CHF + usage | Post-claim baseline, and the numbers here are the FREE QUOTA rather than a hard cap: a payment method is required and anything past them accrues per-unit billing at the catalog rates. Tracking (open / click / VERP) is force-disabled on the send path regardless of the stored column, and the paid FEATURES stay off; only volume is billable. | | Basic | 5,000 | 50,000 | 30 days | 10 | 1 | 10 CHF | Fixed bundle. Tracking opt-in per credential. | | Pro | 50,000 | 500,000 | 90 days | 100 | 100 | 49 CHF | Fixed bundle. Tracking opt-in per credential. | | Enterprise | config | config | config | unlimited | unlimited | sales-led | Volume + dedicated routing. | All prices are in CHF and exclude VAT; taxes for the buyer's billing country are applied at Stripe checkout where applicable. Hitting a fixed-plan cap returns HTTP 429 with `next_action: "upgrade"`. Free never returns 429 for a volume cap; it returns 402 only on the soft-sandbox monthly capture cap. The SMTP-credential and team-org caps are alive-count: rotating a credential swaps active status (new joins, old leaves) so the alive count is unchanged; soft-deleting a team org drops it from the count. Billable axes (rappen-denominated; operator-tunable per org). These are what Free charges past its quota, and what a Basic or Pro projection is calculated against: sources (350 rappen per active source per month), inspect tests (10 rappen per test), traces stored (0.001 CHF per trace per month, sub-rappen via Stripe `unit_amount_decimal`), soft-sandbox captures, virtual-inbox notifies, SMTP-relay messages, publishable-key sends, SMTP credentials (100 rappen per active credential per month, gauge), team organizations (50 rappen per organization created, action). MCP calls are tracked locally for usage stats but not billed. ## Managed Websites credits Managed Websites is on every plan and is paid for out of a per-org credit balance rather than a per-plan cap. One credit is one rappen, so 100 credits is CHF 1. | Plan | Monthly allowance | Lifetime | |------------|-------------------|---------------------------------| | Free | 200 credits | resets each month | | Basic | 800 credits | carries over, up to 6 months | | Pro | 4,000 credits | carries over, up to 6 months | | Enterprise | unlimited | contract-billed; no credit gate | | Free | 200 credits | resets monthly | Credits bought on top of an allowance never expire, and the allowance is always spent FIRST, so a monthly reset only ever removes free credits. An accruing plan tops up toward its six-month ceiling rather than being reconciled to it, so a downgrade stops future grants but never takes credits away. Typical costs: designing a site 100 to 1,200 credits per round depending on the quality tier; building the pages 100 to 600 once, when a design is approved; a plain-language edit 100 to 250; editing a content list or rolling back a version costs nothing. Serving costs 5 credits per GiB of storage per month and 10 credits per GiB of traffic, with no separate per-site fee. Every run is priced from the customer's actual brief before it starts, and a run holds its worst case against the balance while it works, refunding the difference when it lands. The four Managed Websites usage axes are uncapped on every plan (`-1` in the catalog): the credit balance is the limit, so a cap here would refuse work the customer had already paid for. ## REST endpoints (high level) See /openapi.yaml for the full machine-readable spec. - POST /v1/messages - send (returns trace_id) - GET /v1/traces/{trace_id} - load trace (auth required) - GET /share/traces/{trace_id} - public read-only redacted view - GET /v1/destinations - list yours - POST /v1/destinations - create - GET /v1/destinations/{id}/health - health snapshot - PATCH /v1/destinations/{id}/mode - flip sandbox / live (soft sandbox) - POST /v1/destinations/{id}/promote-to-live - shortcut, idempotent - GET /v1/projects/me - the caller's live project + soft-sandbox state - PATCH /v1/projects/me/no-route-policy - capture | reject | relay_default - POST /v1/failover/policy - set primary + fallback chain - POST /v1/routing/rules - create routing rule - POST /v1/inspect/tests - mint an inspect address - GET /v1/inspect/tests/{id} - the deliverability report - GET /v1/suppressions - paginated list (filters: source, project_id, q, created_after) - POST /v1/suppressions - manual add (idempotent on (org, email)) - GET /v1/suppressions/{id} - one entry detail - PATCH /v1/suppressions/{id} - edit reason / expires_at - DELETE /v1/suppressions/{id} - remove (idempotent) - POST /v1/suppressions/bulk-import - JSON or text/csv batch (max 50k rows) - GET /v1/suppressions/export - streamed CSV download - POST /v1/ingest/stalwart - Stalwart bounce/complaint webhook (single event or array) - POST /v1/ingest/arf - RFC 5965 ARF complaint report (raw RFC822 body; ISP feedback loops, self-hosted MTAs) - POST /v1/ingest/logs?source=stalwart - JSON Lines log-shipper endpoint (Vector / Filebeat / curl) - POST /v1/ingest/mailenable - MailEnable bounce envelope from the Sync-MailEnableToMailnix.ps1 PowerShell sync script running on the customer's Windows host. HMAC-signed; bounces from postoffices with no mailenable_postoffice_mappings row are dropped when ANY mapping exists for the config (multi-tenant mode), otherwise every event attributes to the config's owning org (single-tenant mode). See docs/MAILENABLE_INTEGRATION_CONCEPT.md §6.5 + §10. - POST /v1/ingest/mailenable/healthcheck - Validate the HMAC secret at install time; used by Install-MailEnableSync.ps1 to fail fast on misconfig. - POST /v1/ingest/activity-logs?source=mailenable - Activity-log envelope from the LogTailer region of Sync-MailEnableToMailnix.ps1. Phase 5 deliverability-analytics channel parallel to the BadMail bounce channel. Successful RCPT TO (2xx) lines become email_events rows with event_type=delivery; 4xx/5xx lines are metric-only (the BadMail channel is canonical for bounces). Synthetic message_id (mailenable-act-<24 hex>) gives automatic dedup on cursor replay. HMAC verifies against the same per-(org, mailenable) bounce_source_configs secret as the bounce channel. v1 supports source=mailenable only; multi-source dispatch keeps the route stable for future Postfix/Exchange shippers. See docs/MAILENABLE_INTEGRATION_CONCEPT.md §6.6 + §8 + §16 Q3 = Option A. - POST /v1/ingest-sources - Mint per-(org, provider) HMAC config (returns secret once). For postfix/dovecot, an optional imap_config {host,port,username,password,mailbox,poll_interval_seconds} puts the source in IMAP DSN-mailbox poll mode: mailnix polls the mailbox and parses RFC 3464 DSNs (no webhook needed). For mailenable, the secret is consumed by the open PowerShell sync script. - GET /v1/ingest-sources - List org's bounce-source configs (imap_mode + imap_host surfaced; credentials never returned) - DELETE /v1/ingest-sources/{id} - Revoke a bounce-source config (idempotent) - POST /v1/mailenable/postoffice-mappings - Register a MailEnable postoffice → org/project mapping (multi-tenant Windows hoster pattern). org_id from caller; bounce_source_config_id must reference a 'mailenable' provider config the org owns. - GET /v1/mailenable/postoffice-mappings?bounce_source_config_id=... - List the calling org's mappings (optional filter by config). - GET /v1/mailenable/postoffice-mappings/{id} - One mapping's detail. - PATCH /v1/mailenable/postoffice-mappings/{id} - Update project_id / default_destination_id (postoffice + config are immutable). - DELETE /v1/mailenable/postoffice-mappings/{id} - Remove (idempotent). - POST /v1/smtp-credentials - Mint an SMTP-relay credential (username + password) for submitting mail to smtp.mailnix.ch. Returns the password ONCE; only the argon2id hash is stored. Optional scopes: allowed_from_domains, default_destination_id, max_rate_per_min, max_message_bytes, expires_at. - GET /v1/smtp-credentials - List org's SMTP credentials (metadata only; never the password or hash) - GET /v1/smtp-credentials/{id} - One credential's detail - POST /v1/smtp-credentials/{id}/rotate - Mint a replacement (new username + password); old one stops authenticating. Returns the new password ONCE. - PATCH /v1/smtp-credentials/{id} - Update settings + constraints (label, VERP / click / open toggles, verp_bounce_domain, AND default_destination_id / allowed_from_domains / max_rate_per_min / max_message_bytes). Partial: only the body's set fields change; default_destination_id:"" clears to org-default; verp_bounce_domain:"" clears the override. - POST /v1/smtp-credentials/{id}/test-connection - Destination-forward preflight (no real send): per-check transcript (credential active, destination resolves, adapter forwards verbatim). - DELETE /v1/smtp-credentials/{id} - Revoke a credential (idempotent) ## Soft sandbox mailnix decides "relay vs capture" at three signals (NOT the recipient domain - the legacy @sandbox.mailnix.ch rule is gone): 1. Project status - anonymous projects always capture, any recipient. 2. Destination mode - destination.mode = 'sandbox' captures; 'live' relays. New destinations default to sandbox; the explicit promote-to-live mutation flips them. Existing destinations were backfilled to 'live' by Migration081. 3. No-route policy - when no destination matches, project's no_route_policy decides: capture (default for new projects) / reject (default for existing) / relay_default (currently behaves like reject). The trace.Send response (REST + MCP) carries an `outcome` field: - relayed - captured.anonymous_project - captured.sandbox_destination - captured.no_route Each captured row records: - capture_reason (one of the three above) - destination_id (which destination would have handled the relay; null for anonymous + no_route) - intended_to / intended_from (recipient + sender as requested) - addresses_seen (every email extracted from headers + body, GIN indexed; powers cross-recipient search) Anonymous projects can now send to any recipient - the capture happens because the project is anonymous, not because the recipient is on a special domain. The MCP `email_send_test` tool description spells this out for AI clients. Capture cap: each project has a monthly_capture_cap (free tier = 5,000). Sends past it return HTTP 402 with error code `quota_exhausted_captures`. The counter rolls inline at the month boundary; no cron required. See https://mailnix.ch/docs/concepts/soft-sandbox for the full contract. ## Virtual inboxes A virtual inbox is a registered recipient pattern in a project that acts as a saved filter + optional webhook + optional email forward over the soft-sandbox `sandbox_messages` capture table. Three pattern shapes: - "*" - catch-all (auto-provisioned per project) - "@example.com" - any address at that domain (case-insensitive) - "user@example.com" - exact address (case-insensitive on both parts) Multi-match is intentional: a captured send lands in every inbox whose pattern matches its addresses_seen + intended_to. ### MCP tools - virtual_inbox_create - register a new inbox (mcp:admin) - virtual_inbox_list - list project inboxes (mcp:read) - virtual_inbox_get - fetch one by id or address (mcp:read) - virtual_inbox_update - mutate name / notify / retention - virtual_inbox_delete - remove (messages remain in sandbox_messages) - virtual_inbox_messages_list - list captures matching the inbox; an unregistered pattern returns found=false + messages=[] (a normal result, not an error) - virtual_inbox_message_get - fetch one capture's full body - virtual_inbox_message_wait - BLOCKING long-poll (load-bearing) - virtual_inbox_messages_clear - not yet implemented (returns cleared=0) ### Agent pattern: wait-for-message test loops Use `virtual_inbox_message_wait` instead of polling. The tool blocks server-side until a matching capture arrives OR timeout_seconds elapses (default 30, max 120). On timeout, returns { timeout: true }; on capture, returns the same shape as virtual_inbox_message_get. One call per test cycle, no busy-loop: agent: virtual_inbox_create(address_pattern="me@mysite.com") → { id: "inbox-uuid" } agent: writes contact-form.html that posts to /v1/messages agent: virtual_inbox_message_wait( id_or_address="me@mysite.com", timeout_seconds=120 ) # blocks here user submits the form; the capture path inserts a row, the in-process pub/sub publishes on the (project, inbox) topic agent's wait unblocks, receives the message synchronously agent → user: "Got it. Subject 'X', body matched. Form is working." ### REST surface (mirrors MCP) GET /v1/inboxes list inboxes (?project= filters to one project) POST /v1/inboxes create (?project= attaches to a project; else caller's live MCP project) GET /v1/inboxes/lookup?address=... by pattern GET /v1/inboxes/{id} detail PATCH /v1/inboxes/{id} update mutable fields DELETE /v1/inboxes/{id} delete GET /v1/inboxes/{id}/messages list captures DELETE /v1/inboxes/{id}/messages clear (not yet implemented) GET /v1/inboxes/{id}/messages/{id} message detail POST /v1/inboxes/{id}/wait?since=&timeout=30 long-poll ### Notify webhooks When `notify_webhook_url` is set, each matching capture fires a single POST to that URL with a Mailnix-Virtual-Inbox-Signature header (Stripe-compatible HMAC-SHA256 format: `t=,v1=."))>`). The shared signing secret is rotatable via the SES_API_VIRTUAL_INBOX_HMAC_SECRET env var; when unset, a per-inbox derived key (sha256("vinbox:" + inbox_id)) signs instead. Delivery is best-effort: single attempt, no retry. For durable customer subscriptions with retry-with-backoff, use the outbound-webhook surface (POST /v1/webhooks). ### Virtual inboxes vs inbound routes Two unrelated systems. Inbound routes handle REAL incoming mail at *@inbound.mailnix.ch (durable, retried). Virtual inboxes observe CAPTURED outbound sends from the soft-sandbox path (best-effort). A real email never lands in a virtual inbox; a captured send never fires an inbound-route webhook. See https://mailnix.ch/docs/virtual-inboxes for the full contract. ## Publishable keys Publishable keys (mnxpub_*) are bearer tokens SAFE to embed in browser JavaScript. Unlike secret keys, every send is gated server-side by five axes: 1. Origin - the request's Origin header must exact-match an entry in the key's allowed_origins. https-only (http://localhost OK for dev). 2. Recipient - every `to` must match an entry in allowed_to. Entries are user@host (exact) or @host (domain wildcard), case-insensitive. 3. Sender - `from` must equal locked_from (case-insensitive). The visitor's email lives in `reply_to`, not `from`. 4. Rate - four sliding-window counters: per-IP-per-hour, per-recipient-per-hour, per-key-per-hour, per-key-per-day. Locked defaults (Normal preset): 10 / 30 / 300 / 1000. Loose = 2× Normal; Strict = 0.3× Normal. 0 disables an axis. 5. CAPTCHA - optional. When captcha_provider is "turnstile" or "hcaptcha", the request must carry an X-Mailnix-Captcha-Token header that verifies upstream. The secret is AES-GCM-encrypted at rest. Attachments are NEVER allowed on publishable-key sends - they're a textbook exfiltration vector CSP can't defend against. Audience is pinned to /v1; mnxpub_ tokens CANNOT drive /mcp (the seeded mailnix-dashboard-public OAuth client's allowed_audiences enforces the same boundary at the AS level). ### Canonical fetch snippet (vanilla JS) Paste into any HTML file. Zero dependencies. This is the deliverable the descoped npm SDK collapses into - the dashboard's create-key modal renders a per-key version with the freshly-minted PUBLISHABLE_KEY embedded.

Note: `to` is the SITE OWNER's address (the form's destination); `reply_to` is the VISITOR's address so the owner can click reply. Don't try to set `from` to the visitor - locked_from forbids it + deliverability suffers when From doesn't match the sending domain. ### Error codes (publishable-key surface on POST /v1/messages) - invalid_key (401) - JWT expired / never minted / signature invalid. - key_kind_mismatch (401) - mnxpub_-prefixed token but issued_via is not dashboard_public (should be impossible in practice). - origin_not_allowed (403) - Origin not in allowed_origins. details.allowed_origins lists the configured set. - recipient_not_allowed (403) - `to` not in allowed_to. details.allowed_to lists the configured set. - from_locked (403) - `from` ≠ locked_from. - attachments_not_allowed (403) - body contains non-empty attachments array. - captcha_required (400) - key has captcha_provider but request has no X-Mailnix-Captcha-Token header. Render the widget. - captcha_failed (400) - verification failed upstream. - captcha_unavailable (503) - upstream CAPTCHA provider unreachable. - captcha_not_configured (503) - deployment's SES_API_PUBLISHABLE_KEY_ENCRYPTION_KEY is unset; this key was configured with a CAPTCHA secret that can't be decrypted. - rate_limit_exceeded (429) - per-IP / per-recipient / per-key cap hit. Response carries Retry-After header + details.retry_after_seconds. - quota_exhausted (429) - daily cap hit. - request_too_large (413) - body exceeds 64 KiB. - feature_unavailable (503) - publishable keys not configured on this deployment. ### Mint a publishable key REST: POST /v1/publishable-keys (api:admin scope). Body is the PublishableKeyRequest schema; response is PublishableKeyWithSecret which includes raw_token ONCE. Subsequent GETs return key_preview only. GraphQL: createPublishableKey(input: CreatePublishableKeyInput!). The dashboard's Settings → API keys "Publishable keys" tab is the typical UI path. MCP: publishable_key_create (claimed project required). Returns a reveal_url rather than the bare token, so the token doesn't sit in agent conversation history; pass the reveal_url straight into a snippet generator and don't echo it. See https://mailnix.ch/docs/publishable-keys for the full contract - security model, schema, rotation, CAPTCHA secret storage. ## Agent end-to-end flow The canonical build loop. An AI agent connected to the MCP server takes a user from "build me a contact form" to "form is live" with at most ONE synchronous human pause: linking the session to an account. That pause disappears entirely when the user chose "Approve as " on the connect consent page (session starts account-bound; skip steps 1-3 below after confirming status=claimed via account_status), and shrinks to one dashboard click when they are signed in at https://app.mailnix.ch/integrations (Attach button on the pending request). The magic-link email always works as the fallback. Everything else flows. The tool-call sequence: 1. account_status - confirm anonymous; remember claim_progress shape 2. account_claim(email) - emails magic link 3. account_status (poll, every 3-10s) - wait for claim_progress.claimed = true 4. email_provider_list_presets + email_provider_connect - destination is auto-created in mode=sandbox 5. email_domain_verify_records → user publishes DNS → email_domain_ verify_status 6. virtual_inbox_create(address_pattern=customer-recipient-email) [optional - the catch-all '*' works too] 7. publishable_key_create(allowed_origins, allowed_to, locked_from) → returns { reveal_url, reveal_expires_in_seconds, key_preview } 8. email_html_snippet_generate(reveal_url=..., to_address=..., from_address=...) → returns { html, ... } ↳ Write .html to a file in the customer's repo (e.g. contact-form.html). The bare key is embedded inside; the agent never reads it. 9. virtual_inbox_message_wait(id_or_address=customer-recipient, timeout_seconds=120) - BLOCKING long-poll 10. Tell the user "open contact-form.html, fill in a test, click Send" 11. (User submits.) The wait tool unblocks with the captured message - surface the subject + body to the user. 12. Once the customer confirms the capture looks right: destination_promote_to_live(destination_id) → mode flips to live; subsequent sends relay through the real provider. Why reveal URLs (step 7 → step 8): - Tool-call results land in long-lived chat logs (Anthropic, OpenAI, Cursor, all of them). A raw mnxpub_… in tool output sits in those logs indefinitely. - The reveal URL has a 5-minute TTL + single-fetch enforcement via SELECT FOR UPDATE on the publishable_key_reveals row, so even if the URL leaks into an unbounded log, the value behind it is dead 5 minutes after mint. - The snippet generator consumes the URL server-side and embeds the bare token into the rendered HTML - the bare token never appears in agent tool-result text. Safety directive (REPEAT FROM PUBLISHABLE_KEY_CREATE / ROTATE): - DO NOT transcribe reveal_url contents into chat text. - DO NOT echo the URL back to the user as raw text. - Pass reveal_url directly to email_html_snippet_generate OR surface as a clickable link the user opens in a browser. Human-readable docs for the pieces above live at https://mailnix.ch/docs. ## Forms (public submission endpoints) mailnix Forms lets a site collect submissions with zero backend code: the form definition lives in mailnix, the page fetches it and POSTs entries to an unauthenticated endpoint keyed by the form's unguessable public id. Enabled by default; the `forms` flag is a per-org kill switch resolved against the form's OWNING org, so a 404 for a published form means the owning org's Forms access was switched off (or the id is wrong). GET /v1/public/forms/{public_id} Published definition JSON: schema (fields + validation), theme, logic, honeypot_key, fill_time_token, optional captcha config, and the thank-you copy. 404 for drafts / paused / archived / unknown. Schema extras the renderer honors: schema.layout ({"version":1, "rows":[{"fields":["first","last"],"spans":[6,6]}]}) places two or more same-step fields side by side on a 12-column grid row (spans optional, equal split; sum at most 12; rows collapse to one column on narrow screens, and a field hidden by logic vacates its columns without leaving a hole); per-field width tokens (full|half|third|two_thirds) remain for simple side-by-side flow outside grid rows; image fields (https src) display inline, e.g. product photos; page_break fields split the form into steps rendered with a numbered progress stepper (the break's label names the step that starts below it). Theme tokens (mode, hex colors, radius, font) apply on EVERY tier. POST /v1/public/forms/{public_id}/submissions Body: {"data": {"": , ...}} (a bare field map also works). Echo the definition's fill_time_token under the key "_mnx_ft" and leave the honeypot key ("_mnx_hp") EMPTY. Optional captcha token via the X-Mailnix-Captcha-Token header or "captcha_token" in the body. Returns 201 {"id", "message"}. Forms with file_upload fields (paid plans) take multipart/form-data instead: one part named "data" carrying the JSON body above, plus one file part per file_upload field named by its field key. Content types are sniffed server-side; only images (PNG, JPEG, GIF, WebP) and PDF are accepted, 10 MiB per file, max 5 file parts. Every stored file is virus-scanned before it becomes downloadable; infected uploads are deleted. Error codes: form_not_found (404), origin_not_allowed (403), malformed_submission (400, details.fields lists per-field problems), file_uploads_unavailable (400), rate_limit_exceeded / quota_exhausted (429 + Retry-After), captcha_required / captcha_failed (400), captcha_unavailable (503), request_too_large / file_too_large (413), upload_failed (503). Consumption modes (pick the lightest that fits; all fetch the definition live, so publishing an edit updates every embed): Hosted page: https://forms.mailnix.ch/f/{public_id} Zero integration; shareable link / QR. Strict CSP, no third-party scripts except a CAPTCHA provider the owner opted into. Iframe embed: