MailStencil architecture — first public prerelease candidate
Scope and structure
Milestones 1–7 provide Core contracts, Scriban rendering/AST validation, exact FileSystem and Azure Blob readers, runtime orchestration with localization fallback and positive in-memory source caching, and production hardening. Milestone 8 reviews compatibility with future Authoring without implementing it. No writer, catalog, schema-generation, version-management, distributed caching or CLI exists.
MailStencil.sln
Directory.Build.props net10.0, nullable, warnings-as-errors, XML docs
src/
MailStencil.Core/ Contracts, models, DI/options, diagnostics, runtime/cache
MailStencil.Scriban/ Renderer, validator, shared internal schema, DI
MailStencil.FileSystem/ Exact UTF-8 reader, provider options/exception, DI
MailStencil.AzureBlob/ Exact single-blob JSON reader, options/exception, DI
tests/
MailStencil.Core.Tests/ Foundation and runtime orchestration tests
MailStencil.Scriban.Tests/ Rendering, validation, safety and concurrency tests
MailStencil.FileSystem.Tests/ Storage, security, consistency, DI and concurrency tests
MailStencil.AzureBlob.Tests/ Unit tests and opt-in Azurite integration tests
samples/
MailStencil.Sample.Console/ Working service orchestration example and local templates
MailStencil.Sample.AspNetCore/ Working minimal API, configuration and localized templates
Core, Scriban, FileSystem and AzureBlob are packable with MIT license and repository metadata; release automation and publication remain intentionally absent. Core has no Scriban/cloud SDK dependency. Scriban references Core and pins Scriban 7.4.0, the stable 7.4.x package verified for this milestone. Sources consulted: NuGet 7.4.0, Scriban safe runtime, and the source shipped inside that exact NuGet package. No older Scriban dependency is used.
Public responsibilities and API review
The Milestone 1 signatures are unchanged:
- ITemplateReader reads an exact snapshot without write capabilities.
- ITemplateRenderer renders identity-free EmailTemplateContent with an explicit culture and model. This remains appropriate for runtime rendering and eventual unsaved preview.
- IEmailTemplateService coordinates exact reads, culture fallback, source caching and rendering. Its signatures are unchanged; no ValidateAsync member was added.
- EmailTemplateContent and RenderedEmailTemplate still require a non-null subject and at least one non-null body. Empty strings are permitted. Neither needs engine-specific state or diagnostics.
- MailStencilOptions configures the service DefaultCulture and CacheDuration. Direct rendering still takes its culture explicitly.
New Core API: ITemplateValidator plus a validation result, diagnostic, source span, component and severity enums, a static validation exception and a separate execution exception. These types must be public because consumers need structured validation without depending on Scriban. String diagnostic codes avoid a large evolving enum. Source spans do not leak Scriban types. The AST checker, schema, naming policy, projection, execution limits and renderer implementation are internal. See complete API.
AddScribanRenderer registers singleton renderer/validator defaults with TryAdd. Repeated calls do not duplicate registrations and custom implementations remain untouched. The engine is stateless: every operation creates its own schema and parsed templates, and every component creates a fresh TemplateContext, local scope and output writer. No mutable context is shared and no parsing cache has been added to the renderer. Source caching belongs to the service. Safe orchestration/provider logging is described below; model/email contents are not logged automatically.
Expected template problems return diagnostics from ValidateAsync. RenderAsync validates before reading model getters and throws TemplateValidationException for invalid syntax, members, policy or declared model contracts. Its ValidationResult preserves the complete structured diagnostics; callers wanting normal validation flow can use ITemplateValidator first. Null API arguments still throw ArgumentNullException. Projection/execution failures are exceptional and use TemplateRenderingException, with a diagnostic and inner cause. Cancellation is normalized to OperationCanceledException with the supplied token. Static validation does not guarantee success for every possible data value (for example, a null dereference, failing getter or excessive loop).
TemplateComponent values are Subject = 0, HtmlBody = 1, TextBody = 2, General = 3. General is appended without renumbering existing values. Unsupported model contracts, naming collisions and whole-model projection failures use General, with no source span. Parser/member/runtime errors that belong to an email component retain Subject, HtmlBody or TextBody.
Shared declared-type schema and projection
ModelSchema is the only reflection implementation. It builds a graph from typeof(TModel), public
instance readable non-indexed properties, declared nested property types, and IEnumerable
The naming-policy seam calls Scriban's StandardMemberRenamer.Default: CustomerName becomes customer_name. No duplicate snake_case algorithm exists. A future options overload can select CamelCase or Original at this single seam without changing renderer/validator signatures. Such configuration is intentionally not exposed in this milestone; future schema generation must reuse this same graph and policy rather than introducing another reflection walker.
Supported values are strings, chars, bools, ordinary integral/floating/decimal numbers, enums,
Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, custom property-based DTOs/interfaces, and typed
collections (including arrays). Nullable
Object-typed values, delegates, reflection types, dictionaries, IQueryable, untyped/ambiguous collections, and other System/Microsoft infrastructure types are rejected. Dictionaries and dynamic JSON shapes need a separate future contract decision. A plain scalar or collection is not a valid root model; wrap it in a property-based DTO. Root names string/html/array/math/for are reserved.
Projection eagerly creates detached ScriptObject/ScriptArray graphs, with immutable scalar leaves. Model members and containers are marked read-only. No arbitrary CLR references are put in Scriban. Each component gets a separate writable local scope above the model. Assignments to members, indexers, root model variables or builtins are rejected by the AST policy. Allowed builtins do not mutate model containers. Template code therefore cannot mutate the original CLR model.
Getters and enumerators are application code, not sandboxed code. They must be side-effect-free, bounded and safe to call; the library cannot preempt a blocking getter or MoveNext. Projection checks cancellation between reads/items, and bounds nodes and depth, including cycles. Caller concurrent mutation of the supplied model is outside the renderer's thread-safety guarantee.
AST validation and supported language
Each Subject/HtmlBody/TextBody is parsed independently with Scriban's parser. Parser messages retain component and source location. Validation walks the AST without executing the template or calling model getters. It resolves roots and nested members through the shared schema, propagates element shapes into loop variables, and propagates shapes through aliases, arrays and expressions.
Supported constructs:
- Scalar/nested member output, literals, raw text, comments and normal Scriban whitespace controls.
- if / else if / else, conditional expressions, comparisons, boolean logic, basic arithmetic, null/empty coalescing, parentheses, and unary plus/minus/not.
- for over typed collections, array literals and numeric ranges; nested loops; break/continue; for.index/index0/length/rindex/rindex0/first/last/even/odd/changed metadata.
- Local assignment with =, including $locals and aliases. Assignments must preserve the shape of an existing local. Branches merge definitely assigned variables; a variable defined on only one path cannot be used afterwards. Loop-only variables do not become available after the loop. Reusing an existing local as a loop variable is intentionally rejected, including nested-loop name reuse, to avoid ambiguous shadowing. Local member access through ambiguous branch types is rejected conservatively.
- Typed collection numeric indexing, string numeric indexing, and constant-string object indexing for declared member names. Null-conditional member access is supported.
- Direct calls and pipelines (including chained pipelines) to this exact builtin allowlist: string.upcase, string.downcase, string.capitalize, string.strip, string.size, string.contains, string.replace, html.escape, array.size, math.abs, math.round.
The same builtin table populates the runtime and checks validation targets, arity and return shapes. Unknown variables and nested chains are errors even in non-executed branches. Typo suggestions for root members use bounded edit distance with ordinal tie-breaking, and are informational text only. Validation intentionally is not a full Scriban type system: runtime argument values, arithmetic, index bounds and resource usage may still fail. Conservative rejection is preferred when a member surface cannot be established statically.
Unsupported constructs fail closed with a diagnostic, including custom functions, function aliases, dynamic invocation/eval, includes/loaders, this/global-object access, object literals, dynamic object keys, while/tablerow, capture/import/wrap, loop modifiers, compound assignments, member/index writes, and builtins outside the allowlist. No regex engine, object eval, file/network access or reflection function is exposed. These are deliberate restrictions of the untrusted email-template subset, not limitations claimed for Scriban itself. User-defined template recursion is not enabled.
Null, HTML and formatting behavior
- A null root model throws ArgumentNullException, consistent with the notnull generic constraint.
- Null properties/objects/collections rendered directly produce empty text, following Scriban.
- Accessing customer.name when customer is null fails at runtime under strict target access. Use an if guard or customer?.name. Validation checks the declared chain, not runtime nullness.
- A null collection iterates zero times. No synthetic empty collection/object is substituted.
- Null scalar values remain null, rather than zero or an empty string in the projected graph.
- MailStencil does not automatically sanitize or HTML-escape model values. Untrusted strings inserted into HtmlBody should use the explicit html.escape function, for example {{ value | html.escape }}. Raw HTML and Unicode otherwise remain intact. Warnings for unescaped output are a possible future hardening feature; none are emitted yet. Consumers remain responsible for content policy and subject line controls when integrating with an email sender.
- Every component gets a fresh context. Assignment in Subject does not define a variable in a body. Culture is cloned/read-only per component; no ambient culture or localization fallback is used.
Safety limits
Fixed, internal limits keep this milestone's public API small:
| Limit | Value |
|---|---|
| Component template source | 128 Ki UTF-16 code units |
| Parser / AST depth | 64 |
| Scriban cumulative nested loop budget | 1,000 iterations |
| Model projection node budget | 10,000 |
| Model projection / Scriban object depth | 32 |
| Scriban function recursion limit | 32 (user functions are not enabled) |
| Component output writer | 1 Mi UTF-16 code units; exceeding it throws |
| Scriban LimitToString | 1 Mi characters |
| Regex timeout | 100 ms defense-in-depth; regex builtins are not exposed |
StrictVariables and strict member/target/function/index access are explicitly enabled. Null indexing is disabled, MemberFilter denies CLR reflection as a secondary safeguard, TemplateLoader is null, and CancellationToken is passed to Scriban. LimitToString retains Scriban's documented behavior: individual string/object materialization may truncate with an ellipsis, while some operations throw. The independent output writer prevents total component output from exceeding its cap. This is bounded execution inside the process, not OS/process isolation or a guaranteed CPU/memory quota.
Preserved storage and future extension decisions
ITemplateReader remains the only storage capability. It returns coherent materialized snapshots, null only for missing exact variants/versions, and propagates operational errors/cancellation. Names and logical versions are opaque/case-sensitive; request culture is normalized, with null selecting the default variant. Null version selects active. Unsupported explicit versions must be rejected, never silently ignored. Source preserves request identity; optional metadata may identify the resolved active version. An ETag must describe all content parts, not just one storage object.
The runtime service owns culture fallback and exact positive source caching (see below). Future distributed caching may replace its internal implementation using a provider/tenant namespace and unambiguous keys. Conditional revalidation remains a possible separate capability.
| Future provider | Mapping to existing read contract |
|---|---|
| S3 | Bucket/prefix plus a manifest or bundle; native version IDs remain internal; coherent aggregate metadata. |
| Google Cloud Storage | Bucket/object/manifest; generations may enforce coherent reads; expose logical versions separately. |
| Google Drive | Resolve stable file IDs within configured folder/index; reject duplicate ambiguous names; revision/export handling remains internal. |
Future Authoring can compose identity/content/editing metadata and add independent write/delete/list/ history/activation interfaces, without burdening read-only providers. ITemplateValidator already validates unsaved content, and preview can reuse ITemplateRenderer. This does not implement Authoring. Schema generation can reuse ModelSchema. Writer concurrency, create-versus-replace, logical version activation and rollback remain deferred decisions, not speculative interfaces.
Preserved rendering decisions and risks
- Product naming is MailStencil rather than the original plan's CloudEmailTemplates. The prior rename also updated the candidate options name in PLAN.md. No additional plan changes were needed here.
- The supported language is an explicit safe subset, with conservative local-flow rules and a small builtin allowlist. This is narrower than arbitrary Scriban; it is documented and tested, not silently treated as full language support. Additional constructs need both runtime and static policy review.
- Naming defaults are finalized as Scriban snake_case. Configurable alternative naming remains an additive future option; fixed safety limits also remain internal until configuration requirements exist.
- Eager projection copies all declared properties. Expensive getters, huge strings/collections, cycles and polymorphic/dynamic contracts need caller attention. No trimming/Native AOT or source generator support is promised; reflection metadata must remain available.
- A successfully validated model surface is the same surface projected at runtime. Validation is not proof against null/data errors or resource limits. Diagnostics and inner exceptions may contain template text or application exception data and should not be logged indiscriminately.
- The host should evaluate process isolation for truly hostile workloads requiring hard CPU/memory guarantees. HTML sanitization and subject injection controls are separate email integration concerns.
- FileSystem's storage-specific behavior is defined below. No storage concerns or encoding changes were added to the renderer or its model schema.
- Keep future validation additions in the existing separate capability; do not add required methods to renderer/reader interfaces. Preserve constructor signatures when adding optional state. Request record equality and culture/version conventions remain public compatibility commitments.
Filesystem provider
MailStencil.FileSystem references only Core, not Scriban or any cloud SDK. The existing ITemplateReader semantics and all Core/Scriban public signatures are unchanged. Public additions are limited to FileSystemTemplateOptions, FileSystemTemplateException and AddFileSystemTemplateReader. A small provider-specific IOException subtype lets callers distinguish malformed/unsafe/unstable storage from template-language validation; no new Core storage abstraction or exception hierarchy is needed.
Layout and exact lookup
<BasePath>/
order-confirmation/
default/
subject.txt
body.html
body.txt
it/
subject.txt
body.html
it-IT/
subject.txt
body.txt
Null culture maps to default; a non-null normalized request culture maps to that exact directory.
The provider never reads CurrentCulture/CurrentUICulture and never falls back to a parent/default
culture. Names, culture directories and part filenames use ordinal case-sensitive matching even
on Windows: mismatched case is not silently mapped to another logical identity. Explicit culture
default is rejected because the directory name is reserved for null culture.
Version null selects current content. Any explicit version throws NotSupportedException before filesystem lookup, even if the template does not exist. No version directories or management exist.
Missing exact directories (including a missing base/template directory) return null. An existing variant must contain subject.txt and at least one body; absent parts, wrong file/directory types, oversized files and invalid UTF-8 are provider exceptions, not missing templates. Empty subject/body files are valid; additional unrelated files are ignored as content. A directory observed during a read that subsequently disappears fails after bounded retries rather than being reported as a clean missing lookup. Access errors are not hidden by File.Exists/Directory.Exists checks.
Encoding and memory limits
All files use strict UTF-8. One leading UTF-8 BOM is removed, otherwise text and line endings are preserved verbatim. Invalid sequences throw FileSystemTemplateException with a DecoderFallbackException cause. UTF-16 BOMs do not trigger encoding detection. No ANSI/default-codepage decoding is used.
MaxTemplateFileSize is measured in bytes including BOM, default 128 KiB and configurable from 1 byte to 16 MiB. The three-part layout bounds aggregate source bytes to three times that value (384 KiB by default). Each read allocates only the inspected, bounded file length; premature EOF or growth causes a retry. Metadata and size are inspected again on retries, so growing files cannot cause unbounded allocation. Decoded strings and temporary byte arrays add bounded memory overhead. The default is conservative relative to Scriban's 128 Ki UTF-16 source limit, especially for non-ASCII text. Raising the storage limit does not change any renderer limit. There is no separate aggregate configuration knob because only three known files are read.
Path and link security
Logical identifiers are one segment of 1–128 ASCII letters/digits/hyphens/underscores, starting with a letter/digit. Dot segments, separators of either platform, drive prefixes, UNC identifiers, colon/ alternate streams, percent encodings, whitespace and Windows reserved device names are rejected rather than rewritten. Cultures are additionally normalized/validated by the existing TemplateRequest. This restriction is provider-specific and does not narrow the Core contract for other providers.
BasePath and targets are resolved through Path.GetFullPath; Path.GetRelativePath verifies containment
with a directory boundary, not a vulnerable string prefix (templates versus templates-evil).
The configured base is trusted application configuration, not a user-supplied template identifier.
Relative base paths are resolved when the singleton is first constructed and then remain fixed.
All directory ancestors, including the base path and its ancestors, are checked for ReparsePoint before traversal. Template directories and each content file are checked too. Symlinks, junctions, other reparse points and reported device entries are rejected, even when their target stays inside the base; broken links are rejected as well. Checks are repeated immediately before opening a file and during final inspection. Permission/metadata failures do not become successful reads or null. Deploy on a real directory tree; e.g. cloud placeholder reparse files or a symlinked base are unsupported.
These portable .NET path/attribute checks are not an atomic OS-handle traversal sandbox. A hostile actor able to replace ancestors or files between checks can create TOCTOU races; hard links are not identified by ReparsePoint, and Unix special files or mount changes require OS-specific protections. Only regular files in an application-controlled tree are supported. Do not give untrusted principals write access to the template tree or its ancestors; use process/OS isolation or immutable deployment ownership when that threat exists. Known links and failures to establish the inspected path are rejected; complete protection against hostile concurrent filesystem mutation is not claimed.
Multipart snapshot and sharing
Each call has independent state and up to three immediate attempts:
- Inspect the variant directory and all three part names, including absent optional bodies. Record file length, LastWriteTimeUtc and CreationTimeUtc, plus directory creation/write timestamps.
- Read each present file asynchronously, honoring cancellation between chunks and using strict size bounds. FileShare.ReadWrite | FileShare.Delete allows common update/replacement strategies.
- Recheck paths and the complete metadata set. A change, disappearance, short read or growth retries the complete snapshot. Exhaustion throws FileSystemTemplateException, never known mixed content.
- Decode only the accepted snapshot and return immutable Core models. Streams have already closed.
Stable malformed content fails immediately. Optional body addition/removal is detected; directory metadata may conservatively force retries for unrelated changes too. There are no watchers, caches, global locks, cross-process locks or writer requirements. Unrelated requests are not serialized. The internal per-instance test checkpoint runs at before-read/after-chunk/after-read boundaries and is absent from public API/production registration; it makes race and cancellation tests deterministic.
This is metadata-stability detection, not a transactional filesystem snapshot. Same-size updates that restore timestamps, coarse/cached filesystem metadata, replacements preserving all observed metadata, or staged multipart writes paused across a read can evade detection. Writers should deploy complete variants in a coordinated fashion or stop readers for updates where stronger consistency is required. A manifest/commit protocol is future design work, not an implicit Authoring implementation. Windows File.Replace was tested during an open shared read. File.Move overwrite of an open destination was denied by the host OS despite sharing flags; deployment tools must use an OS-supported replacement strategy. Flags do not override OS/remote-filesystem behavior or permissions.
LastModified is the maximum UTC last-write timestamp of the returned parts. Version remains null. ETag remains null: a content hash could be added later, but no consumer in this milestone needs it, and a timestamp of one part would misrepresent an aggregate token. No synthetic version is invented.
DI and Console sample
services.AddMailStencil()
.AddScribanRenderer()
.AddFileSystemTemplateReader(o => o.BasePath = "./templates");
Options validate on resolution. The singleton reader copies the full base path and size limit; mutating options later does not retarget the reader. Registration rejects any existing ITemplateReader, including repeated FileSystem registration, so the extension selects one active reader predictably. Callers manually adding descriptors afterwards retain normal Microsoft DI behavior and must preserve this invariant. No provider-selection or replacement framework is introduced.
The Console project copies an order-confirmation/default template to its output and calls IEmailTemplateService. Configured en-US falls back through en to default while preserving en-US formatting. HtmlBody explicitly uses html.escape for strings. Run with:
dotnet run --project samples/MailStencil.Sample.Console -c Release
Storage risks carried forward
Cross-platform tests are written against portable APIs, but the verification host is Windows; run the suite on Linux/macOS and any intended network filesystem before release. Symlink tests explicitly skip only if the host lacks link-creation privileges. Security and snapshot limitations above remain relevant regardless of passing tests. Relative paths depend on startup working directory; absolute configuration is preferable for services. Blocking filesystem metadata/open calls cannot always be preempted by CancellationToken even though content reads and checks honor it. Localization and caching orchestrate exact reads without adding fallback or state to this provider.
Verification
Tests cover rendering all components, declared root/nested/collection types, snake_case, builtins, Unicode/HTML, nullable members, guarded and unguarded nulls, local scope/aliases/branches, unknown members, syntax/component/span diagnostics, deterministic suggestions, blocked CLR access/mutation, concurrency, output/loop/source/model-depth limits and deterministic cancellation.
Milestone 2 final verification completed on .NET SDK 10.0.400:
- dotnet restore: passed.
- dotnet build -c Release: passed with zero warnings and zero errors.
- dotnet test -c Release: 129 passed (14 Core + 115 Scriban), zero failed or skipped.
- Compiled public API inspected: Core has the intended diagnostic/validation additions; Scriban exports only its DI extension class. XML documentation includes the validation exception and General component.
Milestone 3 verification completed on .NET SDK 10.0.400 / Windows:
- dotnet restore: passed.
- dotnet build -c Release: passed, zero warnings and zero errors.
- dotnet test -c Release: 202 passed (14 Core + 115 Scriban + 73 FileSystem), zero failed or skipped. All four symbolic-link tests ran on this host.
- Console sample: exited 0, loading and validating the filesystem template; Subject was
Order MS-123 confirmed, HTML containedAda & friendsand total42.50, and plain text containedAda & friends. Run used the built Release output with --no-build --no-restore. - Compiled FileSystem API inspected: exactly the options class, provider exception and DI extension are public; reader/test seam remain internal. Its generated XML has 8 entries. Its project references Core only, with no Scriban/cloud dependency.
Core, Scriban and FileSystem emit XML documentation; nullable reference types and warnings-as-errors remain centrally enabled. AzureBlob testing is described in the Milestone 5 section below. The 73 new tests cover exact reads, malformed UTF-8/parts, bounded sizes, path/link rejection, options/DI, concurrent reads and deterministic changes/disappearance/replacement/cancellation. Milestone 4 verification is recorded below.
Runtime orchestration, localization and cache
The internal EmailTemplateService depends only on ITemplateReader, ITemplateRenderer and validated MailStencilOptions. It performs no Scriban validation itself: each call passes the original model, declared generic type and loaded content directly to the renderer. Preview can still render identity-free content directly. Neither storage nor renderer interfaces gained members.
AddMailStencil uses TryAddSingleton for IEmailTemplateService, preserving custom registrations. All three registration extensions work in any order; no provider is built during registration. Reader/renderer are required on service resolution (or eager container validation), and must have lifetimes suitable for concurrent singleton use. The service snapshots options once, owns a private MemoryCache, and disposes it when the container disposes the service. It neither consumes nor configures an application's shared IMemoryCache. Independent containers cannot mix provider sources.
Lookup and formatting
For it-IT the service attempts it-IT, it, then null/default; it attempts it then default; null attempts default only. CultureInfo.Parent defines the chain, including script-bearing cultures. Name and logical version remain unchanged. Only a null exact read advances the chain. Ambient CurrentCulture and CurrentUICulture never select a variant or formatting culture.
The string overload uses normalized DefaultCulture; empty means null/invariant. The explicit request overload respects null even when a different DefaultCulture is configured. This clarifies the old contract remarks that suggested configured formatting for an explicit null request; no signature changed. If fr-CA falls back to fr, formatting still uses fr-CA. Null always uses InvariantCulture.
Cache semantics and freshness
Core uses Microsoft.Extensions.Caching.Memory 10.0.11. A private record key holds name, normalized culture and version with ordinal, case-sensitive equality; it is not a serialized hash or ToString. Because the cache belongs to one service/reader, no additional provider namespace is needed here.
Only successful EmailTemplateSource snapshots are stored under the exact identity read. A parent hit is stored under the parent key, never under the missing child key. There is no negative or resolution cache: the next request can immediately discover a newly added more-specific variant. Direct ITemplateReader calls bypass service caching and remain exact.
CacheDuration defaults to five minutes. Absolute expiration starts at successful insertion; hits do not extend it. Zero disables caching and negative values fail options validation. Changes/deletions to an already cached variant become visible after expiration. No watcher, conditional read, ETag revalidation, sliding expiration, size/priority options or distributed cache is implemented. Models, rendered output, validation results and exceptions are never cached. A source successfully loaded before a rendering failure remains a valid source-cache entry; rendering is retried normally.
MemoryCache operations are thread-safe and all call state is local. Concurrent cold requests may load the same exact identity independently; there are no global locks or shared cancellation tasks. If concurrent reads observe different snapshots, the last insertion wins until expiration. Cancellation flows to reader/renderer, is checked around lookup/render and on cache hits, and stops fallback when observed. Failed/cancelled reads are not inserted; one caller cannot poison another caller's independent load.
Errors and remaining risks before Azure
| Condition | Result |
|---|---|
| Every fallback lookup cleanly returns null | TemplateNotFoundException with original Request |
| Unsupported explicit version | NotSupportedException, no further fallback |
| Malformed/unsafe filesystem source | FileSystemTemplateException |
| Static template validation failure | TemplateValidationException |
| Projection/runtime failure | TemplateRenderingException |
| Cancellation | OperationCanceledException |
| Other storage/access errors | Original exception propagates |
No errors are flattened into missing-template results. Null API arguments remain ArgumentNullException.
The cache intentionally has no public size controls; high key cardinality or large sources can consume memory until expiration. Use bounded application request identities, an appropriate duration or zero to disable caching. This is per-process freshness, not cross-process invalidation. Future Azure reads must still return coherent exact snapshots, distinguish absence from failures and honor cancellation. Provider-specific version support must be explicit; no version management is introduced here. Existing FileSystem race/platform limitations and Scriban execution limits remain unchanged.
MailStencil does not automatically sanitize or HTML-escape model values. Untrusted strings inserted into HtmlBody should explicitly use html.escape. Warnings for unescaped output remain possible future hardening work, not an implemented feature.
Milestone 4 verification
The runtime tests add deterministic fake-reader/clock coverage for fallback, formatting, version preservation, exact cache identity, expiry, zero duration, failure propagation and cancellation. Integration tests exercise all six registration orders, direct-reader bypass and real provider/ renderer exceptions. Release verification totals 244 tests (46 Core, 125 Scriban, 73 FileSystem). The Console sample uses the runtime service; Azure and ASP.NET Core remain untouched scaffolds.
Final Milestone 4 verification on .NET SDK 10.0.400 / Windows:
- dotnet restore: passed.
- dotnet build -c Release: passed, 0 warnings and 0 errors.
- dotnet test -c Release: 244 passed, 0 failed, 0 skipped. AzureBlob remains an empty test scaffold.
- Updated Console sample: exited 0; Subject "Order MS-123 confirmed", HTML "Ada & friends", text "Ada & friends", total "42.50".
- Release Core exported types inspected: TemplateNotFoundException is the only new public type; CacheDuration is the only new options property. Their generated XML entries were verified. Runtime implementation/cache key remain internal. Nullable and warnings-as-errors remain enabled.
Azure Blob provider
MailStencil.AzureBlob references Core and Azure.Storage.Blobs 12.29.2 (stable), with no Scriban, FileSystem, Azure.Identity or ASP.NET dependency. The test project references Scriban solely to exercise runtime composition. No other production package changes are needed.
Client, registration and lifecycle
The application registers BlobServiceClient, constructed using its chosen credentials and SDK retry/transport settings. MailStencil selects ContainerName via GetBlobContainerClient. Choosing this over direct BlobContainerClient injection retains the planned container option and allows one application SDK client to serve other uses. MailStencil has no credential or connection-string fields.
AddAzureBlobTemplateReader registers one singleton ITemplateReader and rejects any existing reader. Registration order with client, AddMailStencil and AddScribanRenderer is unrestricted before resolution; registration never builds a provider. Options validate and are copied on reader construction. The reader shares the SDK client, with independent per-call state and no locks. No account, container or blob is created or changed. SDK operational errors are not wrapped.
Layout and identifiers
Each complete variant is one atomic blob:
<prefix>/<name>/<culture-or-default>/template.json
mailstencil/order-confirmation/default/template.json
mailstencil/order-confirmation/it-IT/template.json
Names and prefix segments use 1–128 ASCII letters/digits/hyphen/underscore, starting with a letter or digit. Unlike FileSystem, Windows device names have no special meaning in Blob Storage. Unsafe names are rejected, not rewritten. Culture is normalized by TemplateRequest, then checked as a safe segment; explicit culture "default" is reserved and rejected. Null maps to default. Blob names are case-sensitive.
Prefix is trusted configuration. Empty means none; leading/trailing/repeated forward slashes collapse to one separator. Every nonempty segment follows the policy above; normalized length is at most 512 characters. Backslashes, dot segments and unsafe characters are rejected. ContainerName requires 3–63 lowercase ASCII letters/digits/hyphens, alphanumeric ends and no consecutive hyphens. Special Azure containers such as $root are intentionally outside this configuration contract.
The reader never falls back or uses ambient cultures. Explicit TemplateRequest.Version throws NotSupportedException before downloading. formatVersion, logical versions and Azure native VersionId are separate concepts; no native version ID is selected or exposed as a logical version.
Strict format v1
{
"formatVersion": 1,
"subject": "Order {{ order_number }} confirmed",
"htmlBody": "<p>Hello {{ customer_name | html.escape }}</p>",
"textBody": "Hello {{ customer_name }}"
}
formatVersion must be the integer 1. Subject must be a string; each body is optional/null or a string, and at least one body must be non-null. Empty strings are valid. Property names are case-sensitive; unknown and duplicate names (including escaped equivalents) are rejected. Future format versions fail explicitly. Comments, trailing commas, malformed JSON/UTF-8, invalid string escapes and unsupported shapes fail. System.Text.Json parses UTF-8 directly after validating all bytes, accepting one leading UTF-8 BOM. No UTF-16 or code-page detection occurs. Parser depth is bounded at eight.
The internal parser returns Core content directly; no public storage DTO is needed. It performs no Scriban validation or HTML sanitization. application/json is recommended, but Content-Type is not trusted as a format discriminator: application/octet-stream is equally acceptable.
Download, bounds and metadata
DownloadStreamingAsync obtains content and metadata together, with no preliminary property request and no multipart retry protocol. SDK retry behavior remains under application client configuration. The successful single-object snapshot supplies the complete template ETag and LastModified; TemplateMetadata.Version remains null even if the response includes an Azure native version.
MaxTemplateBlobSize defaults to 4 MiB and accepts 1 byte through 16 MiB, including BOM and JSON overhead. The default comfortably holds three 128-Ki UTF-16 components even with JSON escaping; Scriban's per-component limits remain independent and are not raised by storage configuration. Reported length is checked before allocation, copying enforces the bound plus one sentinel byte, and a mismatch with reported length is rejected. All buffers are bounded; JSON/string allocations still add overhead to the configured physical-byte limit. Streams and raw responses are disposed on success, parsing/size errors and cancellation. Cancellation reaches SDK calls and stream reads and is checked around bounded synchronous parsing; parsing is not preemptible mid-operation.
Only HTTP 404 with BlobNotFound becomes null. ContainerNotFound, other 404 codes, authentication, authorization, throttling, conflicts, server/network failures propagate. AzureBlobTemplateException covers MailStencil content/format/size/length failures and retains the exact request. Neither provider failures nor malformed content trigger culture fallback.
Runtime, future compatibility and verification limits
Core's service supplies fallback and five-minute positive caching unchanged; direct Azure reader calls bypass that cache. Unit runtime composition covers fallback, cache reuse, new-specific-variant visibility, model-dependent rendering and explicit html.escape. No Azure logic was added to Core.
Future Authoring can write one complete content document atomically and use its aggregate ETag for optimistic concurrency. That requires a separate writer capability. Adding document fields requires an intentional format evolution decision because v1 rejects unknown fields. Logical activation/history/ rollback and any use of native versions remain future design work, not implicitly supported features.
Azurite tests are opt-in and CI-ready; see instructions. During Milestone 5, Docker Desktop's Linux engine was unavailable on this host, so those tests did not run then. Milestone 7 later ran all emulator cases successfully. Live Azure authentication, permissions, network and SDK behavior remain deployment verification concerns. No Milestone 6 work is included.
Milestone 5 final verification
On Windows with .NET SDK 10.0.401:
- dotnet restore: passed.
- dotnet build -c Release: passed, 0 warnings and 0 errors.
- dotnet test -c Release: 310 passed, 0 failed (46 Core, 125 Scriban, 73 FileSystem, 66 Azure units).
- Seven Azurite methods skipped during the Milestone 5 normal run, representing 11 opt-in cases when theories expand. All 11 were run separately during Milestone 7.
- The deterministic Azure/Core/Scriban runtime composition test passed.
- Release exported API inspected: exactly AzureBlobTemplateOptions, AzureBlobTemplateException and MailStencilAzureBlobServiceCollectionExtensions. Generated XML contains all nine documented entries. Nullable reference types and warnings-as-errors remain enabled; existing public contracts are unchanged.
ASP.NET Core experience and logging — Milestone 6
Library public API remains unchanged. Core now references Microsoft.Extensions.Logging 10.0.11, and Core/FileSystem/Azure registration calls AddLogging. Internal typed loggers report Debug cache, exact-read and fallback signals; fixed Warning messages distinguish validation, rendering and provider-content failures. No providers or filters are installed by the library. No output provider is required for correct operation. Logging does not add shared mutable render state.
No model/template/rendered values, paths, blob URIs, credentials, diagnostic messages or exception objects are included in library logs. Only Debug orchestration events include structured logical identity; applications must treat those identifiers according to their own sensitivity. Providers log content failures only, avoiding duplicate Core and Azure HTTP/retry events. Cancellation and exception propagation semantics are unchanged. See the complete policy.
The .NET 10 minimal API sample binds existing options and explicitly requests ValidateOnStart for Core and FileSystem. This remains an application decision rather than forcing host lifecycle changes into libraries. Validation checks configuration only. Sample-relative template paths resolve against content root; resources are copied to build/publish output. The normal endpoint resolves only IEmailTemplateService. Existing content, renderer, reader and service responsibilities remain intact.
GET /preview/order-confirmation returns Subject, HtmlBody and TextBody for fixed example order data. An optional culture query demonstrates fallback while retaining requested numeric formatting. English default and Italian parent variants demonstrate snake_case and explicit html.escape. Expected exception categories become distinct sanitized problem responses without diagnostic/stack disclosure. A documented alternative uses an application-owned BlobServiceClient and replaces the FileSystem registration; there is no new provider selector or Azure.Identity library dependency.
The new tests/MailStencil.Sample.AspNetCore.Tests project covers the actual HTTP endpoint, binding/ startup validation, sanitized errors, no-provider logging, captured events/privacy, concurrent service calls, Azure configuration/registration orders with an SDK HTTP double, and the one-reader invariant. The Azure tests prove no network request is required to validate/start the host. Existing Console and Azurite tests remain intact. The Azurite workflow was reviewed statically and remains opt-in.
At Milestone 6, cross-platform work remained: execute on Linux/macOS, use non-symlink template and temporary ancestors (notably macOS /var), and verify timestamp/replacement/link privileges on each host. The provider's intentional link rejection and metadata consistency limitations remain unchanged. This milestone does not implement Milestone 7 hardening, Authoring, writers, version management, distributed caching, new storage providers, CLI or NuGet release preparation.
Milestone 6 final verification
Windows / .NET SDK 10.0.401:
- dotnet restore succeeded.
- dotnet build -c Release succeeded with 0 warnings and 0 errors.
- dotnet test -c Release: 332 passed, 0 failed; seven opt-in Azurite methods skipped. Breakdown: 46 Core, 125 Scriban, 73 FileSystem, 66 Azure and 22 new ASP.NET/developer-experience cases.
- Console sample ran successfully with subject Order MS-123 confirmed and explicit HTML escaping.
- ASP.NET sample started in Production on localhost:5080. Actual HTTP requests returned all three components: default subject Order MS-123 confirmed / total 42.50; it-IT fell back to it with subject Ordine MS-123 confermato / total 42,50. Both HTML bodies contained Ada & friends. The verification process was stopped after requests completed.
- Compiled exported types remain 19 Core, 1 Scriban, 3 FileSystem and 3 Azure; no library public API growth. XML documentation, nullable and warnings-as-errors remain enabled.
- Azurite was not run during Milestone 6; Milestone 7 later ran all 11 cases. Linux/macOS remain unverified.
Milestone 7 hardening findings
Issues found and fixes made
The production review found one resource-limit defect in Scriban schema construction. The schema limit counted distinct CLR types but did not count exposed properties, so a generated declared type could contain more than 10,000 members while using only one property type. Schema construction now counts every exposed declared member and fails with the existing General/MSV007 validation diagnostic above the 10,000-member boundary. Tests cover limit-1, limit and limit+1. Projection's independent 10,000-node runtime budget is unchanged.
FileSystemTemplateException accepted a runtime-null message despite its non-null public annotation.
It now throws ArgumentNullException, matching AzureBlobTemplateException and the rest of the public
null contract. This changes no signature. Test-owned temporary roots now use the physical
/private/var spelling on macOS so the suite does not weaken or accidentally trip the production
provider's intentional rejection of symlink ancestors. The existing Azurite job now pins the exact
official emulator image digest exercised in this milestone.
No other production defect justified a public API change. The compiled API remains 19 Core, one Scriban, three FileSystem and three Azure Blob exported types. Nullable annotations, XML documentation, warnings-as-errors, enum numeric values and existing dependency directions remain unchanged.
Threat model and security findings
Application configuration, DI registrations, model types and model getters are trusted. Template text
and remotely stored Azure JSON may be untrusted; model values are potentially untrusted and log-sensitive.
The renderer exposes a detached ScriptObject/ScriptArray/primitive graph derived only from typeof(TModel),
uses a fail-closed AST allowlist, no template loader/eval/reflection/CLR method access, fresh contexts and
bounded source/AST/loop/projection/output work. This is bounded in-process execution, not a secure process
sandbox. A getter or enumerable is application code and can block, mutate external state, throw or perform
I/O before MailStencil can regain control; isolate truly hostile workloads at the process/OS boundary.
The FileSystem base tree is trusted application-controlled storage. Segment validation, containment and reparse checks protect against ordinary path traversal and known link redirection, but portable .NET metadata checks cannot provide atomic handle-level confinement against hostile writers, hard links, mount changes or all TOCTOU races. Azure content is size-bounded and strictly parsed; TLS endpoint, credentials, authorization, retry policy and service availability belong to the application-owned client.
MailStencil does not automatically sanitize or HTML-escape model values. Untrusted values inserted into
HtmlBody should use html.escape. Unescaped-output warnings remain a possible future hardening feature
because a partial detector could create false confidence. Subject is returned verbatim, including CR/LF;
the email sender must enforce any required header-injection policy.
Runtime, cancellation, disposal and concurrency
All registered runtime services remain singleton-oriented and build no temporary IServiceProvider. Options are validated and snapshotted; custom reader/renderer dependencies must also be safe singleton dependencies. Every render and provider read uses per-call mutable state. Sequential and concurrent tests cover same/different templates, cultures, models, components and local names; no Scriban state leakage was found. MailStencil owns and disposes its private MemoryCache. It disposes filesystem streams and Azure download streams/responses on success, errors and cancellation, and never disposes the application-owned BlobServiceClient.
Cancellation tokens reach service lookup/cache checkpoints, readers, asynchronous filesystem reads, Azure SDK/stream operations, model projection checks and Scriban execution. OperationCanceledException is not wrapped or logged as failure. Bounded synchronous reflection, Template.Parse, JSON parsing, filesystem metadata calls and arbitrary application getters/enumerators cannot be preempted mid-call. No sync-over-async, async void, Task.Run wrapping or CancellationToken.None was found in production paths.
Cache and culture findings
The source cache remains correct: absolute expiry, zero-duration disablement, positive exact-source entries only, ordinal case-sensitive name/culture/version keys, no fallback-resolution entry, cancellation checks, no rendered/model/error caching and disposal with the service. Concurrent cold requests may duplicate reads by design. There is no public size limit, so high-cardinality identities and large configured source limits can increase process memory until expiration; deployments should bound identity cardinality or disable the cache where that is not possible. Distributed caching and single-flight coordination remain future work.
Culture fallback uses CultureInfo and its Parent chain, including script/region parents. Formatting always uses the original requested/configured culture; explicit null is invariant/default and remains distinct from DefaultCulture and ambient culture. In invariant-globalization mode, non-invariant culture creation is a host runtime limitation and fails deterministically rather than being manually reinterpreted.
Provider and resource-limit findings
FileSystem continues to reject traversal, both separators, rooted/UNC/drive-relative paths, ADS/colon, reserved devices, mismatched case, links/reparse points and unexpected file types. The before/read/after algorithm detects tested replacement, optional-body addition/removal, short read, growth and disappearance and retries exactly three times. Same-size replacements preserving every observed timestamp and identity, coarse/network metadata, hard links and hostile races remain documented limitations; no transactional snapshot is promised.
Azure returns null only for status 404 plus exact BlobNotFound. ContainerNotFound, other 404 responses, 401/403, 429, 5xx and network failures remain Azure RequestFailedException/SDK errors. MailStencil wraps only malformed/unsupported/oversized/inconsistent content. Reported and actual lengths are bounded and matched; BOM/UTF-8, depth-eight strict JSON, escaped duplicate fields, unknown fields, formatVersion, ETag and LastModified behavior remain covered. Content-Type and native VersionId do not change semantics.
Storage byte limits are validated between 1 and 16 MiB and use bounded limit + 1 reads without overflow.
Scriban retains 128-Ki UTF-16 component source, AST depth 64, loop 1,000, schema/projection 10,000,
projection depth 32, model-string/output 1-Mi limits. Boundaries are tested where independently observable.
Logging, dependencies, performance and platform status
MailStencil-generated Warning logs remain fixed and sanitized, with no exception object. Debug state is limited to logical Name/Culture/Version and fallback/cache events. Sentinel tests inspect formatted message, structured state and exception fields and found no model values, template/rendered content, absolute paths, blob URIs, SAS/credential data, diagnostics or inner exceptions. Host, Azure SDK and application logging are separate and must apply their own privacy policy.
dotnet package list --vulnerable --include-transitive reported no findings. The deprecation audit reported
only the test-only xunit 2.9.3 stack as Legacy, recommending the major xunit.v3 migration; no production
package is deprecated. The outdated audit found Microsoft.Extensions 10.0.11 to 10.0.12 maintenance updates
and test-tool major/minor updates (Microsoft.NET.Test.Sdk 17.14.1 to 18.10.0 and xunit runner 3.1.4 to
4.0.0). Scriban 7.4.0 and Azure.Storage.Blobs 12.29.2 were current. The xunit v3/runner migration is deferred
because it is test-only and needs a deliberate runner, attribute/gating and CI compatibility pass; it does
not affect shipped binaries. No package was changed without a security or correctness reason, and there is
no unresolved known vulnerable production dependency.
Schema reflection and Scriban parsing are rebuilt for each validation/render; model projection is eager, filesystem reads materialize parts, and Azure buffers one bounded JSON blob. These are the likely hotspots. An internal bounded parse/schema cache may help after measurement, but cancellation, declared-type/member policy identity and eviction must stay correct. No speculative cache or public performance option was added. Trimming and Native AOT are not guaranteed because reflection metadata is required.
The normal suite has no arbitrary sleeps or order-dependent global state; culture changes are restored,
temporary resources are uniquely owned and Azure unit tests use deterministic transports. Link tests skip
only when the host cannot create links. Test roots now account for the macOS /var alias, but actual Linux
and macOS execution, timestamp precision, replacement/open sharing and network filesystem behavior remain
pre-release work. Windows does not prove those platforms. The normal suite is prepared for future
windows-latest/ubuntu-latest/macos-latest CI without weakening production security.
The opt-in Azurite gate cannot silently skip once MAILSTENCIL_AZURITE=1; setup/connection failures fail,
the connection string is fixed to local development storage, containers are unique and cleaned, and the
workflow always stops the emulator. Docker 29.7.2 was available for Milestone 7: all 11 Azurite cases ran
and passed against the pinned image. This is not live Azure verification.
Milestone 7 final verification
Windows / .NET SDK 10.0.401:
dotnet restorepassed.dotnet build -c Releasepassed with 0 warnings and 0 errors.- The normal Release suite passed 336 tests: 46 Core, 129 Scriban, 73 FileSystem, 66 Azure unit and 22 ASP.NET/developer-experience cases. Seven opt-in Azurite methods were skipped in the normal run.
- A separate enabled Azurite run passed all 11 expanded emulator cases, for 347 successful executions across normal and integration runs.
- The Console sample exited successfully with all three components, invariant-preserving fallback and
explicit HTML escaping. The ASP.NET sample started in Production; real HTTP requests to its default
and
it-ITpreview endpoints returned the expected subjects, escaped HTML and culture-specific totals. - The compiled Release public API was inspected after the fixes and retained exactly 19 Core, one Scriban, three FileSystem and three Azure Blob exported types. Generated XML, nullable annotations and warnings-as-errors remained clean.
- Dependency vulnerability audit had no findings; deprecation found only the deferred test-only xunit v2 stack. The deliberate outdated results and upgrade decisions are recorded above. Linux/macOS execution, xunit v3 migration and live Azure remain pre-release concerns.
Milestone 8 — Authoring compatibility review
Runtime API freeze conclusion
The current runtime API does not block future Authoring. No public or production-code change is required before 1.0. Runtime capabilities should remain small and stable, while Authoring composes beside them:
ITemplateReaderremains an exact, read-only snapshot capability. It should never acquire write, delete, listing, history or activation members.ITemplateRendererremains storage- and identity-independent and already supports unsaved preview.ITemplateValidatorremains the static validation capability for unsaved content and a declared model.IEmailTemplateServiceremains runtime orchestration for load, fallback, positive source cache and render. Authoring can resolveITemplateReaderdirectly when it needs source metadata such as an ETag.TemplateRequest,EmailTemplateContent,EmailTemplateSource,TemplateMetadataandRenderedEmailTemplatehave sufficient separation of identity, source text, snapshot metadata and rendered output. Their sealed shapes encourage composition rather than inheritance.
Adding required members to any existing interface would burden read-only providers and custom consumers. No future workflow reviewed here requires that. Future interfaces and command/result models can be added independently without changing existing method signatures or constructor shapes.
Candidate future capability boundaries
The following are design roles, not frozen type names or public API proposals:
| Capability | Responsibility | Why it stays separate |
|---|---|---|
| Write/create/update | Persist complete content, optionally as a new logical version and with an expected concurrency token | Read-only credentials and providers remain valid; create/update semantics need explicit commands/results |
| Delete | Delete a variant or version with explicit conditional behavior | Delete permission and retention policy commonly differ from write permission |
| Catalog/query | Page through names, cultures and versions with provider-specific consistency/cost | Exact runtime reads must not require enumeration permission or an efficient list operation |
| History | Read version summaries and audit metadata | Some providers have no history; native storage history need not equal logical history |
| Activation | Atomically or conditionally select an active logical version | Activation has a different concurrency target from editing version content |
| Model schema | Describe the exact declared model surface used by validation/rendering | It belongs to the template-language/model policy, not storage |
| Authoring orchestration | Coordinate validate, preview, save and policy | It composes capabilities and application authorization without enlarging runtime contracts |
A provider may implement and register any useful subset. Normal DI availability is sufficient for initial
capability discovery: an application resolves the optional interface it needs. A CanWrite/CanList flags
object would duplicate registration truth and can drift from credentials or runtime availability, so no
formal capability enum is justified now. If a future UI needs richer explanations, an additive descriptor
can report semantics such as pagination, strong conditional writes or immutable history without changing
the operational interfaces.
This separation preserves read-only FileSystem mounts, read-only Blob credentials, custom HTTP and
embedded-resource readers, and third-party readers that implement only ITemplateReader. Writer and
reader instances may also use different credentials or deployment identities.
Unsaved validation and preview
The edit flow already composes without storage:
editor text
-> EmailTemplateContent
-> ITemplateValidator.ValidateAsync<TModel>
-> ITemplateRenderer.RenderAsync<TModel>(content, sampleModel, culture)
-> future writer capability
EmailTemplateContent deliberately has no identity and can represent a draft before any read or write.
Validation does not require a model instance or execute getters; preview takes the caller's sample model
and explicit formatting culture. Both use the same Scriban implementation and declared typeof(TModel)
contract. A separate preview abstraction would only wrap existing calls and is not needed unless a future
product needs orchestration policy beyond validation and rendering.
Validation-before-save belongs in Authoring orchestration or the application workflow. Storage remains
model-agnostic. The application can associate an editor/template kind with TModel through its typed
workflow or a separate schema-key registry. CLR Type information does not belong in
EmailTemplateContent; a future portable logical schema identifier, if needed, is authoring metadata.
The existing generic-only validator/renderer are sufficient for typed applications. A host that discovers
model contracts dynamically can add a separate Type-based Authoring adapter or invoke a closed generic
workflow; it does not require adding a method to either existing interface.
Schema and naming-policy reuse
Scriban's internal ModelSchema is already the single source for declared typeof(TModel), public readable
non-indexed property discovery, collection element types, supported scalar/object restrictions,
snake_case naming, collision detection, projection and validation member lookup. A future schema generator
must traverse this same internal graph and map it to an immutable public schema result. It must not expose
ModelSchema, PropertyInfo, Scriban runtime objects or a second reflection implementation.
The neutral schema capability/result can be added later in a package visible to both the Scriban adapter and Authoring; alternatively, a small Scriban/Authoring bridge can expose the mapping. Package placement must preserve a single implementation of model discovery. This is an additive packaging decision, not a reason to change the current runtime API.
The current member-policy seam can become instance/options-driven later. An additive Scriban options overload can preserve the existing no-argument registration and snake_case default while selecting a future SnakeCase, CamelCase or Original policy. Renderer, validator and schema generation must consume the same snapshotted policy. Changing the default after 1.0 would break stored templates behaviorally and must not occur; a chosen non-default policy likewise becomes part of the application's template contract.
Content and authoring metadata
EmailTemplateContent should remain only Subject/HtmlBody/TextBody. Description, tags, author, status,
created/updated timestamps, logical schema association and editing state can live in a future draft,
definition or command that composes:
identity + EmailTemplateContent + authoring metadata + concurrency condition
EmailTemplateSource remains a read snapshot, not an edit model. TemplateMetadata remains optional
runtime snapshot metadata. Its string ETag is intentionally provider-neutral and opaque; adding Azure SDK
types would couple Core and still would not describe FileSystem guarantees. Authoring operation results may
need additional tokens for version content and activation state rather than overloading one runtime ETag.
Optimistic concurrency by provider
Azure stores one current variant as one complete JSON blob, so the TemplateMetadata.ETag returned by a
read is sufficient as the opaque expected token for a future conditional If-Match replacement of that
blob. The writer translates the string to Azure SDK conditions internally and reports a provider-neutral
conflict. Creates can use If-None-Match; deletes can be conditional. MailStencil must not require callers
to reference Azure ETag or request-condition types.
Logical version activation may update a different active pointer or current blob, so its condition is a separate operation token. A content ETag must not be assumed to protect both version content and activation state. Native Blob VersionId can be used internally for implementation or recovery but is not the MailStencil logical version.
FileSystem LastModified is observational metadata and cannot provide a strong compare-and-swap guarantee.
A future writer can offer documented best effort using an expected timestamp and content hash, but checks
remain vulnerable to races. Stronger designs include staging complete files/directories, OS-supported
atomic replacement, a content-addressed version directory plus conditionally replaced active manifest, or
a writer-owned lock/commit protocol. Network filesystems and platform replacement semantics differ. A
FileSystem writer may reject unsupported strong conditions; all providers need not claim identical
concurrency guarantees.
Logical versions, activation and rollback
The related version, format and concurrency concepts remain distinct:
TemplateRequest.Versionis an opaque MailStencil logical version; null means active/current.TemplateMetadata.Versionmay report the resolved logical version, especially for an active read.- Azure Blob VersionId is a provider-native implementation detail.
- JSON
formatVersionidentifies the storage document schema. - ETag is an opaque change/concurrency token, not a version name.
These contracts are sufficient for provider-independent logical versions. Existing FileSystem and Azure
readers correctly reject explicit versions today; a future internal reader implementation can honor them
without changing ITemplateReader. Creating a version should not implicitly activate it unless the future
command says so. Activation selects the version returned by a null-version request. Rollback is normally an
activation of a prior immutable version, with conditional protection against racing activations; history
and activation remain optional capabilities rather than reader requirements.
Azure could retain the current active template.json while storing immutable logical-version blobs, or
later read an explicit active pointer. FileSystem could retain the current active three-file layout while
storing version directories and an optional manifest. Those physical choices must preserve the public null
equals active semantics and clearly define aggregate snapshot/ETag behavior.
Storage-format evolution
Azure JSON format v1 remains strict and must not silently accept authoring fields. A future writer can emit
v1 for current content. Rich metadata should use provider metadata/sidecars with explicit consistency, or
an intentionally supported formatVersion: 2; the v1 interpretation remains unchanged. Unknown-field
rejection is useful protection against silently ignoring content written for a newer reader.
The FileSystem reader examines only subject.txt, body.html and body.txt; stable unrelated files are
currently ignored. A future writer can therefore write the existing layout without runtime changes. A
stable manifest.json could be additive at the filesystem level, but it has no meaning to the current
reader and concurrent changes may affect directory metadata/retry behavior. If a manifest later becomes
part of identity, versions, activation or aggregate consistency, the reader/writer protocol must be
versioned and updated deliberately rather than treating the ignored file as implicitly authoritative.
Listing, CLI and package direction
Listing stays outside ITemplateReader. Blob/object listing may be paged and eventually consistent;
Google Drive-style discovery can be expensive or ambiguous; embedded/HTTP readers may have no enumeration
endpoint; credentials may allow exact reads but deny list. A future catalog capability should expose
pagination/cancellation and document ordering, consistency and permission behavior rather than promise a
cheap complete collection.
A future CLI can compose the same capabilities: validate and preview use current content/validator/renderer; push and delete use writer capabilities; exact pull uses the reader; discovery uses the catalog; history and activation use their optional interfaces. It needs configuration and model/schema selection, not special methods on Core runtime services.
The preferred future package direction is:
MailStencil.Core stable runtime contracts/models
MailStencil.Authoring authoring orchestration/contracts -> Core
MailStencil.Scriban authoring adapter schema mapping that reuses internal ModelSchema
MailStencil.FileSystem authoring adapter writer/catalog capabilities -> Authoring + provider
MailStencil.AzureBlob authoring adapter writer/catalog/history/activation -> Authoring + provider
MailStencil.Cli composes runtime + selected authoring/provider adapters
Adapters may become separate packages (for example, MailStencil.AzureBlob.Authoring) to keep runtime-only
provider packages free of an Authoring dependency and avoid granting write capability accidentally. Exact
names and whether a small neutral schema abstraction belongs in Core or an Authoring abstractions package
remain future decisions. Dependency direction must avoid Core depending on Scriban/Azure/FileSystem and
must avoid duplicating ModelSchema to preserve that package ideal. Provider adapters must likewise reuse
or internally refactor the existing path/blob-name and storage-format policies instead of creating writer
rules that disagree with their readers; this is an implementation/package-access issue, not public API.
Cache invalidation
The current five-minute private positive source cache is not an architectural blocker, but same-process
Authoring must define read-after-write behavior. Today an authoring host can set CacheDuration to zero and
preview unsaved content directly. Later, an additive optional invalidation/generation capability can be
implemented by the internal runtime service without changing IEmailTemplateService; a writer or Authoring
orchestrator can signal the affected exact identities after a successful commit. Updating/deleting a
variant invalidates that version key; changing activation invalidates the null-version active key.
Simple remove-after-write has a race with an older concurrent load inserting after removal. If strong same-process freshness is required, use a per-identity/global generation captured before load and checked before insertion, or ETag revalidation. Cross-process writers require short expiry, notifications, shared generation state or a future distributed cache protocol. None requires exposing the existing MemoryCache, and no hypothetical invalidation API is added now.
Deferred decisions and recommendation
Future work must still define create-versus-replace commands, conditional conflict results/exceptions, delete retention, catalog pagination, authoring authorization/auditing, schema DTO shape, logical version syntax, immutable-history guarantees, activation atomicity, per-provider capability semantics, metadata storage, cache invalidation and cross-process consistency. UI/editor and CLI workflows must also decide how applications select a declared model/schema without persisting arbitrary CLR type names.
These are additive Authoring design decisions. They do not expose a missing runtime primitive or justify a pre-1.0 breaking change. The recommendation is to freeze the current runtime public API for 1.0 and develop future Authoring contracts separately, validating each against read-only and weak-concurrency providers.
Milestone 8 verification
Milestone 8 changed documentation only. On Windows with .NET SDK 10.0.401, dotnet restore succeeded,
dotnet build -c Release completed with 0 warnings and 0 errors, and dotnet test -c Release passed all
336 normal tests. The seven opt-in Azurite methods were skipped as designed and were not rerun because no
production/provider code changed. The exported runtime API remains the Milestone 7 baseline.
Milestone 9 — packaging and release-candidate engineering
The four runtime assemblies are the only packable projects: MailStencil.Core, MailStencil.Scriban,
MailStencil.FileSystem, and MailStencil.AzureBlob. They share the centrally defined
0.1.0-preview.1 default, deterministic/portable-symbol settings, package validation, and .snupkg
generation. Each package includes its own README, XML documentation, and only its net10.0 runtime
assembly plus normal NuGet metadata. Project references become ordinary minimum-version package
dependencies; no test, sample, build output, or source tree is shipped.
Core remains the dependency root. Scriban depends on Core and Scriban 7.4.0; FileSystem depends only on Core; AzureBlob depends on Core and Azure.Storage.Blobs 12.29.2. Consumers still select exactly one reader. The release work changes no runtime behavior, public type, dependency lifetime, exception, security limit, storage layout, or logging policy.
global.json selects SDK 10.0.401 with latestFeature roll-forward. Local developer builds stay
deterministic while compatible .NET 10 feature-band servicing remains possible. ContinuousIntegrationBuild
is enabled only when the CI environment property is true. Package metadata uses the actual
https://github.com/polletto/MailStencil.git origin and its project page. All packages use the MIT license
expression; the root license names Simone Rossi as the 2026 copyright holder. The SDK-provided Source Link
integration maps deterministic source paths to the real repository and current commit without an additional
Source Link package. Release artifacts must be rebuilt after these metadata changes are committed so the
published source hash and package contents describe the same tree.
Normal CI runs restore, Release build, and the complete non-Azurite test suite on Windows, Ubuntu, and macOS. A nonpublishing package job audits dependencies, packs once, inspects package identities, content, and dependency groups, checks the compiled public API baseline, runs clean FileSystem and Azure DI consumers against the local packages, and uploads only candidate artifacts. The separate pinned Azurite job remains opt-in/path-scoped and never targets real credentials.
The local package consumer projects contain only PackageReferences. The verifier copies them to unique
temporary directories, restores MailStencil from the candidate directory plus NuGet.org for third-party
dependencies, runs a real FileSystem render, and resolves the Azure reader/service using an
application-owned BlobServiceClient without contacting a service. Package inspection rejects unexpected
payloads and local path/secret sentinels in text metadata.
Package IDs were unregistered in the NuGet.org flat-container lookup performed for this milestone, but availability is not ownership reservation and must be checked again immediately before publication. Publishing is intentionally absent. The future release design uses a protected environment and NuGet.org trusted publishing/OIDC with a short-lived key; long-lived API keys must not be stored.
Milestone 9 verification and readiness
Windows verification used .NET SDK 10.0.401. Restore and Release build succeeded with zero warnings and
errors. All 336 normal tests passed; the normal run skipped the seven opt-in Azurite methods as designed.
A separate enabled run against the pinned emulator digest passed all 11 expanded Azurite cases. The Console
sample completed, and the ASP.NET Core sample returned expected default and it-IT preview responses with
escaped HTML and culture-specific decimal formatting.
The exact solution-level dotnet pack -c Release -p:Version=0.1.0-preview.1 -o artifacts/packages command
produced four .nupkg and four .snupkg files without warnings. Inspection verified IDs, version, net10.0
DLL/XML/PDB placement, README inclusion, dependency groups, absence of test/sample/source/build payloads,
and absence of local paths or secret sentinels in textual package metadata. Both temporary package-only
consumers passed: FileSystem performed a real load/render, and Azure resolved the complete singleton DI
graph with an application-owned client without network access. The compiled 115-entry API baseline passed.
The vulnerability audit found no known direct or transitive vulnerability. No production dependency is deprecated. The xunit 2.9.3 test stack remains marked Legacy, and the outdated audit still reports the previously reviewed Microsoft.Extensions 10.0.12 servicing updates plus test-tool major updates; no package was upgraded solely to be latest. CI targets Windows, Ubuntu, and macOS. Windows and Ubuntu hosted jobs pass; the macOS-only deferred-host test-infrastructure failure was corrected by testing direct host startup and awaits hosted rerun confirmation.
MIT licensing, the public repository, and truthful package URLs are now configured. Remaining publication checks are a successful hosted macOS rerun, verified Source Link against the final commit, a confirmed private security-reporting route, and trusted-publishing configuration. Package IDs appeared available on 2026-09-10 but are not reserved and must be rechecked immediately before publication. The test-only xunit v3 migration and nonsecurity 10.0.12 servicing updates remain normal follow-up work rather than blockers.