mailnix / Publishable keys (browser-safe sending)

    Sending

    Publishable keys (browser-safe sending)

    mnxpub_ tokens you can paste straight into client-side JavaScript. Every send is gated server-side on five axes.

    What it is

    Publishable keys (mnxpub_*) are bearer tokens safe to embed in browser JavaScript. Unlike secret keys, every send is constrained server-side by five orthogonal axes. 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 the MCP server; the seeded mailnix-dashboard-public OAuth client's allowed_audiences enforces the same boundary at the AS level.

    The five-axis server-side gate

    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 goes 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. With captcha_provider = "turnstile" or "hcaptcha", the request must carry an X-Mailnix-Captcha-Token header that verifies upstream. The secret is AES-GCM-encrypted at rest.

    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.

    <form id="contact">
      <input name="email" type="email" placeholder="Your email" required>
      <input name="subject" placeholder="Subject" required>
      <textarea name="message" placeholder="Your message" required></textarea>
      <button type="submit">Send</button>
      <p id="status"></p>
    </form>
    
    <script>
    const PUBLISHABLE_KEY = "mnxpub_…"; // paste from the dashboard
    
    document.getElementById("contact").addEventListener("submit", async (e) => {
      e.preventDefault();
      const form = new FormData(e.target);
      const status = document.getElementById("status");
      status.textContent = "Sending…";
    
      try {
        const resp = await fetch("https://api.mailnix.ch/v1/messages", {
          method: "POST",
          headers: {
            "Authorization": "Bearer " + PUBLISHABLE_KEY,
            "Content-Type":  "application/json",
          },
          body: JSON.stringify({
            to:       ["me@mysite.com"],            // must match allowed_to
            from:     "noreply@mysite.com",          // must equal locked_from
            reply_to: form.get("email"),             // visitor's address
            subject:  form.get("subject"),
            text_body: form.get("message") + "\n\n- from " + form.get("email"),
          }),
        });
    
        if (resp.ok) {
          status.textContent = "Sent. Thanks!";
          e.target.reset();
        } else {
          const err = await resp.json();
          status.textContent = "Couldn't send: " + (err.error?.message || resp.statusText);
        }
      } catch (err) {
        status.textContent = "Network error: " + err.message;
      }
    });
    </script>

    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, and deliverability suffers when From doesn't match the sending domain.

    Error codes (POST /v1/messages, publishable-key surface)

    • invalid_key (401): JWT expired, never minted, or 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.
    • from_locked (403): fromlocked_from.
    • attachments_not_allowed (403): body contains non-empty attachments array.
    • captcha_required (400): key has captcha_provider but the request has no X-Mailnix-Captcha-Token header.
    • captcha_failed (400): verification failed upstream.
    • captcha_unavailable (503): upstream CAPTCHA provider unreachable.
    • rate_limit_exceeded (429): per-IP / per-recipient / per-key cap hit. Response carries Retry-After and 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 with raw_token exposed once. Subsequent GETs return key_preview only.
    • GraphQL: createPublishableKey(input: CreatePublishableKeyInput!).
    • Dashboard: Settings → API keys → "Publishable keys" tab.

    Reveal-URL flow (MCP)

    publishable_key_create in the MCP surface returns a reveal URL rather than the bare token. The URL has a 5-minute TTL and single-fetch semantics. Pass it directly to email_html_snippet_generate, which consumes it server-side and embeds the bare key into rendered HTML; the token never appears in agent chat history.

    Safety directive

    Don't transcribe a reveal URL into chat. Don't echo it as raw text. Don't fetch it client-side. Hand it to the snippet generator or surface it as a clickable link the user opens in their browser.