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
- Origin. The request's
Originheader must exact-match an entry in the key'sallowed_origins. https-only (http://localhostOK for dev). - Recipient. Every
tomust match an entry inallowed_to. Entries areuser@host(exact) or@host(domain wildcard), case-insensitive. - Sender.
frommust equallocked_from(case-insensitive). The visitor's email goes inreply_to, notfrom. - 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.
- CAPTCHA. Optional. With
captcha_provider = "turnstile"or"hcaptcha", the request must carry anX-Mailnix-Captcha-Tokenheader 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 butissued_viais notdashboard_public(should be impossible in practice).origin_not_allowed(403): Origin not inallowed_origins.details.allowed_originslists the configured set.recipient_not_allowed(403):tonot inallowed_to.from_locked(403):from≠locked_from.attachments_not_allowed(403): body contains non-empty attachments array.captcha_required(400): key hascaptcha_providerbut the request has noX-Mailnix-Captcha-Tokenheader.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 carriesRetry-Afteranddetails.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:adminscope). Body is thePublishableKeyRequestschema; response isPublishableKeyWithSecretwithraw_tokenexposed once. Subsequent GETs returnkey_previewonly. - 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.