Skip to content

HTML email bodies

Endpoints can send styled HTML mail instead of plain text. Set body_format = "html" and the body template renders as HTML; Posthorn sends a multipart/alternative message with both an HTML part and a plain-text part, through every transport (Postmark, Resend, Mailgun, SES, and outbound SMTP).

[[endpoints]]
path = "/welcome"
to = ["you@example.com"]
from = "Welcome <noreply@example.com>"
subject = "Welcome, {{.name}}"
body_format = "html"
body = "templates/welcome_email.html"

HTML bodies render through Go’s html/template, which escapes every interpolated value for the context it lands in — element text, attributes, URLs. Your own markup in the template renders as HTML; anything a submitter typed renders as inert text.

If someone submits <script>alert(1)</script> as their message, the recipient sees that literal text. There is no configuration that interpolates submitter input as live markup, by design: mail sent from your domain must not carry markup you didn’t write.

Every HTML send includes a plain-text alternative — text-only clients stay readable and spam filters score the message better than HTML-only mail. You have two options:

Do nothing (recommended to start). Posthorn derives the text part from your rendered HTML: tags stripped, links become text (url), lists become dashes, styles dropped. Use dry-run mode to preview both parts — the response includes body_html and body_text.

Write it yourself. Set text_body for full control:

body_format = "html"
body = "templates/welcome_email.html"
text_body = "templates/welcome_email.txt"

text_body uses the same inline-or-file rules as body and the same template variables.

File-based bodies work exactly as before — if you already have HTML email templates, point at them directly:

body = "templates/welcome_email.html"

Inline HTML works too, including static HTML with no template variables:

body = "<p>Thanks! We got your message and will reply soon.</p>"

Email clients render a conservative subset of HTML. For mail that survives Gmail, Outlook, and Apple Mail: inline your CSS, use tables for layout, and skip external stylesheets and JavaScript (most clients strip them).

templates/welcome_email.html
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="max-width: 560px; margin: 0 auto; font-family: Arial, sans-serif;">
<tr>
<td style="background: #1a1a2e; padding: 24px; text-align: center;">
<span style="color: #ffffff; font-size: 20px; font-weight: bold;">Your Project</span>
</td>
</tr>
<tr>
<td style="padding: 32px 24px;">
<h1 style="font-size: 22px; margin: 0 0 16px;">Welcome, {{.name}}</h1>
<p style="font-size: 15px; line-height: 1.6; color: #333333;">
Thanks for signing up. Your account is ready.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 24px 0;">
<tr>
<td style="background: #4361ee; border-radius: 6px;">
<a href="https://example.com/start"
style="display: inline-block; padding: 12px 28px; color: #ffffff;
text-decoration: none; font-weight: bold;">Get started</a>
</td>
</tr>
</table>
<p style="font-size: 13px; color: #888888;">
Questions? Just reply to this email.
</p>
</td>
</tr>
</table>

The derived text part of this template reads:

Your Project
Welcome, Jane
Thanks for signing up. Your account is ready.
Get started (https://example.com/start)
Questions? Just reply to this email.

The custom-fields passthrough works in HTML mode too: form fields your template doesn’t name are appended as an escaped list section in the HTML part and as the classic plain block in the text part, so unexpected fields stay visible in both.

The SMTP listener accepts HTML mail from your apps as of v2.0: a text/html part maps through to the outbound HTML body, and HTML-only messages (which v1.x rejected with 554) are accepted with the text part derived automatically. Apps like Grafana that send HTML-only notifications now relay cleanly.

  • body_format defaults to "text" — existing configs behave identically.
  • Subjects always render as plain text; they’re headers, not markup.
  • Header-injection defenses (NFR1) apply unchanged; the HTML body cannot smuggle headers on any transport.