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, HTML body, and text body from entity fields with Thymeleaf context

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.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

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