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