Skip to content

Invitation Message Templating

The Accounts component uses a small wrapper around jte to render the subject and body of invitation emails. It is implemented under cards.arda.accounts.util.template; it is not a general template API exposed to other components.

PlantUML diagram

TemplateConfiguration reads template.templateDir; the packaged default is /app/conf/. TemplateService searches that directory for system.jte and system.kte and requires exactly one of them. Other files in the directory are ignored. Discovery and front-matter parsing happen when the component starts, and the selected template remains loaded for that process.

The implementation uses a resolver that serves only the selected system template. It does not provide a catalog, runtime template selection, or other named templates to the engine.

The Helm chart stores the default at src/main/helm/config/_shared/system.jte. templates/configmap.yaml combines files from the shared directory with files from config/<purpose>/:

{{ - $sharedFiles := .Files.Glob "config/_shared/*" }}
{{ - $purposeFiles := .Files.Glob (printf "config/%s/*" .Values.global.purpose) }}
{{ - $sharedConfig := fromYaml ($sharedFiles.AsConfig | default "{ }") }}
{{- $purposeConfig := fromYaml ($purposeFiles.AsConfig | default "{ }") }}
{{ - $files := mergeOverwrite (dict) $sharedConfig $purposeConfig }}
{{ - (tpl (toYaml $files) .) | nindent 2 }}

A purpose can therefore replace the shared template by providing its own file with the same system.jte key. The resulting ConfigMap is mounted read-only at /app/conf/, alongside application.conf and the other configuration files. A template change takes effect when the deployment updates and the pod starts with the new ConfigMap content; the service does not reload templates while running.

Do not deploy both system.jte and system.kte. Startup requires exactly one system template in the mounted directory.

A template has three parts:

  1. Required front matter containing string metadata.
  2. Parameter declarations.
  3. A subject, one blank line, and then the body; both subject and body may contain interpolated values.

An invitation template:

---
contentType: text/plain
---
@param String invitation_expiresOn
@param String invitation_invitee
@param String invitation_url
Invitation to Arda Cloud
Hi,
Follow this link to join Arda Cloud: ${invitation_url}

The blank line after the rendered subject is significant. TemplateService splits the output with the contract <first-line subject>\n\n<body> and returns:

data class Message(
val metadata: Map<String, String>,
val subject: String,
val body: String,
)

Leading or trailing output is not normalized beyond that split. Design the template so the first rendered character is part of the subject and the body starts immediately after the separator.

contentType defaults to text/plain. When it is exactly text/html, the engine uses HTML output mode and InvitationService passes the rendered body as EmailMessage.htmlBody. Every other value uses plain output mode and is passed as textBody.

HTML mode escapes interpolated values according to JTE’s HTML rules. Literal markup authored in the template remains markup. The rendered result must also satisfy the System Email body’s allow-list described in Content constraints.

The parser accepts the following delimiter forms. Metadata keys and values are ultimately converted to strings.

FormatOpening delimiterClosing delimiter
YAML--- or ---yaml---
TOML+++ or ---toml+++ or ---, respectively
JSON;;; or ---json;;; or ---, respectively
JSON object{} followed by an empty line

The first non-empty line must be a supported opening delimiter. Duplicate YAML keys, malformed metadata, a non-object document, or an unterminated block is rejected. Metadata other than contentType is preserved in Message.metadata, but invitation delivery currently does not interpret it.

The API accepts messageParameters: Map<String, String> when an invitation is created and persists the map with the invitation. These values let a purpose-specific template declare additional string parameters.

Immediately before rendering, InvitationService adds three authoritative values:

ParameterValue
invitation_expiresOnExpiration instant in ISO-8601 form
invitation_inviteeInvitee email address
invitation_urlGenerated frontend acceptance URL, including the invitee email query parameter

Caller-supplied keys beginning with invitation_ are removed when the invitation is created. The service then adds its own values for the three protected parameters before every render. A caller therefore cannot replace the destination, expiration, or URL used by the system template.

Custom parameters are immutable after creation: invitation updates retain the persisted map and only apply the requested status transition. An unused supplied parameter is harmless. A declared template parameter with no corresponding map entry causes rendering to fail.

Failure handling depends on where the failure occurs:

FailureObserved behavior
Missing template directory, zero or two system templates, or invalid front matterComponent initialization fails.
JTE/KTE render throws TemplateExceptionReturned as a failed Result; invitation delivery records PENDING_FAILED.
Rendered message fails EmailMessage validationInvitation delivery records PENDING_FAILED.
Email sender returns a non-success outcomeInvitation delivery records PENDING_FAILED and stores the outcome text in messageStatus.
Rendered output lacks the subject/blank-line/body shaperenders() throws IllegalArgumentException; this is not converted to a failed Result by PR #204.

messageStatus is cleared before a new transition attempt. It contains useful text for a non-success sender outcome; template and message-construction failures do not currently populate it. A tenant user retries delivery by transitioning PENDING_FAILED to PENDING, which refreshes the invitation timestamps and renders the current template again.

Accounts build process finds every .jte and .kte file under the application and test template roots, generates dummy values for its declared parameters, renders it in isolation, and writes reviewable output under build/templateCatalog/.

Accounts emits three metrics.

Successful engine renders increment cards.arda.accounts.util.template.render. Invitation delivery separately counts cards.arda.accounts.system.invitation.email.send.success and cards.arda.accounts.system.invitation.email.send.failure. A render that fails inside JTE does not increment the render counter; an output-shape failure occurs after that counter has already been incremented.

The template can be configured without modification of the service code.

Any GitHub user with access to the accounts repository can modify the default template with a simple Pull-Request against helm/config/_shared/system.jte. They can also add purpose-specific version of the template with Pull-Request against helm/config/<purpose>/system.jte.

Should new parameters be needed for the template, a Pull-Request must first be opened against the Front-End to collect and send the desired parameters in the invitation’s messageParameters, a json map of parameters to value.

Once that Pull-Request has been tested, merged and deployed, a 2nd Pull-Request can update the template in accounts.