Email Templates Live in the Database
HTML templates seeded in OnModelCreating, a reflection mini-language that resolves hash-brace tokens against a model, a Gmail-versus-SMTP environment branch, Cc quietly added into To, and string.Format run over raw HTML.
The reminder email from part eleven's timer does not exist as a file. There is no .cshtml, no embedded resource, no template on disk anywhere in the repository. The email's subject and body live as rows in a database table, and the platform renders them through a sixty-line reflection engine of its own design. This is a build-versus-buy decision made at a fork most teams never notice they are standing at, and it is worth walking carefully — because the instinct was reasonable, the execution was clever, and the bugs are the exact ones a hand-rolled template language grows.
Why the templates left the codebase
The appeal of database-stored templates is real: non-developers can change email copy without a deploy. HR wants to reword the swipe-out reminder, or add a line about a new policy, and in principle they edit a row instead of filing a change request that waits for a release. For an intranet tool with a lot of transactional email and a lot of copy churn, moving templates to data is a defensible answer to a real friction.
The platform stored each template as a row keyed by name and environment, with the HTML body inline. The rows were seeded from OnModelCreating — the same EF Core method that configures the model also carried the full HTML of every email as seed data:
modelBuilder.Entity<EmailTemplate>().HasData(
new EmailTemplate
{
Id = 1,
Name = "SwipeOutReminder",
Environment = "Production",
Subject = "You are still swiped in, #{Model.EmployeeName}",
Body = "<html><body><p>Hi #{Model.EmployeeName}, ...</p></body></html>"
});
Read that and the tension is immediate. The templates are “in the database” so they can change without a deploy — but the seed is in OnModelCreating, which is C#, which needs a migration and a deploy to change. So the escape hatch had a lock on it: editing the shipped template meant either an UPDATE run by hand against production (untracked, unreviewed, unversioned) or a code change that defeated the entire point. The feature promised deploy-free edits and delivered a table nobody could safely edit. A configuration store you seed from code is a configuration store your code still owns — the data moved, the authority didn't.
A mini-language made of hash-braces
Rendering used a token syntax — #{Model.Property} — resolved by reflection against a supplied model:
public string Render(string template, object model)
{
return Regex.Replace(template, @"#\{Model\.([^}]+)\}", match =>
{
var path = match.Groups[1].Value; // e.g. "Manager.EmployeeName"
object current = model;
foreach (var segment in path.Split('.'))
{
if (current == null) return string.Empty;
current = current.GetType().GetProperty(segment)?.GetValue(current);
}
return current?.ToString() ?? string.Empty;
});
}
This is genuinely tidy for sixty lines. It walks nested property paths (#{Model.Manager.EmployeeName} resolves through two hops), it degrades to empty string on a null anywhere in the chain, and it carries zero dependencies. I will give the author full credit: as a from-scratch template engine, it is clean, readable, and does exactly one thing.
The credit comes with the standing question every hand-rolled engine has to answer: what does Razor, or Scriban, or Fluid, or Handlebars.NET already give you that this reimplements badly? The answer is the unglamorous stuff — HTML encoding by default (this engine emits raw, so a name containing < breaks the markup or worse), conditionals and loops (this handles neither, so “list each pending approval” is impossible), a real error when a token is misspelled (this silently renders empty, so #{Model.EmployeeNam} produces a blank where a name should be and nobody notices until a customer does), and format specifiers for dates and numbers. A sixty-line engine is sixty lines you now maintain, test, and secure — against a library that has already made those mistakes and fixed them. The build-versus-buy footnote here is the same one the companion series draws in its own telling of this engine from the notifier's side.
string.Format over raw HTML
The sharpest self-inflicted wound. The engine also supported inline images, matching cid:filename references in the body against an images folder and attaching them as linked resources. To weave the attachment names in, it ran the entire HTML body through string.Format:
body = string.Format(body, imageFileName); // over a whole HTML document
string.Format treats { and } as its own syntax. An HTML email with any inline CSS — <style>.card { margin: 0 }</style> — contains literal braces, and string.Format reads { margin as a malformed format placeholder and throws FormatException. The whole email fails to render because a stylesheet contained a curly brace. This was not hypothetical: the estate's migration history literally includes a template change made to work around CSS braces breaking exactly this path. The engine's own author had been bitten by it and patched a template instead of fixing the engine.
The lesson generalises past this one bug: do not run a value-substitution function over a document whose syntax overlaps the function's own. string.Format over HTML, or over anything with braces, is a category error — the substitution mechanism and the content are fighting over the same characters. Named-token replacement (which the #{...} engine already did!) has no such overlap; the mistake was reaching for string.Format for the image step instead of extending the token engine that was right there.
Two ways to send, chosen by environment
Transport branched on environment. Production sent through the Gmail API as a service-account mailbox; non-production sent through an SMTP relay for safe test capture:
IEmailSender sender = _env == "Production"
? new GmailApiSender(_credentials) // service account, domain-wide delegation
: new SmtpSender(_vault["SmtpUser"], _vault["SmtpPassword"]);
Routing test email to a capture relay so it never reaches real inboxes is a good practice — it is how you develop email features without spamming employees. The Gmail API path is where the secret-hygiene story sits, and it is Series B's to tell in full (the service account authenticates with a committed key file; the pattern, not the value, is the lesson). One environment-branch note for here: the staging config for the notifier omitted the images folder entirely while the image-association code ran unconditionally, so a code path that was fine in production threw in staging on a missing directory — the kind of gap that only appears when config and code make different assumptions about what exists.
The Cc that becomes a To
Finally, a one-line bug with a privacy edge. Building the outgoing message, the sender added Cc recipients into the To collection:
foreach (var addr in message.To) mail.To.Add(addr);
foreach (var addr in message.Cc) mail.To.Add(addr); // Cc merged into To
Functionally the mail still reaches everyone. But Cc and To are not interchangeable: To says “this is addressed to you”, Cc says “you are being kept informed”. Collapsing Cc into To is mildly wrong for every email and meaningfully wrong when the Cc list was a manager or an HR mailbox being kept in the loop on an employee's attendance — now surfaced as a primary addressee, visible to all the other recipients as though the message were directed at them. It is the sort of defect that is invisible in a test with one recipient and awkward the day a real message goes out with a Cc that reveals who was quietly watching.
The ledger
- Templates as data — reasonable goal, undercut by seeding from code, which kept authority in the deploy pipeline the feature meant to escape.
- The reflection engine — clean for sixty lines; reimplements a library's worst-tested features (encoding, conditionals, misspelled-token errors) from scratch.
string.Formatover HTML — a category error; the substitution syntax collided with the content, and CSS braces brought it down.- Environment-branched transport — good instinct (capture test mail), with a staging/production config-shape mismatch waiting in it.
- Cc into To — one line, functionally harmless, quietly wrong about who a message is for.
Both send paths — Gmail in production, SMTP elsewhere — need credentials, and how this estate stored its secrets is a study in a migration frozen halfway. Two Key Vault SDKs in one class, three different secret postures, and one password sitting in plain sight. Next.