Templating
Posthorn uses Go’s text/template package to render email subjects and bodies. Form fields are exposed to the template as variables, and any field not explicitly named in your config gets appended to a structured “Additional fields” block at the bottom of the body.
Subject
Section titled “Subject”subject = "Contact from {{.name}}"Renders to: Contact from Alice
The subject template has access to every form field by name as {{.fieldname}}. The template is rendered once per submission, after validation. Missing variables render as empty strings (no error).
Body — inline vs. file
Section titled “Body — inline vs. file”If the body value contains {{, it’s treated as an inline template:
body = """From: {{.name}} <{{.email}}>
{{.message}}"""If it doesn’t contain {{, it’s treated as a file path (relative to the binary’s working directory or the config file’s directory):
body = "templates/contact.txt"From: {{.name}} <{{.email}}>
{{.message}}File templates are convenient for longer bodies — multi-line markdown signatures, branded footers, structured templates — without bloating the TOML.
Template syntax
Section titled “Template syntax”Go’s text/template is similar to but distinct from Jinja2, Liquid, or Handlebars. Core features:
| Feature | Syntax | Example |
|---|---|---|
| Variable | {{.fieldname}} | {{.name}} |
| Pipe to function | {{.x | func}} | {{.email | html}} |
| If/else | {{if .x}}A{{else}}B{{end}} | {{if .urgent}}!!!{{end}} |
| Range | {{range .items}}...{{end}} | Not useful for form data (flat) |
| Comparison | {{if eq .x "y"}} | {{if eq .category "bug"}} |
| Comment | {{/* note */}} | {{/* this is a comment */}} |
For the full grammar, see the Go text/template docs.
Variables available
Section titled “Variables available”Every submitted field is available as {{.fieldname}}. Missing fields render as the empty string. Posthorn does not currently inject metadata variables (request ID, timestamp, endpoint path) into templates — those values are present in the structured log line keyed by submission_id, not in the email body.
To include a value like a timestamp in the email, pass it as a form field from the client side, or render it in the template literally.
Custom fields passthrough
Section titled “Custom fields passthrough”The named fields for an endpoint are the union of:
- Fields in
required - The
email_field(default"email") - The
honeypotfield
Any other field submitted to the endpoint is a custom field. Custom fields appear in an “Additional fields:” block appended to the rendered body:
[rendered body template]
Additional fields: company: Acme Corp source: HN newsletter_opt_in: yesThe block is alphabetically sorted by field name for stable output across runs. Empty-valued custom fields are omitted from the block.
This means you can add new fields to your HTML form without touching the Posthorn config — they show up in the email automatically.
Reply-To header from a form field
Section titled “Reply-To header from a form field”For contact-form-shaped endpoints, the practical win is being able to hit reply in your mail client and respond directly to the visitor rather than to your own noreply@ address. Posthorn handles this automatically: when a submission carries a valid email address in a known form field, that address becomes the Reply-To: header on the outbound message.
[[endpoints]]# ...reply_to_email_field = "email" # default; rarely needs to be set explicitlyHow it works:
reply_to_email_fieldnames the form field whose value to use. Default isemail_field(which itself defaults to"email"), so a typical contact form with anemailfield gets this behavior for free.- The field’s value is validated as a syntactic email address before being used; malformed values are dropped silently and
Reply-Tostays unset. - To disable the behavior entirely (e.g., for endpoints where you don’t want the submitter’s address echoed back), point the field at something that doesn’t exist:
reply_to_email_field = "_disabled".
The from field stays as your sending identity (noreply@yourdomain.com or similar); the to field is who receives the mail; the reply_to_email_field setting controls who a “reply” goes to. Three concerns, three fields.
Subject and body validation
Section titled “Subject and body validation”Both templates are parsed at config-load time. Syntax errors fail posthorn validate (and prevent the listener from starting). A subject template that fails to parse looks like:
$ posthorn validate --config posthorn.tomlERROR: endpoint 0 (/api/contact): subject template parse error at line 1: function "foo" not definedexit status 1This means template errors are caught at deploy time, not request time.
Redirects
Section titled “Redirects”When the requesting client prefers text/html (i.e., a normal browser form submission), Posthorn redirects to the configured URL on success or error:
redirect_success = "/thank-you"redirect_error = "/contact?error=1"When neither is configured, the response is JSON regardless of Accept header. When only one is configured, the other still falls back to JSON.
The redirect status is 303 See Other — this tells the browser to issue a GET to the new URL, which prevents form re-submission on refresh.
If your form expects to be progressively enhanced (works without JS, gets nicer with), use a redirect on success and an error redirect that re-renders the form with field errors. If your form is JS-only (e.g., a fetch() from your SPA), omit redirects entirely — the JSON response is what you want.
Edge cases
Section titled “Edge cases”| Scenario | Behavior |
|---|---|
Field name with a dot (e.g., user.name) | Not addressable in Go templates. Avoid dots in form field names. |
Field name with a dash (e.g., first-name) | Not addressable via {{.first-name}} (interpreted as subtraction). Use {{index . "first-name"}} |
| Empty form (all values empty) | Validation fails on required fields → 422, never reaches template |
| HTML in field values | Passed through as-is. Plaintext-only body, no HTML escaping. Use html pipe if you ever switch to HTML body in v2. |