Personalize onboarding emails with an LLM without losing control
Keep the onboarding email a published, reviewed template and give the model one narrow job: fill a few named {{variables}} from your product data. Your code checks each value's length and content, falls back to fixed copy when a check fails, and sends with template.variables and an idempotency key built from the user and the step. Set the template's category first, because it decides the unsubscribe footer.
Unitpost ·
The tempting version of AI onboarding email lets the model write each message from scratch. It reads well in a demo. In production it means every new user gets an email nobody reviewed, and some of them will mention a feature you don't have, a discount nobody approved, or a link that goes nowhere.
The version that holds up keeps the email fixed and gives the model a small, checkable job.
What does the model get to write?
A Unitpost template takes {{variables}} in the body, the subject and even the From name, filled at send from template.variables. Split those variables by where their values come from:
- The contact. When you send to a known contact, its fields, such as
{{first_name}}and any custom field, merge into the variables on their own. A custom field can carry a default that fills in for contacts without a value. - Your product data, passed as is. The plan, the project name, the date of their trial's end. Your code reads these from the database. No model needed.
- The model's slots. One or two variables whose wording should change per person, such as a sentence pointing at the next step that fits what this user has already set up.
Keep the last group small. Unitpost's design guide says a template should read finished in preview, and that more than about five custom variables means you've left the user work to do. The fewer slots the model fills, the less there is to check.
Write the template once and review it
Build the onboarding email as a normal template, have a person read it, and publish it. Only published templates send; a send that names a draft fails with 422 template_not_published.
Pick the editor with model output in mind. In a Design or Simple template, a variable's value is inserted as text: if the model writes <b> or a script tag, the recipient sees the characters, not markup. A Code template puts the value into your HTML before cleaning it, so markup in a value becomes markup in the email. For model-filled slots, prefer Design or Simple.
Leave the links in the template. A button whose address the model writes is a button you can't vouch for.
Then preview it with real-looking values. POST /email/templates/{id}/render (or email_templates_render over MCP, in the templates group) takes variables and an optional contact_id and returns the subject, html and text a send would carry, footer included, plus missing_variables. Nothing is stored or sent. Render the longest value your checks will allow, too, and look at it on a phone.
What should the model see?
Only the product data the slot needs: which steps this user finished, their plan, what they created. Ask for a JSON object whose keys are exactly the slot names, with one instruction per slot and a reminder to state only facts in the data you passed.
Don't feed it text a stranger wrote, such as a support message or a profile bio, without treating it as untrusted. Protecting an email agent from prompt injection covers why.
Check every value before you send
The model's output is input to your code, not to your users. Before it reaches template.variables:
- Keys. Accept exactly the slot names. Drop anything else; never let the model add a variable.
- Length. Give each slot a maximum that fits the layout you reviewed, and reject a value over it rather than cutting it mid-sentence.
- Links. None in a slot. Links live in the template.
- Claims. No prices, discounts, dates, deadlines or promises unless they appear in the data you passed. The same rule runs through Unitpost's own design guide: features, numbers, prices and dates come only from the ask, the Brand Kit or a tool result.
- Facts against the data. If the slot says the user created two projects, your code can count them.
- Markup and tone. Plain text only, in the language of the account.
When a value fails, don't send a blank. Fill the slot with fixed fallback copy that was reviewed with the template, and log the failure so you can see which prompts misfire.
Send it once
Send the published template with template: { id, variables } from a verified domain. Add an idempotency key built from the user and the step, such as onboarding-day-1- plus the user id, so a retry can't send twice. In Node.js it is the second argument; over REST it is the Idempotency-Key header, and the MCP tool email_send takes idempotency_key:
const { data, error } = await unitpost.email.send(
{
from: "you@yourdomain.com",
to: "customer@acme.com",
template: {
id: "tmpl_123",
variables: { plan: "Pro" },
},
},
{ idempotencyKey: `onboarding-day-1-${userId}` },
);A key is 1 to 256 characters and dedupes for 24 hours. A retry with the same key and body returns the first answer, with an Idempotent-Replayed: true header. The same key with a different body is a 409.
That last rule matters here. A model rarely writes the same sentence twice, so a retry that regenerates the copy sends a different body under the same key and gets the 409. Generate once, store the checked variables next to the key, and retry with the stored values. The 409 is useful too: it catches the bug where your code regenerated when it should have replayed.
Add tags, such as the prompt version, so each send records which prompt wrote it. Tags come back on GET /email/{id}.
Keep the category and unsubscribe right
A template's category is transactional or marketing, and it decides what Unitpost adds at send:
- Transactional. Nothing is added: no footer, no
List-Unsubscribeheader, and the send never creates a contact. Right for "your account is ready, here's the first step". - Marketing. Unitpost appends the workspace footer and adds
List-Unsubscribeand one-clickList-Unsubscribe-Postheaders, and there is no flag to remove them. The send goes to one recipient with noccorbcc, creates the contact if needed, and an address that already unsubscribed or complained is suppressed.
Never write an unsubscribe line or postal address into a marketing template; the managed footer adds them.
Choose the category before anyone writes copy, and keep the slots inside it. A transactional welcome whose model slot starts recommending an upgrade has become a promotional message without the consent or unsubscribe a promotional message needs. Your claim check should refuse offers in a transactional template, which is one more reason the model fills slots rather than the whole email.
When the onboarding series turns into nurture mail sent on your schedule, move it to a marketing template and send it only to people who opted in.