Forms, messages and alerts
The site forms post to Markstage on your own host: spam checks, limits, team mail and thank-you mail, the Inbox for messages and who hears about which alerts.
Part of the API contract (docs/API.md: its conventions, prefix and errors apply here). Split out of it to keep each file readable: who hears about what (the alert rules and each person's choices), the site's forms, and the Inbox.
Alerts: who hears about what
The site's rules (Settings → Alerts) say per kind whether it is on, which roles and people it reaches (besides the
people it is about, such as who started the job), extra addresses, and the channels it leaves by (email, slack,
webhook); the editor always shows it. Each person then chooses per kind that can reach them (My alerts): on or off,
and now (mail at once), digest (one mail a day at notify.digestHour, server time) or editor (only in the
editor). A person's default is now when their role is in notify.email.roles (who got alert mail before there were
rules), else editor. Safety kinds (backup.failed, user.invited, user.role, env.rules-changed, signin.new-device) are always
on and always go by mail; a person chooses only now or digest. A rule's extra addresses default to
notify.email.to, except for a personal kind (a sign-in, a message given to you). Mail comes from the rules' sender
name (else the name in notify.email.from) at notify.email.from's address; the subject keeps the site's name.
GET site/alerts(editor and above) →{version, digestHour, channels: {email: {on, from, senderName, host, status, detail, lastAt}, slack: {on, host, status, detail, lastAt}, webhook: {…}}, kinds: [{kind, label, group, safety, on, roles, users: [{id, name}], extra, channels}], people: [{id, name, role}]}. A channel not set up is{on: false};statusis how its last send went (ok,erroror null),detailits answer.PUT site/alerts {version, senderName?, kinds: [{kind, on?, roles?, userIds?, extra?, channels?}]}(owner, administrator): only the kinds and keys sent change.409 stalewithcurrentwhenversionis stale;400in plain words for an unknown kind or role, a person who is not a user, more than 20 addresses or one that is not an address, a sender name with< > @ " , ; :, or a safety kind turned off or out of mail. Kept in<dataDir>/alert-rules.json(0600; addresses never go in the repo). Auditedsite.alerts{changed: [{kind, from, to}], senderName?: {from, to}}→ the view pluschanged: [kinds, 'senderName'].GET me/alerts(anyone signed in) →{version, digestHour, kinds: [{kind, label, group, on, delivery, locked}]}, the kinds that can reach this person (by role, by name, or about them);PUT me/alerts {version?, kinds: [{kind, on, delivery}]}→ the view pluschanged(409 stale;400for a safety kind off oreditor; off keeps the delivery). Auditedme.alerts{changes: [{kind, from, to}]}(tois a delivery oroff).
POST site/notify/test {channel?} (owner, administrator) sends a test alert through that channel (or every one):
{channels: [{channel: "email"|"slack"|"webhook", ok, detail}]} (detail: "sent to …", "answered 200", or what went
wrong in plain words). 400 for an unknown channel; 409 not-set-up without notify or that channel. Audited
(notify.test). GET site/health says which are set up: alerts: {email, slack, webhook}.
Forms and the Inbox
The site's forms post to Markstage on the site's own host names (config.json siteHosts), never on the CMS host;
there the rest of Markstage answers 404. Each form is described in the site's markstage.config forms (or
config.json forms; docs/CONFIG.md): its fields, the visitor context kept beside them, the spam checks, the daily
limit and the mail. Messages are kept in the data dir, never in the content repo.
POST <basePath>/forms/<id> (site host; form-encoded or JSON, at most 64 KB):
- Origin must be
https://one ofsiteHosts(403otherwise, auditedsecurity.origin). - Limits: at most
perDay(3) messages a day from one address or for one email address, any case (429{status: 429, error: limitMessage}). The address is the connection's, or the hop a trusted proxy appended (trustProxy); a postedipis ignored. Spam counts against a cap of 20 a day from one address and 60 a day from one network (an IPv4 /24, an IPv6 /64). - A flood: past
perHour(300) real messages (spam never counts) in an hour, the form is not turned off: a message is stored with statusheld, the visitor is answered as usual, nobody is mailed and nothing goes to the CRM yet, and the administrators get aform.heldalert (once an hour per form, auditedforms.held). Every minute, held messages go out oldest first while the hour is under the cap again (statusnew, the team mail, the thank-you, the CRM and the lead as for any message, "let through" in the history). Nobody can setheldby hand; a person may still mark a held message handled or spam. - Spam: a filled-in
honeypotfield, or astartedtime (ms) less thanminSeconds(3) ago: kept with statusspamand answered like a real message (200), so a bot learns nothing; nobody is told or mailed. - Fields: only the form's own fields are kept (anything else posted is dropped). Text loses control characters,
one-line fields lose line breaks;
required,min,max,type(email,tel: 7 to 15 digits,url: http(s)) andoptionsare checked:400 {status: 400, error, fields: {key: message}}. - Then: the message is stored, and only then
200 {status: 200, message}(a page with no script,Sec-Fetch-Mode: navigate, gets303tothanks): the answer never waits on mail. Editors and above get aform.messagenotice, and the alert channels carry it: the email is the team mail (every field and where the visitor came from,Reply-Tothe visitor, subjectteam.subject). Withreply(or one set in Settings → Forms, below), the visitor gets the thank-you mail throughnotify.email, from the alerts' sender name (greeted by name unless the name looks like a link or an address). A team mail that fails is kept on the message (alert: 'failed', the reason, its history) and tried again after 5, 15, 60, 240 and 720 minutes, then daily, 12 tries in all; Site health counts them (forms: {alertFailed}). - Place: with a city database on the server (docs/DEPLOY.md "Forms"),
meta.locationis the approximate place of the visitor's address ("Toronto, Ontario, Canada"), looked up when the message comes in; never what was posted. Its parts (city, region, country code) are kept beside it for a CRM's own fields (not in the API). - CRM: a form with
crm(docs/CONFIG.md) also sends each message to Odoo as a lead, after the answer (below, "Odoo"). Spam never goes, and nor does anything outside production. - Retention (hourly): messages are kept forever unless the form sets
keepDays; spam goes after 30 days, and the visitor's address and browser are cleared after 30 days (forms.retentionin the audit with the counts).
The Inbox: every message from the site's forms and the viewer's own alerts in one list (editor and above; owner and
administrator also delete, export and see addresses). A message is new, handled, spam or held (by the server, during a flood), can be given to a
person, and gets notes for the team; an alert is new until seen (handled).
GET inbox?q=&kind=message|alert&status=new|handled|spam|held&form=&service=&assignee=<user id>|none&alert=failed&crm=failed&from=&to=&page=→{items, total, page, pages, perPage: 50, facets: {forms: [{id, label, count}], services: [{value, count}], status: {new, handled, spam, held}, alertFailed, crmFailed}, credits}, newest first.credits: the line the city database asks to show with its places (DB-IP's: "IP Geolocation by DB-IP (https://db-ip.com), CC BY 4.0"), or null. A message item:{id, kind: 'message', at, status, form, formLabel, from: {name, email}, service, summary, assignee: {id, name} | null, imported: {from: 'wordpress', id} | null, alert: null | 'pending' | 'sent' | 'none' | 'failed', crm: null | {state: 'pending' | 'sent' | 'failed', name: 'Odoo', leadId, error}}(error: why, when failed); an alert:{id, kind: 'alert', at, status, alertKind, level, link, summary, assignee: null}. Spam is left out unless asked for;form,service,assignee,alert,crm,status=spamandstatus=heldlist messages only.qfinds text in the name, email, fields and context, and in an alert's words (%and_are literal).fromandtoare days (2026-10-28, UTC).400in words for anything else. Theform.messagealerts are the messages themselves, so they are not listed twice.GET inbox/:id→ the item plusfields: [{key, label, type, value}](in the form's order; a field the form no longer has under its own name),meta: {page, referrer, landing, referringDomain, utmSource, utmMedium, utmCampaign, location}(owner and administrator also getipanduserAgent),spam,alertTries,alertError,alertNextAt,crmTries,crmError,crmNextAt,crmAt,notes: [{id, at, by: {id, name}, text}],history: [{at, by: {id, name} | null, change}]andversion(r<n>; an alert:seenorunseen).404for another person's alert.PATCH inbox/:id {status?, assigneeId?, version}:409 stalewithcurrentwhen someone changed it meanwhile.assigneeIdis an active editor, administrator or owner (ornull); they get aform.assignedalert. Each change goes in the history ("marked it handled", "gave it to Sam") and is auditedinbox.change{form, status?: {from, to}, assignee?: {from, to}}, never the message text. On an alert onlystatus(seen or not); giving it or spam is400.POST inbox/:id/notes {text}(at most 5000 characters) →201 {id, at, by, text}; auditedinbox.note.POST inbox/:id/crm("Send to Odoo again"; editor and above): one try now → the message, or502 crm-failed{message: "Not sent: …", item}.409 sentwhen it is in Odoo already,409 not-for-crmfor a message its form did not send (or that came from WordPress: old leads are never sent again). Auditedinbox.crm{form, lead}withok.DELETE inbox/:id(owner, administrator; with its notes and history): auditedinbox.delete{form, at, status}.GET inbox.csv?…the same filters(owner, administrator): every matching message (inbox.csv), columnsreference, form, at, status, assigned to, one per field, the context values,notes,imported from,imported id; a cell starting with=,+,-or@is made text. Auditedinbox.exportwith the count.
The thank-you mail (Settings → Forms; owner, administrator): the site's forms.<id>.reply gives the first words; a
change here is kept in <dataDir>/forms-settings.json (0600) and wins until it is put back. GET site/forms →
{version, siteName, variables, forms: [{id, label, canReply, reply: {subject, text} | null, fromConfig, changed}]};
PUT site/forms/:id/reply {version, on, subject, text} (on: false: none) and DELETE site/forms/:id/reply
{version} (back to the site's own): 409 stale, 404 for no such form, 400 in words; audited forms.reply.
Old WordPress contact messages come in with markstage-server forms import-wordpress (docs/DEPLOY.md "Forms").
They are never sent to Odoo: WordPress kept no record of which went (its wp_contact table has no such column).
Odoo
A form with crm: {kind: 'odoo', source, …} (docs/CONFIG.md "Forms") sends each message to Odoo as a lead, the way
the old WordPress theme did (functions.php odooInsert), over XML-RPC (src/forms/crm.mjs, xmlrpc.mjs):
/xmlrpc/2/commonauthenticate(db, email, password, {})→ the user id./xmlrpc/2/objectexecute_kwsearchonutm.source(name=crm.source),res.country(code= the visitor's country code) andres.country.state(name= the visitor's region): the first id, orfalse. They are looked up, never created, as before. No place: no country or state search.crm.leadcreatewith the old theme's keys and values:
| Odoo field | Value |
|---|---|
name, contact_name | the form's name field |
email_from | the form's email field |
phone | crm.phone (default the phone or number field) |
mobile_country_code | the visitor's country code from their address (the city file), in capitals, e.g. CA; nothing found: empty |
description | crm.description (default the first long text): tags out, on one line, HTML-escaped (WordPress's sanitize_text_field then esc_html) |
country_id, state_id, source_id | the ids found above, or false |
city | the visitor's city from the city database (empty without one) |
url, referred, landing_url, refering_domain | meta.page, meta.referrer, meta.landing, meta.referringDomain |
is_auto_created | true |
Left out on purpose: the file attachment (the old handler attached a server file named in the request) and its
res.users search (its answer was never used). Ids go as numbers and a missing one as false; the old code sent
the source id as text and a missing id as 0 or "", which Odoo reads the same way.
Leads for analytics (Skald)
A form with leads: {kind: 'skald', url, …} (docs/CONFIG.md "Forms") also tells an analytics tool about each real
message, as a lead, so the tool can count it and join it to the visit (src/forms/leads.mjs). Skald only for now,
signed exactly as Skald's own skald-lead.cjs signs it:
POST <leads.url>(Skald's lead hook,https://app.skaldanalytics.com/traffic-hooks/<site>/lead), JSON, withX-Skald-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>.- The lead:
external_ref(the message's id, so a repeat is never counted twice),action(leads.action, defaultcontact),emailandname(the form's fields),ip(the visitor's address as Markstage recorded it: the connection, or the hop a trusted proxy appended; Skald looks up the country and drops it),vid(the visitor id the page posted inleads.vid, Skald'sskald('id'), when it is one),test: true(when the page posted1inleads.test: a team's own test, which Skald keeps out of its counts).
Spam (the honeypot, a form sent too fast) never goes, and nothing goes outside production. It runs after the message
is stored and the visitor answered; a send that fails is tried again like the Odoo push (5, 15, 60, 240, 720 minutes,
then daily; given up after 12 tries). Each outcome is in the message's history ("sent to Skald as a lead", "not sent
to Skald as a lead: …"), GET /inbox/:mid has lead (how it went), and Site health counts the ones that did not
go. The secret is read from the environment at each send (SKALD_LEAD_SECRET; leads.env picks another prefix) and
is never written, logged or audited.
test/odoo.test.mjs reads the keys out of a verbatim copy of the theme's $leadData array
(test/odoo-lead-data.php.txt) and checks that the lead the stand-in Odoo gets has those keys and no others, so a
field left out or renamed fails it.
When: after the message is stored and the visitor answered, never in the way of it. It is marked pending, then
sent with the lead's id (crm.leadId; history "sent to Odoo (lead 123)"), or failed with the reason in words
(crmError), tried again after 5, 15, 60, 240 and 720 minutes, then daily, 12 tries in all (history "not sent to
Odoo: …", then "gave up…"); "Send to Odoo again" tries at once. Site health: forms.crm: {forms: [{form, label, kind,
name, on, problem}], failed}.
In the editor's Inbox: a "Not sent to Odoo" mark on the message (why on hover or focus, with a copy icon), the
filter #/inbox?crm=failed with its count ("2 messages not sent to Odoo"), and in the message's drawer
(&open=<id>) its tries, when it is tried next, why, and "Send to Odoo again", which shows the server's words when it
is refused (502 "Not sent: …", 409 "It is in Odoo already (lead N)."). Proof: e2e/inbox-odoo.e2e.mjs.
The connection is read from the environment at each push, never from a file in the repo: <env>_URL (https; http
only on this machine; no sign-in in it), <env>_DB, <env>_EMAIL, <env>_PASSWORD, and <env>_ENABLE = true
(env defaults to ODOO: the old theme's names). Without _ENABLE=true nothing is marked or sent, as before;
anything else missing fails in words that name the variable. The values are never logged, audited, kept or
answered: an error that quotes one has it replaced by [hidden].
Source: docs/API-INBOX.md on GitHub. Something unclear? Open an issue.