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 (email_template table)

Persist compiled HTML, MJML source, template metadata; global or organisation-scoped with optional event/series/membership-type override

Rendering

Thymeleaf (inline StringTemplateResolver)

Render subject and HTML body in TemplateMode.HTML; render an authored text body in TemplateMode.TEXT, so its variables are not HTML-escaped

Delivery

Spring Mail (SMTP) / AWS SES

Send rendered HTML via configured transport with rate limiting and retry

Audit

Database (communication_log table)

Lightweight per-entity communication log with rendered subject, key fields, and context summary

2.2. Pipeline Diagram

email-pipeline

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 (npx mjml template.mjml -o template.html), paste compiled HTML into admin UI

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 (angular-email-editor)

Cleanest integration path for admin-ui; free tier available

4. Storage Layer

4.1. email_template Table

Column Type Description

id

BIGINT PK

Auto-increment

code

VARCHAR(100)

Machine-readable template key (e.g. event-entry-confirmed)

name

VARCHAR(200)

Human-readable name

description

VARCHAR(500)

Template purpose

template_type

VARCHAR(1)

T=Transactional, E=Event, M=Membership, S=System

subject_template

VARCHAR(500)

Thymeleaf expression for subject line

html_body

CLOB

Compiled HTML with th: directives

mjml_source

CLOB (nullable)

Original MJML source for round-trip editing

text_body

CLOB (nullable)

Plain-text fallback

version

INT

Increments on each edit

active

VARCHAR(1)

Y/N soft-delete flag

event_id

BIGINT FK (nullable)

FK to Event — event-specific override

series_id

BIGINT FK (nullable)

FK to Series — series-specific override

membership_type_id

BIGINT FK (nullable)

FK to MembershipType — membership-type-specific override

organisation_id

BIGINT FK (nullable)

FK to Organisation — NULL for global defaults

created_by

VARCHAR(50)

Audit

created_on

DATETIME

Audit

modified_by

VARCHAR(50)

Audit

modified_on

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_id or series_id

  • event_id and series_id are 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 OrganizationalSecured with direct organisation_id (nullable)

  • Org-scoped templates: CRUD enforced via SecurityDimensionService.requireAccess(entity, AccessLevel.READ_WRITE); queries filtered via SecuritySpecifications.applyOrgFilter() with LEFT JOIN on organisation

  • Global templates (organisation_id IS NULL): require ROLE_ADMIN for create/update/delete; readable by all authenticated users

  • Read queries combine the org filter with OR organisation IS NULL to 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

participant (EventParticipant), event (Event), order (Order)

MEMBERSHIP

membership (Membership), person (Person), period (MembershipPeriod)

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

participant (EventParticipant entity), event (Event entity if present), order (first Order from OrderLineItems if present)

Membership

membership (Membership entity), person (Person entity if present), period (MembershipPeriod if present)

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)

Order.email (billing person)

EventParticipant.email if different from Order.email

No-order (EventParticipant)

EventParticipant.email

None

Membership

Person.userEmail (via membership.person)

None

6. Delivery Layer

6.1. Transport Abstraction

EmailTransport interface abstracts transport protocol:

Transport Implementation Configuration

SMTP

SmtpEmailTransport wrapping JavaMailSender

spring.mail.* properties

AWS SES

SesEmailTransport wrapping AWS SDK v2 SesClient

aws.ses.* properties (Phase 3)

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 rendered EmailMessage (with resolved subject, HTML body, recipients) for audit logging

  • DeliveryResult.failed(errorMessage) — includes the error message; sentMessage is null

  • isSuccess() checks status == 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

EmailBatchService groups participants by Order, sends grouped emails, tracks via database

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-ID and the HELO name are transport properties. No template can set them, and they are identical for every message the service sends.

  • The text/plain alternative is produced in the rendering layer. A template that carries a text_body owns 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

text/plain alternative

None. email_template.text_body exists and is null on every template, so every message went out HTML only — a well-known negative signal, and unreadable to a client that prefers plain text.

Derived from the rendered HTML by HtmlToPlainTextConverter when the template carries no text body of its own. An authored text body still wins, and is rendered by a Thymeleaf engine in TemplateMode.TEXT so its variables are not HTML-escaped.

Message-ID

Generated by JavaMail from the pod hostname: not a public domain, and different on every restart.

Stamped per attempt on the sending domain by SmtpEmailTransport, and restored after saveChanges() by JavaMailRelayDispatch — which would otherwise regenerate it from the pod name again. Stamped per attempt, not per message, so a trace found later names the attempt it belongs to rather than the whole retry ladder.

SMTP HELO name

The pod hostname, for the same reason: JavaMail defaults mail.smtp.localhost to InetAddress.getLocalHost().getHostName(). A bare container name is not the fully qualified domain RFC 5321 asks for.

ems.email.smtp.helo-name, a root-level key referenced from spring.mail.properties.mail.smtp.localhost.

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)

idl-xnl-jhb1-k01

197.189.214.90

90.214.189.197.reverse.xneelo.net

idl-xnl-jhb1-k02

197.189.214.91

91.214.189.197.reverse.xneelo.net

idl-xnl-jhb1-k03

197.189.214.92

92.214.189.197.reverse.xneelo.net

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

id

BIGINT PK

Auto-increment

channel

VARCHAR(1)

E=Email, S=SMS, W=WhatsApp

template_code

VARCHAR(100)

Reference to email_template.code

subject

VARCHAR(500)

Rendered subject line (from DeliveryResult.sentMessage)

recipient_to

VARCHAR(200)

Primary recipient

recipient_cc

VARCHAR(500)

CC recipients (comma-separated)

delivery_status

VARCHAR(1)

P=Pending, S=Sent, F=Failed, B=Bounced

error_message

VARCHAR(1000)

Error detail (if failed)

sent_at

DATETIME

When sent

context_summary

VARCHAR(2000)

Key merge fields as JSON (e.g. {"eventName":"SA Track 2026"})

event_participant_id

BIGINT FK (nullable)

Linked EventParticipant

membership_id

BIGINT FK (nullable)

Linked Membership

organisation_id

BIGINT FK

Required — multi-tenant boundary

created_by

VARCHAR(50)

Audit (write-once)

created_on

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

GET/POST/PUT/DELETE /api/email-templates

Template CRUD (org-filtered + global templates visible to all; global template writes require ROLE_ADMIN)

POST /api/email-templates/{id}/preview

Render preview with sample data (resolves entity by ID with org-security)

POST /api/email-templates/{id}/test-send?toAddress=…​

Send test email to specified address (resolves entity by ID with org-security)

GET /api/email-templates/variables/{templateType}

Get valid template variables for a template type (for UI editors)

POST /api/email/send

Single send (template code + entity ID; composite security check on entity)

POST /api/email/send-batch

Batch send (template code + event + participant filter)

GET /api/event-participants/{id}/communication-log

Communication history for a participant (requires READ access to participant)

GET /api/memberships/{id}/communication-log

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 OrganizationalSecured, inline rendering with StringTemplateResolver, SMTP transport, communication log, global + org-scoped templates, template variable validation

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

SesEmailTransport, Resilience4j rate limiter, template versioning

4: Fluxnova

Workflow integration

SendEmailDelegate, payment reminder workflow, membership lifecycle workflows

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

email_config table, org-specific SMTP/SES settings, custom sender domains per tenant