Meridian handbook
For agentsRecipes

Enquiry form

How an agent adds a contact form or a form about one listing to a Meridian site with dev endpoint add, writes the response, error and email templates the endpoint names, places the form on a page, and verifies it with check, render and a scripted post.

A form is a declared endpoint: a path, its inputs with their rules and labels, spam defences, and the steps run on a valid submission (store it, send mail, subscribe). No code is written. The form a visitor sees is drawn from the declaration, with labels, errors and CSRF handling.

What happens when a visitor posts the form:

sequenceDiagram
  participant V as Visitor
  participant E as Engine
  participant M as Mail queue
  V->>E: GET /contact (the page with the form)
  E-->>V: page, form drawn from endpoints.yaml
  V->>E: POST /contact (or /cy/contact)
  E->>E: rate limit, CSRF, spam defences
  alt input invalid
    E-->>V: on_error: the form again, with errors
  else input valid
    E->>E: store: the enquiry
    E->>M: mail: contact-to-staff
    E->>M: mail: enquiry-receipt
    E-->>V: fragment: enquiry-sent.html
  end

Declare the endpoint

engine-dmo dev endpoint add contact --apply
# config/sites/main/endpoints.yaml
endpoints:
  - key: contact
    id: end_4n96ztbdf6
    path: /contact
    methods: [post]
    access: public
    rate_limit: 5/hour/ip
    spam: { proof_of_work: true, honeypot: true, min_fill_time: 3s }
    input:
      name:    { type: text, max: 120, required: true, autocomplete: name, label: { en: Your name } }
      email:   { type: text, format: email, required: true, autocomplete: email, label: { en: Your email address } }
      message: { type: text, multiline: true, max: 2000, required: true, label: { en: Your message } }
    steps:
      - store: { as: enquiry, retention: 90d }
      - mail:  { template: enquiry-receipt, to: $email }
    respond:
      fragment: partials/enquiry-sent.html
      on_error: partials/enquiry-form.html

The recipe (--from recipe:enquiry, the default) writes the endpoint even though the templates it names do not exist yet, and says so: the recipe's templates (partials/enquiry-sent.html and the rest) are the site's to write. check fails until you write them, reporting each at the endpoint’s line with column 0:

endpoint "contact" responds with partials/enquiry-sent.html, which no template root has
endpoint "contact" responds with partials/enquiry-form.html, which no template root has
endpoint "contact" mails enquiry-receipt, which is not under templates/emails/

“Under templates/emails/” means an emails/ folder in any template root on the site’s chain: templates/_shared/emails/ for every site, or templates/<site>/emails/ for one. This recipe uses _shared.

The endpoint’s path: /contact and a page with the slug contact share an address on purpose: the engine answers a GET with the page and a POST with the endpoint. In another locale the form posts to the endpoint’s path under that locale’s prefix (/cy/contact) whatever the page’s own slug there (/cy/cysylltu); enquiryForm() and render() write that action for you.

To send each enquiry to staff too, add a second mail step to an address in the site’s settings:

    steps:
      - store: { as: enquiry, retention: 90d }
      - mail:  { template: contact-to-staff, to: settings.contact_inbox }
      - mail:  { template: enquiry-receipt, to: $email }

A mail step’s reply_to: does not set the message’s Reply-To header: it only passes the address to the template as replyTo, and the header is always the site’s or the install’s mail.reply_to. Staff cannot answer the visitor by replying, so the staff email prints the visitor’s address as a link. A mail step’s subject: is not used either; the subject comes from the template’s subject block.

# config/sites/main/site.yaml
settings:
  contact_inbox: hello@example.org

Write the templates

{# templates/_shared/partials/enquiry-sent.html: replaces the form after a good post #}
<div id="form-{{ endpoint.key }}" role="status" tabindex="-1">
  <h2>{{ t('contact.sent.title') }}</h2>
  <p>{{ t('contact.sent.body') }}</p>
</div>
{# templates/_shared/partials/enquiry-form.html: the form again, with errors, after a bad post #}
{{ enquiryForm(endpoint.key) }}

The root element of both partials must have the id form-<endpoint key>: an in-place post replaces the form by that id. The drawn form already has it, and endpoint.key lets these two partials serve every endpoint, the listing form below included.

What a response partial sees is the base page context plus errors, values and endpoint, and after a success submission. It has no entry, no page values and no type variable such as listing: the post is not a page view, so the page it makes has the site’s name as its title, no canonical and, on a plain post, no h1 of yours.

{# templates/_shared/emails/enquiry-receipt.html #}
{% extends "emails/_layout.html" %}
{% block subject %}We have your message{% endblock %}
{% block preheader %}A copy of what you sent us.{% endblock %}
{% block body %}
<h1>Thank you, {{ name }}</h1>
<p>We will reply to {{ email }} as soon as we can.</p>
<blockquote>{{ message }}</blockquote>
{% endblock %}
{# templates/_shared/emails/contact-to-staff.html #}
{% extends "emails/_layout.html" %}
{% block subject %}New message from {{ name }}{% endblock %}
{% block preheader %}A message from the contact form.{% endblock %}
{% block body %}
<h1>New message from {{ name }}</h1>
<p>Reply to <a href="mailto:{{ email }}">{{ email }}</a>.</p>
<blockquote>{{ message }}</blockquote>
{% endblock %}

An email template sees the submitted inputs by name, plus site.name, site.url, recipient and locale (the locale’s code as a string). emails/_layout.html is the engine’s own layout. Both emails are written in the locale of the page the form was posted from, the staff one included.

Emails in more than one locale

Email templates have no t(): a call prints nothing, and check does not flag it. On a multilingual site, branch on locale inside each block:

{# templates/_shared/emails/enquiry-receipt.html #}
{% extends "emails/_layout.html" %}
{% block subject %}{% if locale == "cy" %}Mae eich neges gyda ni{% else %}We have your message{% endif %}{% endblock %}
{% block preheader %}{% if locale == "cy" %}Copi o'r hyn a anfonoch.{% else %}A copy of what you sent us.{% endif %}{% endblock %}
{% block body %}
{% if locale == "cy" %}
<h1>Diolch, {{ name }}</h1>
<p>Byddwn yn ateb {{ email }} cyn gynted ag y gallwn.</p>
{% else %}
<h1>Thank you, {{ name }}</h1>
<p>We will reply to {{ email }} as soon as we can.</p>
{% endif %}
<blockquote>{{ message }}</blockquote>
{% endblock %}

Check what an email renders without a running server: engine-dmo mail test --template enquiry-receipt --locale cy --to you@example.org. With the development log driver it sends nothing and prints the subject; an empty subject fails with no subject.

Put the form on a page

Let editors choose the form with a form field on the page type:

engine-dmo dev field add page contactForm --type form --label "Form on this page" --apply
{# templates/_shared/page/entry.html #}
{% extends "base.html" %}
{% block body %}
<h1>{{ entry.title }}</h1>
{% if entry.contactForm %}
<section aria-labelledby="contact-heading">
  <h2 id="contact-heading">{{ t('contact.title') }}</h2>
  {{ entry.contactForm.render() }}
</section>
{% endif %}
{% endblock %}
# config/strings/en.yaml
strings:
  contact.title: Send us a message
  contact.sent.title: Thank you
  contact.sent.body: We have your message and will reply soon.
  form.contact.submit: Send
  form.leave.blank: Leave this field empty

form.<key>.submit is the button text; form.leave.blank labels the hidden honeypot field (without it the label prints as its key). Seed the contact page with the endpoint’s id, which engine-dmo describe project prints:

# seed/documents/page/contact.yaml
locales:
  en:
    title: Contact us
    slug: contact
    contactForm: end_4n96ztbdf6
publish: [en]

A template that always shows the same form can print {{ enquiryForm('contact') }} instead of using a field. check does not check the key passed to enquiryForm(): a wrong one prints nothing, so render the page. enquiryForm() has no render(), whatever template api says: print it as it is.

The drawn form has text, email, URL and telephone inputs, text areas and checkboxes. It does not draw a select or radio buttons from an input’s options; for those, write the form’s markup yourself with csrfInput() (Forms in templates).

A form about a listing

A form that asks one business a question has a subject: the endpoint takes the document from its path. A form field cannot draw one, so print enquiryForm() with the subject, on engine-dmo:

engine-dmo dev endpoint add enquire --subject listing --apply
# config/sites/main/endpoints.yaml (written)
  - key: enquire
    id: end_ns1qvdwjgx
    path: /enquire/{listing}
    methods: [post]
    access: public
    rate_limit: 5/hour/ip
    spam: { proof_of_work: true, honeypot: true, min_fill_time: 3s }
    input:
      listing: { type: relation, to: [listing], from: path, required: true }
      name:    { type: text, max: 120, required: true, autocomplete: name, label: { en: Your name } }
      email:   { type: text, format: email, required: true, autocomplete: email, label: { en: Your email address } }
      message: { type: text, multiline: true, max: 2000, required: true, label: { en: Your message } }
    steps:
      - store: { as: enquiry, retention: 90d, subject: $listing }
      - mail:  { template: enquiry-receipt, to: $email }
    respond:
      fragment: partials/enquiry-sent.html
      on_error: partials/enquiry-form.html

Keep on_error. An endpoint that takes a value from its path is not treated as a form, so with no on_error a failed post answers 422 with the fragment, and the visitor reads “Thank you” over a message that was never sent. The partial has no listing to pass, and needs none: {{ enquiryForm(endpoint.key) }} in an answer to a post takes the address from the post that failed, and draws the errors and a fresh challenge.

{# in templates/_shared/listing/entry.html #}
<section aria-labelledby="enquire-heading">
  <h2 id="enquire-heading">{{ t('listing.enquire') }}</h2>
  {{ enquiryForm('enquire', entry) }}
</section>

Add listing.enquire, form.enquire.submit, form.leave.blank and form.problems (the heading of the error summary, which prints as its key without it, plural-aware with {count}) to your strings. To reach the business itself, add share_with_organisation: true, the mail step to $listing.organisation.enquiryContacts and the analytics step that Directory shows. See also Forms in templates.

Verify

engine-dmo check
engine-dmo render /contact --json

The rendered form has id="form-contact", a labelled input per declared input with required and autocomplete, the hidden honeypot, a _csrf field and the submit button, and a11y is empty.

The _csrf value is empty there, and it stays empty on a running server for an anonymous visitor, with no cookie set. The token exists only for a signed-in member or staff user, whose page has one bound to their session. An anonymous post is checked by where it comes from instead: a post with a cross-site Origin answers 403, and one from a script with neither header passes that check and goes on to validation.

Post it from a script

A submission needs a running server (engine-dmo dev up). A plain curl of the fields does not get through with this endpoint’s spam settings:

PostAnswer
No _ts token, because min_fill_time is set422, the form drawn again, carrying a token. A browser with no script posts twice for this reason
A token younger than min_fill_time422, the form drawn again
A token, old enough, invalid input422 with the errors
A token, old enough, valid input200 and respond.fragment
More than rate_limit in the hour (5/hour/ip)429. Raise it while you test

So fetch a challenge first. GET /_e/challenge/<endpoint key> answers:

{ "field": "_ts", "token": "contact.1793656928.1l2h...", "minFill": 3000, "proofField": "_pow", "proofBits": 8 }

Post token in the field field, at least minFill milliseconds later. A token works once. The proof of work is optional: a post without _pow is accepted, and a wrong one is refused. To send one, find the number n for which sha256(token + n), with n written in decimal, starts with proofBits zero bits.

TOKEN=$(curl -s http://localhost:8080/_e/challenge/contact | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
sleep 4
curl -s -o /dev/null -w '%{http_code}\n' -X POST --data-urlencode "_ts=$TOKEN" \
  -d 'name=Sam&email=sam@example.org&message=Hello' http://localhost:8080/contact

That prints 200. Turning the defences off on the endpoint does not help: an endpoint may tighten them and not loosen them, so proof_of_work: false on it fails check with an endpoint may add abuse controls and may not remove one the install default sets, and a lower min_fill_time is ignored. In development you can lower the install’s default instead, with endpoints: { defaults: { spam: { min_fill_time: 0s } } } in engine.yaml and no min_fill_time on the endpoint, and any post with valid fields goes through; put it back before you hand over. Then see the stored enquiry in the admin. To read the mail it sent, see Read the mail: the log driver writes only the address, subject and tag unless logging.level is debug.

The claim form has a challenge of its own, in a hidden field of its page.

Endpoint keys you will change

KeyMeaning
input.<name>type (text, boolean, relation, …), required, max, format: email, multiline, autocomplete, label per locale, required_value: true for a consent box
validationRules across inputs, each with a message per locale
spamproof_of_work, honeypot, min_fill_time
rate_limitSuch as 5/hour/ip
stepsstore, mail, subscribe, audit, analytics and more, in order
respondfragment after success, on_error after a bad post

recipe:newsletter declares a sign-up that subscribes with double opt-in. See Forms and submissions and endpoints.yaml.