Skip to content
bitzorcas
中EN

Guide

Notifications template rendering, channel adaptation, and content safety

A detailed account of template lookup, Scriban compilation, variable naming, caching, preview/validation, ten channel adapters, security boundaries, and rendering gaps.

Last updated

TemplateRendererService coordinates Repository, variable-name conversion, Compiler, CompilationCache, SecurityChecker, VariableAnalyzer, and ChannelAdapterFactory. The pieces exist, but tenant lookup, engine selection, module authorization, validation reporting, and degradation semantics break the current production path.

1. Current rendering flow

ChannelAdapterScribanTemplateCompilerCompilationCacheTemplateRepositoryTemplateRendererServiceRenderTemplate handlerChannelAdapterScribanTemplateCompilerCompilationCacheTemplateRepositoryTemplateRendererServiceRenderTemplate handleralt[cache miss]key + variables + callerModule + optionsGetByKeyAsync("", key)template or not foundSHA256(template text)CompileAsync(text, engine)Scriban TemplateRenderAsync(compiled, variables)optional AdaptAsync(title, body)TemplateRenderResult

The first break is the fixed empty TenantId. Template creation requires a non-empty tenant and the repository queries TenantId+Key, so RenderAsync normally finds neither tenant nor PLATFORM templates. The command supplies no CurrentTenant and there is no fallback algorithm.

2. CallerModule has no authorization effect

RenderTemplate.Command accepts ModuleCaller and Contracts contains a ModuleAuthorization allowlist. TemplateRendererService never uses callerModule, and no source path invokes ModuleAuthorization. A caller who passes generic template View can claim any registered ModuleCaller.

OwnerModule is currently metadata/search only; it does not restrict rendering or editing.

Target tenant and module authorization boundary
public async Task<Result<TemplateRenderResult>> RenderAsync(
string tenantId,
string key,
ModuleCaller caller,
IReadOnlyDictionary<string, object> variables,
CancellationToken ct)
{
// Derive module identity from a service credential/contract, never an HTTP enum.
var authorization = modulePolicy.Authorize(caller, key);
if (authorization.IsFailure)
return Result.Failure<TemplateRenderResult>(authorization.Error);
// Resolve tenant override, then PLATFORM, with explicit locale/channel dimensions.
var template = await templates.ResolveAsync(
tenantId, key, culture.Current, channel.Current, ct);
return await RenderValidatedAsync(template, variables, ct);
}

3. Engine catalog versus compiler

TemplateEngine declares Scriban, Liquid, and Handlebars. DI registers one ScribanTemplateCompiler. Its Compile/Validate methods receive engine but never branch; every value calls Template.Parse.

Liquid and Handlebars are therefore not implemented capabilities. Their syntax may fail as Scriban or pass as literal text. Reject them at the API until each has an adapter and contract suite.

The Scriban context sets LoopLimit=1000 and a member renamer. Source comments mention recursion limits, but no explicit RecursiveLimit, total execution deadline, or output-size bound is set. An HTTP rate limit does not bound one expensive rendering request.

4. Compile/render degradation

RenderTemplateTextAsync returns the original template text on Compile Failure and also on Render Failure. The outer EnableDegradation path runs only for thrown exceptions and returns [render failed] key.

Syntax/variable errors can therefore look successful while leaking {{ secret }} template source downstream. High-value notifications should return a typed failure or an approved static fallback with degraded=true.

The cache key is SHA256(template text), without tenant, key, version, or engine. Text-based reuse is reasonable today, but Engine and compiler version must join the key when multiple engines exist. Cache get/set failures are ignored and rendering continues.

5. Preview loses validation failures

ValidateTemplateAsync returns Result.Success(TemplateValidationResult) even when IsValid=false. Preview checks only validationResult.IsSuccess; it therefore constructs a new empty IsValid=true report and discards syntax errors, security violations, and missing variables.

Preserve the business validation report in Preview
var validation = await renderer.ValidateTemplateAsync(
titleTemplate, bodyTemplate, engine, variables, ct);
if (validation.IsFailure)
return Result.Failure<TemplatePreviewResult>(validation.Error);
// Forward IsValid/Errors/SecurityViolations/MissingVariables. A successful
// method call does not mean that the submitted template content is valid.
var report = validation.GetValueOrThrow();
return Result.Success(new TemplatePreviewResult(
renderedTitle,
renderedBody,
adaptedContent,
report));

CreateTemplate and UpdateTemplate call neither ValidateTemplateAsync nor SecurityChecker. Invalid or dangerous content can become the active version; Validate/Preview are optional tools, not write gates.

6. Variable naming and schema

NamingConvention supports PascalCase, SnakeCase, and Auto. The renderer adapts the dictionary and the Scriban context installs a MemberRenamer. Aggregate TemplateVariable rows do not participate: required/default/data type do not drive binding.

Variables are arbitrary IReadOnlyDictionary<string,object> values. Restrict allowed scalar/DTO types, nesting, collection size, and string length; never expose EF aggregates, lazy proxies, streams, or objects that contain secrets.

Each published version should snapshot a variable schema and enforce unknown-variable policy, required values, conversion, defaults, PII classification, and maximum lengths before rendering.

7. Channel-adapter matrix

TemplateChannelCurrent behaviorBoundary
EmailHTML wrapper; title encodedbody inserted as raw HTML
DingTalk/WeCom/Feishustrip HTML, Markdown, truncateregex stripping is not a parser
SMSstrip HTML, normalize whitespace, ≤500characters, not provider billing segments/bytes
Inboxtitle encoded; body raw HTMLno sanitizer; computed plain text is unused
Htmlregex removes selected tags/events/js hrefnot an allowlist sanitizer; bypassable
PDF/Word/Excelregistered in factoryAdapt returns NotImplemented Failure

Renderer does not propagate adapter lookup/adaptation failures. It returns overall Success with AdaptedContent=null. PDF/Word/Excel therefore appear successful without producing a document.

8. HTML/XSS boundary

Email and Inbox insert rendered Body directly into HTML. Scriban does not automatically HTML-encode every variable. A variable containing <img onerror=...> can reach mail or web output. HtmlChannelAdapter’s regex covers only selected paired tags, embed, quoted on* attributes, and href=javascript; it is not equivalent to a mature sanitizer.

Use context-specific encoding: HTML text, attributes, URLs, and plain text are distinct. Recommended controls:

  • allow approved components or constrained Markdown for authors;
  • encode variables as text by default; only a trusted sanitizer may create SafeHtml;
  • apply scheme+host allowlists to URL variables;
  • run a DOM-based allowlist sanitizer after rendering;
  • treat CSP/client defenses as a second layer, not encoding replacement;
  • never log full templates or variable values on validation failure.

TemplateSecurityChecker searches source strings for System.IO/Net/Reflection/Database/Process patterns. It is neither a template sandbox nor XSS sanitizer, and the write path does not call it.

9. Combining templates with Notification

There is no single API for “resolve by tenant/language → validate variables → render → create → deliver.” A caller can compose services, but the empty-tenant Render defect and non-idempotent Create make the current combination unsuitable for GA.

A target facade can accept NotificationIntent(Code, Recipient, Locale, Variables, BusinessId), then:

  1. resolve a published template by Tenant+Code+Locale+Channel;
  2. validate variable schema and sensitivity;
  3. render each channel with its own output context;
  4. create one logical row using Tenant+Code+Recipient+BusinessId uniqueness;
  5. transactionally save fact and outbox;
  6. create recoverable DeliveryAttempts.

Do not render one HTML body and reuse it for SMS, IM, and Inbox. Channels need separate templates or context-specific encoding.

10. Required tests

  • tenant template, PLATFORM fallback, override, locale/channel selection;
  • forged CallerModule, OwnerModule mismatch, and permission separation;
  • explicit support/rejection for Scriban/Liquid/Handlebars;
  • loop limit, deep object, large output, cancellation, and timeout;
  • compile/render failures never leak source; fallback is visibly degraded;
  • Preview preserves every Validate error and Create/Update reject invalid content;
  • required/default/type schema and unknown-variable behavior;
  • HTML text/attribute/URL injection and sanitizer bypass corpus;
  • stable NotImplemented for PDF/Word/Excel instead of false success;
  • cache isolation by engine/version and concurrent compilation.

Back to Notifications

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%