Contact us enquiries
POST /admin/v1/contact sends a "Contact us" enquiry (upgrade / enterprise / general contact) from
the AI Gateway app from the server, by email, to a Myra contact mailbox. It is the server-send
backing the Billing & Payments Contact us tab and the Discuss requirements dialog,
replacing the old mailto: links that did nothing where the browser had no mail handler configured.
It is a sibling of the Contact Support feedback type and reuses the same mail machinery (message composition, header/line sanitisation, the shared per-user + per-tenant rate window). It differs in one way: the send is synchronous and its outcome is returned, so the form can show the user success or a retryable error.
Authorization
The endpoint is gated on the SUPPORT_CONTACT permission — the same gate as the feedback
"Support" option. It is a tenant_admin gate-class permission (held by admin and tenant_admin
by default, and delegable by a tenant admin to a custom role). Everyone else is rejected 403.
The client hides the form from non-holders (they see "Please talk to your organisation
administrator." instead), but that is a UX affordance only — the server is the boundary.
- No session →
401. - Authenticated without
SUPPORT_CONTACT→403(no mail is sent).
Recipient (server config only)
The recipient is the contact_email setting, falling back to the built-in default
mail@myrasecurity.com when no row is set. It is resolved on the server and re-validated; it is
never taken from the request and never serialised into any response. A malformed configured
value fails closed — the send is refused with 502 contact_unavailable rather than mailing an unknown
destination. (The contact mailbox must be provisioned by IT/Ops before go-live; until it exists the
relay rejects and each send fails with a surfaced, retryable error.)
Accepted body
Every field is untrusted and validated fail-closed. message is required and must be a
substantive enquiry (at least 15 characters after trimming); name / email / url are optional
(name and email fall back to the signed-in account). A JSON null or a whitespace-only value
counts as absent (not malformed) — and for message, an absent value is rejected exactly like a
too-short one (AGF-3115: the form previously accepted an empty enquiry, which bot signups abused to
spam the contact mailbox).
| Field | Accepted | Notes |
|---|---|---|
name |
A string (≤ 200 bytes after scrubbing). Absent → the signed-in account name is used. | Display only — see Identity below. A non-string (number, boolean, object) is rejected 400 invalid_contact_field. |
email |
A single valid address local@domain.tld (≤ 254 bytes). Absent → the signed-in account email is used. |
Display / listed contact detail only. A present but malformed value (bad shape, a control byte, or a non-string) is rejected 400 invalid_contact_email — it never degrades to the absent-case fallback. |
message |
A string, required, at least 15 characters (codepoints) after trimming and ≤ 8000 bytes after scrubbing. | The enquiry body. Absent / whitespace-only / shorter than the minimum is rejected 400 contact_message_too_short (AGF-3115). The minimum is measured in codepoints, so a multibyte-language enquiry is not penalised. |
url |
A string (≤ 1024 bytes). | The page the enquiry was sent from. |
ABSENT and MALFORMED are different answers: an absent email falls back to the account email (a
legitimate, defaulted shape), while a present-but-invalid email is rejected — sending garbage can
never make the request proceed with a substituted value.
Identity (not client-forgeable)
The authoritative identity in the email — the From: line, the Reply-To, and the audit row —
is the signed-in account (or, under impersonation, the acting admin), never client-supplied text.
Any name / email the user typed into the form is carried as a separate, clearly-labelled
"Contact details the user entered" line in the body. A holder therefore cannot forge the reply target
or the apparent sender by editing those fields.
Rate limit
Charged against the shared support-egress budget (the same window as the feedback "Support"
type): 3 per user per hour and 10 per tenant per hour. Over the cap →
429 support_rate_limited. The window is consulted after authorization, so an unauthorized caller
cannot burn a tenant's budget from behind a 403. A failed send (contact_send_failed /
contact_unavailable) still spends a slot — a failed attempt opens a relay session, the exact abuse
the window bounds — so "retryable" is bounded by the hourly cap, not unlimited.
Responses
| Status | Body | When |
|---|---|---|
200 |
{ "ok": true, "id": "<contact_id>" } |
The email was handed to the relay. |
400 |
{ "error": …, "code": "invalid_contact_email" } |
email is present but not a valid address. |
400 |
{ "error": …, "code": "invalid_contact_field" } |
name / message / url is a non-string. |
400 |
{ "error": …, "code": "contact_message_too_short" } |
message is absent, blank, or shorter than 15 characters. |
403 |
{ "error": "forbidden" } |
Caller lacks SUPPORT_CONTACT. |
401 |
— | No session. |
429 |
{ "error": …, "code": "support_rate_limited" } |
The support-egress window is spent. |
502 |
{ "error": …, "code": "contact_send_failed" } |
The relay rejected the message or the send threw. The failure is logged and audited (delivered=false); the user can retry. |
502 |
{ "error": …, "code": "contact_unavailable" } |
The configured recipient is invalid, so nothing was sent. |
Branch on the top-level code, never on the English error prose (see Error codes).
Egress audit and retention
Every attempt writes an audit_log row (entity_type = "contact_request", action =
"contact_request.emailed") recording the recipient, the delivered flag, the acting user (and, under
impersonation, whom it was on behalf of) and the tenant — metadata only, never the message
content. The enquiry content is not persisted (this endpoint keeps no durable row): a transient
contact enquiry that fails to send is surfaced to the user to retry, and the attempt is logged and
audited. (A data-subject request keys on audit_log.actor_id / tenant_id.)