Version-control your email templates
Track changes to templates like code
Email templates change: brand updates, copy improvements, A/B tests. Version control them like any other code.
Where templates live
Kratos templates are in:
courier/templates/
├── recovery_code/valid/
│ ├── email.body.gotmpl
│ ├── email.body.plaintext.gotmpl
│ └── email.subject.gotmpl
├── verification_code/valid/
├── login_code/valid/
├── registration_code/valid/
└── ...Mount as a volume:
ciam-kratos:
volumes:
- ./courier/templates:/courier/templates:roReference in kratos.yml:
courier:
templates:
recovery_code:
valid:
email:
body: /courier/templates/recovery_code/valid/email.body.gotmpl
subject: /courier/templates/recovery_code/valid/email.subject.gotmplGit the templates
git add courier/templates/
git commit -m "Email: refine recovery template copy"
git pushTracked in Olympus repo. Diffable.
Template structure
// recovery_code/valid/email.body.gotmpl
Hi {{ .Identity.traits.first_name }},
Use this code to reset your password:
{{ .Code }}
Or click: {{ .RecoveryURL }}
This code expires in 1 hour.
{{ .OrganizationName }}Available variables:
.Identity: the user..Code: the OTP..RecoveryURL: full URL with token..OrganizationName: configured org name.
Custom: add to kratos.yml:
courier:
template_override_path: /courier/templates
templates:
recovery_code:
valid:
email:
variables:
organization_name: "Your App"
support_email: "support@your-domain.com"Localization
Per-locale templates:
courier/templates/recovery_code/valid/
├── email.body.gotmpl (default, English)
├── email.body.fr.gotmpl (French)
├── email.body.es.gotmpl (Spanish)
├── email.body.de.gotmpl (German)Kratos picks based on identity.traits.locale.
Plaintext alternative
Always provide plaintext alongside HTML:
email.body.gotmpl (HTML)
email.body.plaintext.gotmpl (text/plain)For email clients that prefer plain, or strip HTML. Inbox providers also prefer multipart emails (HTML + plaintext) for deliverability.
Reviewing changes
PR diff:
- Subject: Reset your password
+ Subject: Your password reset linkReviewer comments. Approved. Merged. Next email goes out with new copy.
Preview locally
# Dev script: render template with sample data
node scripts/preview-template.js recovery_code/valid/email.body.gotmplRender to HTML / text, view in browser, check appearance.
Visual diff for HTML
For HTML templates, diff visually:
# Tool: Litmus, Email on Acid, or self-hosted
diff-html before.html after.html --openSide-by-side. Easy to spot misalignments.
Testing rendering
import { compile } from "@some-go-template/lib";
test("recovery email renders", () => {
const template = fs.readFileSync("courier/templates/recovery_code/valid/email.body.gotmpl");
const rendered = compile(template).render({
Identity: { traits: { first_name: "Alice" } },
Code: "1234",
RecoveryURL: "https://your-app.com/reset?token=X",
});
expect(rendered).toContain("Hi Alice");
expect(rendered).toContain("1234");
});Unit-test templates. Catches broken Go syntax, missing variables.
Deliverability testing
Before shipping major template change, test with Mail Tester:
node scripts/send-test-email.js mailtester+xyz@mail-tester.comReturns score 0-10. Identifies SPF/DKIM issues, spam-triggering words, etc.
Aim for 9+. Don't ship 6 or below.
A/B testing
For copy / subject experiments:
courier:
templates:
recovery_code:
valid:
email:
subject:
variant_a: /courier/templates/.../subject.a.gotmpl
variant_b: /courier/templates/.../subject.b.gotmplPre-courier hook picks variant per user:
const variant = hashToBucket(user.id, 2);
const template = variant === 0 ? "variant_a" : "variant_b";Track open rates per variant.
Compliance check
Some emails require disclaimers (CAN-SPAM, GDPR):
{{ if .IsMarketing }}
Unsubscribe: {{ .UnsubscribeURL }}
Address: [Your company address]
{{ end }}Verify present in all marketing-tagged templates.
Auth emails are usually "transactional", don't require unsubscribe.
Rendering performance
Template rendering is fast (~1 ms per email). Not a bottleneck.
If you have many emails: courier queue is the bottleneck (delivering to SMTP). Templates aren't the issue.