Email Service Architecture
1. Overview
The Email Service is a five-layer pipeline in admin-service providing template-based email rendering, multi-transport delivery, and per-entity communication logging. It implements integration point #3 from the Communication Integration Architecture: "Fluxnova → Direct SMTP for simple transactional emails."
The service is built independently of entity triggers — email sending and templating work standalone, with Membership and EventParticipant integration added separately once the infrastructure is validated.
2. Architecture
2.1. Five-Layer Pipeline
| Layer | Technology | Responsibility |
|---|---|---|
Authoring |
MJML + visual editor (GrapesJS or Unlayer) |
Create responsive HTML email markup with embedded Thymeleaf directives |
Storage |
Database ( |
Persist compiled HTML, MJML source, template metadata; global or organisation-scoped with optional event/series/membership-type override |
Rendering |
Thymeleaf (inline |
Render subject and HTML body in |
Delivery |
Spring Mail (SMTP) / AWS SES |
Send rendered HTML via configured transport with rate limiting and retry |
Audit |
Database ( |
Lightweight per-entity communication log with rendered subject, key fields, and context summary |
3. Authoring Layer
3.1. MJML with Thymeleaf Directives
Authors write responsive email markup in MJML, embedding Thymeleaf th: directives directly. MJML treats th: attributes as pass-through HTML attributes that survive compilation into responsive HTML with inline CSS.
<mj-text>
Dear <span th:text="${participant.firstName}">John</span>,
</mj-text>
<mj-text th:if="${order != null}">
Your order #<span th:text="${order.number}">12345</span> is confirmed.
</mj-text>
3.2. Compilation Strategy
Templates are compiled at authoring time, not at send time. No Node.js runtime is required in admin-service.
| Phase | Approach | Runtime Dependency |
|---|---|---|
Phase 1 |
CLI compilation ( |
None |
Phase 2 |
Optional MJML API sidecar (lightweight Node.js container) for server-side compilation |
Node.js sidecar |
3.3. Visual Editor Options
Mosaico was evaluated and rejected (declining maintenance, Knockout.js incompatible with Angular, proprietary template format).
| Editor | Strengths | Integration Path |
|---|---|---|
GrapesJS MJML plugin |
Open source; also used by Mautic (synergy with marketing stack) |
Wrap as Angular component; outputs MJML directly |
Unlayer |
Modern commercial editor; native Angular SDK ( |
Cleanest integration path for admin-ui; free tier available |
4. Storage Layer
4.1. email_template Table
| Column | Type | Description |
|---|---|---|
|
BIGINT PK |
Auto-increment |
|
VARCHAR(100) |
Machine-readable template key (e.g. |
|
VARCHAR(200) |
Human-readable name |
|
VARCHAR(500) |
Template purpose |
|
VARCHAR(1) |
|
|
VARCHAR(500) |
Thymeleaf expression for subject line |
|
CLOB |
Compiled HTML with |
|
CLOB (nullable) |
Original MJML source for round-trip editing |
|
CLOB (nullable) |
Plain-text fallback |
|
INT |
Increments on each edit |
|
VARCHAR(1) |
Y/N soft-delete flag |
|
BIGINT FK (nullable) |
FK to Event — event-specific override |
|
BIGINT FK (nullable) |
FK to Series — series-specific override |
|
BIGINT FK (nullable) |
FK to MembershipType — membership-type-specific override |
|
BIGINT FK (nullable) |
FK to Organisation — NULL for global defaults |
|
VARCHAR(50) |
Audit |
|
DATETIME |
Audit |
|
VARCHAR(50) |
Audit |
|
DATETIME |
Audit |
4.2. Template Scoping and Resolution
Templates follow a specificity hierarchy. The most specific active template wins.
Event templates (type E or T in event context):
Event-specific -> Series-specific -> Org-level -> Global
(code+org+event) (code+org+series) (code+org) (code, org=NULL)
Membership templates (type M or T in membership context):
MembershipType-specific -> Org-level -> Global
(code+org+membershipType) (code+org) (code, org=NULL)
Transactional templates (type T) follow the caller’s context — when sent for an EventParticipant, the event chain is used; when sent for a Membership, the membership chain is used.
System templates (type S): Org-level → Global.
Scope constraints (enforced at save time):
-
Global templates (
organisation_id IS NULL): event, series, and membership type must all be null -
EVENT type: cannot have
membership_type_id -
MEMBERSHIP type: cannot have
event_idorseries_id -
event_idandseries_idare mutually exclusive (event is more specific than series)
Uniqueness: Enforced at the application layer — the scope tuple (code, organisation_id, event_id, series_id, membership_type_id) must be unique, treating NULL values as equal. A composite index idx_email_template_resolution supports resolution queries. MySQL’s handling of NULLs in unique constraints (NULLs treated as distinct) prevents database-level enforcement.
4.2.1. Resolution is by specificity, not recency
A scoped template always wins over a more general one, however old it is and however recently the general one changed. There is no recency tie-break and no version comparison across scopes.
The consequence is easy to miss and fails silently: a Liquibase changeset that updates a global template does not reach any override of the same code. The changeset succeeds, the template is correct, and the organisations that have an override carry on rendering their own copy. Nothing reports the divergence — no error, no warning, and the delivered mail looks plausible because the override is itself a valid template.
This happened in September 2026. A release added an Item column to the order confirmation’s entry
table, so that a day licence — which carries no category by design — could name the product on its
own row. The changeset updated the global template. The one organisation the change was written for
had an org-level override, so it kept a three-column table in which a day-licence row rendered a
participant, an event, and an empty cell: the customer was told nothing about what the second line
on their order was. The defect was invisible in the code, in the changeset, and in dev, where no
override exists. It surfaced only by sending a real order through stage.
Before shipping a template change, check whether the code has overrides:
SELECT id, organisation_id, event_id, series_id, version, active
FROM email_template
WHERE code = ? AND active = 'Y';
More than one active row means the change will not reach every recipient of that code.
4.2.2. Prefer folding improvements up to retaining a fork
An override is sometimes created as a stopgap — to carry a fix for one organisation ahead of a release rather than to express anything specific to it. Such a fork stops being a customisation the moment the release lands, but nothing retires it, and from then on it silently withholds every later change to the global.
Where an override’s differences are improvements rather than preferences, fold them into the global and deactivate the override. One template serves every tenant, the improvement reaches all of them, and there is no second row for a future changeset to miss. Retain an override only for a difference that is genuinely specific to one organisation.
Folding up is not a copy. An override may depend on data its own organisation happens to have; the global must hold for every organisation, including those where that data is absent. In the September 2026 reconciliation the override’s subject line read the first participant’s event name, which is sound for a tenant whose orders always carry participants and throws for one whose orders may not. It was folded up with a guard and a fallback subject, not verbatim.
4.3. Security Classification
EmailTemplate is classified as Org-Scoped (Type A) per the Entity Classification model, with an extension for global templates:
-
Implements
OrganizationalSecuredwith directorganisation_id(nullable) -
Org-scoped templates: CRUD enforced via
SecurityDimensionService.requireAccess(entity, AccessLevel.READ_WRITE); queries filtered viaSecuritySpecifications.applyOrgFilter()withLEFT JOINon organisation -
Global templates (
organisation_id IS NULL): requireROLE_ADMINfor create/update/delete; readable by all authenticated users -
Read queries combine the org filter with
OR organisation IS NULLto include global defaults alongside org-specific templates
4.4. Template Variable Validation
Template content is validated against the known context variables for each template type. Validation uses regex extraction of top-level Thymeleaf variable names (e.g., ${participant.firstName} → participant).
Behaviour:
-
On save (active=false): Validation runs and warnings are returned in the response. The template is saved regardless, allowing work-in-progress templates.
-
On activate (active=true): If validation produces errors, the save is rejected with
BadRequestAlertException("invalidvariables"). Only valid templates can be activated for sending.
Valid variables per type:
| Template Type | Valid Variables |
|---|---|
EVENT, TRANSACTIONAL |
|
MEMBERSHIP |
|
SYSTEM |
No restrictions — any variable is allowed |
The ContentDefinitionFactory provides the appropriate EmailContentDefinition for each type. The REST API exposes GET /api/email-templates/variables/{templateType} for UI editors to discover valid variables.
5. Rendering Layer
5.1. Inline Template Rendering
EmailDeliveryService renders templates using an inline StringTemplateResolver — it processes the entity’s htmlBody, subjectTemplate, and textBody directly as Thymeleaf strings. There is no database re-lookup during rendering; the resolved EmailTemplate entity is passed directly.
The DatabaseTemplateResolver (prefix: db:) exists as a Thymeleaf ITemplateResolver for integration with the standard template engine when needed (e.g., includes/fragments). It delegates to EmailTemplateService.resolveEventTemplate() or resolveMembershipTemplate() based on available resolution attributes.
Existing JHipster account lifecycle templates (activation, password reset) continue working unchanged via the ClassLoaderTemplateResolver.
5.2. Context Population
EmailContextBuilder populates the Thymeleaf context from domain entities:
| Target Entity | Context Variables |
|---|---|
EventParticipant |
|
Membership |
|
Preview/Test |
Arbitrary key-value map passed via API |
5.3. Recipient Resolution
RecipientResolver determines To/CC addresses from entities:
| Scenario | To | CC |
|---|---|---|
Order-based (EventParticipant) |
|
|
No-order (EventParticipant) |
|
None |
Membership |
|
None |
6. Delivery Layer
6.1. Transport Abstraction
EmailTransport interface abstracts transport protocol:
| Transport | Implementation | Configuration |
|---|---|---|
SMTP |
|
|
AWS SES |
|
|
SmtpEmailTransport catches MessagingException, MailException, and unexpected exceptions, returning DeliveryResult.failed() with the error message and full stack trace in logs.
Email configuration is global initially. The EmailTransport interface accepts configuration as a parameter, enabling future migration to per-organisation email configs (each tenant sending from their own domain).
6.2. DeliveryResult
DeliveryResult carries the delivery outcome:
-
DeliveryResult.sent(message)— includes the renderedEmailMessage(with resolved subject, HTML body, recipients) for audit logging -
DeliveryResult.failed(errorMessage)— includes the error message;sentMessageis null -
isSuccess()checksstatus == DeliveryStatus.SENT
6.3. Resilience
| Mechanism | Implementation |
|---|---|
Rate limiting |
Configurable messages/minute (default 10 for EOP SMTP, higher for SES) |
Retry |
Spring Retry with exponential backoff on transient 4xx SMTP errors |
Batch mode |
|
6.4. Deliverability
Three properties of the message itself decide how a receiving filter scores it, independently of the content. All three were wrong before ADO US #1220, and they are owned in two different layers:
-
The
Message-IDand theHELOname are transport properties. No template can set them, and they are identical for every message the service sends. -
The
text/plainalternative is produced in the rendering layer. A template that carries atext_bodyowns its own, rendered by Thymeleaf like any other template field; only the fallback for a template without one is generated, from the HTML that was just rendered.
That distinction matters operationally: the first two change for every customer at once when the configuration changes, while the third can be overridden one template at a time.
| Property | Before | Now |
|---|---|---|
|
None. |
Derived from the rendered HTML by |
|
Generated by JavaMail from the pod hostname: not a public domain, and different on every restart. |
Stamped per attempt on the sending domain by |
SMTP |
The pod hostname, for the same reason: JavaMail defaults |
|
The HELO name is indirected through an ems. key rather than written under spring.mail.
because the chart’s config.extraProperties merges at the document root — a manifest repeating
spring: is rejected as a duplicate YAML key, so a literal there could not be overridden per
environment at all. See SpringApplication Bootstrap.
|
The HELO name identifies the sending client, not the domain the mail claims to be from. Those are separate claims, and setting HELO to the customer’s own domain would present the connection as that tenant’s mail arriving from an address that is not the tenant’s — the shape a filter scores as spoofing.
6.4.1. Cluster egress addresses
The admin-service pod can be scheduled on any node, and each node has its own public address, so all three are part of the service’s sending identity.
| Node | Egress address | Reverse DNS (PTR) |
|---|---|---|
|
197.189.214.90 |
|
|
197.189.214.91 |
|
|
197.189.214.92 |
|
All three fall inside 197.189.214.88/29, which the sending domains list in SPF — so mail sent
from the cluster is SPF-authorised wherever it lands.
The PTR records are the hosting provider’s generic reverse entries, not names under our control. That has a consequence worth stating plainly: no HELO name that can be configured here will forward-confirm against the reverse DNS of the address it connects from. Achieving that needs a custom PTR from the provider, which is an infrastructure change, not a service setting.
6.4.2. What deliverability work cannot settle from inside the service
Order confirmations pass SPF, DKIM and DMARC at Gmail. The remaining failure is specific: a confirmation to an address inside the customer’s own Microsoft 365 tenant is quarantined as High Confidence Phish, because it arrives at that tenant’s inbound connector as external mail claiming the tenant’s own domain. The identical message to an outside recipient is delivered.
That is a property of the receiving tenant’s policy, not of the message, and it is resolved with a mail flow rule on the tenant — not with anything this service can put on the wire.
7. Audit Layer
7.1. communication_log Table
Lightweight logging — stores key fields and a context summary (serialized via Jackson ObjectMapper), not full message content. The template reference plus context enables message reconstruction if needed.
| Column | Type | Description |
|---|---|---|
|
BIGINT PK |
Auto-increment |
|
VARCHAR(1) |
|
|
VARCHAR(100) |
Reference to |
|
VARCHAR(500) |
Rendered subject line (from |
|
VARCHAR(200) |
Primary recipient |
|
VARCHAR(500) |
CC recipients (comma-separated) |
|
VARCHAR(1) |
|
|
VARCHAR(1000) |
Error detail (if failed) |
|
DATETIME |
When sent |
|
VARCHAR(2000) |
Key merge fields as JSON (e.g. |
|
BIGINT FK (nullable) |
Linked EventParticipant |
|
BIGINT FK (nullable) |
Linked Membership |
|
BIGINT FK |
Required — multi-tenant boundary |
|
VARCHAR(50) |
Audit (write-once) |
|
DATETIME |
Audit (write-once) |
Security classification: Org-Scoped (Type A), same pattern as EmailTemplate. Access to communication log entries requires READ access to the parent entity (EventParticipant or Membership), enforced via composite SecurityDimensionService.requireAccess() checks.
Grouped sends: One log row per linked entity. A multi-participant email to 3 EventParticipants creates 3 rows sharing the same sent_at, template_code, and context_summary.
8. API Surface
| Endpoint | Purpose |
|---|---|
|
Template CRUD (org-filtered + global templates visible to all; global template writes require ROLE_ADMIN) |
|
Render preview with sample data (resolves entity by ID with org-security) |
|
Send test email to specified address (resolves entity by ID with org-security) |
|
Get valid template variables for a template type (for UI editors) |
|
Single send (template code + entity ID; composite security check on entity) |
|
Batch send (template code + event + participant filter) |
|
Communication history for a participant (requires READ access to participant) |
|
Communication history for a membership (requires READ access to membership) |
Error responses follow JHipster patterns with BadRequestAlertException:
-
idexists— create with existing ID -
idnull/idinvalid— update with missing/mismatched ID -
globalscopeconflict— global template with event/series/membershipType links -
typescopeconflict— template type incompatible with scope links -
eventseriesmutualexclusive— both event and series set -
duplicatescope— duplicate template at same scope level -
invalidvariables— activating template with unknown variables
9. Package Structure
za.co.idealogic.event.admin.service.email/
EmailOrchestrationService -- high-level entry point (single + batch)
EmailDeliveryService -- render (inline StringTemplateResolver) + send pipeline
EmailContextBuilder -- Thymeleaf context from entities
RecipientResolver -- To/CC resolution from entities
CommunicationLogService -- write/query communication log
template/
DatabaseTemplateResolver -- Thymeleaf ITemplateResolver for DB (prefix: db:)
EmailTemplateService -- CRUD, resolution chains, scope/uniqueness validation
transport/
EmailTransport -- interface
SmtpEmailTransport -- JavaMailSender wrapper
SesEmailTransport -- AWS SES wrapper (Phase 3)
validation/
EmailContentDefinition -- abstract: valid variables + validation logic
EventEmailContentDefinition -- EVENT/TRANSACTIONAL variables
MembershipEmailContentDefinition -- MEMBERSHIP variables
SystemEmailContentDefinition -- SYSTEM (no restrictions)
ContentDefinitionFactory -- factory: type -> definition
model/
EmailMessage -- send request value object
EmailRecipient -- resolved To/CC/BCC
DeliveryResult -- send outcome (carries rendered EmailMessage)
za.co.idealogic.event.admin.web.rest/
EmailTemplateResource -- template CRUD + preview + test-send + variables
EmailSendResource -- send/batch endpoints (composite security checks)
CommunicationLogResource -- log query endpoints (entity-level access checks)
za.co.idealogic.event.domain/
EmailTemplate -- JPA entity, OrganizationalSecured (nullable org)
CommunicationLog -- JPA entity, OrganizationalSecured
za.co.idealogic.event.admin.process/ -- (Phase 4)
SendEmailDelegate -- Fluxnova JavaDelegate
10. Fluxnova Integration
The email service is decoupled from Fluxnova. A SendEmailDelegate implementing Camunda’s JavaDelegate invokes EmailOrchestrationService — the same service backing the REST API. This implements integration point #3 from the Communication Integration Architecture.
The delegate reads process variables (emailTemplateCode, eventParticipantId or membershipId) and delegates to the email service. The REST API and Fluxnova share the same execution path.
11. Phased Delivery
| Phase | Scope | Deliverables |
|---|---|---|
1: Foundation |
Core email service in admin-service |
DB schema, JPA entities with |
2: Admin UI |
Template management + send UI |
REST controllers (org-secured), template list/editor with preview, test send, variable discovery endpoint, communication log tab on entity views, batch send UI |
3: Advanced Delivery |
SES + resilience |
|
4: Fluxnova |
Workflow integration |
|
5: Visual Editing |
Drag-and-drop templates |
Evaluate GrapesJS MJML plugin vs Unlayer Angular SDK, MJML microservice, round-trip editing |
Future |
Per-org email config |
|
12. Related Documentation
-
Communication Integration Architecture — hybrid orchestration with Fluxnova, Mautic, Chatwoot
-
Async Communication Requirements — FR-AC-001 through FR-AC-011
-
Communication Scenarios — 18 scenarios with timing rules
-
Entity Classification — Org-Scoped (Type A) pattern
-
Security Dimensions — organisational access model